ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

鸿蒙原生应用 ArkTS 严格模式:兴趣匹配兴趣页的四组胶囊多选与实时计数 鸿蒙原生应用 ArkTS 严格模式兴趣匹配兴趣页的四组胶囊多选与实时计数App 39「校园兴趣匹配」兴趣页Func1Tab主题色#FF6B9D粉色pink4 个 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 固定下方整块内容放入 Scrollbuild() { 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 汇报保存数量项目源码开源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 下缘与后续滚动内容形成紧凑的衔接节奏。三、分组数据模型与四组兴趣页面用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 的稳定 keytitle自带 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 数据结构改用Setstring存储选中项add/delete天然幂等配合整体赋值同样可触发刷新语义更清晰。对象映射表Recordstring, boolean记录兴趣 → 是否选中查询 O(1)但每次修改也要整体重新赋值触发响应。Observed 类模型把选中集合封装成Observed classObjectLink子组件让每个胶囊独立订阅点选时只刷新自身性能最优——适合兴趣数量上百的大规模场景。持久化保存后写入 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 之一。Q2indexOf判断选中是否高效23 个标签的场景下完全够用O(n) 每次查询渲染 23 次也就 23×n 次比较。但若标签量级上万建议改Set或Recordstring, 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 行代码浓缩了多选状态管理Stateslice、自动换行胶囊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常量中的圆角尺度让任何新增的卡片都能无缝融入现有视觉秩序是小代码、大规范的又一例证。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进