HarmonyOS7 ArkUI 实战:Toggle、Checkbox、Radio 构建应用设置页

📅 2026/7/15 20:21:47 👁️ 阅读次数 📝 编程学习
HarmonyOS7 ArkUI 实战:Toggle、Checkbox、Radio 构建应用设置页

文章目录

      • 前言
      • 代码讲解
        • 页面入口怎么安排
        • 数据模型先稳住
        • 状态变量控制页面反馈
        • 布局代码不要从样式开始读
        • Builder 和方法承担复用
        • 使用方式
      • 完整代码
      • 关键代码解析
        • `interface` 不是摆设
        • `@State` 是交互的开关
        • 列表和卡片要靠数据驱动
        • 事件回调要短一点
        • 样式参数别急着抽常量
      • 总结

前言

设置页里的开关、多选、单选容易混在一起,拆清状态模型比堆组件更重要。

我建议把这篇当成一个可以直接改造的 ArkUI 页面模板来看。先跑起来,确认页面展示和交互都正常;再把里面的模拟数据替换成接口数据;最后再调整颜色、间距和业务字段。这样改起来比较稳,不容易一边改样式一边把状态逻辑弄乱。

这篇文章会按「页面意图、数据结构、状态流转、布局组织、交互细节」这条线来读代码。你不用从第一行样式硬看到最后,先抓住主线,后面的细节会轻松很多。

代码讲解

页面入口怎么安排

代码里的入口组件是ToggleCheckboxRadioSettingsPage。如果你把它当成单独页面预览,保留@Entry就可以;如果要塞进现有工程的路由体系,通常会去掉@Entry,再由外层页面或路由模块统一管理。

这个习惯很重要。一个项目里入口页面太多,预览时容易混,后期拆组件也会麻烦。我的做法是:案例阶段保留入口,接业务时只保留真正需要被路由访问的页面。

数据模型先稳住

本案例涉及的数据模型主要有:SettingOptionNotifyOptionThemeOptionlanguageOptionsParamsqualityOptionsParams

这些interface的作用不是为了“看起来规范”,而是给 UI 层划边界。比如列表项需要标题、描述、颜色、选中态,就应该在模型里说清楚。后续从接口拿数据时,可以在请求层做字段映射,别让后端字段名直接污染页面代码。

如果你准备接真实业务,建议把模拟数组替换成接口返回后的 ViewModel。页面只关心自己要渲染什么,不关心接口原始字段长什么样。

状态变量控制页面反馈

本案例里的关键状态包括:settingsnotifyOptionsselectedThemeselectedLanguageselectedQuality

@State的职责是让 UI 对用户操作有反馈。比如选中某个卡片、切换分类、展开内容、修改输入值、控制加载中状态,这些都应该由状态驱动。状态一变,相关 UI 自动刷新,这也是 ArkUI 声明式开发最核心的体验。

这里有个容易踩的坑:不要把所有数据都塞进@State。静态配置用普通数组就够了,只有用户会改、接口会更新、页面需要重新渲染的数据,才适合放进状态里。

布局代码不要从样式开始读

这个页面里比较关键的组件有:

  • Row:横向排列、左右分栏、按钮组或卡片行都依赖它来控制水平方向的节奏。
  • Column:纵向组织标题、内容、操作区,适合搭页面主骨架。
  • Divider:负责信息分组,不只是画一条线,而是帮用户理解层级。
  • Scroll:用于长内容容器,锚点跳转和返回顶部都离不开滚动控制。
  • Button:按钮案例要关注 loading、disabled、主题色和点击反馈。
  • Toggle:开关组件适合布尔状态,常用于设置项。

读布局时,我更建议先找大容器,再看内部如何拆块。比如一个页面通常会先用Column纵向放标题区、内容区、操作区;内容区里再用RowGridList或其他容器细分。这样看代码,层级会比逐行看.padding().fontSize()清楚得多。

如果页面显示错位,优先检查容器关系,而不是马上改像素。很多问题不是某个.margin()不对,而是外层容器的宽高、滚动方向、权重分配或对齐方式没想清楚。

Builder 和方法承担复用

这篇没有额外拆出 Builder,页面结构主要集中在build()中。

辅助方法主要有:toggleSettingtoggleNotifygetEnabledCountgetNotifyCount

@Builder更适合复用 UI 片段,比如卡片、列表行、头部区域、按钮组。普通方法更适合放计算逻辑,比如筛选列表、统计数量、切换状态、生成颜色。两者分清楚后,页面会干净很多。

我不建议把所有东西都塞进build()。刚开始代码少还行,需求一多,build()会变成几百行,后面改一个按钮都要找半天。

使用方式

把下面的完整代码放到 ArkTS 页面文件里即可运行。文章中的组件名已经是语义化命名,不包含编号式案例名称,接路由时直接使用ToggleCheckboxRadioSettingsPage就行。

如果你要改成业务页面,优先改三处:数据模型、状态变量、事件方法。样式可以后调,先保证数据流和交互是通的。

完整代码

// ToggleCheckboxRadioSettingsPage: 开关与选择 - Toggle/Checkbox/Radio 组合实战 // 知识点: Toggle(Switch/Button/Checkbox)、Checkbox、Radio、RadioGroup、选项组 interface SettingOption { optionId: number optionLabel: string optionDesc: string optionEmoji: string isEnabled: boolean } interface NotifyOption { notifyId: number notifyLabel: string notifyEmoji: string isChecked: boolean } interface ThemeOption { themeId: string themeLabel: string themeEmoji: string themePreview: string } interface languageOptionsParams { key: string, label: string, emoji: string } interface qualityOptionsParams{ key: string, label: string, desc: string } @Entry @Component struct ToggleCheckboxRadioSettingsPage { @State settings: SettingOption[] = [ { optionId: 1, optionLabel: '消息通知', optionDesc: '接收应用推送消息', optionEmoji: '🔔', isEnabled: true }, { optionId: 2, optionLabel: '自动同步', optionDesc: '数据自动同步到云端', optionEmoji: '☁️', isEnabled: true }, { optionId: 3, optionLabel: '深色模式', optionDesc: '切换深色主题', optionEmoji: '🌙', isEnabled: false }, { optionId: 4, optionLabel: '指纹解锁', optionDesc: '使用指纹快速解锁', optionEmoji: '👆', isEnabled: true }, { optionId: 5, optionLabel: '位置服务', optionDesc: '允许获取位置信息', optionEmoji: '📍', isEnabled: false }, { optionId: 6, optionLabel: '声音反馈', optionDesc: '操作时播放音效', optionEmoji: '🔊', isEnabled: true }, ] @State notifyOptions: NotifyOption[] = [ { notifyId: 1, notifyLabel: '新消息提醒', notifyEmoji: '💬', isChecked: true }, { notifyId: 2, notifyLabel: '订单状态变更', notifyEmoji: '📦', isChecked: true }, { notifyId: 3, notifyLabel: '优惠券到期', notifyEmoji: '🎫', isChecked: false }, { notifyId: 4, notifyLabel: '系统更新', notifyEmoji: '🔄', isChecked: true }, { notifyId: 5, notifyLabel: '好友动态', notifyEmoji: '👥', isChecked: false }, { notifyId: 6, notifyLabel: '签到提醒', notifyEmoji: '✅', isChecked: false }, ] @State selectedTheme: string = 'auto' @State selectedLanguage: string = 'zh-cn' @State selectedQuality: string = 'high' private themeOptions: ThemeOption[] = [ { themeId: 'light', themeLabel: '浅色模式', themeEmoji: '☀️', themePreview: '#FFFFFF' }, { themeId: 'dark', themeLabel: '深色模式', themeEmoji: '🌙', themePreview: '#1A1A1A' }, { themeId: 'auto', themeLabel: '跟随系统', themeEmoji: '🔄', themePreview: '#F0F4FF' }, ] private languageOptions:languageOptionsParams[] = [ { key: 'zh-cn', label: '简体中文', emoji: '🇨🇳' }, { key: 'zh-tw', label: '繁體中文', emoji: '🇹🇼' }, { key: 'en-us', label: 'English', emoji: '🇺🇸' }, { key: 'ja', label: '日本語', emoji: '🇯🇵' }, ] private qualityOptions:qualityOptionsParams[] = [ { key: 'ultra', label: '超清', desc: '4K · 需要高速网络' }, { key: 'high', label: '高清', desc: '1080P · 推荐' }, { key: 'medium', label: '标清', desc: '720P · 省流量' }, { key: 'low', label: '流畅', desc: '480P · 低流量' }, ] toggleSetting(optionId: number) { this.settings = this.settings.map((s: SettingOption) => { if (s.optionId === optionId) { s.isEnabled = !s.isEnabled return s } return s }) } toggleNotify(notifyId: number) { this.notifyOptions = this.notifyOptions.map((n: NotifyOption) => { if (n.notifyId === notifyId) { n.isChecked = !n.isChecked return n } return n }) } getEnabledCount(): number { return this.settings.filter(s => s.isEnabled).length } getNotifyCount(): number { return this.notifyOptions.filter(n => n.isChecked).length } build() { Column({ space: 0 }) { // 标题 Row({ space: 0 }) { Text('⚙️ 应用设置') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .layoutWeight(1) Text(`${this.getEnabledCount()}/${this.settings.length} 已开启`) .fontSize(12) .fontColor('#888888') } .width('100%') .height(52) .padding({ left: 16, right: 16 }) .backgroundColor('#FFFFFF') .border({ width: { bottom: 1 }, color: '#F0F0F0' }) Scroll() { Column({ space: 12 }) { // 一、Toggle Switch 开关(设置列表) Column({ space: 0 }) { Text('一、Toggle Switch 功能开关') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .padding({ left: 16, top: 14, bottom: 10 }) .width('100%') ForEach(this.settings, (setting: SettingOption, idx: number) => { Column({ space: 0 }) { Row({ space: 12 }) { Text(setting.optionEmoji) .fontSize(22) .width(40) .textAlign(TextAlign.Center) .flexShrink(0) Column({ space: 3 }) { Text(setting.optionLabel) .fontSize(15) .fontColor('#1A1A1A') .fontWeight(FontWeight.Medium) Text(setting.optionDesc) .fontSize(12) .fontColor('#AAAAAA') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Toggle({ type: ToggleType.Switch, isOn: setting.isEnabled }) .selectedColor('#007DFF') .switchPointColor('#FFFFFF') .onChange((isOn: boolean) => { this.toggleSetting(setting.optionId) }) .flexShrink(0) } .width('100%') .height(60) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) if (idx < this.settings.length - 1) { Divider().strokeWidth(0.5).color('#F0F0F0').margin({ left: 68 }) } } }, (setting: SettingOption) => setting.optionId.toString()) Row({ space: 0 }).height(8) } .backgroundColor('#FFFFFF') .borderRadius(12) // 二、Checkbox 多选(通知设置) Column({ space: 0 }) { Row({ space: 0 }) { Text('二、Checkbox 通知订阅') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .layoutWeight(1) Text(`已选 ${this.getNotifyCount()} 项`) .fontSize(12) .fontColor('#007DFF') } .width('100%') .padding({ left: 16, right: 16, top: 14, bottom: 10 }) ForEach(this.notifyOptions, (notify: NotifyOption, idx: number) => { Column({ space: 0 }) { Row({ space: 12 }) { Checkbox({ name: notify.notifyLabel }) .select(notify.isChecked) .selectedColor('#007DFF') .onChange((isChecked: boolean) => { this.toggleNotify(notify.notifyId) }) .flexShrink(0) Text(notify.notifyEmoji) .fontSize(18) .flexShrink(0) Text(notify.notifyLabel) .fontSize(14) .fontColor(notify.isChecked ? '#1A1A1A' : '#888888') .layoutWeight(1) } .width('100%') .height(50) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) if (idx < this.notifyOptions.length - 1) { Divider().strokeWidth(0.5).color('#F0F0F0').margin({ left: 54 }) } } }, (notify: NotifyOption) => notify.notifyId.toString()) Row({ space: 0 }).height(8) } .backgroundColor('#FFFFFF') .borderRadius(12) // 三、Radio 单选(主题选择) Column({ space: 10 }) { Text('三、Radio 主题选择(单选)') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .width('100%') ForEach(this.themeOptions, (theme: ThemeOption) => { Row({ space: 12 }) { Radio({ value: theme.themeId, group: 'themeGroup' }) .checked(this.selectedTheme === theme.themeId) .radioStyle({ checkedBackgroundColor: '#007DFF' }) .onChange((checked: boolean) => { if (checked) { this.selectedTheme = theme.themeId } }) .flexShrink(0) Column() .width(32) .height(32) .backgroundColor(theme.themePreview) .borderRadius(6) .border({ width: 1, color: '#E0E0E0' }) .flexShrink(0) Text(theme.themeEmoji) .fontSize(20) .flexShrink(0) Text(theme.themeLabel) .fontSize(14) .fontColor('#1A1A1A') .layoutWeight(1) if (this.selectedTheme === theme.themeId) { Text('✓') .fontSize(16) .fontColor('#007DFF') .fontWeight(FontWeight.Bold) } } .width('100%') .height(52) .padding({ left: 16, right: 16 }) .backgroundColor('#FFFFFF') .borderRadius(10) .alignItems(VerticalAlign.Center) .border({ width: this.selectedTheme === theme.themeId ? 1.5 : 0, color: '#007DFF' }) .onClick(() => { this.selectedTheme = theme.themeId }) }, (theme: ThemeOption) => theme.themeId) } .width('100%') .padding(16) .backgroundColor('#F8F9FA') .borderRadius(12) // 四、Toggle Button 播放质量 Column({ space: 10 }) { Text('四、Toggle Button 画质选择') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .width('100%') ForEach(this.qualityOptions, (q: qualityOptionsParams) => { Row({ space: 12 }) { Toggle({ type: ToggleType.Button, isOn: this.selectedQuality === q.key }) .width(56) .height(32) .selectedColor('#007DFF') .onChange((isOn: boolean) => { if (isOn) { this.selectedQuality = q.key } }) .flexShrink(0) Column({ space: 2 }) { Text(q.label) .fontSize(15) .fontColor(this.selectedQuality === q.key ? '#007DFF' : '#1A1A1A') .fontWeight(this.selectedQuality === q.key ? FontWeight.Bold : FontWeight.Normal) Text(q.desc) .fontSize(12) .fontColor('#AAAAAA') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width('100%') .height(58) .padding({ left: 16, right: 16 }) .backgroundColor('#FFFFFF') .borderRadius(10) .alignItems(VerticalAlign.Center) .onClick(() => { this.selectedQuality = q.key }) }, (q: qualityOptionsParams) => q.key) } .width('100%') .padding(16) .backgroundColor('#F8F9FA') .borderRadius(12) } .padding({ left: 12, right: 12, top: 12, bottom: 24 }) } .scrollBar(BarState.Off) .layoutWeight(1) } .width('100%') .height('100%') .backgroundColor('#F5F6FA') } }

关键代码解析

interface不是摆设

很多新手写 ArkUI 页面时,会直接在ForEach里使用一堆临时对象。页面小的时候没问题,但字段一多,就很难知道每个字段到底用于哪里。本案例把数据结构提前声明出来,读代码时能很快判断每个 UI 区块依赖哪些字段。

后续如果接接口,也建议保留这层结构。接口可以变,页面模型尽量稳定。这样页面不会因为后端多返回一个字段、少返回一个字段就跟着大改。

@State是交互的开关

本案例的交互反馈都围绕状态展开。点击、切换、输入、刷新这类行为,本质上都是修改某个状态值,然后让 UI 重新计算显示结果。

写这类代码时有一个简单判断:如果某个值变化后,页面应该立刻变,那它大概率应该是状态;如果它只是配置项、静态文案、固定颜色,就不要放进状态。

列表和卡片要靠数据驱动

只要页面里出现重复结构,就应该优先想到数据数组加ForEach。这样新增一项、删除一项、调整顺序都只动数据,不用复制粘贴一段 UI。

这个案例的完整代码里已经把主要内容抽成数组或方法。你可以试着多加一条数据,看页面是否能自然渲染出来。如果能,说明结构是健康的;如果加一条就要改很多 UI,说明组件拆分还不够。

事件回调要短一点

.onClick()onChange()这类回调里可以直接改状态,但不建议堆太多逻辑。简单切换可以写在回调里,复杂逻辑最好拆成方法。

这样做有两个好处:一是读页面结构时不会被业务逻辑打断;二是后面要加埋点、请求接口、异常处理时,有明确的位置可以改。

样式参数别急着抽常量

这个案例里有不少颜色、间距、圆角、字号。学习阶段直接写在组件链上,反而更容易看出效果。等你确定页面风格稳定后,再把主题色、通用间距、卡片圆角提到统一常量里。

过早抽象会让示例代码变绕。先让页面清楚,再谈工程化,这是我比较推荐的顺序。

总结

这个案例真正值得学的,不只是某个 ArkUI 组件怎么写,而是一个页面如何从数据走到状态,再走到布局和交互。

你可以按这个顺序改造:先替换数据模型,再确认状态更新是否正确,接着调整布局容器,最后打磨样式细节。只要这条主线不乱,页面规模变大也不会失控。

如果后续继续扩展,我会优先把可复用的卡片、列表行、筛选栏或控制区拆成独立组件。这样主页面只保留数据和流程,代码会更接近真实项目里的写法。