本课以真实 App 中常见的界面为案例,边拆设计、边写 Compose、边讲 UI 思路。
每个案例都按“设计目标 → 界面拆解 → 完整代码(逐行注释) → UI 设计解读 → 可改进方向(含参考答案)”展开。
课后共 8 道练习,每道练习后紧跟参考答案(逐行注释)和设计解读。
学完本课,你应能独立完成消息列表项、登录页、个人资料卡、商品卡和待办首页,并理解 Compose 的声明式 UI、状态、布局、Modifier、列表和主题。
一、本课案例地图
本课不先背 API,而是先做界面。你会依次完成:
每个案例都来自真实产品,但代码经过简化,方便你直接运行和改造。
在开始之前,先建立三个 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 布局系统:约束、IntrinsicSize、自定义 Layout、ConstraintLayout、响应式设计和复杂页面拆解。建议你把本课五个案例独立敲一遍,并完成 8 道练习。每做完一个,都问自己三个问题:这个界面的信息层级是什么?状态在哪里?如果屏幕变宽或变窄,它会怎样变化?这三个问题,就是 Compose UI 设计的起点。





