写鸿蒙界面,但凡页面里出现"选择"两个字,十有八九绕不开这三个基础控件:Checkbox、Radio、Switch。我之前在模拟项目X的偏好设置页里,第一版图省事,把单选需求全用 Checkbox 硬扛,结果测试一张截图甩过来:"这地方明明只能选一个,你给一排放复选框?" 从那以后,我花了一整天把这三个控件连同它们的分组逻辑彻底捋了一遍。这篇就把完整用法、联动取数和踩过的坑一次说清,内容包括 Checkbox 多选、Radio 单选互斥、Switch 开关状态同步,以及最后如何把勾选结果汇总成可提交的数据。适合刚入手鸿蒙应用开发、或者要快速搭建设置页、问卷页和筛选弹窗的同学直接参考。
1. 控件家族职责划分:单选、多选、开关各管一摊
1.1 三个控件分别解决什么问题
很多初学者第一次写设置页,看到 Checkbox、Radio、Switch 都有"勾选/切换"的视觉样式,就把它们当同一类控件随便用。实际上它们的交互语义差别很大,用错了用户马上会困惑。先看一张对照表:
| 控件 | 交互语义 | 适合场景 | 典型页面示例 |
|---|---|---|---|
| Checkbox | 多选:从 N 个候选中挑任意多个,可以全选也可以全不选 | 兴趣爱好、问卷多选题、批量操作 | 问卷页、批量选择文件 |
| Radio | 单选:N 个候选中必须且只能选一个 | 性别、支付方式、排序规则 | 注册页、结算页、筛选弹窗 |
| Switch | 开关:一个布尔状态的开/关切换 | 通知提醒、深色模式、权限授权 | 系统设置页、隐私管理 |
可以这样记:Checkbox 是"我可以都要,也可以都不要,还能要一半";Radio 是"不能不选,但只能挑一个";Switch 是"这事只有一个开关,我要决定它开还是关"。三者本质上对应着多选集合、单选枚举、布尔状态三种完全不同的数据模型,UI 控件只是它们的外壳而已。
1.2 为什么我坚持"单选不碰 Checkbox"
单选需求用 Checkbox 硬扛,虽然代码上可以实现"选了新的就取消旧的",但视觉上用户看到一排复选框,第一反应就是"可以多选"。这就违背了界面设计里最基础的 affordance 原则——控件长什么样,就应该暗示它能怎么操作。Conversely,两个选项如果是"是/否""开/关"这种布尔对立,也别急着用 Radio 加两个选项,直接上 Switch 或单个 Checkbox 反而更简洁。控件的选择要先于代码逻辑做对,否则后面所有校验都是补漏。
1.3 本文示例的运行环境
下面所有代码都基于 ArkUI 声明式 UI,语言是 ArkTS,适配当前的组件式 API 写法,模拟项目工程的页面结构都是@Entry @Component入口。你只要打开鸿蒙官方 IDE 新建一个 ArkTS 空工程,把示例代码复制进build()方法里就能直接跑通。早期 API 版本和跨端兼容方案里参数名可能略有差异,但分组、取值、绑定的核心思路完全一致。
2. Checkbox 与 CheckboxGroup:多选场景从零落地
2.1 单个 Checkbox 的最简写法与状态绑定
先看最小可运行的单个 Checkbox:
@Entry @Component struct CheckboxDemo { @State isAgree: boolean = false build() { Row({ space: 8 }) { Checkbox({ name: 'agree' }) .select(this.isAgree) .selectedColor('#007DFF') .onChange((checked: boolean) => { this.isAgree = checked console.info(`协议勾选状态: ${checked}`) }) Text('我已阅读并同意用户协议') .fontSize(16) } } }这里四个属性各管一件事:name是这个控件的唯一标识,后面用 CheckboxGroup 取值时靠它区分选项;select(boolean)是选中状态的总开关,我建议永远绑定一个@State变量,而不是写死select(true);selectedColor决定选中后的填充色,不写默认是主题蓝;onChange在每次状态变化时回调,参数就是当前是否选中。为什么强制要求绑定状态变量?因为界面显示是 UI 层,业务数据是数据层,勾选状态本质上是要参与提交、校验、重置的数据,如果你只在组件上写死一个select(true),后续程序逻辑里想"根据接口返回值自动勾选"或者"点一下按钮全选",你就要去操作 UI 状态,非常容易失控。
2.2 CheckboxGroup:实现全选、半选和取值
多个 Checkbox 归属同一个业务分组时,比如"兴趣爱好选择",你不仅要知道每个选项的勾选状态,还经常要支持"全选/取消全选",并随时拿到所有选中的选项名称。这时候就要用 CheckboxGroup 把一组 Checkbox 管起来:
@Entry @Component struct CheckboxGroupDemo { @State selectAll: boolean = false @State selectedNames: string[] = [] @State groupStatus: SelectStatus = SelectStatus.None build() { Column({ space: 12 }) { // 全选按钮 Checkbox({ name: 'selectAllCb' }) .select(this.selectAll) .onChange((checked: boolean) => { this.selectAll = checked }) // 分组容器,group 名必须与子项完全一致 CheckboxGroup({ group: 'hobbyGroup' }) .selectAll(this.selectAll) .onChange((result: CheckboxGroupResult) => { this.groupStatus = result.status this.selectedNames = result.names if (result.status === SelectStatus.All) { this.selectAll = true } else if (result.status === SelectStatus.None) { this.selectAll = false } }) Checkbox({ name: 'basketball', group: 'hobbyGroup' }) Checkbox({ name: 'football', group: 'hobbyGroup' }) Checkbox({ name: 'swim', group: 'hobbyGroup' }) } } }代码里三个要点值得展开。第一,CheckboxGroup自身并不会渲染任何可见组件,它更像是"虚拟的管理节点",通过group字符串把同名子项聚合成一组,官方 API 里CheckboxGroupResult会回传两个关键字段:names是当前所有被选中子项的name数组,status是组的整体状态,取值是SelectStatus里的All、Part、None三态。第二,.selectAll()绑定全选按钮的状态变量后,点击全选会同步控制组内所有子项;反过来用户手动取消其中一个子项,组的状态变化就会触发onChange,你再在回调里根据result.status把全选按钮的状态同步回来,这就是"子项-全选按钮"双向联动的标准闭环。第三,半选状态目前公开 API 没有直接给 Checkbox 提供"半勾选"样式,遇到"部分选中"时通常的做法是把全选按钮置为false,或者干脆换成自定义图标,这点心里有数就好。
2.3 ForEach 动态多选列表的推荐写法
实际项目里选项经常来自后端接口,比如标签列表、可批量操作的文件列表,这时候需要用 ForEach 动态渲染 Checkbox。动态渲染的难点不是把控件铺出来,而是每一个子项的选中状态和最终选中的 name 集合怎么对齐。我用下面这套写法,亲测稳定:
interface HobbyItem { id: string label: string } @Entry @Component struct DynamicCheckboxDemo { @State hobbyList: HobbyItem[] = [ { id: 'basketball', label: '篮球' }, { id: 'football', label: '足球' }, { id: 'swim', label: '游泳' } ] @State selectedIds: string[] = [] build() { Column({ space: 10 }) { ForEach(this.hobbyList, (item: HobbyItem) => { Row({ space: 8 }) { Checkbox({ name: item.id, group: 'dynGroup' }) .select(this.selectedIds.includes(item.id)) .onChange((checked: boolean) => { this.toggleSelected(item.id, checked) }) Text(item.label) } }, (item: HobbyItem) => item.id) } } private toggleSelected(id: string, checked: boolean) { const index = this.selectedIds.indexOf(id) if (checked && index === -1) { this.selectedIds = [...this.selectedIds, id] } else if (!checked && index !== -1) { this.selectedIds = this.selectedIds.filter(v => v !== id) } } }这里有两个特别容易踩的细节。一是 ForEach 的第三个参数是键值生成函数,必须给每一项绑定一个稳定的唯一值,我直接用item.id,这样列表增删或排序重排时,重组引擎能找到对应组件,选中状态才不会串位。二是更新selectedIds时不要图省事写成this.selectedIds.push(id),ArkUI 对@State数组的观测依赖"引用替换",直接 push 是原地修改原数组引用,UI 感知不到变化;正确做法是像示例里一样每次返回一个新数组。这个坑我后面还会专门讲。
3. Radio 与 RadioGroup:单选的互斥逻辑与动态渲染
3.1 同组互斥原理与 onChange 的守卫写法
Radio 的互斥机制比 Checkbox 更值得先讲清楚。它的核心是:同一个group字符串下的多个 Radio,系统会自动保证只有一个处于选中态,用户点击新选项时,旧选项会被自动取消。代码里真正要下功夫的是业务值的同步逻辑:
@Entry @Component struct RadioDemo { @State gender: string = 'male' build() { Column({ space: 12 }) { Row({ space: 8 }) { Radio({ value: 'male', group: 'genderGroup' }) .checked(this.gender === 'male') .onChange((checked: boolean) => { if (checked) { this.gender = 'male' } }) Text('男') } Row({ space: 8 }) { Radio({ value: 'female', group: 'genderGroup' }) .checked(this.gender === 'female') .onChange((checked: boolean) => { if (checked) { this.gender = 'female' } }) Text('女') } } } }value是选项的业务值,提交表单时它就是你要的数据;checked绑定一个布尔表达式,从"当前业务值是否等于这个选项的值"推导出来,这样数据源永远只有一个。重点说onChange里的if (checked)守卫:当用户从"男"切到"女"时,"男"这个 Radio 的onChange也会触发一次,参数是false,如果你不在回调里做判断,业务值就会被最后一次触发的false覆盖,造成界面选中了"女"、业务数据却变成空的诡异状态。只想在该选项被选中时才更新业务值的写法,就是对每个回调先判断checked再赋值。
3.2 默认选中值应该怎么给
设置默认选中有两种姿势。第一种很直接,在某个 Radio 上硬写.checked(true),适合纯静态页面,但页面后续如果因数据变化重绘,这个默认选中可能和业务状态脱节。第二种是上面示例里的写法,把checked绑定到业务表达式,默认选中由业务变量的初始值决定,比如@State gender: string = 'male'一上来就天然选中了"男"。我强烈推荐第二种,因为它保证了"界面选中的值"和"业务变量的值"在任何时刻都等价,回显、重置、接口预填全部复用同一套逻辑,不需要额外写同步代码。
3.3 整行点击与动态单选列表
Radio 默认只有圆形按钮本身可以点击,用户手指点旁边的文字没有任何反应,这在移动端的合格体验里是不过关的。常规做法是把 Radio 和 Text 放进一个 Row,给整个 Row 增加点击事件,点击时直接把业务值赋值进去:
@State payMethods: PayMethod[] = [ { id: 'alipay', label: '支付宝' }, { id: 'wechat', label: '微信' }, { id: 'bank', label: '银行卡' } ] @State payMethod: string = 'alipay' ForEach(this.payMethods, (item: PayMethod) => { Row({ space: 8 }) { Radio({ value: item.id, group: 'payGroup' }) .checked(this.payMethod === item.id) .onChange((checked: boolean) => { if (checked) { this.payMethod = item.id } }) Text(item.label) } .onClick(() => { this.payMethod = item.id }) .padding({ top: 10, bottom: 10 }) }, (item: PayMethod) => item.id)这里要注意,Row 的onClick和 Radio 自身的onChange可能同时触发,但因为两者最终写的是同一个值,重复赋值是幂等操作,不会出问题。另外动态渲染单选列表时,最忌讳把group字符串写成循环相关的拼接值,每一项的group必须完全相同,互斥才能生效,这一点我在踩坑篇会展开。
4. Switch 与 Toggle:开关控件的状态与样式定制
4.1 Switch 基础写法与状态复位
Switch 的结构比前两个控件简单,适合表达"单一布尔状态",比如消息通知、深色模式这类设置项。我习惯把同一结构的开关行抽成一个@Builder方法,页面里大量开关时能少写大量重复代码:
@Entry @Component struct SwitchDemo { @State notify: boolean = true @State darkMode: boolean = false build() { Column({ space: 16 }) { this.renderSwitchRow('消息通知', this.notify, (v: boolean) => { this.notify = v }) this.renderSwitchRow('深色模式', this.darkMode, (v: boolean) => { this.darkMode = v }) } } @Builder renderSwitchRow(title: string, checked: boolean, onChange: (v: boolean) => void) { Row() { Text(title).fontSize(16) Blank() Switch({ isOn: checked }) .selectedColor('#007DFF') .onChange((isOn: boolean) => { onChange(isOn) }) } .width('100%') } }isOn是开关初始状态,建议同样绑定状态变量而不是写死,这样页面重置、接口回显时只要重新给状态变量赋值,开关就会自动刷新到正确位置。onChange回调的参数就是开关拨动后的布尔值,直接落回业务状态即可。用@Builder抽公共行还有一个额外好处:将来要统一加副标题、图标或者改间距,只改一处,全页面生效。
4.2 开关样式定制能做到什么程度
Switch 的默认样式足够应付大部分设置页,需要定制时,selectedColor是最常用的属性,它决定开关"打开状态"下的轨道填充色,设置页里要配合品牌主色时基本都用它。如果你的 API 版本支持switchStyle,还能进一步控制轨道圆角、滑块尺寸和内部文字,不同版本能力不完全一致,建议以当前 SDK 的实际属性为准;版本较低时不支持的样式,可以用后面要讲的 Toggle 兜底。这里提醒一句,开关的"打开/关闭"状态对比度要够,浅色背景下灰色轨道和主题色轨道如果色差太小,用户会分不清当前状态,这是体验测试里经常被提的细节。
4.3 Toggle:一个控件三种形态
Toggle 是更通用的选择类控件,通过type参数能切换成三种视觉形态:
| ToggleType 枚举 | 展示形态 | 对应的独立控件 |
|---|---|---|
| ToggleType.Switch | 开关 | Switch |
| ToggleType.Checkbox | 复选框 | Checkbox |
| ToggleType.Button | 按钮式切换 | 无直接对应 |
Toggle({ type: ToggleType.Switch, isOn: this.notify }) .selectedColor('#007DFF') .onChange((isOn: boolean) => { this.notify = isOn })选 Switch 还是 Toggle,我的判断标准很简单:纯布尔开关、只需要调个颜色、页面里没有复杂定制需求,直接用 Switch,代码更短;需要在开关内部加文字、改主题统一风格,或者一个页面里同时出现三种选择形态想保持 API 一致,就用 Toggle。两种控件的onChange参数和状态绑定思路一模一样,切换成本很低。
5. 表单联动:把勾选状态汇总成可提交的数据
5.1 用一个表单模型管理所有控件状态
页面里同时出现多选、单选、开关时,如果每个控件开一个零散的@State变量,提交时东拼西凑,重置时挨个赋值,代码很快就会乱。更好的做法是先定义一个表单模型,用一个对象管理所有控件对应字段:
interface SettingsForm { hobbies: string[] gender: string notify: boolean darkMode: boolean } @State form: SettingsForm = { hobbies: [], gender: 'male', notify: true, darkMode: false }页面里每个控件的绑定和回调统一操作this.form里的字段。提交时只组装一次对象,结构清晰、字段不会漏;重置时直接把form整体赋值为一个新的默认对象,所有绑定了该对象的控件会同步刷新。这种"单一数据源"的写法,在多控件表单场景下是把状态管理复杂度降下来的关键,尤其是后面要加"编辑回显""根据条件联动禁用选项"这类需求时,改数据比改控件容易得多。
5.2 子组件状态回传的两种方式
页面拆成子组件后,子组件里的 Checkbox、Radio、Switch 如何把最新状态传回父组件,是个绕不开的问题。我个人最常用的是回调函数方式:父组件把更新逻辑以闭包形式传下去,子组件在onChange里直接调用。以 Switch 行为例:
@Component struct SettingsItem { title: string value: boolean onValueChange: (v: boolean) => void build() { Row() { Text(this.title) Blank() Switch({ isOn: this.value }) .onChange((isOn: boolean) => { this.onValueChange(isOn) }) } } }父组件使用时这样传参:SettingsItem({ title: '消息通知', value: this.form.notify, onValueChange: (v: boolean) => { this.form.notify = v } })。这种回调方式的优点是显式、可控、没有隐式联动,适合业务逻辑复杂的页面。如果子组件层级固定、页面简单,也可以改用@Link把父组件的状态变量直接绑定进来,子组件内赋值父组件立刻感知。我建议新手先用回调把数据流走通,理解了"状态从哪来、回到哪去"之后,再引入@Link这种更省事但更容易绕晕的写法。
5.3 提交与重置时的取值细节
提交时,三类控件的取值方式各有不同。CheckboxGroup 已经帮你汇总好了选中项,直接用result.names得到的就是选中子项的 name 数组,不用再遍历子项逐个判断;Radio 的业务值就是你初始化选项时写的value字符串,提交时直接用一个变量;Switch 是布尔值,原样落库即可。平时我在页面上会额外放一个"查看当前表单状态"的临时按钮,把this.form打点输出,联调阶段能立刻判断是控件绑定错了还是提交组装错了。重置逻辑就简单了,给form赋一个全新的默认对象,所有控件的checked、isOn、select都因为绑定了它的字段而自动归位,这一步的前提是前面所有控件都严格绑定了状态变量,只要有一个控件用了硬编码初始值,重置就会漏掉它。
6. 踩坑实录:状态不同步、分组失效的完整排查链
6.1 现象:代码里明明写了 select(true),界面却一直未勾选
这是我见过最多的问题。排查链路从简单到复杂依次走:第一步检查是不是把select(true)写死成了常量,没有绑定状态变量,导致界面的显示状态和业务状态各说各话;第二步看绑定的@State变量有没有在build()里被引用,如果变量声明了但构建函数里没用到,状态变化就不会触发相关组件刷新;第三步检查赋值方式,数组或对象是不是原地修改了引用,比如直接 push、直接改对象的某个字段,这在引用类型绑定场景里是隐藏的刷新失效元凶;最后一步,如果选中状态来自接口,确认异步回调返回后赋的是新引用,而不是在原来的数组引用上做修改。
6.2 现象:CheckboxGroup 不联动,点全选只有全选按钮自己勾上
这个坑的根因九成是 group 字符串对不上。CheckboxGroup 的group参数必须和子项 Checkbox 的group参数完全一致,多一个空格、大小写不同、甚至全角半角差异,都会导致分组静默失效。最典型的情况是子项忘了写group,或者从别处复制代码时带了一个不可见的空格。排查时先在代码里全局搜索所有group: 'xxx',逐字对比,或者更好一点,把 group 常量抽出来统一引用,比如页面上方定义private readonly GROUP_HOBBY: string = 'hobbyGroup',所有控件都引用这个常量,从源头杜绝手写错误。
6.3 现象:Radio 出现"两个都能选"或"全都不选"
两个都能选,直接查 Radio 的 group 参数,和 Checkbox 分组失效同理,group 不一致时系统无法识别它们属于同一组,互斥自然失效。全都不选的问题则多半出在onChange回调缺少if (checked)守卫,用户切选项时旧选项触发的false回调把业务值覆盖了,界面绑定的 checked 表达式永远不成立,看起来就是"怎么点都没有选中"。另外还要检查业务变量有没有初始化默认值,一个没有初始值的变量去和选项 value 做===比较,结果永远是false。这三点从互斥逻辑、回调守卫、初始值三个方向逐项排除,基本无往不利。
6.4 现象:勾选状态变了,列表项却不刷新
根本原因是对@State引用的理解偏差。ArkUI 对数组和对象的观测是基于引用替换的,push、splice、直接改对象属性这类原地操作不会触发 UI 重新渲染,你需要让"变化后的数据"成为一个新的引用。正常的做法是像前面动态多选示例那样,用展开运算符或 filter 生成新数组再赋值。与之配套的还有 ForEach 的 key,如果 key 不稳定,组件被错误复用时也会出现状态串位,所以每次渲染都要提供唯一且稳定的 key 值。
6.5 现象:全选按钮和子项互相"打架"
常见两种表现:手动取消一个子项,全选按钮仍保持勾选;或者点击全选后 group 的onChange被反复触发,页面像在闪。第一种是把全选按钮和 CheckboxGroup 割裂开了,没有在 group 的onChange里根据result.status同步全选按钮,补上All / None的状态判断即可。第二种是自己的读写出现了循环:全选按钮的onChange改selectAll,selectAll又通过.selectAll()改动子项,子项变化又触发 grouponChange反向修改selectAll。处理办法是收敛双向写操作,让 group 的onChange成为全选按钮状态的唯一同步入口,全选按钮自身的onChange只负责触发值变化,不要两头都写,必要时加一个执行守卫变量挡住重复流程。
6.6 现象:Switch 变化保存后重进页面,显示又回去了
这个现象多半是持久化或接口回显的数据没有正确落到 Switch 绑定的状态变量上。如果你把Switch({ isOn: false })写死了,那不管数据怎么变,控件永远显示关闭。正确姿势是始终Switch({ isOn: this.form.notify }),页面每次可见时在onPageShow或路由回跳回调里重新从本地存储或接口读取值并赋给form.notify,让绑定自动驱动界面刷新。还有一个容易忽略的点:如果开关变化后保存逻辑是异步的,用户快速连点几次开关,最后一次写入可能和界面状态不一致,稳妥的做法是提交时统一读取form里的最终值,而不是在onChange里逐次保存。
最后再分享两个我在实际项目里养成的习惯。一是所有 group 字符串一律抽成文件顶部的常量,复制粘贴再也不会因为一个空格排错半天;二是凡是要用这些控件搭选择类页面,先花十分钟把表单模型定义好,再动手写 UI。只要数据源是唯一的,后续重置、回显、提交都只是改一个对象的事,界面和业务值永远对得上。