鸿蒙 ArkUI Checkbox 复选框组件:多选管理、全选反选与协议确认
Checkbox 复选框组件完全指南:多选管理、全选反选与协议确认
本文基于 HarmonyOS(ArkTS 声明式开发范式,API 12 / 5.0.0)写作,所有示例均可在 DevEco Studio 模拟器中验证。配套演示工程位于本文同级目录
ohos/,包含完整可运行的EntryAbility.ets与Index.ets。



一、引言
注册页底部那行小字,购物车里"全选"按钮旁边的方块,设置页里"接收推荐"前面的勾——这些看似不起眼的方框,承担着两种截然不同的职责:一种是多选,从一组候选中挑出任意多个,勾了还能反悔;另一种是承诺,点击"同意"往往意味着法律意义上的认可。它们有一个共同的名字:Checkbox 复选框。
复选框与单选框是一对天生的对照。单选框回答"二选一",复选框回答"可多可少"——Radio 的点击逻辑是"从未选到选中,选过的不能再点掉",Checkbox 则是"选中与取消双向切换"。这一条逻辑差异决定了它们的使用场景:凡是"可以不选"或"可以多选"的地方,都是复选框的领地。兴趣爱好、购物车结算、筛选条件、消息订阅……以及那条绕不开的"我已阅读并同意《用户协议》"。
复选框的难点从来不在"怎么勾",而在"状态怎么管"。一个全选复选框面对一列子项时,会出现三种状态:全部勾选、部分勾选、无一勾选——"部分勾选"正是多选管理里最容易出错的三角地带。ArkUI 为此提供了 CheckboxGroup 容器:组内复选框的变化统一收口成 CheckboxGroupResult,其中 status 字段(All/Part/None)直接告诉你整组处于什么状态,免去了手工统计的麻烦。但组件只能帮你报告状态,状态从哪来、由谁持有,仍然是业务代码的责任。
二、环境准备
Checkbox 属于 ArkUI 基础组件,API 8 起提供,CheckboxGroup 与 CheckboxGroupResult 同步可用;本文用到的能力在 API 12 上全部稳定。
| 项目 | 推荐配置 | 说明 |
|---|---|---|
| DevEco Studio | 5.0 及以上 | 需支持 API 12 的 SDK |
| HarmonyOS SDK | 5.0.0(12) | compatibleSdkVersion 与之对应 |
| 设备 | Phone 模拟器或真机 | 本文以模拟器验证为主 |
| 工程类型 | Stage 模型 + ArkTS | EntryAbility 继承 UIAbility |
工程落地路径与前几篇一致,两种方式任选:
- 方式一:在 DevEco Studio 新建 Empty Ability 工程,直接写 ArkTS 原生页面。本文演示工程即采用这种方式(目录结构见
ohos/README.md)。 - 方式二:在已有的 Flutter·鸿蒙壳工程里,把
ohos/entry/src/main/ets/下的页面与组件放进原生工程。这种方式下EntryAbility通常继承自FlutterAbility,演示组件的代码不受影响。
若你用方式二(Flutter 壳),只需关注
pages/Index.ets、model/CheckboxModel.ets与components/下的组件代码,其余配置沿用原工程即可。
三、核心 API 与原理解析
3.1 构造与基本属性:name、select 与 selectedColor
Checkbox 的构造参数是"身份",链式属性是"表现":
Checkbox({ name: 'agree', group: 'agreementGroup' })
.select(true) // 选中状态(受控)
.selectedColor('#0A59F7') // 选中时的填充色
.onChange((isChecked: boolean) => {
this.agree = isChecked;
})
| 参数/属性 | 类型 | 作用 |
|---|---|---|
name |
string | 选项名,组事件里用于识别是谁被勾选 |
group |
string | 所属组名,与 CheckboxGroup 的组名对应 |
select |
boolean | 选中状态,受控时由状态驱动 |
selectedColor |
ResourceColor | 选中填充色(未选中是空心框) |
onChange |
(boolean) => void |
变化回调,参数就是新的选中值 |
两个关键点:其一,onChange 的参数是 boolean 而非 name——想知道"哪个复选框变了",要么按位置捕获(ForEach 的 index),要么用受控写法把 select 绑到数据上;其二,select 是受控入口,同时把 selectedColor 主动定下来——默认主题色在不同深色模式下观感可能不一致,UI 一致性要求主动配色。
3.2 CheckboxGroup:组事件的统一收口
与 RadioGroup 类似,CheckboxGroup 把整组变化收口成一次回调,但回调参数丰富得多:
CheckboxGroup({ group: 'interestGroup' }) {
Checkbox({ name: 'tech', group: 'interestGroup' })
Checkbox({ name: 'music', group: 'interestGroup' })
}
.onChange((result: CheckboxGroupResult) => {
// result.name / result.value:选中的名称列表
// result.status:SelectStatus.All / Part / None
})
CheckboxGroupResult 的三个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
name |
string[] | 选中的 name 列表 |
value |
string[] | 选中的 value 列表 |
status |
SelectStatus | 整组状态:All / Part / None |
status 是"部分选中"的直接答案:一组 5 项,勾了 2 项,回调里 status === SelectStatus.Part。它省掉的是手工统计,但三态如何呈现在 UI 上仍是你的工作——ArkUI 的原生 Checkbox 没有"半选"图形(无 indeterminate 属性),“部分选中"通常用文字或徽标表达(如"已选 2/5”)。
3.3 全选联动的数据流
全选复选框与列表子项之间是典型的"投影"关系——列表数据是唯一真相源,全选框只是它的投影:
两条规则贯穿始终:写入路径——任何勾选动作最终都落在数据数组上,UI 只是读取;读取路径——全选框的 select、已选计数、合计金额,全部由数据推导,不另存一份"视图状态"。这样无论用户从哪条路径操作(点全选、点子项、点反选),界面永远一致。这也是 4.5 购物车场景的骨架。
3.4 状态空间:为什么复选框是"组合"的
单选与多选的状态空间差异可以用组合数学精确刻画:n 个复选框的任意勾选组合是一种状态,其总数为:
[
\sum_{k=0}^{n} \binom{n}{k} = 2^{n}
]
n 项里恰好勾 k 项的方案数是 (\binom{n}{k}),全部状态加起来是 (2^n)。对照 Radio 组的 n 种互斥状态,复选框的状态空间是指数级的——这正是"状态管理"在多选场景里比单选复杂得多的数学根源。也正因如此,用单一数据数组承载状态、用函数推导派生值(而非为每个复选框各存一个 @State)才是可维护的做法。
3.5 协议确认:勾选是门禁,点击是阅读
协议场景有两个动作必须分离:勾选复选框是"我同意"(承诺),点击协议文字是"我要看"(阅读)。常见错误是把两者绑在一个 onClick 上——点文字自动勾选,等于"同意前没看过协议"。正确写法:
Checkbox({ name: 'agree' })
.select(this.agree)
.onChange((isChecked: boolean) => {
this.agree = isChecked;
})
Text('《用户协议》')
.fontColor('#0A59F7')
.textDecoration(TextDecoration.Underline)
.onClick(() => {
this.showProtocol = !this.showProtocol; // 只展开内容,不动勾选
})
Button('注册')
.enabled(this.agree) // 未勾选则禁用
"阅读"与"同意"分离后,注册按钮用 enabled(this.agree) 做门禁——未勾选置灰,勾选才可点。这是协议场景的行业惯例,也是无障碍的底线。
3.6 与相关组件的选型对比
| 组件 | 可多选 | 可取消 | 适用场景 |
|---|---|---|---|
Checkbox |
是 | 是 | 多选、全选反选、协议勾选 |
Radio |
否 | 否 | 必选其一(性别、支付方式) |
Toggle |
- | 是 | 二态开关(通知、夜间模式) |
CheckboxGroup |
是 | 是 | 整组事件收口与三态统计 |
选型一句话:"可多可少"用 Checkbox,"必选其一"用 Radio,"开或关"用 Toggle——016 的单选框与本章的复选框是互斥 vs 可多选的分工,两条路线的状态管理范式也一脉相承。
四、完整代码实现
下面给出演示工程的完整可运行代码。工程以 Tabs 组织三个模块:多选设置(核心场景)、商品全选、协议确认。数据模型 CheckboxModel.ets 提供兴趣与商品数据。
4.1 入口:EntryAbility.ets
import { UIAbility } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
export default class EntryAbility extends UIAbility {
private readonly TAG: string = 'CheckboxGuideAbility';
onCreate(want: object, launchParam: object): void {
hilog.info(0x0000, this.TAG, '%{public}s', 'Ability onCreate');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(0x0000, this.TAG, 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(0x0000, this.TAG, '%{public}s', 'Succeeded in loading the content.');
});
}
// onForeground / onBackground / onDestroy / onWindowStageDestroy 生命周期
// 回调仅打 hilog 日志,完整版见演示工程源码
}
4.2 数据模型:CheckboxModel.ets
export class InterestItem {
name: string;
label: string;
constructor(name: string, label: string) {
this.name = name;
this.label = label;
}
}
export const INTERESTS: InterestItem[] = [
new InterestItem('tech', '技术'),
new InterestItem('music', '音乐'),
new InterestItem('movie', '电影'),
new InterestItem('sport', '运动'),
new InterestItem('travel', '旅行'),
];
export class GoodsItem {
name: string;
price: number;
checked: boolean;
constructor(name: string, price: number, checked: boolean = false) {
this.name = name;
this.price = price;
this.checked = checked;
}
}
// GOODS 常量:4 件商品(鸿蒙开发入门 69 元、蓝牙降噪耳机 399 元、
// 智能手环 249 元、快充充电器 129 元),完整定义见演示工程
4.3 主页面:Index.ets
import { MultiSelectDemo } from '../components/MultiSelectDemo';
import { SelectAllDemo } from '../components/SelectAllDemo';
import { AgreementDemo } from '../components/AgreementDemo';
@Entry
@Component
struct Index {
@State currentIndex: number = 0;
build() {
Column() {
Tabs({ barPosition: BarPosition.Start, index: this.currentIndex }) {
TabContent() { MultiSelectDemo() }.tabBar('多选设置')
TabContent() { SelectAllDemo() }.tabBar('商品全选')
TabContent() { AgreementDemo() }.tabBar('协议确认')
}
.vertical(false)
.scrollable(true)
.barMode(BarMode.Scrollable)
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
```### 4.4 核心场景一:多选设置(MultiSelectDemo.ets)
兴趣组演示了"受控复选框 + CheckboxGroup 三态"的完整闭环:`selected` 数组是唯一真相源,每个 Checkbox 的 `select` 由它驱动,组事件的 `onChange` 反哺它,全选/反选按钮改写它,组状态与已选计数由它推导。
```typescript
import { promptAction } from '@kit.ArkUI';
import { InterestItem, INTERESTS } from '../model/CheckboxModel';
@Component
export struct MultiSelectDemo {
@State remember: boolean = true;
@State selected: string[] = [];
@State groupStatus: string = 'None';
build() {
Scroll() {
Column({ space: 16 }) {
Text('多选设置')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('92%')
.textAlign(TextAlign.Start)
Row({ space: 12 }) {
Checkbox({ name: 'remember' })
.select(this.remember)
.selectedColor('#0A59F7')
.onChange((isChecked: boolean) => {
this.remember = isChecked;
})
Text('记住我的选择偏好').fontSize(15).fontColor('#333333')
Blank()
}
.width('92%')
.padding(14)
.borderRadius(12)
.backgroundColor(Color.White)
.border({ width: 1, color: '#F0F0F0' })
Row({ space: 12 }) {
Text('选择你的兴趣爱好')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Blank()
Text(`已选 ${this.selected.length}/${INTERESTS.length}`)
.fontSize(13)
.fontColor('#0A59F7')
}
.width('92%')
CheckboxGroup({ group: 'interestGroup' }) {
Column({ space: 0 }) {
ForEach(INTERESTS, (item: InterestItem, index: number) => {
Row({ space: 12 }) {
Checkbox({ name: item.name, group: 'interestGroup' })
.select(this.isSelected(item.name))
.selectedColor('#0A59F7')
Text(item.label).fontSize(15).fontColor('#333333')
Blank()
}
.width('100%')
.height(52)
.padding({ left: 16, right: 16 })
if (index < INTERESTS.length - 1) {
Divider().strokeWidth(1).color('#F2F2F2').margin({ left: 12 })
}
}, (item: InterestItem) => item.name)
}
.width('100%')
}
.width('92%')
.borderRadius(14)
.backgroundColor(Color.White)
.border({ width: 1, color: '#F0F0F0' })
.onChange((result: CheckboxGroupResult) => {
this.selected = result.value;
this.groupStatus = this.statusName(result.status);
})
Row({ space: 10 }) {
Button('全选')
.layoutWeight(1).height(40).fontSize(14)
.backgroundColor('#0A59F7')
.onClick(() => { this.selectAll(true); })
Button('反选')
.layoutWeight(1).height(40).fontSize(14)
.backgroundColor('#07C160')
.onClick(() => { this.invertAll(); })
}
.width('92%')
Row({ space: 12 }) {
Text(`组状态:${this.groupStatus}`)
.fontSize(14)
.fontColor(this.statusColor())
Blank()
Text(`已选 ${this.selected.length} 项`)
.fontSize(13)
.fontColor('#666666')
}
.width('92%')
.padding(14)
.borderRadius(12)
.backgroundColor('#F7F9FF')
Button('提交')
.width('92%').height(44).borderRadius(22).fontSize(16)
.onClick(() => {
promptAction.showToast({ message: `已选择 ${this.selected.length} 项`, duration: 1500 });
})
}
.width('100%')
.padding({ top: 16, bottom: 24 })
}
.width('100%')
.height('100%')
}
// 其余工具方法见演示工程源码:
// isSelected/selectAll/invertAll/statusName/statusNameFromCount/statusColor
}
值得注意:按钮全选/反选直接改写 selected 数组并自行推导 groupStatus——因为 CheckboxGroup.onChange 只在用户操作组内复选框时触发,程序化改数组不会回调它。两套入口最终都收敛到同一个 selected,界面自然一致。
4.5 场景二:商品全选(SelectAllDemo.ets)
购物车演示"行数据为源、全选为投影"的另一种组织方式——这里不使用 CheckboxGroup,而是把选中状态直接放进 GoodsItem.checked,通过统计函数推导全选三态与合计金额:
import { promptAction } from '@kit.ArkUI';
import { GoodsItem, GOODS } from '../model/CheckboxModel';
@Component
export struct SelectAllDemo {
@State goods: GoodsItem[] = [];
aboutToAppear(): void {
this.goods = GOODS.map((item: GoodsItem) => {
return new GoodsItem(item.name, item.price, item.checked);
});
}
build() {
Column({ space: 16 }) {
Text('购物车').fontSize(16).fontWeight(FontWeight.Bold)
.width('92%').textAlign(TextAlign.Start)
Row({ space: 12 }) {
Checkbox({ name: 'all' })
.select(this.allChecked())
.selectedColor('#0A59F7')
.onChange((isChecked: boolean) => {
this.setAll(isChecked);
})
Text('全选').fontSize(15).fontColor('#333333')
Blank()
Text(this.partialText()).fontSize(13).fontColor(this.partialColor())
}
.width('92%').padding(14).borderRadius(12)
.backgroundColor(Color.White).border({ width: 1, color: '#F0F0F0' })
Column({ space: 0 }) {
ForEach(this.goods, (item: GoodsItem, index: number) => {
Row({ space: 12 }) {
Checkbox({ name: `goods_${index}` })
.select(item.checked)
.selectedColor('#0A59F7')
.onChange((isChecked: boolean) => {
this.toggleItem(index, isChecked);
})
Text(item.name)
.fontSize(15)
.fontColor(item.checked ? '#333333' : '#999999')
.textDecoration(item.checked ? TextDecoration.LineThrough : TextDecoration.None)
Blank()
Text(`¥${item.price}`)
.fontSize(15).fontWeight(FontWeight.Bold).fontColor('#E84026')
}
.width('100%').height(56).padding({ left: 16, right: 16 })
if (index < this.goods.length - 1) {
Divider().strokeWidth(1).color('#F2F2F2').margin({ left: 12 })
}
}, (item: GoodsItem, index: number) => item.name + index.toString())
}
.width('92%').borderRadius(14)
.backgroundColor(Color.White).border({ width: 1, color: '#F0F0F0' })
Row({ space: 12 }) {
Button('反选')
.layoutWeight(1).height(44).fontSize(15)
.backgroundColor('#07C160')
.onClick(() => { this.invertAll(); })
Button(`结算(¥${this.totalPrice()})`)
.layoutWeight(2).height(44).fontSize(15)
.backgroundColor('#E84026')
.onClick(() => {
if (this.checkedCount() === 0) {
promptAction.showToast({ message: '请先勾选商品', duration: 1200 });
return;
}
promptAction.showToast({ message: `结算金额:¥${this.totalPrice()}`, duration: 1500 });
})
}
.width('92%')
}
.width('100%')
.padding({ top: 16 })
}
allChecked(): boolean {
return this.checkedCount() === this.goods.length && this.goods.length > 0;
}
checkedCount(): number {
// 统计 checked === true 的个数
return this.goods.filter((item: GoodsItem) => item.checked).length;
}
totalPrice(): number {
// 勾选商品单价求和
return this.goods.filter((item: GoodsItem) => item.checked)
.reduce((sum: number, item: GoodsItem) => sum + item.price, 0);
}
// partialText / partialColor:三态文案与颜色(未选灰/部分橙/全选绿),见演示工程源码
setAll(value: boolean): void {
// 用 map 生成新数组,保证 @State 引用比较能感知变化
this.goods = this.goods.map((item: GoodsItem) => {
return new GoodsItem(item.name, item.price, value);
});
}
toggleItem(index: number, value: boolean): void {
this.goods = this.goods.map((item: GoodsItem, i: number) => {
if (i === index) {
return new GoodsItem(item.name, item.price, value);
}
return item;
});
}
invertAll(): void {
this.goods = this.goods.map((item: GoodsItem) => {
return new GoodsItem(item.name, item.price, !item.checked);
});
}
}
一个 ArkTS 陷阱值得点名:@State goods 持有对象数组时,直接改元素的 checked 字段不会触发重绘——@State 做的是引用比较。所以每次修改都用 map 生成新对象数组(toggleItem/setAll/invertAll),既保持数据不可变,又保证状态更新可靠。
4.6 场景三:协议确认(AgreementDemo.ets)
协议页演示"勾选是门禁、点击是阅读"的分离设计,以及 enabled 对主按钮的守门:
import { promptAction } from '@kit.ArkUI';
@Component
export struct AgreementDemo {
@State agree: boolean = false;
@State showProtocol: boolean = false;
build() {
Column({ space: 16 }) {
Text('注册账号').fontSize(16).fontWeight(FontWeight.Bold)
.width('92%').textAlign(TextAlign.Start)
// …… 账号/密码输入等表单占位行(此处省略,完整代码见演示工程)……
Row({ space: 12 }) {
Checkbox({ name: 'agree' })
.select(this.agree)
.selectedColor('#0A59F7')
.onChange((isChecked: boolean) => {
this.agree = isChecked;
})
Text('我已阅读并同意').fontSize(14).fontColor('#666666')
Text('《用户协议》')
.fontSize(14).fontColor('#0A59F7')
.textDecoration(TextDecoration.Underline)
.onClick(() => {
this.showProtocol = !this.showProtocol;
})
Text('和').fontSize(14).fontColor('#666666')
Text('《隐私政策》')
.fontSize(14).fontColor('#0A59F7')
.textDecoration(TextDecoration.Underline)
.onClick(() => {
promptAction.showToast({ message: '已打开隐私政策', duration: 1200 });
})
Blank()
}
.width('92%')
.alignItems(VerticalAlign.Center)
if (this.showProtocol) {
Scroll() {
Text('《用户协议》摘要:服务内容、账号规范、隐私保护、知识产权、'
+ '协议更新(重大变更提前 7 日通知)。(演示用摘要,以官方文本为准)')
.fontSize(13).fontColor('#555555').lineHeight(22).width('100%')
}
.width('92%').height(160).padding(14)
.borderRadius(12).backgroundColor('#F7F9FF')
}
Button('注册')
.width('92%').height(44).borderRadius(22).fontSize(16)
.enabled(this.agree)
.backgroundColor(this.agree ? '#0A59F7' : '#C9D8F5')
.onClick(() => {
promptAction.showToast({ message: '注册成功', duration: 1500 });
})
}
.width('100%')
.padding({ top: 16 })
}
}
协议展开区是条件渲染而非覆盖层:showProtocol 为 true 时插入带边框的文本区,展开/收起不打断勾选状态;《隐私政策》用 Toast 示意详情入口。注册按钮未勾选时呈浅灰禁用,勾选后点亮品牌色。这里略去了账号/密码占位行与"保持登录状态"复选框,完整代码见演示工程。
五、模拟器运行与效果展示
启动模拟器,点击 Run 部署演示工程,依次切换三个 Tab 验证:
- 多选设置 Tab:单个复选框"记住偏好"默认勾选;兴趣组逐个勾选时,"已选 x/5"实时变化,组状态徽标在 未选择(灰)/部分选中(橙)/全部选中(绿) 间切换;全选/反选按钮联动整组。
- 商品全选 Tab:勾选商品行出现删除线与橙色金额;"全选"复选框随行数据实时推导;反选按钮一步翻转;结算按钮金额随勾选即时刷新。
- 协议确认 Tab:未勾选时注册按钮置灰;点击"《用户协议》"文字展开摘要(不改变勾选);勾选后按钮点亮。
截图占位(模拟器实机拍摄后替换):
| 占位图 | 场景 |
|---|---|
screenshot_01_initial.png |
初始状态:兴趣组未勾选、组状态为 None、注册按钮置灰 |
screenshot_02_partial.png |
部分选中:已勾选 2 项,组状态为 Part(橙色"已选 2/5") |
screenshot_03_all.png |
全选状态:5 项全勾,组状态为 All(绿色"全部选中") |
六、调试与常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 点击复选框 UI 不变 | 忘了把 select 绑到数据,或数据是局部变量 |
受控写法:select(this.isSelected(x)) |
onChange 拿不到"谁被勾了" |
回调参数是 boolean 不是 name |
用 index 捕获,或用组回调的 result.name |
| 全选后组状态仍是 Part | 程序化赋值不触发组回调 | 赋值路径自行推导状态,不把 UI 状态当唯一来源 |
改了 item.checked 不刷新 |
@State 是引用比较 |
map 生成新对象数组再赋值 |
| 全选框没有"半选"图形 | 原生无 indeterminate | 用文字/徽标表达部分选中 |
| 点协议文字自动勾选 | 阅读与同意绑在一个 onClick |
拆开:文字只展开内容,勾选只写 agree |
| 注册按钮点击无反馈 | 协议门禁未生效 | enabled(this.agree) + 禁用色 |
调试技巧:状态链路肉眼难查时,可以在 CheckboxGroup.onChange 里 hilog 打印 result.name 与 result.status,对照 3.3 流程图逐条核对"点击 → 数据 → 推导 → UI"四步。另注意 group 参数是字符串匹配,拼写不一致会静默失效(不报错)。
七、总结与扩展
-
状态单一来源
- 选中数据(数组或行字段)是唯一真相源,全选框与统计都由它推导,任何操作路径最终都写入同一份数据。 三态用统计表达
- ArkUI 复选框没有原生半选图形,用"已选 x/n"与 All/Part/None 徽标表达部分选中。 点击与勾选分离
- 协议文本的阅读点击不改变勾选,勾选是承诺、点击是阅读,注册按钮用 enabled 做门禁。 多选即可多可少
- 可多选、可取消的场景归 Checkbox,必选其一的场景归 Radio,二态开关归 Toggle。
扩展方向:① 购物车多级全选(分类全选 → 全局全选,仍是一条统计链);② 权限申请多选(一次勾选多个权限,每项附说明文字);③ 与 ListItem 组合做滑动批量选择;④ 深色模式为 selectedColor 配置资源色;⑤ 无障碍:协议文本独立可聚焦朗读,勾选控件补语义标签。下一篇将进入日期时间选择器家族(TimePicker)。
更多推荐



所有评论(0)