鸿蒙原生应用 ArkTS 严格模式:兴趣匹配兴趣页的四组胶囊多选与实时计数
鸿蒙原生应用 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 块结构:
- Header — 单行品牌头("我的兴趣")
- 计数行 — "已选 3 个兴趣",直接读取
this.selected.length,随用户点选实时变化,是页面状态的最直观反馈 - 分组胶囊 — 4 组兴趣卡片用
ForEach遍历groups数组渲染,每组内部再用Flex自动换行排列兴趣胶囊 - 保存按钮 — 全宽 48 高粉色按钮,点击后 Toast 汇报保存数量
二、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 下缘,与后续滚动内容形成紧凑的衔接节奏。

三、分组数据模型与四组兴趣
页面用 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: ['摄影', '绘画', '阅读', '写作', '书法', '手工'] }
];
分组盘点:
- 🎵 音乐(6 项):流行、民谣、摇滚、古典、电音、说唱 —— 覆盖主流音乐风格,从大众到小众
- ⚽ 运动(6 项):篮球、足球、跑步、健身、游泳、羽毛球 —— 兼顾球类、田径与健身房场景
- 🎮 游戏(5 项):电竞、手游、桌游、主机、独立游戏 —— 按游戏载体而非具体游戏分类,思路更抽象通用
- 🎨 文艺(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();
}
实现要点:
- isSelected 查询:用
indexOf >= 0判断是否已选,配合渲染函数让每个胶囊的样式实时反映选中状态。 - toggle 增删:已选则从数组
splice移除,未选则push追加——一个函数完成"选中/取消"双向切换。 - slice 触发刷新:
this.selected = this.selected.slice()是关键一步。ArkUI 的@State对数组的响应式监听依赖整体赋值,splice/push这类原地修改不会被检测到,必须重新赋值一个新数组(浅拷贝)才能触发 UI 刷新。这是 ArkUI 状态管理最经典的"坑"之一,值得反复强调。 - 渲染分支:选中时
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 多选的入门写法,真实工程里还有更优解:
- Set 数据结构:改用
Set<string>存储选中项,add/delete天然幂等,配合整体赋值同样可触发刷新,语义更清晰。 - 对象映射表:
Record<string, boolean>记录"兴趣 → 是否选中",查询 O(1),但每次修改也要整体重新赋值触发响应。 - @Observed 类模型:把选中集合封装成
@Observed class+@ObjectLink子组件,让每个胶囊独立订阅,点选时只刷新自身,性能最优——适合兴趣数量上百的大规模场景。 - 持久化:保存后写入 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 个胶囊的场景下代码虽可接受,但若兴趣扩充到上百个,就需要组件化重构:
- 封装 InterestChip 子组件:抽出
@Component export struct InterestChip,接收label(文字)、selected(是否选中)两个参数,内部统一渲染样式与点击回调,父组件只需ForEach生成即可——代码量大幅缩减、样式单一来源。 - @Prop / @Event 通信:子组件用
@Prop selected: boolean接收选中态、@Event onToggle: (label: string) => void上报点击,父组件持有真正的selected数组做状态仲裁,保证"状态只存一处、渲染处处同步"。 - 多选上限控制:真实产品通常会限制兴趣上限(如最多 10 个),可在
toggle中加if (this.selected.length >= 10) { toast('最多选择 10 个'); return; }的防抖逻辑,防止用户无节制堆叠标签。 - LazyForEach 大数据量:若分组与标签来自服务端且数量巨大,应将
ForEach换成LazyForEach搭配IDataSource,实现按需渲染,保证首屏秒开——当前 23 个胶囊场景下 ForEach 足够,但演进路径要清晰。

十、扩展细节:表单校验与提交链路设计
兴趣页的"保存"按钮目前只弹 Toast,展开来看,真实产品的保存链路至少包含四层:
- 本地校验:保存前先检查
selected非空("请至少选择一个兴趣")、数量不超过上限,不合格时 Toast 提示并中断——当前 Demo 未设下限,默认 3 个已保证非空。 - 接口提交:把
selected数组作为请求体 POST 到用户画像接口,同时记录操作时间、设备信息等元数据。 - 乐观更新:请求发出即更新本地状态并 Toast"保存成功",接口失败时再回滚并提示重试——避免用户等待、提升流畅感,这是移动端表单的标准体验。
- 数据回显:保存成功后,我的页"我的兴趣标签"应读取同一份数据源实时刷新(而非静态数组),让"兴趣页编辑 → 我的页展示"形成真正闭环——Demo 中我的页 5 个标签与默认 3 个 + 补选 2 个的暗合关系,正是为这条链路预留的伏笔。
十一、彩蛋细节与终评
- 默认已选 3 个:初始
selected = ['民谣', '摄影', '篮球'],与我的页前三个标签一致,暗示用户画像从第一天起就有积累。 - 每组都含"已选":默认选中项恰好分散在音乐(民谣)、文艺(摄影)、运动(篮球)三组,四组中只有游戏组空白,引导用户去补全,交互设计上非常"心机"。
- 双 emoji 复用:⚽ 既出现在组标题"⚽ 运动",也出现在首页用户头像里(🏀 运动大伟)——emoji 即是兴趣分类的视觉符号系统。
- 独立游戏与主机:游戏组刻意收录"独立游戏""主机"这类细分标签,说明分类设计参考了真实游戏社区的话语体系。
- 保存按钮圆角:48 高的按钮用
borderRadius(D.rMd)(14)而非全圆角,与胶囊的 16 圆角刻意区分——主按钮方正、标签圆润,对比出"操作 vs 选择"的不同语义。
兴趣页承担着整个产品最核心的"用户画像采集"职能。23 个胶囊、4 组分类、实时计数、一键保存,四个环节串起完整交互闭环。技术上最值得学习的当属 slice() 重赋值触发 @State 刷新的响应式要点,以及 Flex 自动换行胶囊的实现手法。整页代码仅 86 行却五脏俱全,是 ArkUI 表单类页面的高质量范本。
十二、常见问题与开发避坑
围绕"多选标签 + 保存"这类表单页面,开发中有几个高频问题需要特别注意:
- @State 数组改了不刷新:这是本页最核心的坑。
splice/push属于原地修改,ArkUI 的 @State 监听不到,必须整体重新赋值(如this.selected = [...this.selected]或slice())才能触发 UI 更新。本页的slice()写法是正确示范,漏掉这行代码页面将"纹丝不动",且不报任何错误,调试时极难定位。 - ForEach key 用 item 字符串:本页内层
ForEach用(item: string) => item作为 key,好处是兴趣名唯一、增删时节点可精准 diff;坏处是若两个分组出现同名标签(如音乐组和文艺组都有"流行"),key 会冲突——真实场景建议 key 拼接分组 id,如${g.id}-${item}。 - Button 的 width('100%') 失效:
Button组件默认自适应内容宽度,加width('100%')才能撑满整行——本页保存按钮正是全宽样式,若忘写会缩成"内容宽度"的小按钮。 - 胶囊点击误触:胶囊的
margin({ right: 10, bottom: 10 })在 Flex 换行时既提供间距又避免误触相邻胶囊;若间距过小(< 6),快速点选时容易点到旁边标签,注意保持 8-12 的安全间距。 - 保存按钮被键盘顶起:若页面后续加入 TextInput(如自定义兴趣输入框),键盘弹出会压缩 Scroll 可视区——记得用
expandSafeArea或监听键盘高度做适配,本页纯胶囊选择暂无此问题,但演进时务必留意。

十三、系列横向对比:表单采集页的四种范式
兴趣页属于"用户画像采集"类表单,本系列各 App 提供了四种不同的采集范式,可横向对比学习:
| 范式 | 代表页 | 交互方式 | 特点 |
|---|---|---|---|
| 多选胶囊 | App 39 兴趣页 | 点选标签、实时计数 | 轻量、无键盘、最适合标签 |
| 输入表单 | App 38 发布页 | TextInput + 校验 | 自由文本、需校验与键盘适配 |
| 选择器组 | App 34 上传页 | 分类胶囊 + 文件选择 | 复合型、数据依赖前置步骤 |
| 步进向导 | App 27 提醒页 | 分步完成 | 流程长、适合逐步引导 |
兴趣页的"胶囊 + 计数 + 保存"组合是四者中上手最快、容错率最高的范式:没有输入框就没有键盘遮挡与非法输入问题,点选即时反馈、计数即时更新,用户完成度天然很高。如果要把这套范式做深,可参考 App 38 发布页在保存前增加必填校验("至少选择 1 个兴趣"),以及保存成功后的页面跳转反馈(Toast + 自动切回首页),让"采集 → 确认 → 跳转"的链路一气呵成。这种跨 App 的范式对照,比单独看一个页面更能看清交互设计的取舍之道。

十四、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 常量中的圆角尺度,让任何新增的卡片都能无缝融入现有视觉秩序,是"小代码、大规范"的又一例证。

更多推荐



所有评论(0)