CMP for OpenHarmony:Checkbox 多选框的“集合状态机”实现——三态全选 + 批量操作 + 多弹窗入口(项目实战代码)

代码地址:通过网盘分享的文件:cmp_openharmony.zip
链接: https://pan.baidu.com/s/15rN1LvJ0KENMkYZfLq_R1Q?pwd=nhqe 提取码: nhqe

多选框(Checkbox)在工程里真正麻烦的部分通常不是“画一个勾”,而是:

  • 如何表达“选中集合”(而不是一个个 boolean)
  • 如何实现“全选/清空/反选/预设选择”这种批量动作
  • 如何展示“部分选中”(也就是全选按钮的半选态)
  • 当页面有多个入口(主界面、弹窗、抽屉、菜单、底部弹层)都能改选中集合时,如何避免状态分裂

本文基于本仓库真实可运行代码,给出一套可直接复用的 Checkbox 组织方式:

  • selectedIds: Set<String> 作为单一数据源
  • TriStateCheckbox 展示“全选/半选/未选”汇总态
  • locked 统一拦截“拖动/点击输入 + 外部入口改写”
  • 用 Modal / Drawer / Popover / BottomSheet 四类入口对同一份集合做批量操作

1. 代码位置:示例页在哪

本文所有 Kotlin 代码均来自下面文件(复制路径即可定位):

  • composeApp/src/commonMain/kotlin/com/tencent/compose/sample/checkbox/MultiPopupCheckboxDemoPage.kt

2. 选中集合:用 Set<String> 表达“多选状态”

示例页将多选结果收口为一个集合(节选,保持项目原样):

var selectedIds by remember { mutableStateOf(setOf<String>()) }
var locked by remember { mutableStateOf(false) }

这里的核心思想是:

  • 多选的“本体状态”应该是集合(哪些 id 被选中),而不是一堆零散的 var checkedA / var checkedB
  • 集合更适合承载批量操作:全选就是把所有 id 放进集合;清空就是置空集合;反选就是做集合差。
  • locked 属于页面策略:当锁定时,所有入口都不应该再改集合。

3. 三态全选:TriStateCheckbox 的状态必须由集合推导

示例页用 derivedStateOfselectedIds 推导 triState(节选,保持项目原样):

val triState by remember(options, selectedIds) {
    derivedStateOf {
        when {
            selectedIds.isEmpty() -> ToggleableState.Off
            selectedIds.size == options.size -> ToggleableState.On
            else -> ToggleableState.Indeterminate
        }
    }
}

这段代码解决的是“全选按钮如何正确展示半选态”的问题:

  • 未选(Off):集合为空。
  • 全选(On):集合大小等于总选项数。
  • 部分选中(Indeterminate):既不为空,也不满。

为什么要用推导而不是再维护一个 var allChecked

  • 因为 allChecked 很容易与 selectedIds 不一致(尤其当选择入口变多:列表点选、抽屉按钮、弹层预设、Popover 菜单等)。
  • 推导能保证:只要 selectedIds 对了,三态展示就永远正确。

4. 锁定拦截:统一封装成 applyIfUnlocked,避免每个入口重复写 if

示例页提供了一个小工具函数(节选,保持项目原样):

fun applyIfUnlocked(block: () -> Unit): Boolean {
    if (locked) {
        lastResult = "已锁定,忽略操作"
        return false
    }
    block()
    return true
}

这段代码的价值在于:

  • 统一策略:所有入口都通过它判断是否允许写状态。
  • 减少重复:不用在每个按钮/弹窗/菜单里重复写 if (locked) return
  • 可观测性:锁定时顺便更新 lastResult,Demo 页面可以直观看到“为什么没生效”。

5. 批量动作:全选 / 清空 / 反选其实就是对集合的三种变换

示例页把批量操作实现成三个函数(节选,保持项目原样):

fun selectAll() {
    selectedIds = options.map { it.id }.toSet()
}

fun clearAll() {
    selectedIds = emptySet()
}

fun invertSelection() {
    val all = options.map { it.id }.toSet()
    selectedIds = all - selectedIds
}

这里的可复用点:

  • 全选:把所有 id 变成一个集合。
  • 清空:直接置空集合。
  • 反选:用全集 all 减去当前选中集合。

这种写法的优势是清晰、无副作用、也不依赖 UI 组件;同一套逻辑可以被任何入口复用。


6. 列表项点击:只维护 selectedIds,不要给每项建第二份状态

示例页在列表里切换某一项选中状态(节选,保持项目原样):

selectedIds = if (selectedIds.contains(option.id)) {
    selectedIds - option.id
} else {
    selectedIds + option.id
}

解释要点:

  • 选中 -> 取消:从集合中删除 id。
  • 未选 -> 选中:向集合中添加 id。

注意这里没有出现 option.checked = ... 之类的字段,因为选项本身是静态数据(id + label),选中状态应该由 selectedIds.contains(id) 推导。


7. 顶部全选点击:三态按钮的点击语义要和状态推导保持一致

三态按钮点击逻辑(节选,保持项目原样):

TriStateCheckbox(
    state = triState,
    onClick = {
        applyIfUnlocked {
            when (triState) {
                ToggleableState.On -> clearAll()
                else -> selectAll()
            }
        }
        lastResult = when (triState) {
            ToggleableState.On -> "顶部:取消全选"
            ToggleableState.Off -> "顶部:全选"
            ToggleableState.Indeterminate -> "顶部:全选"
        }
    },
    enabled = !locked
)

这里的设计边界是:

  • 当前是 全选(On)时点击 -> 执行清空。
  • 当前是 未选/半选(Off/Indeterminate)时点击 -> 执行全选。

这样用户操作的预期更稳定:

  • 只要不是全选,点一次就全选
  • 只有在全选态时,点一次才是取消全选

8. 列表行点击与 Checkbox 点击:避免“双触发”

示例页把整行设置为可点击,同时将 CheckboxonCheckedChange 设为 null(节选,保持项目原样):

Row(
    modifier = Modifier
        .clickable(
            enabled = enabled,
            indication = null,
            interactionSource = remember { MutableInteractionSource() }
        ) { onToggle() }
) {
    Checkbox(
        checked = checked,
        onCheckedChange = null,
        enabled = enabled
    )
}

这段代码解决的是一个很常见的交互坑:

  • 如果行可点击、Checkbox 也可点击,并且两边都触发 onToggle(),用户点在 Checkbox 上可能会触发两次,导致“看起来没变化”。
  • 通过把 Checkbox 的 onCheckedChange 置为 null,让“点击源”只有行本身,从而保证一次点击只触发一次状态切换。

9. 多入口控制:同一份集合被 Modal / Drawer / Popover / BottomSheet 改写

9.1 Drawer(抽屉)适合放“批量控制面板”

DrawerContent 中把全选/清空/反选集中(节选,保持项目原样):

onSelectAll = {
    if (applyIfUnlocked({ selectAll() })) lastResult = "抽屉:全选"
},
onClear = {
    if (applyIfUnlocked({ clearAll() })) lastResult = "抽屉:清空"
},
onInvert = {
    if (applyIfUnlocked({ invertSelection() })) lastResult = "抽屉:反选"
}

这里的关键点:

  • 抽屉按钮并不直接操作 UI,只是调用集合变换函数。
  • 写入之前统一走 applyIfUnlocked,把锁定策略收口。

9.2 BottomSheet / Popover:锁定时“提前返回”要写对作用域

BottomSheet 的回调(节选,保持项目原样):

onAction = sheetAction@{ label ->
    showBottomSheet = false
    if (locked && label != "关闭") {
        lastResult = "BottomSheet:已锁定,忽略操作"
        return@sheetAction
    }
    // ...
}

Popover 的回调(节选,保持项目原样):

onAction = popAction@{ action ->
    showPopover = false
    if (locked && action != "关闭") {
        lastResult = "Popover:已锁定,忽略操作"
        return@popAction
    }
    // ...
}

解释要点:

  • 当你在 lambda 中需要提前退出时,建议像示例这样用 sheetAction@ / popAction@ 显式标记作用域,再 return@label
  • 这样可以避免嵌套 lambda 场景下 return 作用域不明确导致的编译/逻辑问题。

10. 如何在工程里直接体验该示例

建议做法与其它 Demo 一样:打开入口文件并临时切换渲染页面。

  • 入口文件:composeApp/src/commonMain/kotlin/com/tencent/compose/sample/mainpage/MainPage.kt
  • 将入口渲染切换到:com.tencent.compose.sample.checkbox.MultiPopupCheckboxDemoPage()

(这里不贴固定代码块,避免入口频繁切换导致文内代码与仓库不一致。)


11. 自检清单:Checkbox 在工程里最容易踩的坑

  • 三态必须推导TriStateCheckbox 的状态一定要从 selectedIds 推导,避免双状态。
  • 批量操作要落到集合变换:全选/清空/反选用函数收口,别散落在各个按钮里。
  • 锁定要统一:锁定后不仅禁用点击,还要拦截弹窗/菜单/弹层写入。
  • 避免双触发:行可点时,Checkbox 本体建议 onCheckedChange = null
  • 提前返回写对作用域:用 sheetAction@ / popAction@ 明确 return@label

在这里插入图片描述

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

社区链接

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐