VocabVerse背单词应用故事生成界面优化

书接上回,我们设计好了“故事生成”的deepseek接口,并且利用这个故事来生成漫画,并且返回到客户端。

那我们的思路就是把 漫画生成的服务器接口部署暴露 ,将故事格式化时候传递到服务器接口 ,然后将以上步骤打包。

接下来我们就面临一些小问题来完善这个故事界面

完善的思路就很简单了:

①把我们生成的故事的界面,太过冗杂,可以进行少了优化。比如选定单词的选举和故事界面的相对大小

②数据和真实数据库的连接

明确了需求,那我们就可以着手了

本次我们可以处理第一步

step1 界面优化动态效果

一、改造

在这里插入图片描述

我们可以查看我们之前设计的界面的代码:

@Composable
fun DeepSeekChatScreen(
    modifier: Modifier = Modifier,
    viewModel: DeepSeekViewModel = hiltViewModel()
) {

    val words = viewModel.spellingList

    // 使用Set来记录选中的单词,支持多选
    val selectedWords = remember { mutableStateOf(emptySet<String>()) }

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
            verticalArrangement = Arrangement.SpaceBetween
    ) {
        // 单词选择区域
        // 单词选择区域 - 添加卡片容器
        Surface(
                modifier = Modifier
                        .weight(0.77f)
                        .fillMaxWidth(),
                shape = MaterialTheme.shapes.medium,

                shadowElevation = 4.dp // 修正参数名
        ) {
            //省略其他代码
                
        }
        Spacer(Modifier.height(8.dp)) // 添加垂直间隔

        // 结果显示区域
        ChatHistory(
                history = viewModel.uiState.fullResponse,
                currentResponse = viewModel.uiState.currentResponse,
                isLoading = viewModel.uiState.isLoading,
                modifier = Modifier.weight(1f)
        )

        // 底部按钮区域
        Column( // 用Column包裹两个Row(第1处改动)
                modifier = Modifier
                        .padding(vertical = 2.dp)
                        .drawBehind { // 添加虚线绘制(第2处改动)
                            drawRect(
                                    color =Color(0xFF2C7F3E) ,
                                    style = Stroke(
                                            width = 1.dp.toPx(),
                                            pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 15f), 0f)
                                    )
                            )
                        }
        ) 

                // 查询按钮
                
    }
}
@Composable
fun WordButton(
        word: String,
        isSelected: Boolean,
        onClick: () -> Unit
) {
    Surface(
            modifier = Modifier
                    .height(48.dp)
                    .padding(4.dp),
            shape = RoundedCornerShape(20.dp),  // 1. 改为圆形胶囊形状
            color = if (isSelected) Color(0xFF4CAF50) else Color(0xFFE8F5E9), // 2. 修改选中颜色
            border = BorderStroke(1.dp, Color(0xFF81C784)), // 3. 添加浅绿色边框
            onClick = onClick
    ) {
        Text(
                text = word,
                modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
                color = if (isSelected) Color.White else Color(0xFF2E7D32), // 4. 调整文字颜色
                fontSize = 14.sp,
                fontWeight = FontWeight.Medium // 增加字重

        )
    }
}

我们可以看到,无论是单词选择区域

Surface(
    modifier = Modifier
        .weight(0.77f)
        .fillMaxWidth(),
    shape = MaterialTheme.shapes.medium,
    shadowElevation = 4.dp
) {
    // ... 
}

还是聊天区域

ChatHistory(
    history = viewModel.uiState.fullResponse,
    currentResponse = viewModel.uiState.currentResponse,
    isLoading = viewModel.uiState.isLoading,
    modifier = Modifier.weight(1f)
)

可以看到modifier = Modifier.weight(XXXX)的权重都是固定值

那么我们设计时候,如果连接的数据库的单词多了,显示就会出问题,比如刚刚step1提到的,如果我们把累计的单词加入这个界面,这个势必会随着时间的推移越来越多,显然不合理

那么我们可以研究,像chatgpt一样,询问的时候,对话的窗口就会动态拉大。

但是我们研究modifier = Modifier.weight(XXXX)的源码

在这里插入图片描述

可以看到他接收的权重参数只是一个浮点数而已,怎么做得到动态变化呢?

这时候自然有对策,那就是animateFloatAsState

但是animateFloatAsState 类型又不是float,怎么实现呢?

在 Jetpack Compose 中,Modifier.weight() 可以接收 animateFloatAsState 是因为 Compose 的声明式特性和状态管理机制。让我们深入理解这一点:

1. Compose 的声明式特性

Jetpack Compose 是基于状态的 UI 框架,当状态变化时,Compose 会自动重组(recompose)相关的 UI 部分。animateFloatAsState 返回的是一个 State<Float> 对象,Compose 可以观察这个状态的变化并触发重组。

2. Modifier.weight() 的参数类型

weight() 函数接收一个 Float 类型的参数。而 animateFloatAsState 返回的是一个 State<Float>,但在 Kotlin 中,State 对象可以通过属性委托(by 关键字)或直接调用 .value 来获取其存储的值。Compose 在重组时会自动读取 .value,所以它实际上传递的是一个 Float

在这里插入图片描述
在这里插入图片描述

3. 动画状态的工作机制

当使用 animateFloatAsState

val weight by animateFloatAsState(targetValue)

实际上是:

  • 声明了一个可观察的 State<Float>
  • targetValue 变化时,动画系统会平滑地过渡到新值
  • 在每一帧,Compose 都会读取当前动画值并触发重组

4. 为什么可以这样用

Modifier.weight(weight) 在内部只是使用这个 Float 值来计算布局权重。由于 animateFloatAsState 在动画过程中会不断产生新的 Float 值并触发重组,所以权重会平滑过渡,从而实现动态效果。

5. 底层原理

在 Compose 的布局阶段:

  1. 动画系统每帧更新 State<Float> 的值
  2. Compose 检测到状态变化,触发重组
  3. LayoutColumn/Row 等布局组件会重新计算子项的位置和大小
  4. 由于权重值在动画,布局会平滑变化

6. 对比传统视图系统

在传统 Android 视图中,要实现类似效果需要:

  • 手动计算 View 的大小
  • 使用 ValueAnimator 并手动更新 LayoutParams
  • 请求重新布局

而 Compose 的声明式方式让这一切变得非常简单,只需声明状态如何变化,框架处理其余工作。

示例代码

@Composable
fun DynamicWeightExample() {
    var expanded by remember { mutableStateOf(false) }
    // 动画权重值
    val weight by animateFloatAsState(
        targetValue = if (expanded) 0.7f else 0.3f,
        animationSpec = tween(500)
    )

    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(weight)  // 这里使用动画权重值
                .background(Color.Red)
        )
        Box(
            Modifier
                .weight(1 - weight)
                .background(Color.Blue)
        )
        Button(onClick = { expanded = !expanded }) {
            Text("Toggle")
        }
    }
}

在这个例子中,点击按钮会平滑地调整红蓝两个区域的比例,这就是 animateFloatAsStateModifier.weight() 配合使用的典型场景。

DeepSeekChatScreen添加动态界面调整功能,使得单词按钮栏和聊天栏的大小能根据是否有聊天内容自动调整。以下是改进方案:

修改后的Composable函数

@Composable
fun DeepSeekChatScreen(
    modifier: Modifier = Modifier,
    viewModel: DeepSeekViewModel = hiltViewModel()
) {
    val words = viewModel.spellingList
    val selectedWords = remember { mutableStateOf(emptySet<String>()) }
    
    // 根据是否有聊天内容决定权重分配
    val hasChatContent = viewModel.uiState.fullResponse.isNotEmpty() || 
                        viewModel.uiState.currentResponse.isNotEmpty() ||
                        viewModel.uiState.isLoading
    
    val wordSectionWeight by animateFloatAsState(
        targetValue = if (hasChatContent) 0.5f else 0.8f,
        animationSpec = tween(durationMillis = 300)
    )

    val chatSectionWeight by animateFloatAsState(
        targetValue = if (hasChatContent) 1.3f else 0.5f,
        animationSpec = tween(durationMillis = 300)
    )

    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.SpaceBetween
    ) {
        // 单词选择区域 - 动态权重
        Surface(
            modifier = Modifier
                .weight(wordSectionWeight)
                .fillMaxWidth(),
            shape = MaterialTheme.shapes.medium,
            shadowElevation = 4.dp
        ) {
            // ... 原有单词选择区域内容不变 ...
        }
        
        Spacer(Modifier.height(8.dp))

        // 聊天区域 - 动态权重
        ChatHistory(
            history = viewModel.uiState.fullResponse,
            currentResponse = viewModel.uiState.currentResponse,
            isLoading = viewModel.uiState.isLoading,
            modifier = Modifier.weight(chatSectionWeight)
        )

        // 底部按钮区域 - 保持不变
        Column(
            modifier = Modifier
                .padding(vertical = 2.dp)
                .drawBehind {
                    drawRect(
                        color = Color(0xFF2C7F3E),
                        style = Stroke(
                            width = 1.dp.toPx(),
                            pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 15f), 0f)
                        )
                    )
                }
                .padding(4.dp),
            verticalArrangement = Arrangement.spacedBy(1.dp)
        ) {
            // ... 原有按钮区域内容不变 ...
        }
    }
}

这样当聊天内容出现或消失时,界面会平滑过渡,提供更好的用户体验。

这种动态调整的方案既满足了需求,又保持了代码的简洁性和可维护性。

二、参考:Kotlin 的 animateFloatAsState 函数

animateFloatAsState 是 Jetpack Compose 中的一个动画 API,用于创建一个可以自动动画过渡的 State 对象,当目标值改变时,它会平滑地从当前值动画过渡到新值。

在这里插入图片描述

1.基本用法

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.runtime.getValue

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    animationSpec = tween(durationMillis = 300)
)

2.参数说明

  • targetValue: 动画的目标值(Float 类型)
  • animationSpec: 动画规格,定义动画如何执行(默认是 spring()
    • 常用规格:
      • tween(): 指定持续时间和缓动曲线
      • spring(): 基于物理的弹簧动画
      • keyframes(): 关键帧动画
      • repeatable(): 可重复的动画
  • finishedListener: 动画完成时的回调(可选)

3.示例

@Composable
fun AnimatedButton() {
    var enabled by remember { mutableStateOf(false) }
    
    // 动画透明度值
    val alpha: Float by animateFloatAsState(
        targetValue = if (enabled) 1f else 0.5f,
        animationSpec = tween(durationMillis = 500)
    )
    
    Button(
        onClick = { enabled = !enabled },
        modifier = Modifier.alpha(alpha)
    ) {
        Text("Toggle")
    }
}

4.特点

  1. 声明式:只需指定目标值和动画规格,Compose 会处理动画过程
  2. 可组合:可以在任何 Composable 函数中使用
  3. 自动取消:如果目标值在动画过程中再次改变,当前动画会被取消并开始新动画
  4. 高效:使用 Compose 的状态管理,只在需要时重组

5.类似函数

Compose 还提供了其他类似的动画状态函数:

  • animateColorAsState - 用于颜色动画
  • animateDpAsState - 用于尺寸动画
  • animateIntAsState - 用于整数值动画
  • animate*AsState 系列函数都遵循相同的模式

animateFloatAsState 是实现简单属性动画的最便捷方式之一,特别适合 UI 元素的透明度、比例、旋转等属性的动画效果。

根据这个原理完全可以设计一个可复用的公共组件。可以创建一个通用的 AnimatedWeightColumn 组件,封装权重动画逻辑,使它在不同场景下都能方便使用。

三、设计公共组件方案

1. 创建基础动画权重组件

/**
 * 带权重动画的Column组件
 * @param mainContentWeight 主内容区域的权重目标值
 * @param secondaryContentWeight 次要内容区域的权重目标值
 * @param animationSpec 动画规格
 * @param mainContent 主内容区域
 * @param secondaryContent 次要内容区域
 */
@Composable
fun AnimatedWeightColumn(
    mainContentWeight: Float,
    secondaryContentWeight: Float,
    animationSpec: AnimationSpec<Float> = tween(durationMillis = 300),
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.SpaceBetween,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    mainContent: @Composable () -> Unit,
    secondaryContent: @Composable () -> Unit
) {
    val animatedMainWeight by animateFloatAsState(
        targetValue = mainContentWeight,
        animationSpec = animationSpec
    )
    
    val animatedSecondaryWeight by animateFloatAsState(
        targetValue = secondaryContentWeight,
        animationSpec = animationSpec
    )

    Column(
        modifier = modifier,
        verticalArrangement = verticalArrangement,
        horizontalAlignment = horizontalAlignment
    ) {
        Box(
            modifier = Modifier
                .weight(animatedMainWeight)
                .fillMaxWidth()
        ) {
            mainContent()
        }
        
        Box(
            modifier = Modifier
                .weight(animatedSecondaryWeight)
                .fillMaxWidth()
        ) {
            secondaryContent()
        }
    }
}

2. 创建针对聊天场景的专用组件

/**
 * 专门为聊天界面优化的动画权重布局
 * @param hasContent 是否有内容决定权重分配
 * @param mainContentWeightWithContent 有内容时主内容权重
 * @param mainContentWeightWithoutContent 无内容时主内容权重
 * @param secondaryContentWeightWithContent 有内容时次要内容权重
 * @param secondaryContentWeightWithoutContent 无内容时次要内容权重
 */
@Composable
fun ChatAnimatedLayout(
    hasContent: Boolean,
    modifier: Modifier = Modifier,
    mainContentWeightWithContent: Float = 0.5f,
    mainContentWeightWithoutContent: Float = 0.8f,
    secondaryContentWeightWithContent: Float = 1.3f,
    secondaryContentWeightWithoutContent: Float = 0.5f,
    animationSpec: AnimationSpec<Float> = tween(durationMillis = 300),
    mainContent: @Composable () -> Unit,
    secondaryContent: @Composable () -> Unit
) {
    AnimatedWeightColumn(
        mainContentWeight = if (hasContent) mainContentWeightWithContent else mainContentWeightWithoutContent,
        secondaryContentWeight = if (hasContent) secondaryContentWeightWithContent else secondaryContentWeightWithoutContent,
        animationSpec = animationSpec,
        modifier = modifier,
        mainContent = mainContent,
        secondaryContent = secondaryContent
    )
}

3. 使用示例 - 重构原代码

@Composable
fun DeepSeekChatScreen(
    modifier: Modifier = Modifier,
    viewModel: DeepSeekViewModel = hiltViewModel()
) {
    val words = viewModel.spellingList
    val selectedWords = remember { mutableStateOf(emptySet<String>()) }
    
    val hasChatContent = viewModel.uiState.fullResponse.isNotEmpty() || 
                        viewModel.uiState.currentResponse.isNotEmpty() ||
                        viewModel.uiState.isLoading

    ChatAnimatedLayout(
        hasContent = hasChatContent,
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // 主内容区域 - 单词选择
        Surface(
            modifier = Modifier.fillMaxWidth(),
            shape = MaterialTheme.shapes.medium,
            shadowElevation = 4.dp
        ) {
            // ... 单词选择区域内容 ...
        }
    } {
        // 次要内容区域 - 聊天历史
        ChatHistory(
            history = viewModel.uiState.fullResponse,
            currentResponse = viewModel.uiState.currentResponse,
            isLoading = viewModel.uiState.isLoading,
            modifier = Modifier.fillMaxWidth()
        )
        
        // 底部按钮区域
        Column(
            modifier = Modifier
                .padding(vertical = 2.dp)
                .drawBehind {
                    drawRect(
                        color = Color(0xFF2C7F3E),
                        style = Stroke(
                            width = 1.dp.toPx(),
                            pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 15f), 0f)
                        )
                    )
                }
                .padding(4.dp),
            verticalArrangement = Arrangement.spacedBy(1.dp)
        ) {
            // ... 按钮区域内容 ...
        }
    }
}

设计优势

  1. 复用性:可以在任何需要权重动画布局的地方使用
  2. 可配置性:可以灵活调整权重值和动画参数
  3. 类型安全:通过参数明确表达设计意图
  4. 维护性:动画逻辑集中管理,修改只需调整一处
  5. 可扩展性:可以轻松添加更多区域或配置选项

参考效果:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐