本课目标:掌握 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 的主轴是水平方向,交叉轴是垂直方向

基于这个模型,对齐参数的含义就清晰了:

容器主轴排列交叉轴对齐
ColumnverticalArrangement(Top/Center/Bottom/SpaceBetween/SpaceAround/SpaceEvenly)horizontalAlignment(Start/CenterHorizontally/End)
RowhorizontalArrangement(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课 导航与路由

Logo

一站式 AI 云服务平台

更多推荐