简介:面向Android开发者的Kotlin Compose列表交互实现资源,集中展示单选与多选两种高频场景。基于声明式UI框架,通过LazyColumn高效渲染长列表,并配合RadioGroup、RadioButton与Checkbox完成选项状态管理,替代传统RecyclerView中复杂的适配器与ViewHolder编写,适合正在学习Compose或希望提升列表开发效率的工程师。压缩包共44个文件,主要包含11个Kotlin源码、11个XML布局或配置文件、10个WebP图片资源及3个Gradle构建脚本,整体大小仅113KB,轻量且结构完整,可作为独立工程直接导入Android Studio查阅运行。源码中提供了多选列表的可运行示例,清晰展示如何通过可变集合记录选中项并通过回调更新界面状态,便于理解声明式编程中“状态驱动UI”的核心思路。目前已有236人学习/下载,资源体量虽小但覆盖选型、布局、状态同步等关键细节,可直接参考或移植到实际项目,有效降低Compose列表功能的实现门槛。
1. 单选多选列表的核心不是列表,是状态该放哪里
写 Compose 列表的人十个里有八个会把“单选多选”做成data class Item(val checked: Boolean),然后在下拉刷新或翻页时发现勾选状态丢了一屏。因为选择状态本质上是 UI 状态,不应该塞进业务数据模型里。拿 ID 当唯一标识去驱动选中态,这是 Kotlin 写 Compose 列表时最先要立的规矩。这篇文章要解决的不只是“怎么写一个带勾选框的 LazyColumn”,而是把单选、多选、批量操作、分页加载时状态怎么存、怎么更新、怎么不丢,拆成能直接复现的代码。
适合读这篇文章的人是有 Kotlin 基础、刚把 Jetpack Compose 用上手、但一碰复杂列表就被状态刷新搞懵的移动端工程师。也适合那些已经把列表写完,但总感觉选中和点击事件偶尔打架的人。下面所有的代码片段都是可编译的最小模型,复制到 Android Studio 里跑一个空 Composable 就能验证,不依赖任何第三方列表库。
前端工程里对列表的常见误解是“列表 = 遍历 + 模板”,Compose 里这个误解会放大:重组是不可控的,你无法靠“手动刷新某一行”来救场。所以从模型层开始就要想清楚:数据长什么样、选择态长什么样、用户操作如何映射成状态变化。
2. 单选实现:记住选择 ID,而不是记住被选中的对象
2.1 为什么单选状态用remember { mutableStateOf<String?>() }而不是 Boolean 标志
单选列表的业务含义通常是:一组数据中只能选一个,选新的自动取消旧的。如果你把isSelected直接写到实体类里,那个实体类就成了“临时 State”,它跑到数据库、网络层、缓存里都会带着一个不该存在的字段。
正确的做法是记住“哪个 ID 被选中”。ID 用String而非Int的原因在真实项目中很常见:从后端拿到的列表可能用 UUID、雪花 ID、复合键,强行转 Int 会埋雷。
@Composable fun SingleSelectList(items: List<Store>) { var selectedId by remember { mutableStateOf<String?>(null) } LazyColumn { items(items, key = { it.id }) { store -> StoreRow( store = store, selected = store.id == selectedId, onClick = { selectedId = store.id } ) } } } @Composable fun StoreRow(store: Store, selected: Boolean, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .background(if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent) .padding(16.dp) ) { Text(store.name, modifier = Modifier.weight(1f)) if (selected) { Icon(Icons.Default.Check, contentDescription = "已选中") } } }这段代码里的selectedId是唯一的状态源,Store数据类不需要任何“是否被选中”的字段。key = { it.id }让 LazyColumn 知道行的身份标识,配合selected = store.id == selectedId的派生判断,能保证列表项在滑动回收复用后依然展示正确的选中状态。
你可能好奇为什么不直接用selectedId == store.id写在 Row 里,而是先算好selected再传进去。原因有两个:一是语义清晰,不把比较逻辑散落在 UI 层;二是为后面的“选中行自动滚动到可视区”留一个统一的判断入口。
2.2 用下拉框做单选时,把“展开态”和“选中态”分开处理
下拉框(ExposedDropdownMenuBox)在 Material 3 中也是常见的单选用例,但它和列表单选不同:展开态是临时 UI 状态,选中态才是业务状态。
var expanded by remember { mutableStateOf(false) } var selectedRegion by remember { mutableStateOf("华东") } val regions = listOf("华东", "华北", "华南") ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = !expanded } ) { OutlinedTextField( value = selectedRegion, onValueChange = {}, readOnly = true, label = { Text("选择大区") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier.menuAnchor().fillMaxWidth() ) ExposedDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { regions.forEach { region -> DropdownMenuItem( text = { Text(region) }, onClick = { selectedRegion = region expanded = false } ) } } }写这段代码时最容易翻车的是menuAnchor()的用法。
在 Material 3 1.2 以前的版本里,menuAnchor()直接加在OutlinedTextField的modifier上就行。但从 1.2.0 开始,menuAnchor()必须传入type: MenuAnchorType,否则编译报错或点击无反应。如果升级后出现“点击 TextField 没反应,要点旁边的空白区域才弹出”,检查版本后改成这样:
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable)PrimaryNotEditable表示这个 anchor 是主输入框且不可编辑,正好匹配只读的下拉场景。
下拉框单选的边界情况是这样:列表可能有几十个元素,但当前选中项在很下面的位置。DropdownMenuItem没有自动滚动到选中项的能力,需要在DropdownMenu显示后手动控制滚动。常见做法是用rememberLazyListState()配合LaunchedEffect,在expanded变为 true 时滚动到选中项的索引。
2.3 单选列表的键盘导航和状态恢复
TV 和车机场景下,LazyColumn默认支持方向键导航,但这和单选状态是两回事。方向键只移动焦点,不会改变选中态。要达成“焦点移动后自动选中”的效果,需要监听焦点变化:
val focusManager = LocalFocusManager.current LazyColumn { itemsIndexed(items, key = { _, item -> item.id }) { index, store -> StoreRow( store = store, selected = store.id == selectedId, onClick = { selectedId = store.id focusManager.clearFocus() }, modifier = Modifier.onFocusChanged { focusState -> if (focusState.isFocused) { selectedId = store.id } } ) } }onFocusChanged回调的触发时机包含获得焦点和失去焦点两个方向,这里只关心isFocused = true。这种写法适合遥控器场景,用户按上下键时焦点在行间移动,选中态跟随焦点走,按确认键时触发真正的点击逻辑。
状态恢复需要单独处理,remember在 Activity 重建(旋转屏幕、主题切换)时会丢失。单选场景下最少要保住selectedId,用rememberSaveable替代:
var selectedId by rememberSaveable { mutableStateOf<String?>(null) }注意rememberSaveable对自定义类型有限制,String?和Int这种基础类型没问题,自定义 data class 需要实现Parcelable或自定义Saver。单选只要存一个 ID,天然适合rememberSaveable。
3. 多选实现:用Set<String>当状态源,注意点击事件的二次触发
3.1mutableStateOf(setOf<String>())与全选、反选、批量操作
多选和单选的最大差别在于状态不是单个值,而是一个集合。Kotlin 里最自然的表达是Set<String>——保证不重复,查询contains是 O(1) 常量时间,对列表这种频繁查询选中态的场景至关重要。
@Composable fun MultiSelectList( items: List<Document>, onSelectionChange: (Set<String>) -> Unit = {} ) { var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) } LazyColumn { items(items, key = { it.id }) { doc -> val selected = doc.id in selectedIds DocumentRow( doc = doc, selected = selected, onClick = { selectedIds = if (selected) { selectedIds - doc.id } else { selectedIds + doc.id } onSelectionChange(selectedIds) } ) } } }这里用selectedIds - doc.id和selectedIds + doc.id,而不是先拷贝成MutableSet再 add/remove。原因是mutableStateOf的相等性比较基于equals,Set + 元素会返回一个新 Set 实例,能触发重组。如果改成selectedIds.toMutableSet().apply { remove(doc.id) },虽然内容变了,但新实例的 equals 和旧实例相等,Compose 的 Snapshot 系统可能跳过重组。
全选操作也基于这个 Set 做整体切换。全选和取消全选不用状态值去判断“当前是否全选”,而是通过“选中数量 == 列表总量”推导出来:
var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) } val allSelected = items.isNotEmpty() && selectedIds.size == items.size TextButton(onClick = { selectedIds = if (allSelected) emptySet() else items.map { it.id }.toSet() }) { Text(if (allSelected) "取消全选" else "全选") }推导而不是存储isAllSelected的好处是避免两个状态源不同步:列表增删、过滤条件变化时,allSelected自动跟着变,不会出现“明明全选了按钮还显示全选”的 bug。
| 场景 | 状态更新写法 | 是否触发重组 |
|---|---|---|
| 单选选中 | selectedId = newId | 是(值变化) |
| 多选添加 | set + newId | 是(新实例) |
| 多选移除 | set - oldId | 是(新实例) |
| 原地修改 Set | mutableSet.add(newId) | 否(实例没变,equals 未比较) |
| 原地修改后重新赋值 | set = mutableSet | 否(equals 相等则跳过) |
上表最后两行是实际项目里最常见的坑。很多人从 Java 习惯带过来,先拿mutableStateOf(mutableSetOf<String>())再用.add(),结果 UI 一动不动。在 Compose 里用不可变集合是“减少一类 Bug”的优先手段。
3.2 Checkbox 的行点击冲突:toggleable还是clickable包Checkbox
多选列表最常见的做法是整行可点 + 右侧一个 Checkbox。但这样有两个点击源:行的clickable和 Checkbox 内部的toggleable。用户点 Checkbox 时事件会冒泡到行的clickable,导致一次点击触发两次切换,选中状态闪一下又变回去。
解决方法是让 Checkbox 不监听点击,只做展示:
@Composable fun DocumentRow(doc: Document, selected: Boolean, onClick: () -> Unit) { Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( text = doc.title, modifier = Modifier.weight(1f) ) Checkbox( checked = selected, onCheckedChange = null // 关键:禁用 Checkbox 自身的点击逻辑 ) } }Checkbox的onCheckedChange传null时组件自动进入只读模式,点击事件不会消费,会继续往上层传递。用户无论点行还是点勾选框,都只触发Row的onClick一次,selected翻转一下,表现一致。
如果产品的交互要求是“点和勾选框是不同行为”,比如点行是进入详情、点勾选框才切换选择,那就反过来处理,把行的clickable去掉,只保留 Checkbox 的可点击状态:
Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text(doc.title, modifier = Modifier.weight(1f)) Checkbox( checked = selected, onCheckedChange = { checked -> onSelectionChange(doc.id, checked) } ) }这两种模式没有优劣之分,取决于列表所在的页面层级。消息列表通常是“点行进详情”,文件管理器通常是“点勾选框才进多选模式”。关键是在设计 Composable 的接口时就要定义清楚行点击与勾选框点击的关系,不要两个都能点还共用同一个 handler。
3.3 多选模式下的限量选择:超过 N 个时给出反馈
电商、工单、标签选择场景里经常有“最多选 5 个”的限制。这个逻辑不能放在onClick里一个个判断,而是抽成一个纯函数,方便测试和复用:
fun toggleSelection( current: Set<String>, id: String, maxCount: Int = Int.MAX_VALUE ): Set<String> { return when { id in current -> current - id current.size < maxCount -> current + id else -> current } } // 使用示例 selectedIds = toggleSelection(selectedIds, doc.id, maxCount = 5)当current.size == maxCount且用户点了一个新 ID,toggleSelection返回原集合,UI 不会变化。但用户需要知道“为什么没反应”,所以要有反馈机制:
val maxCount = 5 val toastMsg = remember { mutableStateOf<String?>(null) } var selectedIds by remember { mutableStateOf<Set<String>>(emptySet()) } LazyColumn { items(items, key = { it.id }) { doc -> DocumentRow( doc = doc, selected = doc.id in selectedIds, onClick = { val newSet = toggleSelection(selectedIds, doc.id, maxCount) if (newSet == selectedIds && doc.id !in selectedIds) { toastMsg.value = "最多选择 $maxCount 项" } selectedIds = newSet } ) } }这里判断“是否触发提示”的条件是newSet == selectedIds && doc.id !in selectedIds——计算后的集合没变,且点击的项本来不在选中集里,说明是被 maxCount 拦下来的。有限量选择时,这个逻辑隔离在toggleSelection函数里,UI 层只负责读返回值。
当列表项数量大、选中数量需要实时显示在顶部标题栏时,用derivedStateOf做聚合,避免每次选中变化都全量重组:
val selectedCount by remember { derivedStateOf { selectedIds.size } }derivedStateOf只在selectedIds变化时才重新计算闭包,且只有当selectedIds.size的返回值变化时才会通知重组。这样顶部“已选 3 项”的文本不需要 LazyColumn 整体刷新,性能上比反复读selectedIds.size更可控。
4. 列表增删、分页加载时,选中状态的保持和清理策略
4.1 用稳定 ID 当 key,避免行位置偏移引发的状态错乱
当列表支持刷新、分页加载、删除时,items不带key是最常见的坑。Compose 的 LazyColumn 默认按 index 标识项,数据源增删导致 index 变化时,哪些项复用哪些项可能全都错位,肉眼看到的现象就是“勾选项漂移”。
正确的 key 策略是优先用业务唯一 ID:
LazyColumn { items( items = pagedItems, key = { it.id } ) { item -> // ... } }如果列表数据本身没有 ID(比如纯本地计算的临时列表),退而求其次用内容哈希,但不要用 index。key = { index -> items[index].hashCode() }比裸 index 好一档,但 hashCode 碰撞仍然可能。最保险的方案是让数据类自带不变 ID。
4.2 下拉刷新和分页加载时,选中状态该如何保留
分页加载场景中,新页数据到达后selectedIds不应被清空,这是“列表 UI 状态”和“网络数据状态”分离带来的直接收益。前面把选择态放在remember里,翻页时 LazyColumn 里的 items 是增量的,新 item 的选中态通过doc.id in selectedIds直接判断,不用额外同步。
真正的边界问题是“删除选中的项后,选中集合里残留了不存在的 ID”。比如批量删除后,selectedIds还存着被删掉的 ID,下次全选时selectedIds.size == items.size永远不成立。在删除操作后要主动清理:
fun removeItems( allItems: List<Document>, selectedIds: Set<String> ): Pair<List<Document>, Set<String>> { val newItems = allItems.filter { it.id !in selectedIds } val aliveIds = newItems.map { it.id }.toSet() val newSelectedIds = selectedIds intersect aliveIds return newItems to newSelectedIds }这里用intersect把selectedIds和存活 ID 做交集,一次性移除所有失效的选中项。操作完成后selectedIds只包含还存在于列表中的项,UI 和数据的同步问题从这个根上解决。
下拉刷新时如果列表整体替换,同样用intersect做过滤。不要把selectedIds.clear()放在刷新回调里,那会丢掉用户原本的选中意图——除非产品明确要求“刷新后重置选择”。
4.3 筛选后列表变短,全选框状态如何自适应
搜索筛选是多选列表的标配动作。用户先勾了几项,然后在搜索框输入关键词,列表立刻变短,此时顶部全选框的状态该怎么算?如果用「选中的数量 == 当前筛选结果数量」来判断,会出现三种情况:
- 筛选结果没包含任何已选项,全选框是未选中态,点全选选中筛出来的所有项。
- 筛选结果包含部分已选项,全选框应该是半选(indeterminate)或未选,分别处理。
- 筛选结果恰好等于已选项,全选框显示选中,点一下取消全选(只取消当前筛出来的项,不影响筛掉项的选中状态)。
实现上关键是区分“全局选中态”和“可见项选中态”,代码层面要做两件事:
val visibleItems = remember(items, query) { if (query.isBlank()) items else items.filter { it.title.contains(query) } } val visibleIds = visibleItems.map { it.id }.toSet() val visibleSelectedCount = selectedIds.count { it in visibleIds } val allVisibleSelected = visibleSelectedCount == visibleItems.sizeallVisibleSelected驱动全选框,点击时只切换visibleIds范围内的选择状态:
TextButton(onClick = { selectedIds = if (allVisibleSelected) { selectedIds - visibleIds // 取消当前可见的 } else { selectedIds + visibleIds // 全选当前可见的 } }) { Text(if (allVisibleSelected) "取消全选" else "全选可见") }这种“按可见范围选择”的方案比“全列表选择”在用户心智上更自然。如果产品要求全选必须作用于后端全量数据(比如 1 万条选 1 万条),那就脱离 UI 状态,直接在 ViewModel 里维护一份List<Document>作为数据全量,selectedIds与之求交集,UI 层的visibleItems只负责展示。
4.4mutableStateListOf还是mutableStateOf(List<T>)
有人习惯把列表本身也放进mutableStateListOf,然后向列表 add、remove。这在单一页面内可控,但多级页面、跨 ViewModel 传值时容易把状态流搞乱。推荐的做法是:列表数据放 ViewModel 的StateFlow<List<T>>,UI 层用collectAsStateWithLifecycle收集,选择态放在 UI 层remember;更新列表时整体替换List引用。
如果列表数据非常简单、只在单个 Composable 内自产自销,可以用mutableStateListOf减少样板代码。但要注意它不支持key的自动追踪,和LazyColumn配合时仍然要手动传key。
5. 把整个多选列表封装成可复用组件:记住选择操作的进阶技巧
把“列表 + 单选/多选 + 全选 + 统计”抽成一个通用 Composable 不是炫技,工程上最常见的诉求是“聊天页附件选一个、文件页多选批量删、标签页多选限数量”,三处交互略有不同,但状态模式一致。
5.1 用位置索引跟踪界面元素,实现折叠状态下的多选
遇到“分组折叠列表 + 多选”时,selectedIds有一个额外的作用:折叠状态的判断依据。
var collapsedGroups by remember { mutableStateOf(setOf<String>()) } LazyColumn { groups.forEach { group -> val isCollapsed = group.id in collapsedGroups stickyHeader(key = "header_${group.id}") { GroupHeader( title = group.title, selected = group.items.isNotEmpty() && group.items.all { it.id in selectedIds }, onToggleCollapse = { collapsedGroups = if (isCollapsed) { collapsedGroups - group.id } else { collapsedGroups + group.id } } ) } if (!isCollapsed) { items(group.items, key = { it.id }) { item -> ItemRow( item = item, selected = item.id in selectedIds, onToggle = { /* 切换逻辑 */ } ) } } } }这个技巧不改变多选的数据结构,但把“折叠”也当成一组Set<String>状态,和selectedIds形成两个独立状态源。列表重组时折叠状态不参与选中逻辑,选中逻辑也不参与折叠计算,两个 Set 各管各的,互不干扰。
嵌套滚动容器里使用stickyHeader时,注意LazyColumn的userScrollEnabled默认是 true,如果列表里有横向滚动的子项,要手动协调手势方向。这不是本节的必选项,但确实是折叠列表 + 多选的常见伴随场景。
5.2 批量删除二次确认时,把选中列表快照化而不是实时引用
多选列表的最后一个高频操作是“删除选中项”。删除前弹确认框,用户点确认时items已经是新状态了,如果直接读selectedIds会拿到最新值,而不是用户最初勾选的那一批——虽然通常结果一致,但极端情况下删除过程中列表被刷新,会出现“弹窗里的数量和实际删除数量对不上”。
正确做法是在弹窗弹出时快照选中列表:
var pendingDeleteIds by remember { mutableStateOf<Set<String>>(emptySet()) } // 用户点击“删除选中”按钮时触发 pendingDeleteIds = selectedIds // 弹窗确认按钮的 onClick 里使用 pendingDeleteIds,而不是 selectedIds AlertDialog( onDismissRequest = { pendingDeleteIds = emptySet() }, confirmButton = { TextButton(onClick = { viewModel.deleteByIds(pendingDeleteIds) pendingDeleteIds = emptySet() }) { Text("删除") } }, text = { Text("确定删除选中的 ${pendingDeleteIds.size} 项吗?") } )pendingDeleteIds是纯动作参数,不属于 UI 状态,不参与重组驱动的业务判断。它的生命周期从点击删除按钮开始,到对话框关闭结束,不会因为 LazyColumn 中的 item 因滚动而重组受干扰。
最后说一个 Compose 特有的验证方式:打开 Layout Inspector,在selectedIds值变化时看 LazyColumn 下有多少个 item 发生了重组。如果只有点击的那一项和顶部计数文本变了,说明状态设计是对的;如果一整个列表都在闪,多半是items忘写key,或者selectedIds用了可变 Set 原地修改。多选列表的代码难度不在写多少行,而在状态路径是否收得住——能用一个Set<String>说明白的事,别让它扩散到三个 class 里。
本文还有配套的精品资源,点击获取