简介:这是一套面向Android开发者的Kotlin Compose列表交互示例代码,重点解决单选与多选场景的实现问题,适合正在学习声明式界面或需要在项目中快速集成列表选择的开发者。内容包括LazyColumn长列表的渲染方式、通过状态集合维护选中项、以及RadioButton与Checkbox的切换逻辑,可直接借鉴到实际项目,也可用于理解Compose的状态驱动界面更新机制。压缩包共44个文件,核心代码集中在11个Kotlin源文件中,另有11个XML资源文件用于界面与主题配置,配合Gradle构建脚本和属性文件,便于直接编译运行,整体约113KB,体量小巧。已有238人学习,作者为wy313622821。通过这套代码,读者可以掌握单选列表的互斥选中管理、多选列表的集合更新技巧,以及列表项复用时的状态保持方法,对构建功能完整且性能良好的Compose列表界面具有参考价值。
1. 单选、多选列表:Compose 里最容易被写崩的交互
在 Jetpack Compose 里写一个带单选、多选的列表,看起来是个入门级需求,实际翻车率极高。很多人第一次写完发现:滑出去再滑回来,选中状态丢了;列表中间插入一条数据,所有勾选错位;点了一行,上一行的选中图标却没取消。这些问题的根源几乎都一样——状态放错了地方,key 选错了对象。
这篇文章要解决的就是「kotlin compose 代码的列表,包括单选,多选」这件事本身:先讲清楚 LazyColumn 的状态归属逻辑,再给出一套可以照着改的单选、多选实现,最后把几个高频坑逐个拆开。适合正在用 Compose 写表单、选择器、批量操作页面的 Android 开发者,也适合从 View 体系刚切过来、对「状态到底该谁持有」还没形成直觉的人。这套方案不依赖任何第三方库,纯 Compose API 就能跑。
2. 列表骨架与状态归属:先决定「选中态」放在哪一层
2.1 为什么是 LazyColumn 而不是 Column
写 Compose 列表,第一步是选容器。数据量小(比如几十条以内)用 Column 没问题,但一旦条目数量不确定,或者后面要接分页加载,LazyColumn 是唯一合理的选择。它只组合当前可见范围内的 item,滑出去的组合项会被回收,和 RecyclerView 的 ViewHolder 复用是同一个思路。
@Composable fun SimpleList(items: List<String>) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(items) { item -> Text( text = item, modifier = Modifier .fillMaxWidth() .padding(12.dp) ) } } }contentPadding控制列表整体与屏幕边缘的间距,verticalArrangement控制条目之间的间隔。这两个参数在列表项很多时比在每个 item 上单独加 padding 更省事,还能避免首尾条目贴边。items(items)是 LazyColumn 的扩展函数,内部会为每个条目生成一个 item 组合;返回值里没有 index,如果后面需要根据位置做操作,要改用itemsIndexed。
2.2 选中状态放 item 内部,是大多数翻车的起点
刚接触 Compose 的人最容易写出这样的代码:在每个 item 的 composable 里用remember { mutableStateOf(false) }存选中状态。运行起来第一次点击确实有效果,但一旦列表滑动,item 被回收重建,状态就丢了。更隐蔽的问题是:如果两个 item 内部各自存了一份状态,它们之间无法互相通知——单选场景下,点第 3 行时第 2 行根本不知道要取消选中。
Compose 的状态管理核心原则是「状态提升」:谁需要这份状态,状态就放在谁的层级。单选、多选列表的选中态是整个列表的公共状态,必须提升到 LazyColumn 的调用方持有,item 只负责展示和触发回调。
@Composable fun ItemRow( text: String, selected: Boolean, onClick: () -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(16.dp) ) { Text(text = text, modifier = Modifier.weight(1f)) // 选中标识由 selected 决定,item 自身不保存任何状态 } }这段代码里,ItemRow是一个纯展示组件:selected是外部传进来的,onClick是外部定义的。它不remember、不mutableStateOf,所有数据流都是单向的。这样做的好处是:不管列表怎么滑动、item 怎么回收重建,显示结果永远和调用方持有的状态一致。接下来单选、多选的实现,都建立在这个基础上。
3. 单选列表:互斥逻辑与回调设计
3.1 最小可用实现:一个 selectedIndex 管住全部
单选的核心诉求是「同时只能选中一个」。用selectedIndex: Int作为唯一状态源就能满足:点击第 N 项时,把状态改成 N,列表重组后只有 index 等于 N 的条目显示选中。
@Composable fun SingleSelectList(options: List<String>) { var selectedIndex by remember { mutableStateOf(-1) } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { itemsIndexed(options) { index, option -> Row( modifier = Modifier .fillMaxWidth() .clickable { selectedIndex = index } .padding(horizontal = 16.dp, vertical = 12.dp) ) { Text( text = option, modifier = Modifier.weight(1f) ) RadioButton( selected = selectedIndex == index, onClick = { selectedIndex = index } ) } } } }selectedIndex初始值设为 -1,表示「未选中任何项」。itemsIndexed提供了 index 参数,点击回调里直接把 index 写入状态。RadioButton的selected判断条件是selectedIndex == index,因为状态只有一个,所以天然互斥——点新的,旧的自动就不满足了。
3.2 参数说明:为什么 RadioButton 的 onClick 可以留空
这里有一个容易困惑的点:RadioButton的onClick参数在 Compose 的 Material 组件里是可空的。我实际写的时候经常只给Row加clickable,RadioButton的onClick传null,让整行的点击统一处理。好处是点击区域变大,不会出现「点文字没反应、必须点小圆点才行」的体验问题。
如果不用 Material 的RadioButton,想自己控制选中样式,只需要把选中态映射到视觉上:
Row( modifier = Modifier .fillMaxWidth() .clickable { selectedIndex = index } .padding(16.dp) ) { Text(text = option, modifier = Modifier.weight(1f)) if (selectedIndex == index) { Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = "已选中", tint = MaterialTheme.colorScheme.primary ) } }Icons.Filled.CheckCircle是 Material 内置图标,tint改颜色就能适配主题。这个写法适合不想引入 RadioButton 视觉效果、想自定义选中图标的场景。核心逻辑没变,变的只是「选中态长什么样」。
3.3 用数据类承载选中项,而不是只存 index
index 方案在列表数据固定不变时够用,但一旦列表支持增删、排序,index 就不可靠了。更稳的做法是给数据加唯一 id,用 id 记录选中项。
data class SelectableItem( val id: Int, val name: String ) @Composable fun SingleSelectById(items: List<SelectableItem>) { var selectedId by remember { mutableStateOf<Int?>(null) } LazyColumn { items(items, key = { it.id }) { item -> Row( modifier = Modifier .fillMaxWidth() .clickable { selectedId = item.id } .padding(16.dp) ) { Text(text = item.name, modifier = Modifier.weight(1f)) RadioButton( selected = selectedId == item.id, onClick = null ) } } } }selectedId的类型是Int?,null表示未选中。items(items, key = { it.id })给每个条目指定了稳定 key,这个 key 后面会专门讲。用 id 而不是 index 之后,列表做局部更新、删除、排序,选中状态都不会错乱。这是我在实际项目里更推荐的单选写法。如果你的数据本来就没有 id 字段,可以考虑用hashCode()或者给数据类加一个自增字段。
4. 多选列表:选中集合的增删与批量操作
4.1 用 Set 存选中项,天然去重
多选和单选的差别在于:状态从一个值变成一个集合。集合类型选什么很关键。有人用MutableList<Int>,每次 toggle 先contains判断再 add/remove,功能能跑,但contains是 O(n) 的,列表几百项时不明显,上千项就开始卡。用Set更合适:toggle 操作稳定 O(1),而且天然去重,不会出现同一项被 add 两次的脏数据。
@Composable fun MultiSelectList(options: List<SelectableItem>) { var selectedIds by remember { mutableStateOf(setOf<Int>()) } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(options, key = { it.id }) { item -> val isSelected = item.id in selectedIds Row( modifier = Modifier .fillMaxWidth() .clickable { selectedIds = if (isSelected) { selectedIds - item.id } else { selectedIds + item.id } } .padding(16.dp) ) { Text(text = item.name, modifier = Modifier.weight(1f)) Checkbox( checked = isSelected, onCheckedChange = null ) } } } }selectedIds用setOf<Int>()初始化,每次 toggle 生成一个新的 Set 赋值给selectedIds。这里注意:我用的是mutableStateOf(setOf<Int>())而不是mutableStateOf(mutableSetOf<Int>())。前者是不可变 Set + 整体替换,Compose 能正确检测到状态变化并触发重组;后者如果直接add,MutableState 默认的 equality check 可能检测不到变化,导致界面不刷新。这是一个非常容易踩的坑,后面避坑章节还会单独说。
4.2 全选、反选与批量操作:把集合运算写清楚
多选列表的产品形态通常不止"点一点",还会带全选、反选、删除选中项这类批量操作。这些逻辑本质上都是集合运算,写之前先在纸上列清楚:全选 = 集合变成所有 id;全不选 = 集合变空;反选 = 在全集和当前集合之间取差集。
@Composable fun MultiSelectWithActions( options: List<SelectableItem>, onDeleteSelected: (Set<Int>) -> Unit ) { var selectedIds by remember { mutableStateOf(setOf<Int>()) } val allIds = remember(options) { options.map { it.id }.toSet() } val allSelected = selectedIds.size == allIds.size Row( modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { TextButton(onClick = { selectedIds = if (allSelected) emptySet() else allIds }) { Text(if (allSelected) "取消全选" else "全选") } TextButton( onClick = { selectedIds = allIds - selectedIds }, enabled = selectedIds.isNotEmpty() ) { Text("反选") } TextButton( onClick = { onDeleteSelected(selectedIds) }, enabled = selectedIds.isNotEmpty() ) { Text("删除选中 (${selectedIds.size})") } } LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(options, key = { it.id }) { item -> // item 行布局同 4.1,略 } } }allIds用remember(options)缓存,options 不变时不会重复计算。allSelected的判断只看数量相等,因为 Set 里不可能有重复 id,数量相等就是全选。allIds - selectedIds是反选的集合运算:从全集中去掉当前选中的,剩下的就是该选中的。onDeleteSelected把选中集合抛给上层,列表数据和选中状态怎么清理,由调用方决定——这样多选列表组件本身不用关心数据源细节。
4.3 横竖屏旋转与进程回收:remember 不够,rememberSaveable 才行
remember只能保组合生命周期内的状态,Activity 重建(比如转屏)时会丢。多选场景里用户勾到一半转个屏,全没了,这是最让人崩溃的体验。解决办法是把状态容器从remember换成rememberSaveable。
var selectedIds by rememberSaveable { mutableStateOf(setOf<Int>()) }但直接这么写会编译报错:Set<Int>不是Bundle能直接存的类型。需要给rememberSaveable配一个 Saver,把 Set 转成 ArrayList:
var selectedIds by rememberSaveable( stateSaver = Saver( save = { it.toList() }, restore = { it.toSet() } ) ) { mutableStateOf(setOf<Int>()) }save把 Set 转成 List,Bundle 能存 ArrayList;restore再从 List 恢复成 Set。rememberSaveable的保存时机是onSaveInstanceState,覆盖转屏和进程被系统回收的场景。这套 Saver 写法记下来,凡是遇到 Bundle 不支持的类型的自定义状态,都用同样的模式处理。
5. 单选多选列表的三个典型坑:现象、原因、解决
5.1 滑动后选中状态错乱、跑到别的行上
现象:列表滑出去再滑回来,勾选标记出现在错误的条目上,或者多个条目同时显示选中。
原因:item 组合被回收重建时,如果用了itemsIndexed且没指定key,Compose 按位置复用组合对象。列表中间插入或删除数据后,位置和数据的对应关系变了,旧状态就"粘"到了新数据上。
解决:给items或itemsIndexed传key = { it.id },用数据的稳定唯一标识,而不是位置。加了 key 之后,Compose 按 key 匹配组合对象,数据位置怎么变,状态都跟着正确的数据走。这条同样适用于LazyColumn之外的其他延迟布局组件。
5.2 多选时用 mutableSetOf 直接 add,界面不刷新
现象:点击 Checkbox 后数据变了,日志打印集合里有值,但 UI 上的勾选不出现。
原因:mutableStateOf(mutableSetOf<Int>())的默认 equality policy 是结构性比较。直接add是原地修改,前后比较结果相同,Compose 认为状态没变,跳过重组。
解决:始终用不可变集合 + 整体赋值。选中用selectedIds + id,取消用selectedIds - id,每次产生新集合。或者给mutableStateOf传入structuralEqualityPolicy()之外的策略,但那样改不如换不可变集合来得干净。这条坑在 List 类型上同样存在,mutableListOf加add一样不触发重组。
5.3 点击选中区域小,误触率高的体验问题
现象:测试反馈"点文字没反应""老是点不中那个圆圈"。
原因:clickable只加在RadioButton或Checkbox上,点击区域只有控件本身那么大,选项文字区域没有点击响应。
解决:把clickable移到Row或外层Modifier上,RadioButton和Checkbox的onClick传null。整行可点,视觉上点击范围大了,事件处理也统一。如果需要更宽的点击容错,可以给Row加minimumInteractiveComponentSize()或直接加大padding。
5.4 用 index 当 key,列表删除后勾选错位
现象:多选删除了几项,剩余项里有的勾选状态跑到别的行上。
原因:删除操作改变了列表顺序,而 key 用的是 index,删除后 index 全部前移,Compose 复用旧组合时把旧数据和旧状态一起错位了。
解决:数据类必须带稳定 id,key 用 id 而不是 index。如果数据源来自后端,id 用服务端主键;如果是本地数据,用自增字段或 UUID。另一个技巧是删除操作完成时,把选中集合也做一次过滤,去掉已删除的 id,双保险防止脏状态残留。
5.5 item 内部用 remember 存选中态,父组件无法同步
现象:单选列表点击第 3 项,第 1 项的勾选没取消;或者弹窗关闭再打开,勾选全没了。
原因:每个 item 用自己的remember维护了独立状态,多个 item 之间互不可见;弹窗关闭时整个列表组合销毁,item 内部状态全部丢失。
解决:状态全部提升到列表调用方,item 改成纯展示组件。单选用selectedId,多选用Set<Int>,item 层只接收selected: Boolean和onClick: () -> Unit。这条是做列表选择功能最核心的一条方法论,前面所有实现都遵循这个原则。
6. 进阶:稳定 key、跨页面状态同步与条件更新
单选多选列表做到能跑只是第一步,真正接入业务后还有几个高频场景要处理:列表数据从接口返回后需要回显选中态;切到详情页再返回,选中态不能丢;列表更新时只刷新变化的条目,不整页重组。
回显的核心是初始化状态。接口数据加载完成后,把返回的 id 集合赋值给selectedIds。注意这个赋值时机要在数据到齐之后,不能在rememberSaveable初始化时设——那时列表还是空的。常见的做法是:
LaunchedEffect(loadedData) { selectedIds = loadedData.filter { it.preSelected }.map { it.id }.toSet() }LaunchedEffect以loadedData为 key,数据变化时执行一次,把服务端返回的预选状态写入集合。跨页面状态同步则把选中集合提升到 ViewModel,用StateFlow持有,页面级 composable 通过collectAsStateWithLifecycle订阅。这样列表、详情页、批量操作栏读的都是同一份数据,任何一处修改,其他界面自动刷新。
items的key参数除了保证状态稳定,还能做细粒度更新。Compose 的 LazyColumn 在数据变化时,只重组 key 变化或内容变化的条目,而不是全部。这意味着列表项的数据类如果重写了equals,字段变化时对应行自动刷新,未变化的行跳过重组。所以数据类建议写成:
data class SelectableItem( val id: Int, val name: String, val enabled: Boolean = true )enabled之类的附加字段不在 key 里,不影响组合复用,但会影响内容比较。这个细节对列表性能影响很大,尤其是条目多、数据频繁刷新的场景。我自己吃过一次亏:列表项内容没变但每次刷新整页闪一下,后来发现是没加 key 导致全部重组。加了稳定 key、保证数据类是纯值对象之后,闪烁问题就消失了。如果你正在做的列表出现「滚动卡顿」「刷新时整页跳一下」,优先检查这两点。希望这些经验对你有帮助,少走几个我已经走过的弯路。
本文还有配套的精品资源,点击获取