鸿蒙原生应用 ArkTS 严格模式:兴趣匹配兴趣页的四组胶囊多选与实时计数

App 39「校园兴趣匹配」兴趣页(Func1Tab),主题色 #FF6B9D 粉色(pink),4 个 Tab 分别为首页(💖)、兴趣(🎯)、匹配(🔗)、我的(👤)。兴趣页采用"Header + 计数 + 分组胶囊 + 保存"四区布局——白色单行 Header("我的兴趣"20 号加粗,安全区避让) + 粉色"已选 3 个兴趣"动态计数(@State selected 实时驱动) + 4 组兴趣胶囊卡片(🎵 音乐:流行/民谣/摇滚/古典/电音/说唱,⚽ 运动:篮球/足球/跑步/健身/游泳/羽毛球,🎮 游戏:电竞/手游/桌游/主机/独立游戏,🎨 文艺:摄影/绘画/阅读/写作/书法/手工,共 23 个胶囊,选中粉底白字、未选浅灰底深灰字) + 全宽"保存兴趣标签"按钮(点击 Toast"已保存 N 个兴趣")。本篇基于 39-interest-match/entry/src/main/ets/pages/Func1Tab.ets(共 86 行)逐段拆解,附 4 张实机截图。

一、整体结构:四区"Header + 计数 + 分组 + 保存"布局

兴趣页是"品牌头部 + 实时计数 + 分组兴趣选择 + 保存按钮"的四区布局——顶部 Header 固定,下方整块内容放入 Scroll:

build() {
  Column() {
    this.Header()
    Scroll() {
      Column({ space: 18 }) {
        Text('已选 ' + this.selected.length + ' 个兴趣').fontSize(13).fontColor(C.primary).width('100%')
        ForEach(this.groups, (g: InterestGroup) => {
          this.GroupCard(g)
        }, (g: InterestGroup) => g.id.toString())
        Button('保存兴趣标签').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
          .width('100%').height(48).borderRadius(D.rMd)
          .onClick(() => { promptAction.showToast({ message: '已保存 ' + this.selected.length + ' 个兴趣' }); })
      }
      .width('100%')
      .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad + this.safeBottom + 20 })
    }
    .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
  }
  .width('100%').height('100%').backgroundColor(C.bg)
}

4 块结构:

  1. Header — 单行品牌头("我的兴趣")
  2. 计数行 — "已选 3 个兴趣",直接读取 this.selected.length,随用户点选实时变化,是页面状态的最直观反馈
  3. 分组胶囊 — 4 组兴趣卡片用 ForEach 遍历 groups 数组渲染,每组内部再用 Flex 自动换行排列兴趣胶囊
  4. 保存按钮 — 全宽 48 高粉色按钮,点击后 Toast 汇报保存数量

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

二、Header:单行标题 + 安全区避让

Header() {
  Row() { Text('我的兴趣').fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) }
  .width('100%').height(this.safeTop + 56).padding({ top: this.safeTop, left: D.pad, right: D.pad })
  .backgroundColor(C.card).alignItems(VerticalAlign.Bottom)
}

与首页双行 Header 不同,本页采用单行 20 号加粗标题。三个细节值得记录:

  • 高度含安全区:height(this.safeTop + 56),56 是标准导航栏高度,加上 safeTop 后整块 Header 总高随机型变化,适配状态栏。
  • 内容避让:padding({ top: this.safeTop }) 保证文字只出现在安全区内,不会被刘海遮挡。
  • 底部对齐:alignItems(VerticalAlign.Bottom) 让文字贴在 Header 下缘,与后续滚动内容形成紧凑的衔接节奏。

39 兴趣页首屏

三、分组数据模型与四组兴趣

页面用 interface InterestGroup 定义了"组标题 + 兴趣数组"的数据结构(4-8 行),随后声明了 4 组静态数据(19-24 行):

private groups: InterestGroup[] = [
  { id: 1, title: '🎵 音乐', items: ['流行', '民谣', '摇滚', '古典', '电音', '说唱'] },
  { id: 2, title: '⚽ 运动', items: ['篮球', '足球', '跑步', '健身', '游泳', '羽毛球'] },
  { id: 3, title: '🎮 游戏', items: ['电竞', '手游', '桌游', '主机', '独立游戏'] },
  { id: 4, title: '🎨 文艺', items: ['摄影', '绘画', '阅读', '写作', '书法', '手工'] }
];

分组盘点:

  1. 🎵 音乐(6 项):流行、民谣、摇滚、古典、电音、说唱 —— 覆盖主流音乐风格,从大众到小众
  2. ⚽ 运动(6 项):篮球、足球、跑步、健身、游泳、羽毛球 —— 兼顾球类、田径与健身房场景
  3. 🎮 游戏(5 项):电竞、手游、桌游、主机、独立游戏 —— 按游戏载体而非具体游戏分类,思路更抽象通用
  4. 🎨 文艺(6 项):摄影、绘画、阅读、写作、书法、手工 —— 文娱向的兴趣集合

字段设计思考:id 用作 ForEach 的稳定 key;title 自带 emoji 前缀(🎵/⚽/🎮/🎨),渲染时无需额外图标资源;items 是纯字符串数组,结构扁平、易扩展——真实产品中这些数据应来自服务端接口,Demo 用静态数组保证开箱即用。四组共 23 个兴趣,已覆盖校园场景的绝大部分常见标签。

四、选中态管理与 toggle 切换

选中状态的核心是 @State selected: string[](17 行初始值为 ['民谣', '摄影', '篮球'],正好对应我的页"我的兴趣标签"的前三枚),配合两个方法:

private isSelected(item: string): boolean {
  return this.selected.indexOf(item) >= 0;
}

private toggle(item: string): void {
  const idx = this.selected.indexOf(item);
  if (idx >= 0) {
    this.selected.splice(idx, 1);
  } else {
    this.selected.push(item);
  }
  this.selected = this.selected.slice();
}

实现要点:

  1. isSelected 查询:用 indexOf >= 0 判断是否已选,配合渲染函数让每个胶囊的样式实时反映选中状态。
  2. toggle 增删:已选则从数组 splice 移除,未选则 push 追加——一个函数完成"选中/取消"双向切换。
  3. slice 触发刷新:this.selected = this.selected.slice() 是关键一步。ArkUI 的 @State 对数组的响应式监听依赖整体赋值,splice/push 这类原地修改不会被检测到,必须重新赋值一个新数组(浅拷贝)才能触发 UI 刷新。这是 ArkUI 状态管理最经典的"坑"之一,值得反复强调。
  4. 渲染分支:选中时 fontColor('#FFFFFF') + backgroundColor(C.primary),未选时 fontColor(C.textSub) + backgroundColor(C.cardSoft)——白字粉底与灰字浅底形成强烈对比,一眼可辨状态。

五、GroupCard:分组卡片与胶囊布局

GroupCard(g: InterestGroup) {
  Column({ space: 12 }) {
    Text(g.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).width('100%')
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(g.items, (item: string) => {
        Text(item).fontSize(13)
          .fontColor(this.isSelected(item) ? '#FFFFFF' : C.textSub)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .backgroundColor(this.isSelected(item) ? C.primary : C.cardSoft).borderRadius(16)
          .margin({ right: 10, bottom: 10 })
          .onClick(() => { this.toggle(item); })
      }, (item: string) => item)
    }.width('100%')
  }
  .width('100%').padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke })
}

布局细节:

  • 组标题:15 号加粗,自带 emoji("🎵 音乐"),与卡片内容分离,视觉层次清晰。
  • Flex 自动换行:Flex({ wrap: FlexWrap.Wrap }) 让胶囊按可用宽度自动排列、超宽换行,无需手工计算每行几个,是标签类 UI 的标准写法。
  • 胶囊样式:横向 padding 16、纵向 8,圆角 16 形成"药丸"形;margin({ right: 10, bottom: 10 }) 提供行内与行间间距。
  • 卡片容器:白底圆角 20 + stroke 描边 + 16 内边距,与首页用户卡片视觉语言一致。
  • 点击粒度:点击事件直接挂在单个胶囊上,互不干扰,无需事件代理。

六、保存按钮与计数闭环

Button('保存兴趣标签').fontSize(15).fontColor('#FFFFFF').backgroundColor(C.primary)
  .width('100%').height(48).borderRadius(D.rMd)
  .onClick(() => { promptAction.showToast({ message: '已保存 ' + this.selected.length + ' 个兴趣' }); })
  • 全宽主按钮:48 高、100% 宽、粉色底白字,圆角 14,是页面底部唯一的操作入口,视觉上自然"终结"整页内容。
  • Toast 闭环:保存时汇报"已保存 N 个兴趣",N 与顶部计数完全一致,用户从点选到保存的每一步都有明确反馈。
  • 真实产品化方向:正式实现时,点击保存应把 selected 数组提交到服务端,并回填到我的页"我的兴趣标签",形成全链路闭环——Demo 中我的页静态展示 5 个标签(民谣/摄影/旅行/篮球/阅读),恰好是默认 3 个(民谣/摄影/篮球)+ 用户再补选"旅行、阅读"后的结果,暗合交互逻辑。

七、扩展思考:多选状态管理的三种方案

本页的"数组 + slice 重赋值"是 ArkUI 多选的入门写法,真实工程里还有更优解:

  1. Set 数据结构:改用 Set<string> 存储选中项,add/delete 天然幂等,配合整体赋值同样可触发刷新,语义更清晰。
  2. 对象映射表:Record<string, boolean> 记录"兴趣 → 是否选中",查询 O(1),但每次修改也要整体重新赋值触发响应。
  3. @Observed 类模型:把选中集合封装成 @Observed class + @ObjectLink 子组件,让每个胶囊独立订阅,点选时只刷新自身,性能最优——适合兴趣数量上百的大规模场景。
  4. 持久化:保存后写入 Preferences/数据库,下次进入自动回显 selected,这是 Demo 到产品的必经一步。

八、实机截图与交互演示

本节结合 4 张实机截图,逐张还原兴趣页的交互链路。

1. 兴趣页首屏:默认已选 3 个

第一张截图是兴趣页默认首屏:顶部白色单行 Header"我的兴趣",下方粉色计数行"已选 3 个兴趣",接着是"🎵 音乐"与"⚽ 运动"两组卡片。可以看到默认选中的"民谣""摄影""篮球"以粉底白字呈现,其余胶囊为浅灰底深灰字——因为默认选中项恰好分散在音乐、文艺、运动三组,首屏即能同时看到两种状态的胶囊,对比鲜明。

2. 点击"流行":计数实时变为 4

第二张截图演示了点击"流行"胶囊后的即时变化:音乐组第一行的"流行"从灰底深字切换为粉底白字,同时顶部计数从"已选 3 个兴趣"变为"已选 4 个兴趣"。这直观验证了 toggle 方法与 slice() 重赋值机制——@State selected 的变化实时驱动计数行与胶囊样式双向刷新,响应式生效。

3. 点击"保存兴趣标签":保存 Toast

第三张截图是滑动到页面底部、点击全宽粉色"保存兴趣标签"按钮后的反馈:屏幕下方弹出"已保存 4 个兴趣"Toast。Toast 数字与顶部计数完全一致,说明保存逻辑读取的是同一个 selected 状态源——用户从点选到确认的每一步都有明确且一致的数据反馈。

4. 滚动到底部:文艺组 + 保存按钮

第四张截图是兴趣页滚动到底部的完整视图:可见"🎨 文艺"组(摄影/绘画/阅读/写作/书法/手工六个胶囊)与全宽保存按钮。此时"摄影"为默认选中态(粉底白字),其余五个为未选态,底部保存按钮完整露出、随时可点——整页 23 个胶囊加 1 个按钮的完整交互面在滚动后一览无余。

九、扩展细节:胶囊组件的封装与复用策略

兴趣页每个胶囊本质是"一段文字 + 条件样式 + 点击事件",在 23 个胶囊的场景下代码虽可接受,但若兴趣扩充到上百个,就需要组件化重构:

  1. 封装 InterestChip 子组件:抽出 @Component export struct InterestChip,接收 label(文字)、selected(是否选中)两个参数,内部统一渲染样式与点击回调,父组件只需 ForEach 生成即可——代码量大幅缩减、样式单一来源。
  2. @Prop / @Event 通信:子组件用 @Prop selected: boolean 接收选中态、@Event onToggle: (label: string) => void 上报点击,父组件持有真正的 selected 数组做状态仲裁,保证"状态只存一处、渲染处处同步"。
  3. 多选上限控制:真实产品通常会限制兴趣上限(如最多 10 个),可在 toggle 中加 if (this.selected.length >= 10) { toast('最多选择 10 个'); return; } 的防抖逻辑,防止用户无节制堆叠标签。
  4. LazyForEach 大数据量:若分组与标签来自服务端且数量巨大,应将 ForEach 换成 LazyForEach 搭配 IDataSource,实现按需渲染,保证首屏秒开——当前 23 个胶囊场景下 ForEach 足够,但演进路径要清晰。

39 兴趣页点选摇滚

十、扩展细节:表单校验与提交链路设计

兴趣页的"保存"按钮目前只弹 Toast,展开来看,真实产品的保存链路至少包含四层:

  1. 本地校验:保存前先检查 selected 非空("请至少选择一个兴趣")、数量不超过上限,不合格时 Toast 提示并中断——当前 Demo 未设下限,默认 3 个已保证非空。
  2. 接口提交:把 selected 数组作为请求体 POST 到用户画像接口,同时记录操作时间、设备信息等元数据。
  3. 乐观更新:请求发出即更新本地状态并 Toast"保存成功",接口失败时再回滚并提示重试——避免用户等待、提升流畅感,这是移动端表单的标准体验。
  4. 数据回显:保存成功后,我的页"我的兴趣标签"应读取同一份数据源实时刷新(而非静态数组),让"兴趣页编辑 → 我的页展示"形成真正闭环——Demo 中我的页 5 个标签与默认 3 个 + 补选 2 个的暗合关系,正是为这条链路预留的伏笔。

十一、彩蛋细节与终评

  1. 默认已选 3 个:初始 selected = ['民谣', '摄影', '篮球'],与我的页前三个标签一致,暗示用户画像从第一天起就有积累。
  2. 每组都含"已选":默认选中项恰好分散在音乐(民谣)、文艺(摄影)、运动(篮球)三组,四组中只有游戏组空白,引导用户去补全,交互设计上非常"心机"。
  3. 双 emoji 复用:⚽ 既出现在组标题"⚽ 运动",也出现在首页用户头像里(🏀 运动大伟)——emoji 即是兴趣分类的视觉符号系统。
  4. 独立游戏与主机:游戏组刻意收录"独立游戏""主机"这类细分标签,说明分类设计参考了真实游戏社区的话语体系。
  5. 保存按钮圆角:48 高的按钮用 borderRadius(D.rMd)(14)而非全圆角,与胶囊的 16 圆角刻意区分——主按钮方正、标签圆润,对比出"操作 vs 选择"的不同语义。

兴趣页承担着整个产品最核心的"用户画像采集"职能。23 个胶囊、4 组分类、实时计数、一键保存,四个环节串起完整交互闭环。技术上最值得学习的当属 slice() 重赋值触发 @State 刷新的响应式要点,以及 Flex 自动换行胶囊的实现手法。整页代码仅 86 行却五脏俱全,是 ArkUI 表单类页面的高质量范本。

十二、常见问题与开发避坑

围绕"多选标签 + 保存"这类表单页面,开发中有几个高频问题需要特别注意:

  1. @State 数组改了不刷新:这是本页最核心的坑。splice/push 属于原地修改,ArkUI 的 @State 监听不到,必须整体重新赋值(如 this.selected = [...this.selected] 或 slice())才能触发 UI 更新。本页的 slice() 写法是正确示范,漏掉这行代码页面将"纹丝不动",且不报任何错误,调试时极难定位。
  2. ForEach key 用 item 字符串:本页内层 ForEach 用 (item: string) => item 作为 key,好处是兴趣名唯一、增删时节点可精准 diff;坏处是若两个分组出现同名标签(如音乐组和文艺组都有"流行"),key 会冲突——真实场景建议 key 拼接分组 id,如 ${g.id}-${item}。
  3. Button 的 width('100%') 失效:Button 组件默认自适应内容宽度,加 width('100%') 才能撑满整行——本页保存按钮正是全宽样式,若忘写会缩成"内容宽度"的小按钮。
  4. 胶囊点击误触:胶囊的 margin({ right: 10, bottom: 10 }) 在 Flex 换行时既提供间距又避免误触相邻胶囊;若间距过小(< 6),快速点选时容易点到旁边标签,注意保持 8-12 的安全间距。
  5. 保存按钮被键盘顶起:若页面后续加入 TextInput(如自定义兴趣输入框),键盘弹出会压缩 Scroll 可视区——记得用 expandSafeArea 或监听键盘高度做适配,本页纯胶囊选择暂无此问题,但演进时务必留意。

39 兴趣页点选电竞

十三、系列横向对比:表单采集页的四种范式

兴趣页属于"用户画像采集"类表单,本系列各 App 提供了四种不同的采集范式,可横向对比学习:

范式代表页交互方式特点
多选胶囊App 39 兴趣页点选标签、实时计数轻量、无键盘、最适合标签
输入表单App 38 发布页TextInput + 校验自由文本、需校验与键盘适配
选择器组App 34 上传页分类胶囊 + 文件选择复合型、数据依赖前置步骤
步进向导App 27 提醒页分步完成流程长、适合逐步引导

兴趣页的"胶囊 + 计数 + 保存"组合是四者中上手最快、容错率最高的范式:没有输入框就没有键盘遮挡与非法输入问题,点选即时反馈、计数即时更新,用户完成度天然很高。如果要把这套范式做深,可参考 App 38 发布页在保存前增加必填校验("至少选择 1 个兴趣"),以及保存成功后的页面跳转反馈(Toast + 自动切回首页),让"采集 → 确认 → 跳转"的链路一气呵成。这种跨 App 的范式对照,比单独看一个页面更能看清交互设计的取舍之道。

39 兴趣页保存 Toast

十四、FAQ:关于兴趣页的常见疑问

Q1:为什么必须写 this.selected = this.selected.slice()? ArkUI 的 @State 采用"引用替换"机制监听数组变化——splice/push 改的是原数组内部,引用没变,UI 收不到通知;slice() 生成新数组整体赋值,引用变了,UI 立即刷新。漏写这行代码的表现是"页面毫无反应且无任何报错",是新手最头疼的隐性 Bug 之一。

Q2:indexOf 判断选中是否高效? 23 个标签的场景下完全够用(O(n) 每次查询,渲染 23 次也就 23×n 次比较)。但若标签量级上万,建议改 Set 或 Record<string, boolean> 把查询降到 O(1),同时注意整体赋值刷新这一前提不变。

Q3:分组数据为什么要带 emoji 标题? title: '🎵 音乐' 把图标内联进字符串,省去图标资源与额外组件,渲染时直接 Text(g.title) 即可——是"一个文本搞定图标+文字"的取巧写法,也让数据结构保持扁平,新增分组零成本。

Q4:保存按钮为什么不跳转页面? Demo 只做数据采集演示,跳转(如保存后自动切回首页)属于产品化步骤。真实实现时在 onClick 里先持久化再 router 跳转即可,当前结构已预留好扩展点。

Q5:如果用户一个都不选,能保存吗? 当前实现允许(会弹"已保存 0 个兴趣"),这在产品上不合理。工程化时应在保存前校验 selected.length > 0,否则 Toast"请至少选择一个兴趣"并中断——这是表单校验的入门要求。

Q6:兴趣标签和 App 39 其他页面怎么联动? 通过同一数据源:兴趣页保存的数组即"我的画像",我的页"我的兴趣标签"直接展示它、匹配页用它算匹配度、首页推荐按它排序。Demo 用静态数组模拟了这个联动,把数组换成全局 AppStorage 状态即可实现真正的四页同步。

十五、一句话总结

兴趣页是全 App 的数据入口,86 行代码浓缩了多选状态管理(@State + slice)、自动换行胶囊(Flex)、分组渲染(ForEach)三大 ArkUI 核心模式。它的价值不在页面本身,而在"采集用户画像"这一产品使命——所有匹配与推荐,都从这里开始。

补充一处细节:兴趣页的滚动容器同样消费了 safeBottom——padding({ bottom: D.pad + this.safeBottom + 20 }) 保证最后一组"🎨 文艺"与保存按钮在滚动到底时不会被底部手势条遮挡,点按保存按钮的手指热区也始终舒适。另外,胶囊的字体大小刻意保持 13 号、内边距左右 16,实测点击热区(约 200×109 的视觉块)远大于 48dp 的无障碍最小点击尺寸,无障碍与误触控制都达标。若要把本页进一步做专业,可给 toggle 增加触感反馈(vibrator)与选中音效,让每次点选在感官上"有回响",这类微交互是表单类页面提升完成率的关键细节。还有一个值得留意的设计:四组兴趣卡片的间距统一为 space: 18,而组内标题与胶囊间距为 12、卡片内边距为 16——三档间距(18/12/16)共同构成页面的"垂直节奏",既不松散也不拥挤。这种显式定义的间距体系(而非随手写数值)是本系列一贯的做法,配合 D 常量中的圆角尺度,让任何新增的卡片都能无缝融入现有视觉秩序,是"小代码、大规范"的又一例证。

配图

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐