【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
本课目标:掌握 Column/Row/Box 三种基础布局的组合策略与对齐规则,深入理解 LazyColumn 的按需组合机制与性能优化手段,系统学习 Material3 组件库的语义层级与 Slot API 设计,建立“布局即结构,组件即语义”的开发思维。
系列整体规划
| 课次 | 主题 | 核心内容 | 难度 |
|---|---|---|---|
| 第1课 | 从零开始 | 技术概览、环境搭建、第一个应用、代码解读 | ⭐ |
| 第2课 | Compose 基础语法 | @Composable、状态管理、重组机制、Modifier 体系 | ⭐⭐ |
| 第3课 | 布局与组件 | Column/Row/Box、LazyColumn、Material3 组件库 | ⭐⭐ |
| 第4课 | 导航与路由 | Navigation Compose、类型安全路由、深层链接 | ⭐⭐⭐ |
| 第5课 | 网络与数据层 | Ktor 客户端、序列化、Repository 模式 | ⭐⭐⭐ |
| 第6课 | 状态管理与架构 | ViewModel、单向数据流、依赖注入 | ⭐⭐⭐⭐ |
| 第7课 | 平台适配与互操作 | expect/actual、SwiftUI 互操作、平台特定 API | ⭐⭐⭐⭐ |
| 第8课 | 资源管理与主题 | 多平台资源、图片加载、深浅色主题 | ⭐⭐⭐ |
| 第9课 | 测试与调试 | Compose UI 测试、单元测试、性能分析 | ⭐⭐⭐⭐ |
| 第10课 | 发布与部署 | Android/iOS/桌面/Web 打包发布、CI/CD | ⭐⭐⭐⭐⭐ |
第3课 布局与组件
一、布局的基础:Column、Row、Box
1.1 三种基础布局的定位
Compose 的布局系统建立在三个基础容器之上,理解它们的定位是布局能力的第一步:
Column 将子元素垂直排列。这是最常用的布局容器,适用于表单、列表、卡片内容等从上到下的结构。它解决的是“纵向信息组织”问题。
Row 将子元素水平排列。适用于工具栏、按钮组、图标加文字的组合等从左到右的结构。它解决的是“横向空间分配”问题。
Box 将子元素堆叠在一起。后添加的子元素绘制在之前元素的上面,适用于角标、遮罩、叠加文字等场景。它解决的是“层叠定位”问题。
这三个容器都有一个共同特点:它们是内联函数。第2课已经指出,这意味着 Column/Row/Box 本身不形成独立的重组作用域。很多初学者以为“把 UI 拆成 Column 就能限制重组范围”,实际上 Column 是内联的,拆分 Column 并不天然形成独立重组作用域。真正决定重组范围的是状态读取点和编译器生成的分组边界。
1.2 组合策略:嵌套而非堆砌
布局的艺术在于嵌套组合,而非堆砌。一个典型的聊天列表项可以这样组织:
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(Res.drawable.avatar),
contentDescription = null,
modifier = Modifier.size(48.dp).clip(CircleShape),
)
Column(
modifier = Modifier.padding(start = 12.dp).weight(1f),
) {
Text("用户名", style = MaterialTheme.typography.titleMedium)
Text("最后一条消息内容...", style = MaterialTheme.typography.bodySmall)
}
Text("12:30", style = MaterialTheme.typography.labelSmall)
}
这个结构体现了布局嵌套的核心思维:Row 负责水平分割空间,Column 负责垂直组织信息,weight 负责分配剩余空间。weight(1f) 告诉中间的 Column:“占据所有其他元素确定尺寸后的剩余空间。”这是 Compose 布局中最常用的空间分配手段。
1.3 对齐与排列:主轴与交叉轴
三个容器都提供了对齐参数,但语义略有不同。理解这些参数的关键是主轴/交叉轴的概念:
- Column 的主轴是垂直方向,交叉轴是水平方向
- Row 的主轴是水平方向,交叉轴是垂直方向
基于这个模型,对齐参数的含义就清晰了:
| 容器 | 主轴排列 | 交叉轴对齐 |
|---|---|---|
| Column | verticalArrangement(Top/Center/Bottom/SpaceBetween/SpaceAround/SpaceEvenly) | horizontalAlignment(Start/CenterHorizontally/End) |
| Row | horizontalArrangement(Start/Center/End/SpaceBetween/SpaceAround/SpaceEvenly) | verticalAlignment(Top/CenterVertically/Bottom) |
| Box | 无 | contentAlignment(同时控制两个方向) |
Arrangement 的几种模式各有用途:SpaceBetween 让首尾元素贴边、中间均分空隙(适合工具栏);SpaceAround 让每个元素两侧等距(适合导航栏);SpaceEvenly 让所有间距完全相等(适合均匀分布)。
1.4 响应式布局:FlowRow 与 FlowColumn
当容器空间不足以容纳所有子元素时,Column 和 Row 会直接溢出(超出边界的部分不可见)。FlowRow 和 FlowColumn 解决了这个问题:当一行(或一列)放不下时,子元素自动换行到下一行(或下一列)。
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// 标签组:空间不够时自动换行
tags.forEach { tag ->
AssistChip(onClick = {}, label = { Text(tag) })
}
}
FlowRow/FlowColumn 是构建响应式布局的基础工具,特别适合标签云、筛选条件、自适应卡片网格等场景。它们的核心价值在于:布局自适应容器宽度,而非要求容器适配布局。
1.5 BoxWithConstraints:获取约束信息
有时候布局逻辑需要依赖父容器提供的实际约束(比如根据可用宽度决定列数)。BoxWithConstraints 提供了 maxWidth、maxHeight、minWidth、minHeight 等约束信息:
BoxWithConstraints {
val columns = (maxWidth / 120.dp).toInt().coerceAtLeast(2)
// 根据 columns 构建网格
}
BoxWithConstraints 的代价是它会在约束变化时触发重组,因此不适合放置在高频变化的区域。它的典型用途是响应式布局的顶层决策——在页面根部决定整体布局结构。
二、LazyColumn:列表的性能基石
2.1 Lazy 与非 Lazy 的本质区别
Column 配合 verticalScroll() 可以滚动,但它有一个致命的性能问题:所有子元素都会被组合,无论是否可见。一个 1000 项的列表,即使用户只能看到 10 项,1000 个 Composable 都会被创建和维护。
LazyColumn 的核心机制是按需组合:只组合当前可见的项(加上少量预取项),滚动时复用离开视口的组合项。这是处理长列表的唯一正确方式。
理解这个区别有一个直观的类比:Column + verticalScroll 就像把一本书的所有页面都打印出来再装订;LazyColumn 就像电子书阅读器,只渲染当前屏幕显示的内容。
2.2 items 的 key 参数
LazyColumn 的 items() 函数接受一个可选的 key 参数。这是列表性能的关键开关:
LazyColumn {
items(
items = users,
key = { user -> user.id },
) { user ->
UserItem(user)
}
}
不指定 key 时,Compose 按位置索引识别列表项。如果在列表头部插入一个新项,所有项的索引都会偏移,Compose 认为每个位置的“项”都变了,触发全量重组和状态丢失。
指定稳定 key 后,Compose 能精确追踪每个项的身份。插入、删除、移动操作只影响真正变化的项,其他项的组合状态(焦点、滚动位置、动画)得以保留。
key 必须是可序列化到 Bundle 的类型(Int、Long、String 等原始类型),因为配置更改时需要恢复滚动位置。用自定义对象做 key 会导致滚动状态静默丢失——这是 Compose 列表开发中最隐蔽的坑之一。
2.3 items 与 itemsIndexed
items() 遍历集合,itemsIndexed() 额外提供索引:
LazyColumn {
itemsIndexed(items = messages, key = { _, msg -> msg.id }) { index, msg ->
MessageItem(msg, showDivider = index < messages.lastIndex)
}
}
2.4 contentType:多类型列表的优化
当一个列表包含多种不同类型的项时(如“文章卡片”和“广告横幅”交替出现),用 contentType 告诉 Compose 哪些项可以复用组合结构:
LazyColumn {
items(
items = feedItems,
key = { it.id },
contentType = { item ->
when (item) {
is Article -> "article"
is Ad -> "ad"
}
},
) { item ->
when (item) {
is Article -> ArticleCard(item)
is Ad -> AdBanner(item)
}
}
}
没有 contentType 时,Compose 会尝试用同一个组合槽位复用所有类型的项,导致类型不匹配和频繁的组合重建。contentType 告诉运行时:“文章项只能复用文章项的组合结构,广告项只能复用广告项的结构。”
2.5 列表项内部的重组隔离
即使 key 和稳定性都正确,列表项内部的状态管理仍然可能导致性能问题。一个典型模式是:项内有一个展开/收起状态,点击时只重组该状态相关的部分:
@Composable
fun UserItem(user: User) {
var expanded by remember { mutableStateOf(false) }
Column(Modifier.clickable { expanded = !expanded }) {
Text(user.name) // 不依赖 expanded,不重组
if (expanded) {
Text(user.detail) // 依赖 expanded,只重组这部分
}
}
}
更激进的优化是将 expanded 状态下沉到更小的 Composable 中,或者用 derivedStateOf 隔离高频状态的影响。原则是:状态越靠近读取点,重组范围越小。
2.6 列表状态的持久化
LazyColumn 的滚动位置通过 rememberLazyListState() 管理。如果你需要跨配置更改保留滚动位置,应该用 rememberSaveable 配合 LazyListState.Saver:
val listState = rememberLazyListState()
// 或使用 rememberSaveable 持久化
val savedState = rememberSaveable(saver = LazyListState.Saver) {
LazyListState()
}
LazyListState 还提供了 scrollToItem()、animateScrollToItem() 等控制方法,以及 firstVisibleItemIndex、firstVisibleItemScrollOffset 等观察属性,用于实现“回到顶部”、“滚动到指定项”等交互。
三、Material3 组件库
3.1 常用组件速览
Material3 提供了完整的组件体系,按用途可分为四类:
展示类:
Text:文本显示,style参数映射到排版系统Card/ElevatedCard/OutlinedCard:卡片容器,控制阴影和边框样式Icon:图标显示,通常配合Icons.Default.xxx使用Divider:分隔线Badge:徽章标记
交互类:
Button/OutlinedButton/TextButton/FilledTonalButton:四种视觉层级的按钮IconButton:图标按钮,常用于工具栏Checkbox/Switch/RadioButton:状态选择控件Slider/RangeSlider:滑动选择FloatingActionButton:浮动操作按钮
输入类:
TextField/OutlinedTextField:文本输入DropdownMenu+ExposedDropdownMenuBox:下拉选择SearchBar:搜索栏
反馈类:
CircularProgressIndicator/LinearProgressIndicator:进度指示Snackbar+SnackbarHost:轻量提示AlertDialog:模态对话框
3.2 组件的“语义选择”原则
Material3 的组件设计遵循语义层级原则。以按钮为例:
| 组件 | 强调级别 | 典型场景 |
|---|---|---|
Button(填充按钮) | 最高 | 页面的主操作(提交、确认) |
FilledTonalButton(色调按钮) | 中高 | 次要但重要的操作 |
OutlinedButton(轮廓按钮) | 中低 | 备选操作 |
TextButton(文本按钮) | 最低 | 取消、跳过、辅助操作 |
选择组件时,先判断操作的语义权重,再选择对应层级的组件。这不仅保证视觉一致性,也让无障碍功能(屏幕阅读器播报的按钮角色)更加准确。一个页面上通常只有一个“最高强调”按钮,多个高强调按钮会削弱视觉引导。
3.3 Slot API 的设计哲学
许多 Material3 组件使用 Slot API 设计。以 Scaffold 为例:
Scaffold(
topBar = { TopAppBar(title = { Text("标题") }) },
bottomBar = { NavigationBar { /* ... */ } },
floatingActionButton = { FloatingActionButton(onClick = {}) { /* ... */ } },
) { padding ->
// 内容区域
}
topBar、bottomBar、floatingActionButton 都是 slot,按需填充。Slot API 的核心价值是:组件提供结构和行为,调用方提供内容和布局。
这种设计的优势在于:Scaffold 只需要关心“顶部栏应该占据多少高度、内容区域应该留多少 padding”这些结构问题,而不需要知道顶部栏是 TopAppBar 还是自定义搜索栏。调用方获得了最大的灵活性,同时保持了布局骨架的一致性。
ListItem 是另一个典型的 Slot 设计:
ListItem(
headlineContent = { Text("标题") },
supportingContent = { Text("副标题") },
leadingContent = { Icon(Icons.Default.Person, null) },
trailingContent = { Text("12:30") },
)
每个 slot 都是可选的,按需填充,组件自动处理间距和对齐。
3.4 主题与颜色的语义化
Material3 的颜色系统基于语义角色而非具体色值。MaterialTheme.colorScheme.primary、onPrimary、primaryContainer、surface、onSurface 等都是有明确语义的颜色槽位。使用语义颜色的好处是:当主题切换(深浅色模式)或品牌色调整时,所有组件自动适配,无需逐个修改。
颜色语义的对应关系:
primary/onPrimary:主色及其上的文字颜色primaryContainer/onPrimaryContainer:主色的容器变体,用于次级强调surface/onSurface:页面背景色及其上的文字颜色surfaceVariant/onSurfaceVariant:弱化的背景及文字,用于次要信息error/onError:错误状态
规则:永远不要硬编码颜色,始终使用 MaterialTheme.colorScheme.xxx。 这是保证跨平台主题一致性的基础。
四、习题与参考答案
本课习题分为三类:概念理解(1-5 题)、代码实践(6-11 题)、综合设计(12-15 题)。
概念理解
习题 1:布局选择判断
题目:以下场景分别应该使用 Column、Row、Box 还是 FlowRow?
A. 一个登录表单,从上到下依次是用户名输入、密码输入、登录按钮
B. 一个底部导航栏,四个图标水平排列
C. 一条消息的右上角显示未读数字角标
D. 一组标签,空间不够时自动换行
参考答案:A 用 Column,B 用 Row,C 用 Box(角标叠加在消息图标上),D 用 FlowRow。
延伸思考:实际布局几乎不会只用一种容器,而是嵌套组合。比如 A 的登录表单,每个输入框内部可能用 Row 组织“标签 + 输入框”,整体用 Column 纵向排列。
习题 2:weight 的语义
题目:Modifier.weight(1f) 在 Row 中作用于一个子元素时,它做了什么?如果两个子元素都设置了 weight(1f),结果是什么?
参考答案:weight(1f) 表示该子元素占据父容器在分配完所有非 weight 元素后的剩余空间,按权重比例分配。两个子元素都设 weight(1f) 时,剩余空间被均分,两者各占一半。如果一个是 weight(1f) 一个是 weight(2f),则按 1:2 分配。
延伸思考:weight 的本质是“参与剩余空间的分配”,而非“按比例分配总空间”。这意味着非 weight 元素的尺寸优先确定,weight 元素只在剩余空间中竞争。这个区别在调试布局问题时很关键。
习题 3:LazyColumn 的 key 为什么重要
题目:解释不指定 key 时,在列表头部插入一项会发生什么。指定 key 后有什么变化?
参考答案:不指定 key 时,Compose 按位置索引识别项。头部插入后,所有位置的内容都偏移了,Compose 认为每个位置上的“项”都变了,触发全量重组,所有项的内部状态(滚动位置、动画、焦点)丢失。指定稳定 key 后,Compose 能追踪每个项的身份,插入操作只导致新项被组合,其他项的状态保持不变。
延伸思考:key 的选择也影响状态恢复。如果用 user.id 作为 key,列表项内部的 rememberSaveable 状态会跟随 id 迁移;如果用索引作为 key,状态会留在原位置,导致“状态错位”的诡异 bug。
习题 4:contentType 的作用
题目:一个列表交替显示“文章卡片”和“广告横幅”。不设置 contentType 会有什么问题?
参考答案:Compose 的 Lazy 布局会尝试复用离开视口的组合项。如果文章和广告共享相同的组合结构槽位,Compose 可能用广告的组件去复用文章的位置,导致类型不匹配和性能下降。设置 contentType 后,Compose 知道哪些项可以复用,文章项只复用文章项的组合结构,广告项只复用广告项的结构。
习题 5:Slot API 的设计价值
题目:Scaffold 提供了 topBar、bottomBar、floatingActionButton、snackbarHost 等参数。为什么采用 Slot 而不是固定的属性配置?
参考答案:Slot 设计让 Scaffold 只负责结构和间距,具体放什么组件由调用方决定。topBar 可以是 TopAppBar,也可以是自定义的搜索栏;floatingActionButton 可以是简单的 FloatingActionButton,也可以是展开式 FAB 菜单。这种设计在保持布局骨架一致的前提下,提供了最大的灵活性。
代码实践
习题 6:用户卡片布局
题目:实现一个用户卡片:左侧圆形头像,右侧上方用户名、下方个性签名,最右侧一个“关注”按钮。使用 Row + Column 组合。
参考答案:
@Composable
fun UserCard(name: String, bio: String, avatar: Painter) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = avatar,
contentDescription = null,
modifier = Modifier.size(56.dp).clip(CircleShape),
)
Column(
modifier = Modifier.padding(start = 12.dp).weight(1f),
) {
Text(name, style = MaterialTheme.typography.titleMedium)
Text(bio, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
OutlinedButton(onClick = {}) { Text("关注") }
}
}
关键点:weight(1f) 让中间的 Column 占据剩余空间,把“关注”按钮推到最右侧。如果不用 weight,Column 的宽度由内容决定,按钮会紧贴文字。
习题 7:角标实现
题目:用 Box 实现一个消息图标,右上角显示未读数量角标。
参考答案:
@Composable
fun MessageIcon(unreadCount: Int) {
Box {
Icon(Icons.Default.Email, contentDescription = "消息")
if (unreadCount > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(x = 6.dp, y = (-6).dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
.padding(horizontal = 5.dp, vertical = 2.dp),
) {
Text(
text = unreadCount.toString(),
color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
关键点:Box 的 align(Alignment.TopEnd) 让角标定位到右上角,offset 微调位置使其部分溢出主图标边界,形成“角标”效果。
习题 8:带 key 的 LazyColumn
题目:创建一个消息列表,每项显示头像、名称、消息预览、时间。使用稳定 key 优化。
参考答案:
data class Message(val id: Long, val name: String, val preview: String, val time: String)
@Composable
fun MessageList(messages: List<Message>, modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier) {
items(
items = messages,
key = { it.id },
) { msg ->
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(48.dp).background(Color.Gray, CircleShape))
Column(Modifier.padding(start = 12.dp).weight(1f)) {
Text(msg.name, style = MaterialTheme.typography.titleSmall)
Text(msg.preview, maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall)
}
Text(msg.time, style = MaterialTheme.typography.labelSmall)
}
}
}
}
习题 9:FlowRow 标签云
题目:用 FlowRow 实现一组标签,每个标签是一个 AssistChip,空间不够时自动换行。
参考答案:
@Composable
fun TagCloud(tags: List<String>) {
FlowRow(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
tags.forEach { tag ->
AssistChip(
onClick = {},
label = { Text(tag) },
)
}
}
}
关键点:Arrangement.spacedBy(8.dp) 保证标签之间间距一致,FlowRow 自动处理换行。
习题 10:Scaffold 骨架
题目:用 Scaffold 搭建一个页面骨架:顶部 TopAppBar 显示“消息”,底部 NavigationBar 有三个项,中间内容区域放置习题 8 的消息列表。
参考答案:
@Composable
fun MessageScreen(messages: List<Message>) {
Scaffold(
topBar = {
TopAppBar(title = { Text("消息") })
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = true,
onClick = {},
icon = { Icon(Icons.Default.Home, null) },
label = { Text("首页") },
)
NavigationBarItem(
selected = false,
onClick = {},
icon = { Icon(Icons.Default.Email, null) },
label = { Text("消息") },
)
NavigationBarItem(
selected = false,
onClick = {},
icon = { Icon(Icons.Default.Person, null) },
label = { Text("我的") },
)
}
},
) { padding ->
MessageList(messages, modifier = Modifier.padding(padding))
}
}
关键点:Scaffold 的 content lambda 提供 padding 参数,必须应用到内容区域,否则内容会被顶部栏和底部栏遮挡。
习题 11:列表项展开/收起
题目:实现一个可展开的列表项:默认显示标题,点击后展开显示详情,再次点击收起。确保展开状态变化只重组受影响的区域。
参考答案:
@Composable
fun ExpandableItem(title: String, detail: String) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp)
.clickable { expanded = !expanded },
) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
if (expanded) {
Text(detail, modifier = Modifier.padding(top = 8.dp))
}
}
}
}
关键点:expanded 变化时,只有 if (expanded) 内部的 Text 被添加或移除,title 的 Text 不会因为参数变化而重组(title 本身没有变化)。
综合设计
习题 12:联系人列表
题目:实现一个按首字母分组的联系人列表。每个分组有一个吸顶的字母标题,下面是该组的联系人。使用 stickyHeader 实现。
参考答案:
data class Contact(val id: Long, val name: String)
@Composable
fun ContactList(contacts: List<Contact>) {
val grouped = contacts.groupBy { it.name.first().uppercaseChar() }
.toSortedMap()
LazyColumn {
grouped.forEach { (letter, list) ->
stickyHeader(key = "header_$letter") {
Text(
text = letter.toString(),
modifier = Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(8.dp, 4.dp),
style = MaterialTheme.typography.labelMedium,
)
}
items(list, key = { it.id }) { contact ->
Text(contact.name, modifier = Modifier.padding(16.dp, 12.dp))
}
}
}
}
关键点:stickyHeader 的 key 必须唯一且稳定,使用 "header_$letter" 而不是字母本身,避免与联系人 key 冲突。stickyHeader 创建的标题会在滚动时吸顶到顶部,直到下一个分组标题将其推走。
习题 13:多类型 Feed 流
题目:实现一个 Feed 流,包含三种类型:纯文字动态、带图动态、广告。用 contentType 优化。
参考答案:
sealed interface FeedItem {
val id: Long
data class TextPost(override val id: Long, val content: String) : FeedItem
data class ImagePost(override val id: Long, val content: String, val image: String) : FeedItem
data class Ad(override val id: Long, val title: String) : FeedItem
}
@Composable
fun FeedList(items: List<FeedItem>) {
LazyColumn {
items(
items = items,
key = { it.id },
contentType = { item ->
when (item) {
is FeedItem.TextPost -> "text"
is FeedItem.ImagePost -> "image"
is FeedItem.Ad -> "ad"
}
},
) { item ->
when (item) {
is FeedItem.TextPost -> TextPostCard(item)
is FeedItem.ImagePost -> ImagePostCard(item)
is FeedItem.Ad -> AdCard(item)
}
}
}
}
关键点:contentType 的返回值只需在同类项之间一致,跨类型不同即可。通常用字符串标识类型。
习题 14:自适应商品网格
题目:实现一个商品网格,每行根据屏幕宽度自动决定显示 2、3 或 4 个商品。
参考答案:
@Composable
fun ProductGrid(products: List<Product>, minItemWidth: Dp = 120.dp) {
BoxWithConstraints {
val columns = (maxWidth / minItemWidth).toInt().coerceAtLeast(2)
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}
}
}
关键点:BoxWithConstraints 提供了 maxWidth 约束信息,据此计算列数,实现真正的自适应网格。也可以使用 GridCells.Adaptive(minSize) 让 Compose 自动计算列数,但手动计算能提供更精确的控制。
习题 15:完整聊天界面
题目:综合运用本课知识,实现一个聊天界面:顶部 TopAppBar,底部输入栏(OutlinedTextField + IconButton),中间消息列表,消息分为“我”和“对方”两种,靠右/靠左显示,带时间戳。
参考答案:
data class ChatMessage(
val id: Long,
val content: String,
val time: String,
val isMine: Boolean,
)
@Composable
fun ChatScreen(messages: List<ChatMessage>) {
var input by remember { mutableStateOf("") }
Scaffold(
topBar = { TopAppBar(title = { Text("聊天") }) },
bottomBar = {
Row(
modifier = Modifier.fillMaxWidth()
.padding(8.dp)
.imePadding(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("输入消息...") },
)
IconButton(onClick = { /* 发送逻辑 */ }) {
Icon(Icons.Default.Send, contentDescription = "发送")
}
}
},
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
}
}
}
@Composable
fun MessageBubble(msg: ChatMessage) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.isMine) Arrangement.End else Arrangement.Start,
) {
Column(
horizontalAlignment = if (msg.isMine) Alignment.End else Alignment.Start,
modifier = Modifier.widthIn(max = 280.dp),
) {
Card(
colors = if (msg.isMine) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
)
} else CardDefaults.cardColors(),
) {
Text(msg.content, modifier = Modifier.padding(12.dp))
}
Text(
msg.time,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
关键点:
imePadding()让底部输入栏在键盘弹出时自动上移(跨平台兼容)widthIn(max = 280.dp)限制气泡最大宽度,避免长消息占满整行Arrangement.End/Arrangement.Start控制消息左右对齐primaryContainer色用于“我”的消息,视觉上区分双方
五、本课小结
基础布局:Column/Row/Box 是布局的原子。理解主轴/交叉轴、weight 的空间分配、嵌套组合策略,就能构建绝大多数界面结构。FlowRow/FlowColumn 解决溢出换行问题,BoxWithConstraints 提供约束信息用于响应式决策。
LazyColumn:按需组合是处理长列表的唯一正确方式。key 保证项身份追踪,contentType 优化多类型复用,状态隔离控制重组范围。key 必须是可序列化到 Bundle 的类型,否则滚动状态会静默丢失。
Material3:组件的选择应基于语义层级而非视觉偏好。Slot API 提供了结构固定、内容灵活的设计模式。颜色使用语义槽位而非硬编码色值。
性能意识:布局代码的每一行都可能影响重组范围。列表项的 state 应尽量下沉,高频状态用 derivedStateOf 隔离。
六、下一课预告
第4课 导航与路由
更多推荐




所有评论(0)