欢迎光临
我们一直在努力

【用案例学Jetpack ComposeUI设计】第1课 从消息列表到待办首页:用真实案例建立声明式UI思维

本课以真实 App 中常见的界面为案例,边拆设计、边写 Compose、边讲 UI 思路。
每个案例都按“设计目标 → 界面拆解 → 完整代码(逐行注释) → UI 设计解读 → 可改进方向(含参考答案)”展开。
课后共 8 道练习,每道练习后紧跟参考答案(逐行注释)和设计解读。
学完本课,你应能独立完成消息列表项、登录页、个人资料卡、商品卡和待办首页,并理解 Compose 的声明式 UI、状态、布局、Modifier、列表和主题。

一、本课案例地图

本课不先背 API,而是先做界面。你会依次完成:

  • 微信消息列表项:信息层级与列表项设计。
  • 银行 App 登录页:表单、校验与安全感。
  • 社交 App 个人资料卡:视觉中心与关系状态。
  • 美团/京东商品卡片:价格、决策与行动按钮。
  • Todoist 风格待办首页:状态、列表与空状态。
  • 每个案例都来自真实产品,但代码经过简化,方便你直接运行和改造。

    在开始之前,先建立三个 Compose 核心认知。

    1. UI 是状态的函数

    Compose 的核心公式:

    UI = f(State)

    状态变化,界面自动重新计算。你不需要手动找控件、调 setText、改 setVisibility。

    传统 View 写法:

    button.setOnClickListener { // 给按钮设置点击监听
    count++ // 手动修改计数变量
    textView.text = "Count: $count" // 手动找到 TextView 并更新文字
    }

    Compose 写法:

    var count by remember { mutableStateOf(0) } // 声明可观察状态 count,初始值为 0

    Text("Count: $count") // 文字直接读取 count,状态变则文字自动变
    Button(onClick = { count++ }) { // 点击按钮只修改状态
    Text("加一") // 按钮内部文字
    }

    你只管理 count,Compose 负责更新 Text。

    2. 可组合函数描述界面

    任何带 @Composable 的函数都是可组合函数。它不返回 View,而是发射 UI 节点。

    @Composable // 标记这是可组合函数
    fun Greeting(name: String) { // 函数名大驼峰,参数是数据
    Text("Hello $name") // 描述要显示的文字
    }

    可组合函数应该小巧、纯粹、可预览,不要在内部直接做网络请求、数据库读写等副作用。

    3. 重组是自动更新机制

    当状态变化时,Compose 会重新执行读取了该状态的可组合函数,这个过程叫重组。重组不是重建整个界面,而是智能更新变化部分。

    理解这一点后,你就明白:可组合函数可能执行很多次,所以不能依赖它只执行一次。

    案例一:微信消息列表项——信息层级与列表项设计

    1. 设计目标

    模拟微信消息列表中的一项:

    • 左侧圆形头像。
    • 右侧第一行显示昵称和时间。
    • 右侧第二行显示最后一条消息。
    • 消息最多一行,超出省略。
    • 整项可点击。
    • 未读消息有小红点。
    • 使用统一的水平、垂直间距。

    这是列表 UI 最典型的案例。它看起来简单,但包含了信息层级、对齐、间距、溢出、触控区域等设计要点。

    2. 界面拆解

    从布局看:

    • 外层:Row,水平排列头像和内容。
    • 左侧:头像,圆形裁剪。
    • 右侧:Column,纵向排列“昵称+时间”和“消息内容”。
    • 第一行:Row,昵称在左,时间在右。
    • 第二行:Row,消息在左,未读红点在右。

    从信息层级看:

    • 昵称:最重要,加粗,深色。
    • 时间:次要,小字,浅色。
    • 消息:中等,常规,浅灰。
    • 未读红点:强提醒,红色。

    从交互看:

    • 整项可点击。
    • 触控区域要足够大。
    • 文字溢出要省略。

    3. 完整代码(逐行注释)

    @Composable // 声明这是一个可组合函数,Compose 编译器会处理它
    fun WeChatMessageItem( // 函数名使用大驼峰,表示一个 UI 组件
    name: String, // 昵称,外部传入,保证组件可复用
    message: String, // 最后一条消息内容
    time: String, // 时间文本,例如 10:32
    unreadCount: Int, // 未读数量,0 表示没有未读
    modifier: Modifier = Modifier, // 修饰符,允许调用方添加尺寸、间距、点击等
    onClick: () -> Unit = {} // 点击回调,默认空实现,方便预览
    ) { // 函数体开始
    Row( // 横向布局:头像在左,内容在右
    modifier = modifier // 先应用外部传入的修饰符,方便调用方定制
    .fillMaxWidth() // 让整行占满可用宽度
    .clickable { onClick() } // 整行可点击,扩大触控区域
    .padding(horizontal = 16.dp, vertical = 12.dp), // 设置水平 16dp、垂直 12dp 内边距
    verticalAlignment = Alignment.CenterVertically // 子项在垂直方向居中
    ) { // Row 内容开始
    Box( // 用 Box 模拟头像容器
    modifier = Modifier // 修饰符开始
    .size(52.dp) // 头像尺寸 52dp
    .clip(CircleShape) // 裁剪成圆形
    .background(MaterialTheme.colorScheme.primaryContainer), // 背景使用主题色容器色
    contentAlignment = Alignment.Center // 内部文字居中
    ) { // Box 内容开始
    Text( // 显示昵称首字母作为头像占位
    text = name.take(1), // 取第一个字符
    style = MaterialTheme.typography.titleMedium, // 使用中等标题样式
    color = MaterialTheme.colorScheme.onPrimaryContainer, // 文字颜色与背景形成对比
    fontWeight = FontWeight.Bold // 加粗,增强识别度
    ) // Text 结束
    } // Box 结束

    Spacer(modifier = Modifier.width(12.dp)) // 头像和右侧内容之间留 12dp 间距

    Column(modifier = Modifier.weight(1f)) { // 右侧内容列,weight 占满剩余宽度
    Row( // 第一行:昵称在左,时间在右
    modifier = Modifier.fillMaxWidth(), // 占满 Column 宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Text( // 昵称文本
    text = name, // 显示昵称
    style = MaterialTheme.typography.titleMedium, // 中等标题样式
    fontWeight = FontWeight.Bold, // 加粗,作为第一视觉层级
    maxLines = 1, // 最多一行
    overflow = TextOverflow.Ellipsis, // 超出显示省略号
    modifier = Modifier.weight(1f) // 占剩余宽度,防止把时间挤出屏幕
    ) // Text 结束
    Spacer(modifier = Modifier.width(8.dp)) // 昵称和时间之间留 8dp
    Text( // 时间文本
    text = time, // 显示时间
    style = MaterialTheme.typography.labelSmall, // 小标签样式,弱化视觉
    color = MaterialTheme.colorScheme.outline // 使用轮廓色,进一步弱化
    ) // Text 结束
    } // 第一行 Row 结束

    Spacer(modifier = Modifier.height(4.dp)) // 第一行和第二行之间留 4dp

    Row( // 第二行:消息在左,未读红点在右
    modifier = Modifier.fillMaxWidth(), // 占满 Column 宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Text( // 消息内容
    text = message, // 显示最后一条消息
    style = MaterialTheme.typography.bodyMedium, // 正文中等样式
    color = MaterialTheme.colorScheme.onSurfaceVariant, // 使用较浅颜色,弱于昵称
    maxLines = 1, // 最多一行
    overflow = TextOverflow.Ellipsis, // 超出省略
    modifier = Modifier.weight(1f) // 占剩余宽度,给红点留位置
    ) // Text 结束

    if (unreadCount > 0) { // 只有未读数大于 0 才显示红点
    Spacer(modifier = Modifier.width(8.dp)) // 消息和红点之间留 8dp
    Box( // 红点容器
    modifier = Modifier // 修饰符开始
    .size(18.dp) // 红点尺寸 18dp
    .clip(CircleShape) // 圆形
    .background(MaterialTheme.colorScheme.error), // 使用错误色,醒目
    contentAlignment = Alignment.Center // 内部文字居中
    ) { // Box 内容开始
    Text( // 未读数量文本
    text = if (unreadCount > 99) "99+" else unreadCount.toString(), // 超过 99 显示 99+
    color = MaterialTheme.colorScheme.onError, // 文字颜色与红底对比
    style = MaterialTheme.typography.labelSmall // 小标签样式
    ) // Text 结束
    } // Box 结束
    } // if 结束
    } // 第二行 Row 结束
    } // Column 结束
    } // 外层 Row 结束
    } // 函数结束

    预览:

    @Preview(showBackground = true) // 开启预览,并显示背景
    @Composable // 预览函数也必须是可组合函数
    fun WeChatMessageItemPreview() { // 预览函数名
    MyAppTheme { // 包裹主题,保证颜色样式正确
    WeChatMessageItem( // 调用消息列表项组件
    name = "张三", // 传入昵称
    message = "今晚一起吃饭吗?我发现了公司附近一家很不错的面馆。", // 传入消息
    time = "10:32", // 传入时间
    unreadCount = 3 // 传入未读数
    ) // 调用结束
    } // 主题结束
    } // 预览函数结束

    4. UI 设计解读

    第一,头像尺寸 52dp。
    微信、QQ 的头像通常不会太小,因为头像是识别用户的第一视觉元素。48dp 到 56dp 是移动端列表头像的常见范围。这里用 Box + Text 模拟头像,避免依赖图片资源。圆形通过 clip(CircleShape) 实现。背景使用 primaryContainer,文字使用 onPrimaryContainer,保证深浅色模式下都有足够对比度。

    第二,昵称和时间在同一行。
    昵称使用 Modifier.weight(1f) 占据剩余空间,时间靠右。这样即使昵称很长,时间也不会被挤出屏幕。很多初学者会忘记 weight,导致长昵称把时间顶掉。maxLines = 1 和 overflow = TextOverflow.Ellipsis 保证昵称只显示一行,超出省略。

    第三,消息和未读红点在同一行。
    消息文本使用 weight 占剩余空间,红点固定在右侧。红点尺寸很小,但颜色使用 error,非常醒目。未读数量超过 99 显示“99+”,这是真实 App 常见处理方式。红点内部文字使用 onError,保证在红色背景上清晰可读。

    第四,信息层级非常明确。
    昵称加粗、深色;消息常规、浅灰;时间小字、更浅;红点强提醒。用户扫一眼就能知道:谁发的、发了什么、什么时候、有没有未读。UI 设计不是把所有信息都放大,而是通过字号、字重、颜色建立阅读顺序。

    第五,点击区域是整行。
    Modifier.clickable 放在最外层 Row 上,而不是只放在文字上。移动端触控目标建议至少 48dp,整行点击体验最好。注意 clickable 放在 padding 前面还是后面会影响点击区域,这里放在 padding 前,点击区域包含 padding。

    第六,间距节奏统一。
    水平内边距 16dp,垂直内边距 12dp,头像和内容间距 12dp,昵称和时间间距 8dp。统一间距让列表更透气。真实 App 的列表项不会把所有元素挤在一起,统一间距是 UI 设计的基本功。

    5. 可改进方向(含参考答案)

    可改进点
    • 增加在线状态小绿点。
    • 增加消息类型图标,例如图片、语音。
    • 支持置顶、免打扰。
    • 增加长按弹出菜单。
    • 使用真实头像加载库。
    增强版参考答案

    // 消息类型枚举:文本、图片、语音
    enum class MessageType { Text, Image, Voice }

    @OptIn(ExperimentalFoundationApi::class) // combinedClickable 需要此注解
    @Composable // 可组合函数
    fun WeChatMessageItemEnhanced( // 增强版消息项
    name: String, // 昵称
    message: String, // 消息内容
    time: String, // 时间
    unreadCount: Int, // 未读数
    isOnline: Boolean = false, // 是否在线
    isPinned: Boolean = false, // 是否置顶
    isMuted: Boolean = false, // 是否免打扰
    messageType: MessageType = MessageType.Text, // 消息类型
    onClick: () -> Unit = {}, // 点击回调
    onLongClick: () -> Unit = {} // 长按回调
    ) {
    var menuExpanded by remember { mutableStateOf(false) } // 控制长按菜单显示

    Box(modifier = Modifier.fillMaxWidth()) { // 外层 Box,用于叠加菜单
    Row( // 横向布局
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .combinedClickable( // 同时支持点击和长按
    onClick = { onClick() }, // 单击回调
    onLongClick = { // 长按回调
    menuExpanded = true // 显示菜单
    onLongClick() // 通知外部
    }
    )
    .background( // 背景色
    if (isPinned) { // 如果置顶
    MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) // 使用浅色背景突出
    } else { // 未置顶
    Color.Transparent // 透明背景
    }
    )
    .padding(horizontal = 16.dp, vertical = 12.dp), // 内边距
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Box { // 头像外层 Box,用于叠加在线小绿点
    Box( // 头像本体
    modifier = Modifier
    .size(52.dp) // 头像尺寸
    .clip(CircleShape) // 圆形裁剪
    .background(MaterialTheme.colorScheme.primaryContainer), // 背景色
    contentAlignment = Alignment.Center // 内容居中
    ) {
    Text( // 头像占位文字
    text = name.take(1), // 取昵称首字
    style = MaterialTheme.typography.titleMedium, // 中等标题样式
    color = MaterialTheme.colorScheme.onPrimaryContainer, // 对比色
    fontWeight = FontWeight.Bold // 加粗
    )
    }

    if (isOnline) { // 如果在线
    Box( // 在线小绿点
    modifier = Modifier
    .size(14.dp) // 小绿点尺寸
    .clip(CircleShape) // 圆形
    .background(Color(0xFF4CAF50)) // 绿色
    .border(2.dp, MaterialTheme.colorScheme.surface, CircleShape) // 白色描边,与头像分隔
    .align(Alignment.BottomEnd) // 放在头像右下角
    )
    }
    }

    Spacer(modifier = Modifier.width(12.dp)) // 头像和内容间距

    Column(modifier = Modifier.weight(1f)) { // 右侧内容列
    Row( // 第一行:昵称、置顶、免打扰、时间
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    if (isPinned) { // 如果置顶
    Icon( // 置顶图标
    imageVector = Icons.Default.PushPin, // 图钉图标,需 material-icons-extended
    contentDescription = "置顶", // 无障碍描述
    modifier = Modifier.size(14.dp), // 图标尺寸
    tint = MaterialTheme.colorScheme.primary // 主题色
    )
    Spacer(modifier = Modifier.width(4.dp)) // 图标和昵称间距
    }

    Text( // 昵称
    text = name, // 昵称文字
    style = MaterialTheme.typography.titleMedium, // 中等标题
    fontWeight = FontWeight.Bold, // 加粗
    maxLines = 1, // 最多一行
    overflow = TextOverflow.Ellipsis, // 超出省略
    modifier = Modifier.weight(1f) // 占剩余宽度
    )

    if (isMuted) { // 如果免打扰
    Icon( // 免打扰图标
    imageVector = Icons.Default.NotificationsOff, // 静音图标,需 material-icons-extended
    contentDescription = "免打扰", // 无障碍描述
    modifier = Modifier.size(14.dp), // 图标尺寸
    tint = MaterialTheme.colorScheme.outline // 浅色
    )
    Spacer(modifier = Modifier.width(4.dp)) // 图标和时间间距
    }

    Text( // 时间
    text = time, // 时间文字
    style = MaterialTheme.typography.labelSmall, // 小标签样式
    color = MaterialTheme.colorScheme.outline // 浅色
    )
    }

    Spacer(modifier = Modifier.height(4.dp)) // 第一行和第二行间距

    Row( // 第二行:消息类型、消息、未读红点
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Icon( // 消息类型图标
    imageVector = when (messageType) { // 根据类型选择图标
    MessageType.Text -> Icons.Default.Chat // 文本消息
    MessageType.Image -> Icons.Default.Image // 图片消息
    MessageType.Voice -> Icons.Default.Mic // 语音消息
    },
    contentDescription = when (messageType) { // 无障碍描述
    MessageType.Text -> "文本消息"
    MessageType.Image -> "图片消息"
    MessageType.Voice -> "语音消息"
    },
    modifier = Modifier.size(14.dp), // 图标尺寸
    tint = MaterialTheme.colorScheme.outline // 浅色
    )

    Spacer(modifier = Modifier.width(4.dp)) // 图标和消息间距

    Text( // 消息内容
    text = message, // 消息文字
    style = MaterialTheme.typography.bodyMedium, // 正文样式
    color = MaterialTheme.colorScheme.onSurfaceVariant, // 浅色
    maxLines = 1, // 最多一行
    overflow = TextOverflow.Ellipsis, // 超出省略
    modifier = Modifier.weight(1f) // 占剩余宽度
    )

    if (unreadCount > 0) { // 如果有未读
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Box( // 未读红点
    modifier = Modifier
    .size(18.dp) // 红点尺寸
    .clip(CircleShape) // 圆形
    .background(MaterialTheme.colorScheme.error), // 错误色
    contentAlignment = Alignment.Center // 内容居中
    ) {
    Text( // 未读数字
    text = if (unreadCount > 99) "99+" else unreadCount.toString(), // 超过 99 显示 99+
    color = MaterialTheme.colorScheme.onError, // 对比色
    style = MaterialTheme.typography.labelSmall // 小标签
    )
    }
    }
    }
    }
    }

    DropdownMenu( // 长按弹出菜单
    expanded = menuExpanded, // 是否展开
    onDismissRequest = { menuExpanded = false } // 点击外部关闭
    ) {
    DropdownMenuItem( // 菜单项:标为已读
    text = { Text("标为已读") }, // 菜单文字
    onClick = { menuExpanded = false } // 点击后关闭
    )
    DropdownMenuItem( // 菜单项:置顶
    text = { Text(if (isPinned) "取消置顶" else "置顶") }, // 动态文字
    onClick = { menuExpanded = false } // 点击后关闭
    )
    DropdownMenuItem( // 菜单项:删除
    text = { Text("删除") }, // 菜单文字
    onClick = { menuExpanded = false } // 点击后关闭
    )
    }
    }
    }

    改进解读

    在线状态小绿点用 Box 叠加在头像右下角,并加一圈背景色描边,避免和头像颜色混在一起。消息类型图标放在消息文字前面,让用户不用读文字就知道是图片、语音还是文本。置顶项使用浅色背景,并在昵称前加图钉图标,双重提示。免打扰用静音图标,放在时间前面,不抢主视觉。长按菜单使用 combinedClickable 和 DropdownMenu,这是列表项常见交互。真实头像可接入 Coil 的 AsyncImage,把头像 Box 替换为图片组件即可。

    案例二:银行 App 登录页——表单、校验与安全感

    1. 设计目标

    模拟银行或电商 App 的登录页:

    • 顶部欢迎语。
    • 用户名输入框。
    • 密码输入框,可切换可见性。
    • 登录按钮。
    • 输入为空时按钮禁用。
    • 密码少于 6 位显示错误。
    • 整体垂直居中,简洁、可信。

    登录页是用户进入 App 的第一道门。UI 设计要传达:安全、清晰、少干扰。

    2. 界面拆解

    • 外层:Column,垂直居中。
    • 标题:欢迎登录。
    • 输入区:两个 OutlinedTextField。
    • 密码框:带 trailingIcon 眼睛图标。
    • 错误提示:放在密码框下方。
    • 按钮:全宽 Button。

    状态:

    • username
    • password
    • passwordVisible
    • error

    3. 完整代码(逐行注释)

    @Composable // 声明可组合函数
    fun BankLoginScreen( // 登录页组件
    modifier: Modifier = Modifier // 允许外部定制样式
    ) { // 函数体开始
    var username by rememberSaveable { mutableStateOf("") } // 用户名状态,配置变更后保留
    var password by rememberSaveable { mutableStateOf("") } // 密码状态,配置变更后保留
    var passwordVisible by rememberSaveable { mutableStateOf(false) } // 密码是否可见
    var error by rememberSaveable { mutableStateOf<String?>(null) } // 错误提示,null 表示无错误

    val canSubmit = username.isNotBlank() && password.length >= 6 // 计算能否提交

    Column( // 纵向布局
    modifier = modifier // 应用外部修饰符
    .fillMaxSize() // 占满整个屏幕
    .padding(24.dp), // 四周 24dp 内边距
    verticalArrangement = Arrangement.Center, // 垂直居中
    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) { // Column 内容开始
    Text( // 欢迎标题
    text = "欢迎登录", // 标题文字
    style = MaterialTheme.typography.headlineMedium, // 大标题样式
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束

    Spacer(modifier = Modifier.height(8.dp)) // 标题和说明之间 8dp

    Text( // 说明文字
    text = "请使用您的账号继续", // 说明内容
    style = MaterialTheme.typography.bodyMedium, // 正文样式
    color = MaterialTheme.colorScheme.onSurfaceVariant // 较浅颜色
    ) // Text 结束

    Spacer(modifier = Modifier.height(28.dp)) // 说明和输入框之间 28dp

    OutlinedTextField( // 用户名输入框
    value = username, // 当前值来自状态
    onValueChange = { // 输入变化回调
    username = it // 更新用户名状态
    error = null // 清空错误
    }, // 回调结束
    label = { Text("用户名 / 手机号") }, // 输入框标签
    singleLine = true, // 只允许单行
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) // 用户名输入框结束

    Spacer(modifier = Modifier.height(12.dp)) // 两个输入框之间 12dp

    OutlinedTextField( // 密码输入框
    value = password, // 当前密码
    onValueChange = { // 密码变化回调
    password = it // 更新密码状态
    error = if (it.length in 1..5) "密码至少 6 位" else null // 实时校验
    }, // 回调结束
    label = { Text("密码") }, // 标签
    singleLine = true, // 单行
    isError = error != null, // 有错误时输入框变红
    visualTransformation = if (passwordVisible) { // 根据可见性选择转换方式
    VisualTransformation.None // 可见:不转换
    } else { // 不可见
    PasswordVisualTransformation() // 隐藏为圆点
    }, // 转换结束
    trailingIcon = { // 尾部图标
    IconButton(onClick = { passwordVisible = !passwordVisible }) { // 点击切换可见性
    Icon( // 图标
    imageVector = if (passwordVisible) { // 根据状态选图标
    Icons.Default.VisibilityOff // 可见时显示“隐藏”
    } else { // 不可见时
    Icons.Default.Visibility // 显示“显示”
    }, // 图标选择结束
    contentDescription = if (passwordVisible) "隐藏密码" else "显示密码" // 无障碍描述
    ) // Icon 结束
    } // IconButton 结束
    }, // trailingIcon 结束
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) // 密码输入框结束

    if (error != null) { // 有错误时
    Spacer(modifier = Modifier.height(6.dp)) // 错误提示上方 6dp
    Text( // 错误文字
    text = error!!, // 显示错误内容
    color = MaterialTheme.colorScheme.error, // 错误色
    style = MaterialTheme.typography.bodySmall, // 小号正文
    modifier = Modifier.align(Alignment.Start) // 左对齐
    ) // Text 结束
    } // if 结束

    Spacer(modifier = Modifier.height(24.dp)) // 输入区和按钮之间 24dp

    Button( // 登录按钮
    onClick = { // 点击逻辑
    if (username.isBlank()) { // 用户名为空
    error = "请输入用户名" // 设置错误
    } else if (password.length < 6) { // 密码太短
    error = "密码至少 6 位" // 设置错误
    } else { // 校验通过
    error = null // 清空错误
    // 执行登录
    } // 条件结束
    }, // 点击逻辑结束
    enabled = canSubmit, // 能否点击由状态决定
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) { // 按钮内容
    Text("登录") // 按钮文字
    } // 按钮结束

    Spacer(modifier = Modifier.height(12.dp)) // 按钮和辅助链接之间 12dp

    TextButton(onClick = { }) { // 忘记密码按钮
    Text("忘记密码?") // 按钮文字
    } // TextButton 结束
    } // Column 结束
    } // 函数结束

    4. UI 设计解读

    第一,登录页要减少干扰。
    银行 App 登录页不会放太多装饰。标题、说明、两个输入框、一个按钮、一个辅助链接,足够了。用户目标明确:快速登录。任何多余元素都会分散注意力。

    第二,垂直居中带来稳定感。
    内容不多时,垂直居中让视觉重心稳定。如果表单很长,应改为顶部对齐并支持滚动,否则键盘弹出会遮挡。登录页通常内容少,居中是最稳妥的选择。

    第三,输入框使用 OutlinedTextField。
    轮廓输入框边界清晰,适合正式、安全的场景。label 在聚焦时上浮,既节省空间又保持说明。密码框使用 PasswordVisualTransformation 隐藏输入内容。

    第四,按钮禁用与错误提示配合。
    canSubmit 控制按钮是否可点,error 告诉用户为什么不能提交。按钮禁用不是万能的,用户可能不知道为什么禁用,所以错误提示要就近显示。这里错误提示放在密码框下方,左对齐,使用错误色。

    第五,密码可见性切换是标准交互。
    眼睛图标放在 trailingIcon,点击切换。contentDescription 随状态变化,提升无障碍体验。可见时显示“隐藏密码”,不可见时显示“显示密码”。

    第六,错误提示靠近密码框。
    错误信息放在密码框下方,使用 error 色,并且左对齐。UI 设计原则:错误要靠近错误源,告诉用户如何修正。isError = error != null 让输入框边框变红,视觉上直接关联错误。

    5. 可改进方向(含参考答案)

    可改进点
    • 增加加载状态,登录按钮显示进度条。
    • 增加键盘 IME 动作:下一步、完成。
    • 使用 ViewModel 管理登录状态。
    • 增加协议勾选。
    • 增加密码强度提示。
    增强版参考答案

    // 登录页 UI 状态
    data class LoginUiState(
    val username: String = "", // 用户名
    val password: String = "", // 密码
    val passwordVisible: Boolean = false, // 密码是否可见
    val agreed: Boolean = false, // 是否同意协议
    val loading: Boolean = false, // 是否正在登录
    val error: String? = null // 错误提示
    ) {
    val canSubmit: Boolean // 计算属性:能否提交
    get() = username.isNotBlank() && // 用户名非空
    password.length >= 6 && // 密码至少 6 位
    agreed && // 已同意协议
    !loading // 不在加载中
    }

    // 登录 ViewModel
    class LoginViewModel : ViewModel() {
    var uiState by mutableStateOf(LoginUiState()) // 可观察状态
    private set // 只允许内部修改

    fun onUsernameChange(value: String) { // 用户名变化
    uiState = uiState.copy(username = value, error = null) // 更新并清空错误
    }

    fun onPasswordChange(value: String) { // 密码变化
    uiState = uiState.copy(
    password = value,
    error = if (value.length in 1..5) "密码至少 6 位" else null // 实时校验
    )
    }

    fun onPasswordVisibleChange() { // 切换密码可见性
    uiState = uiState.copy(passwordVisible = !uiState.passwordVisible)
    }

    fun onAgreedChange(value: Boolean) { // 协议勾选变化
    uiState = uiState.copy(agreed = value, error = null)
    }

    fun login() { // 登录
    if (!uiState.canSubmit) return // 不能提交则返回
    uiState = uiState.copy(loading = true, error = null) // 进入加载
    viewModelScope.launch { // 启动协程
    delay(1500) // 模拟网络请求
    uiState = uiState.copy(loading = false) // 结束加载
    }
    }
    }

    @Composable // 可组合函数
    fun BankLoginScreenEnhanced( // 增强版登录页
    viewModel: LoginViewModel = viewModel() // 默认注入 ViewModel
    ) {
    val state = viewModel.uiState // 读取状态
    val focusManager = LocalFocusManager.current // 焦点管理器

    Column( // 纵向布局
    modifier = Modifier
    .fillMaxSize() // 占满屏幕
    .padding(24.dp), // 内边距
    verticalArrangement = Arrangement.Center, // 垂直居中
    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
    Text( // 标题
    text = "欢迎登录", // 文字
    style = MaterialTheme.typography.headlineMedium, // 大标题
    fontWeight = FontWeight.Bold // 加粗
    )

    Spacer(modifier = Modifier.height(28.dp)) // 间距

    OutlinedTextField( // 用户名输入框
    value = state.username, // 状态值
    onValueChange = viewModel::onUsernameChange, // 变化回调
    label = { Text("用户名 / 手机号") }, // 标签
    singleLine = true, // 单行
    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), // 键盘下一步
    keyboardActions = KeyboardActions( // 键盘动作
    onNext = { focusManager.moveFocus(FocusDirection.Down) } // 焦点下移
    ),
    modifier = Modifier.fillMaxWidth() // 占满宽度
    )

    Spacer(modifier = Modifier.height(12.dp)) // 间距

    OutlinedTextField( // 密码输入框
    value = state.password, // 状态值
    onValueChange = viewModel::onPasswordChange, // 变化回调
    label = { Text("密码") }, // 标签
    singleLine = true, // 单行
    isError = state.error != null, // 错误状态
    visualTransformation = if (state.passwordVisible) { // 可见性
    VisualTransformation.None // 可见
    } else {
    PasswordVisualTransformation() // 隐藏
    },
    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), // 键盘完成
    keyboardActions = KeyboardActions( // 键盘动作
    onDone = { // 完成时
    focusManager.clearFocus() // 清除焦点
    viewModel.login() // 尝试登录
    }
    ),
    trailingIcon = { // 尾部图标
    IconButton(onClick = viewModel::onPasswordVisibleChange) { // 切换可见性
    Icon( // 图标
    imageVector = if (state.passwordVisible) { // 根据状态
    Icons.Default.VisibilityOff // 隐藏
    } else {
    Icons.Default.Visibility // 显示
    },
    contentDescription = if (state.passwordVisible) "隐藏密码" else "显示密码" // 无障碍
    )
    }
    },
    modifier = Modifier.fillMaxWidth() // 占满宽度
    )

    if (state.error != null) { // 如果有错误
    Spacer(modifier = Modifier.height(6.dp)) // 间距
    Text( // 错误文字
    text = state.error, // 错误内容
    color = MaterialTheme.colorScheme.error, // 错误色
    style = MaterialTheme.typography.bodySmall, // 小号
    modifier = Modifier.align(Alignment.Start) // 左对齐
    )
    }

    Spacer(modifier = Modifier.height(12.dp)) // 间距

    Row( // 协议行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Checkbox( // 复选框
    checked = state.agreed, // 状态
    onCheckedChange = viewModel::onAgreedChange // 变化回调
    )
    Text( // 协议文字
    text = "我已阅读并同意用户协议", // 文字
    style = MaterialTheme.typography.bodySmall // 小号
    )
    }

    Spacer(modifier = Modifier.height(16.dp)) // 间距

    Button( // 登录按钮
    onClick = viewModel::login, // 点击登录
    enabled = state.canSubmit, // 能否点击
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) {
    if (state.loading) { // 加载中
    CircularProgressIndicator( // 进度条
    modifier = Modifier.size(18.dp), // 尺寸
    strokeWidth = 2.dp, // 线宽
    color = MaterialTheme.colorScheme.onPrimary // 颜色
    )
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Text("登录中") // 文字
    } else {
    Text("登录") // 文字
    }
    }
    }
    }

    改进解读

    ViewModel 把用户名、密码、协议、加载、错误集中管理,UI 只负责显示和发送事件。canSubmit 同时考虑用户名、密码、协议和加载状态,避免无效提交。键盘 ImeAction.Next 和 Done 提升表单填写效率,focusManager 控制焦点移动。协议勾选是金融类 App 的合规要求。加载状态显示进度条并禁用按钮,防止重复提交。密码强度提示目前只校验长度,实际项目可以按大小写、数字、符号计算强度并显示颜色条。

    案例三:社交 App 个人资料卡——视觉中心与关系状态

    1. 设计目标

    模拟微博、Instagram 的个人资料卡:

    • 圆形大头像。
    • 姓名加粗。
    • 个人签名次要。
    • 关注按钮可切换“关注/已关注”。
    • 数据统计:关注、粉丝、动态。
    • 整体居中,视觉重心在头像。

    2. 界面拆解

    • 外层:Card。
    • 内部:Column,水平居中。
    • 头像:Box 圆形。
    • 姓名:titleLarge + Bold。
    • 签名:bodyMedium + 浅色。
    • 统计:Row,三个数字。
    • 按钮:切换状态。

    3. 完整代码(逐行注释)

    @Composable // 可组合函数
    fun ProfileCard( // 个人资料卡组件
    name: String, // 姓名
    bio: String, // 签名
    following: Int, // 关注数
    followers: Int, // 粉丝数
    posts: Int, // 动态数
    modifier: Modifier = Modifier // 外部修饰符
    ) { // 函数体开始
    var followed by rememberSaveable { mutableStateOf(false) } // 是否已关注状态

    Card( // 卡片容器
    modifier = modifier // 应用外部修饰符
    .fillMaxWidth() // 占满宽度
    .padding(16.dp), // 外边距 16dp
    shape = RoundedCornerShape(20.dp), // 圆角 20dp
    elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) // 阴影 4dp
    ) { // Card 内容开始
    Column( // 纵向布局
    modifier = Modifier // 修饰符
    .fillMaxWidth() // 占满宽度
    .padding(24.dp), // 内边距 24dp
    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) { // Column 内容开始
    Box( // 头像容器
    modifier = Modifier // 修饰符
    .size(84.dp) // 头像 84dp
    .clip(CircleShape) // 圆形
    .background(MaterialTheme.colorScheme.primaryContainer), // 背景色
    contentAlignment = Alignment.Center // 内容居中
    ) { // Box 内容开始
    Text( // 头像文字
    text = name.take(1), // 取姓名首字
    style = MaterialTheme.typography.headlineLarge, // 大标题样式
    color = MaterialTheme.colorScheme.onPrimaryContainer, // 对比色
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束
    } // Box 结束

    Spacer(modifier = Modifier.height(14.dp)) // 头像和姓名间距

    Text( // 姓名
    text = name, // 姓名文字
    style = MaterialTheme.typography.titleLarge, // 大标题样式
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束

    Spacer(modifier = Modifier.height(6.dp)) // 姓名和签名间距

    Text( // 签名
    text = bio, // 签名文字
    style = MaterialTheme.typography.bodyMedium, // 正文样式
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束

    Spacer(modifier = Modifier.height(18.dp)) // 签名和统计间距

    Row( // 统计行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    horizontalArrangement = Arrangement.SpaceEvenly // 均匀分布
    ) { // Row 内容开始
    StatItem("关注", following) // 关注统计
    StatItem("粉丝", followers) // 粉丝统计
    StatItem("动态", posts) // 动态统计
    } // Row 结束

    Spacer(modifier = Modifier.height(18.dp)) // 统计和按钮间距

    Button( // 关注按钮
    onClick = { followed = !followed }, // 点击切换状态
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) { // 按钮内容
    Text(if (followed) "已关注" else "关注") // 根据状态显示文字
    } // Button 结束
    } // Column 结束
    } // Card 结束
    } // 函数结束

    @Composable // 可组合函数
    fun StatItem(label: String, value: Int) { // 统计项小组件
    Column(horizontalAlignment = Alignment.CenterHorizontally) { // 纵向居中
    Text( // 数字
    text = value.toString(), // 数字转字符串
    style = MaterialTheme.typography.titleMedium, // 中等标题
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束
    Text( // 标签
    text = label, // 标签文字
    style = MaterialTheme.typography.bodySmall, // 小号正文
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束
    } // Column 结束
    } // 函数结束

    4. UI 设计解读

    第一,头像尺寸 84dp。
    个人资料卡的头像是视觉中心,通常比列表头像大。84dp 到 96dp 能形成足够强的视觉焦点。头像使用圆形裁剪,符合社交产品习惯。

    第二,所有内容水平居中。
    个人资料卡是“展示型”界面,不是“操作型”界面。居中让用户感觉正式、对称、稳定。姓名、签名、统计、按钮都围绕中轴线排列。

    第三,统计区使用 SpaceEvenly。
    三个统计项均匀分布,视觉平衡。每个统计项内部上下排列数字和标签,数字加粗,标签浅色。这样用户先看到数字,再看懂含义。

    第四,关注按钮全宽。
    全宽按钮在移动端非常容易点击,也强化了主要操作。按钮文本随状态变化,让用户知道当前关系。已关注状态通常可以改用 OutlinedButton 或灰色按钮,避免误操作。

    第五,状态使用 rememberSaveable。
    关注状态是用户操作结果,旋转屏幕后不应丢失。rememberSaveable 比 remember 更适合。对于简单 UI 状态,这样处理足够;复杂业务应放入 ViewModel。

    5. 可改进方向(含参考答案)

    可改进点
    • 已关注状态使用 OutlinedButton。
    • 增加认证徽章。
    • 增加背景封面图。
    • 增加更多操作,如私信、分享。
    • 增加点击统计项跳转。
    增强版参考答案

    @Composable // 可组合函数
    fun ProfileCardEnhanced( // 增强版个人资料卡
    name: String, // 姓名
    bio: String, // 签名
    following: Int, // 关注数
    followers: Int, // 粉丝数
    posts: Int, // 动态数
    verified: Boolean = true, // 是否认证
    onStatClick: (String) -> Unit = {}, // 统计项点击
    onMessageClick: () -> Unit = {}, // 私信点击
    onShareClick: () -> Unit = {} // 分享点击
    ) {
    var followed by rememberSaveable { mutableStateOf(false) } // 关注状态

    Card( // 卡片
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .padding(16.dp), // 外边距
    shape = RoundedCornerShape(20.dp), // 圆角
    elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) // 阴影
    ) {
    Column( // 纵向布局
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) {
    Box( // 封面区域
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .height(120.dp) // 高度
    .background(MaterialTheme.colorScheme.secondaryContainer) // 背景占位
    ) {
    Text( // 封面占位文字
    text = "封面", // 文字
    modifier = Modifier.align(Alignment.Center), // 居中
    color = MaterialTheme.colorScheme.onSecondaryContainer // 对比色
    )
    }

    Box( // 头像外层,用于上移叠加
    modifier = Modifier
    .offset(y = (–42).dp) // 向上偏移,压在封面上
    .size(84.dp) // 头像尺寸
    .clip(CircleShape) // 圆形
    .background(MaterialTheme.colorScheme.primaryContainer) // 背景
    .border(4.dp, MaterialTheme.colorScheme.surface, CircleShape), // 描边
    contentAlignment = Alignment.Center // 内容居中
    ) {
    Text( // 头像文字
    text = name.take(1), // 首字
    style = MaterialTheme.typography.headlineLarge, // 大标题
    color = MaterialTheme.colorScheme.onPrimaryContainer, // 对比色
    fontWeight = FontWeight.Bold // 加粗
    )

    if (verified) { // 如果认证
    Icon( // 认证徽章
    imageVector = Icons.Default.CheckCircle, // 对勾圆
    contentDescription = "已认证", // 无障碍
    tint = MaterialTheme.colorScheme.primary, // 主题色
    modifier = Modifier
    .size(22.dp) // 尺寸
    .align(Alignment.BottomEnd) // 右下角
    .background(MaterialTheme.colorScheme.surface, CircleShape) // 背景圆
    )
    }
    }

    Text( // 姓名
    text = name, // 文字
    style = MaterialTheme.typography.titleLarge, // 大标题
    fontWeight = FontWeight.Bold, // 加粗
    modifier = Modifier.offset(y = (–28).dp) // 上移,靠近头像
    )

    Text( // 签名
    text = bio, // 文字
    style = MaterialTheme.typography.bodyMedium, // 正文
    color = MaterialTheme.colorScheme.onSurfaceVariant, // 浅色
    modifier = Modifier.offset(y = (–20).dp) // 上移
    )

    Row( // 统计行
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .offset(y = (–8).dp), // 微调
    horizontalArrangement = Arrangement.SpaceEvenly // 均匀分布
    ) {
    StatItemEnhanced("关注", following) { onStatClick("following") } // 关注
    StatItemEnhanced("粉丝", followers) { onStatClick("followers") } // 粉丝
    StatItemEnhanced("动态", posts) { onStatClick("posts") } // 动态
    }

    Spacer(modifier = Modifier.height(8.dp)) // 间距

    Row( // 操作按钮行
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .padding(horizontal = 24.dp), // 水平内边距
    horizontalArrangement = Arrangement.spacedBy(8.dp) // 按钮间距
    ) {
    if (followed) { // 已关注
    OutlinedButton( // 轮廓按钮
    onClick = { followed = false }, // 取消关注
    modifier = Modifier.weight(1f) // 占一半
    ) {
    Text("已关注") // 文字
    }
    } else { // 未关注
    Button( // 填充按钮
    onClick = { followed = true }, // 关注
    modifier = Modifier.weight(1f) // 占一半
    ) {
    Text("关注") // 文字
    }
    }

    OutlinedButton( // 私信按钮
    onClick = onMessageClick, // 点击
    modifier = Modifier.weight(1f) // 占一半
    ) {
    Text("私信") // 文字
    }

    IconButton(onClick = onShareClick) { // 分享按钮
    Icon(Icons.Default.Share, contentDescription = "分享") // 分享图标
    }
    }

    Spacer(modifier = Modifier.height(16.dp)) // 底部间距
    }
    }
    }

    @Composable // 可组合函数
    fun StatItemEnhanced( // 可点击统计项
    label: String, // 标签
    value: Int, // 数值
    onClick: () -> Unit // 点击回调
    ) {
    Column( // 纵向布局
    horizontalAlignment = Alignment.CenterHorizontally, // 居中
    modifier = Modifier.clickable { onClick() } // 可点击
    ) {
    Text( // 数值
    text = value.toString(), // 转字符串
    style = MaterialTheme.typography.titleMedium, // 中等标题
    fontWeight = FontWeight.Bold // 加粗
    )
    Text( // 标签
    text = label, // 文字
    style = MaterialTheme.typography.bodySmall, // 小号
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    )
    }
    }

    改进解读

    封面图让个人主页更有辨识度,头像通过 offset 上移压住封面,形成层次。认证徽章放在头像右下角,使用对勾图标和背景圆,避免和头像混在一起。已关注状态改用 OutlinedButton,视觉上比主按钮弱,表示这是可取消的次要操作。私信和分享增加更多操作入口。统计项包在 clickable 中,可跳转到关注列表、粉丝列表。真实项目可把封面和头像换成 AsyncImage。

    案例四:商品卡片——价格、决策与行动按钮

    1. 设计目标

    模拟商品列表中的商品卡片:

    • 左侧商品图占位。
    • 右侧商品标题、描述、价格。
    • 右下角“加入购物车”按钮。
    • 标题最多两行。
    • 价格突出。
    • 列表使用 LazyColumn。

    商品卡片的目标是促成决策:让用户快速看到商品名、价格、行动按钮。

    2. 界面拆解

    • 外层:Card。
    • 内部:Row。
    • 左:图片占位 80dp。
    • 右:Column,标题、描述、价格行。
    • 价格行:价格在左,按钮在右。

    3. 完整代码(逐行注释)

    data class Product( // 商品数据类
    val id: Long, // 唯一 id,用于列表 key
    val name: String, // 商品名
    val description: String, // 商品描述
    val price: String // 价格文本
    ) // 数据类结束

    @Composable // 可组合函数
    fun ProductList(products: List<Product>) { // 商品列表组件
    LazyColumn( // 惰性纵向列表
    contentPadding = PaddingValues(16.dp), // 列表内容内边距
    verticalArrangement = Arrangement.spacedBy(10.dp) // 项之间 10dp
    ) { // LazyColumn 内容开始
    items(products, key = { it.id }) { product -> // 遍历商品,key 用 id
    ProductCard(product) // 每个商品显示一张卡片
    } // items 结束
    } // LazyColumn 结束
    } // 函数结束

    @Composable // 可组合函数
    fun ProductCard(product: Product) { // 商品卡片组件
    Card( // 卡片容器
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    shape = RoundedCornerShape(14.dp), // 圆角 14dp
    elevation = CardDefaults.cardElevation(defaultElevation = 3.dp) // 阴影 3dp
    ) { // Card 内容开始
    Row( // 横向布局
    modifier = Modifier.padding(12.dp), // 内边距 12dp
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Box( // 图片占位
    modifier = Modifier // 修饰符
    .size(80.dp) // 尺寸 80dp
    .clip(RoundedCornerShape(10.dp)) // 圆角 10dp
    .background(MaterialTheme.colorScheme.secondaryContainer), // 背景色
    contentAlignment = Alignment.Center // 内容居中
    ) { // Box 内容开始
    Text("图", style = MaterialTheme.typography.titleLarge) // 占位文字
    } // Box 结束

    Spacer(modifier = Modifier.width(12.dp)) // 图片和右侧内容间距

    Column(modifier = Modifier.weight(1f)) { // 右侧内容列,占剩余宽度
    Text( // 商品标题
    text = product.name, // 标题文字
    style = MaterialTheme.typography.titleMedium, // 中等标题
    fontWeight = FontWeight.Bold, // 加粗
    maxLines = 2, // 最多两行
    overflow = TextOverflow.Ellipsis // 超出省略
    ) // Text 结束

    Spacer(modifier = Modifier.height(4.dp)) // 标题和描述间距

    Text( // 商品描述
    text = product.description, // 描述文字
    style = MaterialTheme.typography.bodySmall, // 小号正文
    color = MaterialTheme.colorScheme.onSurfaceVariant, // 浅色
    maxLines = 1, // 最多一行
    overflow = TextOverflow.Ellipsis // 超出省略
    ) // Text 结束

    Spacer(modifier = Modifier.height(10.dp)) // 描述和价格行间距

    Row( // 价格和按钮行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Text( // 价格
    text = product.price, // 价格文字
    style = MaterialTheme.typography.titleMedium, // 中等标题
    color = MaterialTheme.colorScheme.primary, // 主题色突出
    fontWeight = FontWeight.Bold, // 加粗
    modifier = Modifier.weight(1f) // 占剩余宽度
    ) // Text 结束

    Button( // 加入按钮
    onClick = { }, // 点击逻辑
    contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) // 按钮内边距
    ) { // 按钮内容
    Text("加入") // 按钮文字
    } // Button 结束
    } // Row 结束
    } // Column 结束
    } // Row 结束
    } // Card 结束
    } // 函数结束

    4. UI 设计解读

    第一,图片占位 80dp。
    商品图是决策重要因素,不能太小。80dp 在列表卡片中比较常见。右侧内容用 weight(1f) 占剩余空间。如果图片太大,会压缩文字和按钮;太小又看不清商品。

    第二,标题最多两行。
    商品标题通常较长,限制两行并省略,可以保持卡片高度一致。描述限制一行,进一步控制高度。列表里卡片高度一致,滚动时视觉更稳定。

    第三,价格使用主题色加粗。
    价格是用户最关心的信息之一,使用 primary 色和 Bold,形成视觉焦点。按钮放在价格右侧,形成“看价格—点按钮”的自然路径。

    第四,按钮文本简短。
    “加入”比“加入购物车”更节省空间。小屏幕上,按钮文字太长会挤压价格。如果屏幕更宽,可以显示完整文本。

    第五,LazyColumn 提供 key。
    商品列表可能很长,LazyColumn 只渲染可见项。key = { it.id } 保证增删改时正确复用。没有 key,删除中间项可能导致状态错乱。

    5. 可改进方向(含参考答案)

    可改进点
    • 增加原价、折扣标签。
    • 增加评分、销量。
    • 增加收藏按钮。
    • 增加“已售罄”禁用状态。
    • 增加点击卡片进入详情。
    增强版参考答案

    data class ProductEnhanced( // 增强商品数据
    val id: Long, // 唯一 id
    val name: String, // 商品名
    val description: String, // 描述
    val price: String, // 现价
    val originalPrice: String? = null, // 原价
    val discount: String? = null, // 折扣
    val rating: Float = 4.8f, // 评分
    val sales: Int = 1000, // 销量
    val soldOut: Boolean = false, // 是否售罄
    val favorite: Boolean = false // 是否收藏
    )

    @Composable // 可组合函数
    fun ProductCardEnhanced( // 增强商品卡片
    product: ProductEnhanced, // 商品数据
    onFavoriteClick: () -> Unit = {}, // 收藏回调
    onClick: () -> Unit = {} // 卡片点击
    ) {
    Card( // 卡片
    modifier = Modifier
    .fillMaxWidth() // 占满宽度
    .clickable { onClick() }, // 点击进入详情
    shape = RoundedCornerShape(14.dp), // 圆角
    elevation = CardDefaults.cardElevation(defaultElevation = 3.dp) // 阴影
    ) {
    Row( // 横向布局
    modifier = Modifier.padding(12.dp), // 内边距
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Box( // 图片区域
    modifier = Modifier
    .size(86.dp) // 尺寸
    .clip(RoundedCornerShape(10.dp)) // 圆角
    .background(MaterialTheme.colorScheme.secondaryContainer), // 背景
    contentAlignment = Alignment.Center // 居中
    ) {
    Text("图", style = MaterialTheme.typography.titleLarge) // 占位

    IconButton( // 收藏按钮
    onClick = onFavoriteClick, // 点击收藏
    modifier = Modifier.align(Alignment.TopEnd) // 右上角
    ) {
    Icon( // 收藏图标
    imageVector = if (product.favorite) { // 根据状态
    Icons.Default.Favorite // 已收藏
    } else {
    Icons.Default.FavoriteBorder // 未收藏
    },
    contentDescription = if (product.favorite) "取消收藏" else "收藏", // 无障碍
    tint = if (product.favorite) { // 颜色
    MaterialTheme.colorScheme.error // 红色
    } else {
    MaterialTheme.colorScheme.outline // 浅色
    }
    )
    }
    }

    Spacer(modifier = Modifier.width(12.dp)) // 间距

    Column(modifier = Modifier.weight(1f)) { // 右侧内容
    Text( // 标题
    text = product.name, // 文字
    style = MaterialTheme.typography.titleMedium, // 中等标题
    fontWeight = FontWeight.Bold, // 加粗
    maxLines = 2, // 最多两行
    overflow = TextOverflow.Ellipsis // 省略
    )

    Spacer(modifier = Modifier.height(4.dp)) // 间距

    Text( // 描述
    text = product.description, // 文字
    style = MaterialTheme.typography.bodySmall, // 小号
    color = MaterialTheme.colorScheme.onSurfaceVariant, // 浅色
    maxLines = 1, // 一行
    overflow = TextOverflow.Ellipsis // 省略
    )

    Spacer(modifier = Modifier.height(6.dp)) // 间距

    Row(verticalAlignment = Alignment.CenterVertically) { // 评分销量行
    Text( // 评分
    text = "★ ${product.rating}", // 文字
    style = MaterialTheme.typography.labelSmall, // 小号
    color = MaterialTheme.colorScheme.primary // 主题色
    )
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Text( // 销量
    text = "已售 ${product.sales}", // 文字
    style = MaterialTheme.typography.labelSmall, // 小号
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    )
    }

    Spacer(modifier = Modifier.height(8.dp)) // 间距

    Row( // 价格和按钮行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Column(modifier = Modifier.weight(1f)) { // 价格列
    Row(verticalAlignment = Alignment.CenterVertically) { // 价格行
    Text( // 现价
    text = product.price, // 文字
    style = MaterialTheme.typography.titleMedium, // 中等标题
    color = MaterialTheme.colorScheme.primary, // 主题色
    fontWeight = FontWeight.Bold // 加粗
    )
    if (product.originalPrice != null) { // 如果有原价
    Spacer(modifier = Modifier.width(6.dp)) // 间距
    Text( // 原价
    text = product.originalPrice, // 文字
    style = MaterialTheme.typography.bodySmall, // 小号
    color = MaterialTheme.colorScheme.outline, // 浅色
    textDecoration = TextDecoration.LineThrough // 删除线
    )
    }
    }
    if (product.discount != null) { // 如果有折扣
    Text( // 折扣标签
    text = product.discount, // 文字
    style = MaterialTheme.typography.labelSmall, // 小号
    color = MaterialTheme.colorScheme.error // 错误色
    )
    }
    }

    if (product.soldOut) { // 已售罄
    OutlinedButton( // 轮廓按钮
    onClick = { }, // 无操作
    enabled = false // 禁用
    ) {
    Text("已售罄") // 文字
    }
    } else { // 未售罄
    Button( // 加入按钮
    onClick = { }, // 点击
    contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp) // 内边距
    ) {
    Text("加入") // 文字
    }
    }
    }
    }
    }
    }
    }

    改进解读

    收藏按钮放在图片右上角,使用心形图标,已收藏用红色,未收藏用浅色。原价用删除线,折扣用错误色,形成价格对比。评分和销量放在描述下方,帮助用户快速判断商品口碑。售罄状态使用禁用按钮,避免用户点击后才发现不能购买。整张卡片可点击进入详情,但收藏按钮和加入按钮会消费点击事件,不会误触。

    案例五:待办首页——综合状态、列表与空状态

    1. 设计目标

    模拟 Todoist 或 Things 的待办首页:

    • 顶部标题“我的待办”。
    • 输入框和添加按钮。
    • 待办列表。
    • 每项有复选框、标题、删除按钮。
    • 已完成显示删除线。
    • 空列表显示提示。
    • 使用 LazyColumn。
    • 支持深色模式。

    2. 完整代码(逐行注释)

    data class TodoItem( // 待办数据类
    val id: Long, // 唯一 id
    val title: String, // 待办标题
    val done: Boolean // 是否完成
    ) // 数据类结束

    @Composable // 可组合函数
    fun TodoScreen( // 待办首页
    modifier: Modifier = Modifier // 外部修饰符
    ) { // 函数体开始
    var input by rememberSaveable { mutableStateOf("") } // 输入框状态
    var todos by remember { // 待办列表状态
    mutableStateOf( // 创建可观察状态
    listOf( // 初始列表
    TodoItem(1, "学习 Compose 基础", false), // 第一条待办
    TodoItem(2, "完成第一个界面", true) // 第二条待办
    ) // 列表结束
    ) // 状态结束
    } // todos 状态结束

    Column( // 纵向布局
    modifier = modifier // 应用外部修饰符
    .fillMaxSize() // 占满屏幕
    .padding(16.dp) // 内边距 16dp
    ) { // Column 内容开始
    Text( // 标题
    text = "我的待办", // 标题文字
    style = MaterialTheme.typography.headlineMedium, // 大标题样式
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束

    Spacer(modifier = Modifier.height(16.dp)) // 标题和输入区间距

    Row( // 输入行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    OutlinedTextField( // 输入框
    value = input, // 当前输入值
    onValueChange = { input = it }, // 输入变化更新状态
    label = { Text("输入待办") }, // 标签
    singleLine = true, // 单行
    modifier = Modifier.weight(1f) // 占剩余宽度
    ) // 输入框结束

    Spacer(modifier = Modifier.width(8.dp)) // 输入框和按钮间距

    Button( // 添加按钮
    onClick = { // 点击逻辑
    if (input.isNotBlank()) { // 输入非空
    todos = todos + TodoItem( // 创建新待办并加入列表
    id = System.currentTimeMillis(), // 用时间戳作为 id
    title = input.trim(), // 去除首尾空格
    done = false // 默认未完成
    ) // 新待办结束
    input = "" // 清空输入框
    } // if 结束
    } // 点击逻辑结束
    ) { // 按钮内容
    Text("添加") // 按钮文字
    } // Button 结束
    } // Row 结束

    Spacer(modifier = Modifier.height(16.dp)) // 输入区和列表间距

    if (todos.isEmpty()) { // 空列表
    Box( // 空状态容器
    modifier = Modifier.fillMaxSize(), // 占满剩余空间
    contentAlignment = Alignment.Center // 居中
    ) { // Box 内容开始
    Text( // 空状态文字
    text = "暂无待办,添加一个吧", // 提示文字
    style = MaterialTheme.typography.bodyLarge, // 大号正文
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束
    } // Box 结束
    } else { // 非空列表
    LazyColumn( // 惰性列表
    verticalArrangement = Arrangement.spacedBy(8.dp) // 项之间 8dp
    ) { // LazyColumn 内容开始
    items(todos, key = { it.id }) { item -> // 遍历待办,key 用 id
    TodoRow( // 单行待办
    item = item, // 当前待办
    onCheckedChange = { checked -> // 勾选变化
    todos = todos.map { // 创建新列表
    if (it.id == item.id) it.copy(done = checked) else it // 更新对应项
    } // map 结束
    }, // 勾选回调结束
    onDelete = { // 删除回调
    todos = todos.filterNot { it.id == item.id } // 过滤掉当前项
    } // 删除回调结束
    ) // TodoRow 结束
    } // items 结束
    } // LazyColumn 结束
    } // if 结束
    } // Column 结束
    } // 函数结束

    @Composable // 可组合函数
    fun TodoRow( // 待办单行
    item: TodoItem, // 待办数据
    onCheckedChange: (Boolean) -> Unit, // 勾选回调
    onDelete: () -> Unit // 删除回调
    ) { // 函数体开始
    Card( // 卡片容器
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    shape = RoundedCornerShape(12.dp) // 圆角 12dp
    ) { // Card 内容开始
    Row( // 横向布局
    modifier = Modifier // 修饰符
    .fillMaxWidth() // 占满宽度
    .padding(12.dp), // 内边距 12dp
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Checkbox( // 复选框
    checked = item.done, // 当前完成状态
    onCheckedChange = onCheckedChange // 变化回调
    ) // Checkbox 结束

    Spacer(modifier = Modifier.width(8.dp)) // 复选框和标题间距

    Text( // 待办标题
    text = item.title, // 标题文字
    modifier = Modifier.weight(1f), // 占剩余宽度
    style = MaterialTheme.typography.bodyLarge, // 大号正文
    textDecoration = if (item.done) { // 已完成加删除线
    TextDecoration.LineThrough // 删除线
    } else { // 未完成
    TextDecoration.None // 无装饰
    }, // 装饰结束
    color = if (item.done) { // 已完成颜色
    MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    } else { // 未完成颜色
    MaterialTheme.colorScheme.onSurface // 正常色
    } // 颜色结束
    ) // Text 结束

    IconButton(onClick = onDelete) { // 删除按钮
    Icon( // 删除图标
    imageVector = Icons.Default.Delete, // 删除图标
    contentDescription = "删除" // 无障碍描述
    ) // Icon 结束
    } // IconButton 结束
    } // Row 结束
    } // Card 结束
    } // 函数结束

    3. UI 设计解读

    第一,输入区 Row + weight。
    输入框占剩余空间,按钮固定宽度。不同屏幕宽度下,输入框自适应。singleLine = true 让输入框保持单行,避免高度跳动。

    第二,空状态设计。
    空列表不是“什么都不显示”,而是告诉用户“这里本来有内容,现在没有,你可以添加”。空状态是 UI 设计的重要组成部分。居中显示提示文字,让用户知道下一步可以做什么。

    第三,已完成样式。
    删除线 + 浅色,降低已完成事项的视觉权重,让未完成事项更突出。复选框状态与文字样式同步变化,形成一致反馈。

    第四,LazyColumn + key。
    列表可能很长,LazyColumn 性能更好。key 保证增删改时正确复用。没有 key,删除中间项可能导致状态错乱。

    第五,数据不可变更新。
    map、copy、filterNot 创建新列表,触发重组。Compose 依赖状态变化刷新 UI。不要直接修改原列表,否则 Compose 可能检测不到变化。

    第六,主题自适应。
    颜色全部来自 MaterialTheme.colorScheme,自动支持深色模式。不要在业务代码中硬编码 Color.Black 或 Color.White。

    4. 可改进方向(含参考答案)

    可改进点
    • 状态放入 ViewModel。
    • 增加“全部完成”“清除已完成”。
    • 增加编辑、滑动删除。
    • 增加优先级、截止日期。
    • 增加撤销删除。
    • 本地数据库持久化。
    增强版参考答案

    enum class Priority { Low, Medium, High } // 优先级枚举

    data class TodoItemEnhanced( // 增强待办数据
    val id: Long, // 唯一 id
    val title: String, // 标题
    val done: Boolean = false, // 是否完成
    val priority: Priority = Priority.Medium, // 优先级
    val dueDate: String? = null // 截止日期
    )

    data class TodoUiState( // 待办 UI 状态
    val input: String = "", // 输入框
    val todos: List<TodoItemEnhanced> = emptyList(), // 待办列表
    val editingId: Long? = null, // 正在编辑的 id
    val editingText: String = "" // 编辑中的文字
    )

    class TodoViewModel : ViewModel() { // 待办 ViewModel
    var uiState by mutableStateOf( // 可观察状态
    TodoUiState(
    todos = listOf( // 初始数据
    TodoItemEnhanced(1, "学习 Compose", priority = Priority.High, dueDate = "今天"),
    TodoItemEnhanced(2, "完成第一个界面", done = true, priority = Priority.Medium)
    )
    )
    )
    private set // 私有 set

    fun onInputChange(value: String) { // 输入变化
    uiState = uiState.copy(input = value) // 更新输入
    }

    fun addTodo() { // 添加待办
    val title = uiState.input.trim() // 去空格
    if (title.isEmpty()) return // 空则返回
    uiState = uiState.copy(
    input = "", // 清空输入
    todos = uiState.todos + TodoItemEnhanced( // 新增
    id = System.currentTimeMillis(), // 时间戳 id
    title = title // 标题
    )
    )
    }

    fun toggleTodo(id: Long, done: Boolean) { // 切换完成
    uiState = uiState.copy(
    todos = uiState.todos.map { // 遍历
    if (it.id == id) it.copy(done = done) else it // 更新对应项
    }
    )
    }

    fun deleteTodo(id: Long): TodoItemEnhanced? { // 删除并返回被删项
    val deleted = uiState.todos.find { it.id == id } // 找到被删项
    uiState = uiState.copy(todos = uiState.todos.filterNot { it.id == id }) // 过滤
    return deleted // 返回用于撤销
    }

    fun undoDelete(item: TodoItemEnhanced) { // 撤销删除
    uiState = uiState.copy(todos = uiState.todos + item) // 加回列表
    }

    fun markAllDone() { // 全部完成
    uiState = uiState.copy(todos = uiState.todos.map { it.copy(done = true) }) // 全部标记
    }

    fun clearCompleted() { // 清除已完成
    uiState = uiState.copy(todos = uiState.todos.filterNot { it.done }) // 过滤已完成
    }

    fun startEdit(item: TodoItemEnhanced) { // 开始编辑
    uiState = uiState.copy(editingId = item.id, editingText = item.title) // 记录编辑状态
    }

    fun onEditChange(value: String) { // 编辑文字变化
    uiState = uiState.copy(editingText = value) // 更新
    }

    fun confirmEdit() { // 确认编辑
    val id = uiState.editingId ?: return // 无编辑则返回
    uiState = uiState.copy(
    editingId = null, // 退出编辑
    editingText = "", // 清空编辑文字
    todos = uiState.todos.map { // 更新列表
    if (it.id == id) it.copy(title = uiState.editingText) else it // 修改标题
    }
    )
    }

    fun cancelEdit() { // 取消编辑
    uiState = uiState.copy(editingId = null, editingText = "") // 清空编辑状态
    }
    }

    @Composable // 可组合函数
    fun TodoScreenEnhanced( // 增强待办首页
    viewModel: TodoViewModel = viewModel() // 注入 ViewModel
    ) {
    val state = viewModel.uiState // 读取状态
    val snackbarHostState = remember { SnackbarHostState() } // Snackbar 状态
    val scope = rememberCoroutineScope() // 协程作用域

    Scaffold( // 脚手架
    snackbarHost = { SnackbarHost(snackbarHostState) } // 显示 Snackbar
    ) { padding ->
    Column( // 纵向布局
    modifier = Modifier
    .fillMaxSize() // 占满
    .padding(padding) // 应用脚手架内边距
    .padding(16.dp) // 内容内边距
    ) {
    Text( // 标题
    text = "我的待办", // 文字
    style = MaterialTheme.typography.headlineMedium, // 大标题
    fontWeight = FontWeight.Bold // 加粗
    )

    Spacer(modifier = Modifier.height(12.dp)) // 间距

    Row( // 输入行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    OutlinedTextField( // 输入框
    value = state.input, // 值
    onValueChange = viewModel::onInputChange, // 变化
    label = { Text("输入待办") }, // 标签
    singleLine = true, // 单行
    modifier = Modifier.weight(1f) // 占剩余
    )
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Button(onClick = viewModel::addTodo) { // 添加按钮
    Text("添加") // 文字
    }
    }

    Spacer(modifier = Modifier.height(8.dp)) // 间距

    Row( // 批量操作行
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    horizontalArrangement = Arrangement.spacedBy(8.dp) // 间距
    ) {
    OutlinedButton( // 全部完成
    onClick = viewModel::markAllDone, // 点击
    modifier = Modifier.weight(1f) // 等宽
    ) {
    Text("全部完成") // 文字
    }
    OutlinedButton( // 清除已完成
    onClick = viewModel::clearCompleted, // 点击
    modifier = Modifier.weight(1f) // 等宽
    ) {
    Text("清除已完成") // 文字
    }
    }

    Spacer(modifier = Modifier.height(8.dp)) // 间距

    if (state.todos.isEmpty()) { // 空状态
    Box( // 容器
    modifier = Modifier.fillMaxSize(), // 占满
    contentAlignment = Alignment.Center // 居中
    ) {
    Text( // 文字
    text = "暂无待办,添加一个吧", // 提示
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    )
    }
    } else {
    LazyColumn( // 列表
    verticalArrangement = Arrangement.spacedBy(8.dp) // 间距
    ) {
    items(state.todos, key = { it.id }) { item -> // 遍历
    TodoRowEnhanced( // 增强单行
    item = item, // 数据
    editing = state.editingId == item.id, // 是否编辑
    editingText = state.editingText, // 编辑文字
    onCheckedChange = { viewModel.toggleTodo(item.id, it) }, // 勾选
    onDelete = { // 删除
    val deleted = viewModel.deleteTodo(item.id) // 删除
    if (deleted != null) { // 如果删除了
    scope.launch { // 启动协程
    val result = snackbarHostState.showSnackbar( // 显示 Snackbar
    message = "已删除 ${deleted.title}", // 消息
    actionLabel = "撤销" // 操作
    )
    if (result == SnackbarResult.ActionPerformed) { // 如果点击撤销
    viewModel.undoDelete(deleted) // 恢复
    }
    }
    }
    },
    onStartEdit = { viewModel.startEdit(item) }, // 开始编辑
    onEditChange = viewModel::onEditChange, // 编辑变化
    onConfirmEdit = viewModel::confirmEdit, // 确认编辑
    onCancelEdit = viewModel::cancelEdit // 取消编辑
    )
    }
    }
    }
    }
    }
    }

    @Composable // 可组合函数
    fun TodoRowEnhanced( // 增强单行
    item: TodoItemEnhanced, // 数据
    editing: Boolean, // 是否编辑
    editingText: String, // 编辑文字
    onCheckedChange: (Boolean) -> Unit, // 勾选
    onDelete: () -> Unit, // 删除
    onStartEdit: () -> Unit, // 开始编辑
    onEditChange: (String) -> Unit, // 编辑变化
    onConfirmEdit: () -> Unit, // 确认编辑
    onCancelEdit: () -> Unit // 取消编辑
    ) {
    Card( // 卡片
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    shape = RoundedCornerShape(12.dp) // 圆角
    ) {
    Row( // 横向布局
    modifier = Modifier
    .fillMaxWidth() // 占满
    .padding(12.dp), // 内边距
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) {
    Checkbox( // 复选框
    checked = item.done, // 状态
    onCheckedChange = onCheckedChange // 回调
    )

    Spacer(modifier = Modifier.width(8.dp)) // 间距

    if (editing) { // 编辑模式
    OutlinedTextField( // 编辑框
    value = editingText, // 值
    onValueChange = onEditChange, // 变化
    modifier = Modifier.weight(1f), // 占剩余
    singleLine = true // 单行
    )
    IconButton(onClick = onConfirmEdit) { // 确认
    Icon(Icons.Default.Check, contentDescription = "确认") // 对勾
    }
    IconButton(onClick = onCancelEdit) { // 取消
    Icon(Icons.Default.Close, contentDescription = "取消") // 关闭
    }
    } else { // 非编辑模式
    Column(modifier = Modifier.weight(1f)) { // 文字列
    Text( // 标题
    text = item.title, // 文字
    style = MaterialTheme.typography.bodyLarge, // 大号
    textDecoration = if (item.done) TextDecoration.LineThrough else TextDecoration.None, // 删除线
    color = if (item.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface // 颜色
    )
    Row { // 元信息行
    Text( // 优先级
    text = when (item.priority) { // 根据优先级
    Priority.High -> "高" // 高
    Priority.Medium -> "中" // 中
    Priority.Low -> "低" // 低
    },
    style = MaterialTheme.typography.labelSmall, // 小号
    color = when (item.priority) { // 颜色
    Priority.High -> MaterialTheme.colorScheme.error // 红
    Priority.Medium -> MaterialTheme.colorScheme.primary // 主色
    Priority.Low -> MaterialTheme.colorScheme.outline // 浅色
    }
    )
    if (item.dueDate != null) { // 如果有截止日期
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Text( // 日期
    text = item.dueDate, // 文字
    style = MaterialTheme.typography.labelSmall, // 小号
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    )
    }
    }
    }

    IconButton(onClick = onStartEdit) { // 编辑按钮
    Icon(Icons.Default.Edit, contentDescription = "编辑") // 编辑图标
    }

    IconButton(onClick = onDelete) { // 删除按钮
    Icon(Icons.Default.Delete, contentDescription = "删除") // 删除图标
    }
    }
    }
    }
    }

    改进解读

    ViewModel 集中管理输入、列表、编辑状态、删除撤销。批量操作按钮放在输入区下方,方便全局操作。编辑模式在单行内切换,避免跳转页面。删除后通过 Snackbar 显示撤销,避免误删。优先级用颜色区分,截止日期作为辅助信息。滑动删除可以用 SwipeToDismissBox 替代删除按钮,但要注意提供撤销。本地持久化可以用 Room,把 todos 存入数据库,ViewModel 启动时读取。

    从案例中提炼的 Compose UI 设计原则

  • 信息层级优先。
    标题、正文、辅助信息通过字号、字重、颜色区分。

  • 间距形成节奏。
    统一使用 8dp、12dp、16dp、24dp 等间距,避免随意数值。

  • 触控区域足够大。
    列表项整行可点,按钮至少 48dp 高。

  • 状态驱动 UI。
    不手动找控件,而是修改状态,让 Compose 重组。

  • 列表使用 LazyColumn。
    大量数据不要用 Column + forEach。

  • 空状态、错误状态、加载状态都要设计。
    真实 App 不是只有“正常状态”。

  • 颜色来自主题。
    不要硬编码黑白,否则深色模式会出问题。

  • 组件拆小、可预览、可复用。
    一个可组合函数只做一件事。

  • 课后练习与参考答案

    练习 1:实现一个“设置项”行

    要求:左侧图标,中间标题和副标题,右侧箭头。整行可点击。

    参考答案:

    @Composable // 可组合函数
    fun SettingItem( // 设置项组件
    icon: ImageVector, // 左侧图标
    title: String, // 标题
    subtitle: String, // 副标题
    onClick: () -> Unit // 点击回调
    ) { // 函数体开始
    Row( // 横向布局
    modifier = Modifier // 修饰符
    .fillMaxWidth() // 占满宽度
    .clickable { onClick() } // 整行可点击
    .padding(horizontal = 16.dp, vertical = 14.dp), // 内边距
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    Icon( // 左侧图标
    imageVector = icon, // 图标资源
    contentDescription = null, // 装饰性图标可空
    tint = MaterialTheme.colorScheme.primary // 主题色
    ) // Icon 结束

    Spacer(modifier = Modifier.width(16.dp)) // 图标和文本间距

    Column(modifier = Modifier.weight(1f)) { // 中间文本列
    Text( // 标题
    text = title, // 标题文字
    style = MaterialTheme.typography.bodyLarge, // 大号正文
    fontWeight = FontWeight.Medium // 中等字重
    ) // Text 结束
    Text( // 副标题
    text = subtitle, // 副标题文字
    style = MaterialTheme.typography.bodySmall, // 小号正文
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束
    } // Column 结束

    Icon( // 右侧箭头
    imageVector = Icons.Default.KeyboardArrowRight, // 右箭头图标
    contentDescription = "进入", // 无障碍描述
    tint = MaterialTheme.colorScheme.outline // 浅色
    ) // Icon 结束
    } // Row 结束
    } // 函数结束

    解读:
    设置项是“左图标—中文本—右箭头”的经典结构。图标使用主题色,箭头使用浅色,形成主次。副标题提供补充说明。整行点击符合移动端习惯。weight(1f) 保证标题和副标题占据中间剩余空间,箭头始终靠右。

    练习 2:实现一个“加载中”按钮

    要求:按钮点击后显示 CircularProgressIndicator,2 秒后恢复。

    参考答案:

    @Composable // 可组合函数
    fun LoadingButton() { // 加载按钮组件
    var loading by remember { mutableStateOf(false) } // 加载状态

    LaunchedEffect(loading) { // 监听 loading 的副作用
    if (loading) { // 如果正在加载
    delay(2000) // 延迟 2 秒
    loading = false // 恢复
    } // if 结束
    } // LaunchedEffect 结束

    Button( // 按钮
    onClick = { loading = true }, // 点击后进入加载
    enabled = !loading, // 加载中禁用
    modifier = Modifier.fillMaxWidth() // 占满宽度
    ) { // 按钮内容
    if (loading) { // 加载中
    CircularProgressIndicator( // 进度指示器
    modifier = Modifier.size(18.dp), // 尺寸 18dp
    strokeWidth = 2.dp, // 线宽 2dp
    color = MaterialTheme.colorScheme.onPrimary // 与按钮文字同色
    ) // 进度指示器结束
    Spacer(modifier = Modifier.width(8.dp)) // 进度条和文字间距
    Text("处理中") // 加载文字
    } else { // 非加载
    Text("提交") // 提交文字
    } // if 结束
    } // Button 结束
    } // 函数结束

    解读:
    加载状态是真实 App 必备。按钮禁用防止重复点击,进度条提供反馈。LaunchedEffect 用于副作用,不要在点击回调里直接 delay。enabled = !loading 让按钮在加载时不可点击,避免重复提交。

    练习 3:实现一个“空购物车”界面

    要求:图标、标题、说明、去逛逛按钮,整体居中。

    参考答案:

    @Composable // 可组合函数
    fun EmptyCart() { // 空购物车组件
    Column( // 纵向布局
    modifier = Modifier // 修饰符
    .fillMaxSize() // 占满屏幕
    .padding(32.dp), // 内边距 32dp
    verticalArrangement = Arrangement.Center, // 垂直居中
    horizontalAlignment = Alignment.CenterHorizontally // 水平居中
    ) { // Column 内容开始
    Icon( // 空状态图标
    imageVector = Icons.Default.ShoppingCart, // 购物车图标
    contentDescription = null, // 装饰图标
    modifier = Modifier.size(72.dp), // 尺寸 72dp
    tint = MaterialTheme.colorScheme.outline // 浅色
    ) // Icon 结束
    Spacer(modifier = Modifier.height(16.dp)) // 图标和标题间距
    Text( // 标题
    text = "购物车还是空的", // 标题文字
    style = MaterialTheme.typography.titleLarge, // 大标题
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束
    Spacer(modifier = Modifier.height(8.dp)) // 标题和说明间距
    Text( // 说明
    text = "去挑选一些喜欢的商品吧", // 说明文字
    style = MaterialTheme.typography.bodyMedium, // 正文
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束
    Spacer(modifier = Modifier.height(24.dp)) // 说明和按钮间距
    Button(onClick = { }) { // 按钮
    Text("去逛逛") // 按钮文字
    } // Button 结束
    } // Column 结束
    } // 函数结束

    解读:
    空状态要解释“为什么空”和“下一步做什么”。图标、标题、说明、按钮四件套是常见结构。整体居中让空状态不显得零散,按钮给出明确行动指引。

    练习 4:实现一个“聊天输入栏”

    要求:左侧加号,中间输入框,右侧发送按钮。输入为空时发送禁用。

    参考答案:

    @Composable // 可组合函数
    fun ChatInputBar( // 聊天输入栏
    onSend: (String) -> Unit // 发送回调
    ) { // 函数体开始
    var text by rememberSaveable { mutableStateOf("") } // 输入文字状态

    Row( // 横向布局
    modifier = Modifier // 修饰符
    .fillMaxWidth() // 占满宽度
    .padding(8.dp), // 内边距 8dp
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    IconButton(onClick = { }) { // 加号按钮
    Icon(Icons.Default.Add, contentDescription = "更多") // 加号图标
    } // IconButton 结束

    OutlinedTextField( // 输入框
    value = text, // 当前文字
    onValueChange = { text = it }, // 更新文字
    modifier = Modifier.weight(1f), // 占剩余宽度
    placeholder = { Text("输入消息") }, // 占位提示
    maxLines = 4 // 最多 4 行
    ) // 输入框结束

    Spacer(modifier = Modifier.width(8.dp)) // 输入框和按钮间距

    Button( // 发送按钮
    onClick = { // 点击逻辑
    onSend(text) // 回调发送内容
    text = "" // 清空输入
    }, // 点击逻辑结束
    enabled = text.isNotBlank() // 非空才可点
    ) { // 按钮内容
    Text("发送") // 按钮文字
    } // Button 结束
    } // Row 结束
    } // 函数结束

    解读:
    聊天输入栏要兼顾输入和操作。输入框用 weight 占剩余空间,发送按钮禁用防止空消息。maxLines = 4 允许输入多行,但不会无限增高。发送后清空输入框,符合聊天习惯。

    练习 5:实现“分类标签”横向滚动

    要求:选中项高亮,使用 LazyRow。

    参考答案:

    @Composable // 可组合函数
    fun CategoryTabs( // 分类标签组件
    categories: List<String>, // 分类列表
    selected: String, // 当前选中项
    onSelected: (String) -> Unit // 选中回调
    ) { // 函数体开始
    LazyRow( // 横向惰性列表
    contentPadding = PaddingValues(horizontal = 16.dp), // 内容水平内边距
    horizontalArrangement = Arrangement.spacedBy(8.dp) // 项之间 8dp
    ) { // LazyRow 内容开始
    items(categories) { category -> // 遍历分类
    val isSelected = category == selected // 是否选中
    Surface( // 表面容器
    shape = RoundedCornerShape(50), // 胶囊圆角
    color = if (isSelected) { // 选中颜色
    MaterialTheme.colorScheme.primary // 主色
    } else { // 未选中颜色
    MaterialTheme.colorScheme.surfaceVariant // 表面变体色
    }, // 颜色结束
    modifier = Modifier.clickable { onSelected(category) } // 点击回调
    ) { // Surface 内容开始
    Text( // 标签文字
    text = category, // 分类名
    modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), // 内边距
    color = if (isSelected) { // 文字颜色
    MaterialTheme.colorScheme.onPrimary // 选中时对比色
    } else { // 未选中
    MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    } // 颜色结束
    ) // Text 结束
    } // Surface 结束
    } // items 结束
    } // LazyRow 结束
    } // 函数结束

    解读:
    分类标签用于筛选。选中项使用主色,未选中使用表面色。胶囊形状通过大圆角实现。LazyRow 保证标签多时可以横向滚动。选中状态通过颜色和文字颜色双重区分。

    练习 6:实现“订单状态”时间线

    要求:三个节点,已完成打勾,当前高亮,未来灰色。

    参考答案:

    @Composable // 可组合函数
    fun OrderTimeline(currentStep: Int) { // 订单时间线
    val steps = listOf("已下单", "已发货", "已送达") // 步骤列表

    Column(modifier = Modifier.padding(16.dp)) { // 纵向布局
    steps.forEachIndexed { index, step -> // 遍历步骤和索引
    Row(verticalAlignment = Alignment.CenterVertically) { // 每行节点
    Box( // 节点圆点
    modifier = Modifier // 修饰符
    .size(24.dp) // 尺寸 24dp
    .clip(CircleShape) // 圆形
    .background( // 背景色
    if (index <= currentStep) { // 已完成或当前
    MaterialTheme.colorScheme.primary // 主色
    } else { // 未来
    MaterialTheme.colorScheme.surfaceVariant // 灰色
    } // 条件结束
    ), // 背景结束
    contentAlignment = Alignment.Center // 内容居中
    ) { // Box 内容开始
    if (index < currentStep) { // 已完成节点
    Icon( // 打勾图标
    Icons.Default.Check, // 对勾
    contentDescription = null, // 装饰
    tint = MaterialTheme.colorScheme.onPrimary, // 对比色
    modifier = Modifier.size(16.dp) // 图标尺寸
    ) // Icon 结束
    } // if 结束
    } // Box 结束

    Spacer(modifier = Modifier.width(12.dp)) // 节点和文字间距

    Text( // 步骤文字
    text = step, // 步骤名
    color = if (index <= currentStep) { // 已完成或当前
    MaterialTheme.colorScheme.onSurface // 正常色
    } else { // 未来
    MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    } // 颜色结束
    ) // Text 结束
    } // Row 结束

    if (index < steps.lastIndex) { // 不是最后一个节点
    Box( // 连接线
    modifier = Modifier // 修饰符
    .padding(start = 11.dp) // 左偏移,与节点中心对齐
    .width(2.dp) // 线宽 2dp
    .height(24.dp) // 线高 24dp
    .background(MaterialTheme.colorScheme.outlineVariant) // 线颜色
    ) // Box 结束
    } // if 结束
    } // forEachIndexed 结束
    } // Column 结束
    } // 函数结束

    解读:
    时间线用节点和连线表达进度。已完成节点用主色,当前节点高亮,未来节点灰色。连接线通过 padding(start = 11.dp) 与节点中心对齐。已完成节点显示对勾,当前节点只高亮,未来节点灰色。

    练习 7:实现“带删除按钮的标签”

    要求:输入标签,点击添加,标签右侧有删除图标。

    参考答案:

    @OptIn(ExperimentalLayoutApi::class) // FlowRow 是实验性 API
    @Composable // 可组合函数
    fun TagEditor() { // 标签编辑器
    var input by rememberSaveable { mutableStateOf("") } // 输入状态
    var tags by remember { mutableStateOf(listOf<String>()) } // 标签列表状态

    Column(modifier = Modifier.padding(16.dp)) { // 纵向布局
    Row { // 输入行
    OutlinedTextField( // 输入框
    value = input, // 当前输入
    onValueChange = { input = it }, // 更新输入
    modifier = Modifier.weight(1f), // 占剩余宽度
    label = { Text("标签") } // 标签
    ) // 输入框结束
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Button( // 添加按钮
    onClick = { // 点击逻辑
    if (input.isNotBlank()) { // 非空
    tags = tags + input.trim() // 添加标签
    input = "" // 清空输入
    } // if 结束
    } // 点击逻辑结束
    ) { // 按钮内容
    Text("添加") // 按钮文字
    } // Button 结束
    } // Row 结束

    Spacer(modifier = Modifier.height(12.dp)) // 输入和标签间距

    FlowRow( // 流式布局,自动换行
    horizontalArrangement = Arrangement.spacedBy(8.dp), // 水平间距
    verticalArrangement = Arrangement.spacedBy(8.dp) // 垂直间距
    ) { // FlowRow 内容开始
    tags.forEach { tag -> // 遍历标签
    AssistChip( // 辅助标签
    onClick = { }, // 点击逻辑
    label = { Text(tag) }, // 标签文字
    trailingIcon = { // 尾部图标
    Icon( // 删除图标
    Icons.Default.Close, // 关闭图标
    contentDescription = "删除", // 无障碍
    modifier = Modifier // 修饰符
    .size(16.dp) // 尺寸 16dp
    .clickable { tags = tags – tag } // 点击删除
    ) // Icon 结束
    } // trailingIcon 结束
    ) // AssistChip 结束
    } // forEach 结束
    } // FlowRow 结束
    } // Column 结束
    } // 函数结束

    解读:
    标签编辑器适合用 FlowRow 自动换行。删除图标放在标签内部,点击删除。注意 tags – tag 会删除所有相同标签,实际项目应使用唯一 id。FlowRow 是实验性 API,需要 @OptIn。

    练习 8:把待办首页拆成多个可组合函数

    要求:拆成 TodoHeader、TodoInput、TodoList、TodoEmpty。

    参考答案:

    @Composable // 可组合函数
    fun TodoHeader() { // 标题组件
    Text( // 标题文字
    text = "我的待办", // 文字内容
    style = MaterialTheme.typography.headlineMedium, // 大标题
    fontWeight = FontWeight.Bold // 加粗
    ) // Text 结束
    } // 函数结束

    @Composable // 可组合函数
    fun TodoInput( // 输入组件
    input: String, // 输入值
    onInputChange: (String) -> Unit, // 输入变化回调
    onAdd: () -> Unit // 添加回调
    ) { // 函数体开始
    Row( // 横向布局
    modifier = Modifier.fillMaxWidth(), // 占满宽度
    verticalAlignment = Alignment.CenterVertically // 垂直居中
    ) { // Row 内容开始
    OutlinedTextField( // 输入框
    value = input, // 当前值
    onValueChange = onInputChange, // 变化回调
    label = { Text("输入待办") }, // 标签
    singleLine = true, // 单行
    modifier = Modifier.weight(1f) // 占剩余宽度
    ) // 输入框结束
    Spacer(modifier = Modifier.width(8.dp)) // 间距
    Button(onClick = onAdd) { // 添加按钮
    Text("添加") // 按钮文字
    } // Button 结束
    } // Row 结束
    } // 函数结束

    @Composable // 可组合函数
    fun TodoList( // 列表组件
    todos: List<TodoItem>, // 待办列表
    onCheckedChange: (TodoItem, Boolean) -> Unit, // 勾选回调
    onDelete: (TodoItem) -> Unit // 删除回调
    ) { // 函数体开始
    LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { // 惰性列表
    items(todos, key = { it.id }) { item -> // 遍历待办
    TodoRow( // 单行待办
    item = item, // 当前项
    onCheckedChange = { onCheckedChange(item, it) }, // 传递勾选
    onDelete = { onDelete(item) } // 传递删除
    ) // TodoRow 结束
    } // items 结束
    } // LazyColumn 结束
    } // 函数结束

    @Composable // 可组合函数
    fun TodoEmpty() { // 空状态组件
    Box( // 容器
    modifier = Modifier.fillMaxSize(), // 占满剩余空间
    contentAlignment = Alignment.Center // 居中
    ) { // Box 内容开始
    Text( // 空状态文字
    text = "暂无待办,添加一个吧", // 提示
    color = MaterialTheme.colorScheme.onSurfaceVariant // 浅色
    ) // Text 结束
    } // Box 结束
    } // 函数结束

    解读:
    拆分的目的是让每个函数只负责一件事。TodoHeader 负责标题,TodoInput 负责输入,TodoList 负责列表,TodoEmpty 负责空状态。这样便于阅读、预览、测试和复用。每个组件都通过参数接收数据和回调,保持纯粹。

    本课总结

    本课用五个真实案例,从微信消息列表项、银行登录页、个人资料卡、商品卡片,到待办首页,完整走了一遍 Compose UI 设计流程。所有示例代码都加了逐行注释,解读也尽量从设计意图、布局原因、状态流向、视觉层级几个角度展开。每个案例的“可改进方向”都给出了增强版参考答案,让你看到真实 App 是如何在基础结构上叠加状态、交互、异常处理和可访问性的。

    你需要记住:

  • Compose 是声明式 UI,UI 是状态的函数。
  • @Composable 函数描述界面,应该小巧、纯粹、可预览。
  • Modifier 决定尺寸、间距、背景、圆角、点击,顺序很重要。
  • Column、Row、Box 是最基础的布局容器。
  • remember 和 mutableStateOf 保存状态,状态变化触发重组。
  • 列表使用 LazyColumn,并提供稳定 key。
  • 主题统一管理颜色、字体和形状,自动支持深色模式。
  • 真实 UI 设计要关注信息层级、间距节奏、触控区域、空状态、错误状态和加载状态。
  • 好的 Compose 代码不是写得多,而是拆得清楚、复用得好、预览得方便。
  • 下一课,我们会继续用真实案例深入 Compose 布局系统:约束、IntrinsicSize、自定义 Layout、ConstraintLayout、响应式设计和复杂页面拆解。建议你把本课五个案例独立敲一遍,并完成 8 道练习。每做完一个,都问自己三个问题:这个界面的信息层级是什么?状态在哪里?如果屏幕变宽或变窄,它会怎样变化?这三个问题,就是 Compose UI 设计的起点。

    赞(0)
    未经允许不得转载:171主机测评 » 【用案例学Jetpack ComposeUI设计】第1课 从消息列表到待办首页:用真实案例建立声明式UI思维
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址