鸿蒙原生应用实战:HarmonyOS 6.0 进度条双按钮驱动志愿活动记录卡状态设计

鸿蒙原生应用实战:HarmonyOS 6.0 进度条双按钮驱动志愿活动记录卡状态设计 鸿蒙原生应用实战HarmonyOS 6.0 进度条双按钮驱动志愿活动记录卡状态设计App 17「志愿服务」记录 TabFunc2Tab是志愿活动的历史记录页。整页用 Header 3 栏彩色统计总数 28/进行中 3/已完成 25 3 段下划线 Tab 筛选全部/进行中/已完成 5 条记录卡片含进度条 双按钮查看详情更多。本篇基于17-volunteer/entry/src/main/ets/pages/Func2Tab.ets约 153 行逐段拆解附 4 张实机截图。一、整体结构Header StatsCard TabBar 滚动记录Func2Tab 的结构与 App 13 歌单记录页几乎完全同源——固定 Header StatsCard TabBar Scroll 装记录build() { Column() { this.Header() this.StatsCard() this.TabBar() Scroll() { Column({ space: 12 }) { ForEach(this.records, (item: RecordItem) { this.RecordCard(item) }, (item: RecordItem) item.id.toString()) Blank().height(this.safeBottom 20) } .width(100%).padding({ left: D.pad, right: D.pad, top: 6 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width(100%).height(100%).backgroundColor(C.bg) }3 段固定 1 段滚动Header / StatsCard / TabBar三段在 Scroll 外不滚动切换 Tab 时保持原位Scroll 容器只装记录卡 安全区缓冲与 App 13 歌单记录页完全同源——只有主题色不同紫→红。项目源码开源https://gitee.com/codenestFlow/HarmonyOSHub二、Header单行标题 5 条记录副标题 搜索图标Header 单行结构与 App 13 同构Builder Header() { Row({ space: 12 }) { Column({ space: 2 }) { Text(记录).fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) Text(${this.records.length} 条记录).fontSize(10).fontColor(C.textDim) }.alignItems(HorizontalAlign.Start) Blank() Row() { Text().fontSize(18) } .width(36).height(36).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center) .onClick(() { promptAction.showToast({ message: 搜索 }); }) } .width(100%).height(this.safeTop 60) .padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card).alignItems(VerticalAlign.Bottom) .border({ width: { bottom: 1 }, color: C.stroke }) }Text(\${this.records.length} 条记录) 动态副标题——this.records.length是RecordItem[]数组长度5。State一变或数组长度变化副标题自动更新为 5 条记录。onClick(() promptAction.showToast({ message: 搜索 }))搜索图标点击弹 Toast——轻反馈策略。三、StatsCard3 栏彩色统计本页特色StatsCard 是 3 个统计数字总数 28 / 进行中 3 / 已完成 25每个数字带自己的颜色Builder StatsCard() { Row() { ForEach(this.stats, (s: StatItem, idx: number) { Column({ space: 3 }) { Text(s.value).fontSize(20).fontWeight(FontWeight.Bold).fontColor(s.color) Text(s.label).fontSize(10).fontColor(C.textDim) }.layoutWeight(1) if (idx this.stats.length - 1) { Divider().vertical(true).height(28).color(C.stroke) } }, (s: StatItem) s.label) } .width(100%).height(64).backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }).justifyContent(FlexAlign.SpaceAround) .margin({ top: 10, left: D.pad, right: D.pad }) }3 色映射数据自带 color 字段private stats: StatItem[] [ { value: 28, label: 总数, color: C.primary }, { value: 3, label: 进行中, color: C.warn }, { value: 25, label: 已完成, color: C.ok } ];总数 28→C.primary红色中性聚合进行中 3→C.warn橙色警示待办已完成 25→C.ok绿色成功完成3 色 3 语义——color字段把业务状态映射到视觉颜色。颜色 状态是数据可视化的基础。3 25 28——数据自洽进行中 已完成 总数。这是 demo 的隐性数据校验——**统计数据应该能加回去**是检测数据一致性的简单方法。Divider().vertical(true).height(28)竖向分隔线——vertical(true)让默认的横线变成竖线限定高度 28vp 与数字大致对齐。if (idx this.stats.length - 1)防止最后一条也加分隔线。margin({ top: 10, left: D.pad, right: D.pad })让 StatsCard 距离 Header 10vp视觉隔开左右各 16vpD.pad。注意 StatsCard 用margin而非外层padding——因为它在父 Column 内独立占位。四、TabBar3 段下划线指示器TabBar 是 3 个分类全部/进行中/已完成横向排列选中态用 24×2 的下划线标记Builder TabBar() { Row({ space: 0 }) { ForEach(this.tabs, (t: string, idx: number) { Column({ space: 6 }) { Text(t).fontSize(13) .fontColor(this.activeTab idx ? C.primary : C.textDim) .fontWeight(this.activeTab idx ? FontWeight.Bold : FontWeight.Normal) Column().width(24).height(2).borderRadius(1) .backgroundColor(this.activeTab idx ? C.primary : transparent) } .layoutWeight(1).padding({ top: 8, bottom: 8 }) .onClick(() { this.activeTab idx; }) }, (t: string) t) } .width(100%).backgroundColor(C.card).borderRadius(D.rSm) .border({ width: 1, color: C.stroke }) .margin({ top: 10, left: D.pad, right: D.pad }) }下划线指示器的实现Column().width(24).height(2).borderRadius(1) .backgroundColor(this.activeTab idx ? C.primary : transparent)文字下面用Column()画一条 24×2vp 的横线选中时C.primary红色未选时transparent透明注意必须写字符串transparent不能省略Row({ space: 0 })让 3 个 Tab 紧贴配合每个Column自己的padding({ top: 8, bottom: 8 })——3 个 Tab 看起来是独立的可点击区但视觉上是一整条 TabBar。下划线 vs 胶囊对比App 17 是下划线适合 2-3 个 TabApp 14/15/16 是胶囊适合 4 个以上。Tab 数量决定 UI 形态2-3 个用下划线更轻4 用胶囊更清晰。注意 demo 没有联动ForEach(this.records, ...)过滤——点击 Tab 只切换下划线高亮不是真筛选与 App 13 歌单记录页相同的假筛选问题。真实项目应加filteredRecords()。五、RecordCard状态标签 进度条 双按钮RecordCard 是 5 条记录卡片每条都含状态标签、进度条仅进行中、双按钮Builder RecordCard(item: RecordItem) { Column({ space: 10 }) { Row({ space: 12 }) { Row() { Text(item.emoji).fontSize(24) } .width(44).height(44).backgroundColor(C.cardSoft).borderRadius(D.rSm).justifyContent(FlexAlign.Center) Column({ space: 4 }) { Row() { Text(item.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).layoutWeight(1) Text(item.status).fontSize(10).fontColor(#FFFFFF) .backgroundColor(item.statusColor) .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(D.rSm) }.width(100%) Text(item.desc).fontSize(12).fontColor(C.textSub) Text(⏱ ${item.time}).fontSize(10).fontColor(C.textDim) }.alignItems(HorizontalAlign.Start).layoutWeight(1) Text(›).fontSize(20).fontColor(C.textDim) }.width(100%) if (item.status 进行中) { Row() { Text(进度).fontSize(10).fontColor(C.textDim) Stack({ alignContent: Alignment.Start }) { Column().width(100%).height(4).backgroundColor(C.cardSoft).borderRadius(2) Column().width(75%).height(4).borderRadius(2).backgroundColor(C.warn) }.layoutWeight(1).margin({ left: 8, right: 8 }) Text(75%).fontSize(10).fontColor(C.warn).fontWeight(FontWeight.Bold) }.width(100%) } Row({ space: 10 }) { Button(查看详情) .fontSize(12).fontColor(#FFFFFF).backgroundColor(C.primary) .borderRadius(D.rSm).height(34).layoutWeight(1) .onClick(() { promptAction.showToast({ message: item.title }); }) Button(更多) .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(34).layoutWeight(1) .onClick(() { promptAction.showToast({ message: 更多操作 }); }) }.width(100%) } .width(100%).padding(12).backgroundColor(C.card).borderRadius(D.rMd) .border({ width: 1, color: C.stroke }) }5.1 状态色 状态文字色 文双通道Row() { Text(item.title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text).layoutWeight(1) Text(item.status).fontSize(10).fontColor(#FFFFFF) .backgroundColor(item.statusColor) .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(D.rSm) }item.statusColor字段把颜色直接绑到数据上——5 条记录状态颜色进行中 2 条C.warn橙/ 已完成 3 条C.ok绿。白字 彩色背景让进行中/已完成标签醒目。5 条记录的状态分布 项目一进行中 75% 项目二已完成 5星⭐ 项目三已完成 4星 项目四进行中 40% 项目五已完成 5星2 条进行中 3 条已完成是当前列表的 5 条样本StatsCard 的 3/25 是全部 28 条记录的汇总——两者口径不同样本 vs 全量不矛盾。5.2 条件渲染进行中显示进度条已完成不显示if (item.status 进行中) { Row() { Text(进度).fontSize(10).fontColor(C.textDim) Stack({ alignContent: Alignment.Start }) { Column().width(100%).height(4).backgroundColor(C.cardSoft).borderRadius(2) Column().width(75%).height(4).borderRadius(2).backgroundColor(C.warn) }.layoutWeight(1).margin({ left: 8, right: 8 }) Text(75%).fontSize(10).fontColor(C.warn).fontWeight(FontWeight.Bold) }.width(100%) }if (item.status 进行中)条件渲染——只有进行中显示进度 75%的进度条橙色已完成不显示。两种状态的卡片高度不同进行中约 200vp、已完成约 130vp这是状态驱动布局。Stack进度条与首页同构背景 前景叠加 百分比文字唯一差异颜色跟随状态首页进度条是主色本页进行中是C.warn橙色。颜色 状态原则一致。潜在 bugwidth(75%)是硬编码——项目一 75%、项目四 40% 应该是不同百分比。真实项目应把宽度从数据item.progress读取width(\${item.progress}%)并在 RecordItem 加progress?: number 字段。5.3 双按钮查看详情 更多Row({ space: 10 }) { Button(查看详情) .fontSize(12).fontColor(#FFFFFF).backgroundColor(C.primary) .borderRadius(D.rSm).height(34).layoutWeight(1) .onClick(() { promptAction.showToast({ message: item.title }); }) Button(更多) .fontSize(12).fontColor(C.textSub).backgroundColor(C.cardSoft) .borderRadius(D.rSm).height(34).layoutWeight(1) .onClick(() { promptAction.showToast({ message: 更多操作 }); }) }两个并排等宽按钮查看详情— 主操作红色背景C.primary白字更多— 次操作浅灰背景C.cardSoft灰字两者都是layoutWeight(1)等宽——等宽双按钮的视觉平衡比内容宽度更稳定更多只有 2 个字按内容宽度会非常窄。主次按钮的视觉权重差异通过背景色 文字色两个维度同时区分——比单靠按钮大小更细腻。**主次 视觉权重**是按钮设计的基本原则。promptAction.showToast({ message: item.title })反馈——与首页、创建页一致。更多点击弹更多操作提示伪操作占位真实项目应弹操作菜单删除/分享/复制/置顶等。六、数据模型与筛选潜在改进点RecordItem接口有 7 个字段interface RecordItem { id: number; emoji: string; title: string; status: string; time: string; desc: string; statusColor: string; }status: stringstatusColor: string携带了视觉属性——这是数据携带样式的模式demo 方便真实项目应从 status 推 color。改进方向// 推荐枚举 映射表 enum RecordStatus { InProgress 1, Completed 2 } const STATUS_META { [RecordStatus.InProgress]: { text: 进行中, color: C.warn }, [RecordStatus.Completed]: { text: 已完成, color: C.ok } };if (item.status 进行中)用字符串判断也脆弱——真实项目应if (item.status RecordStatus.InProgress)。Tab 筛选的真假——App 17 与 App 13 相同Tab 切换没联动 ForEach 过滤点击全部/进行中/已完成都显示全部 5 条。真实项目应实现filteredRecords()private filtered(): RecordItem[] { if (this.activeTab 0) return this.records; const target this.activeTab 1 ? 进行中 : 已完成; return this.records.filter(r r.status target); }Tab id 状态值0 全部/1 进行中/2 已完成——通过target变量将数字 id 映射为状态字符串再 filter与 App 14 预约记录页相同的id 映射设计。七、State 的克制Func2Tab 只有1 个 StateactiveTab: number 0。records是private不可变数据。4 个 Tab 的 State 分布页面State 数量类型首页2activeCat/selectedBar可交互活动5inputTitle/inputDesc/selectedType/selectedPriority/remindOn表单记录1activeTab筛选我的0纯展示小明同学 Lv.4无 StateState 数量 交互点数规律延续表单 5、列表 1-2、纯展示 0。注意App 17 我的页ProfileTab0 个 State——与 App 13 同款个人中心 0 状态App 14/15/16 是 1 个checked。ProfileTab 有每日签到按钮但无状态——点击只弹 Toast 不改状态demo 不实现真正的签到逻辑。个人中心 0 状态是一种简化demo 不实现签到。八、Builder 参数化的设计RecordCard(item: RecordItem)接收参数RecordItem接口定义在本文件第 4-12 行Builder RecordCard(item: RecordItem) {Builder接收参数vsComponent接收 propsBuilder接收参数轻量、无生命周期Component接收Prop更重、可挂载生命周期App 17 选Builder接收参数——因为 RecordCard 只是渲染逻辑无内部状态、无交互用最轻量的 Builder 参数即可。**轻量渲染用 Builder需要状态用 Component**是设计原则。ForEach 里的传参ForEach(this.records, (item: RecordItem) { this.RecordCard(item) }, (item: RecordItem) item.id.toString())回调拿到 item →this.RecordCard(item)传入——整个数据流array → ForEach → RecordCard(item)一气呵成。item.id.toString()是稳定 keyid 唯一不变。九、与 App 13 歌单记录页的对比总结维度App 13 歌单记录App 17 志愿记录主题色紫 #8B5CF6红 #EF4444Tab 状态全部/进行中/已完成全部/进行中/已完成统计14/3/2彩28/3/25彩记录5 条项目一~五5 条项目一~五完全同构状态操作详情更多详情更多进度条进行中显进行中显状态色warn/okwarn/ok同款App 17 与 App 13 几乎 100% 同构——除了 Theme.ets 里的几个颜色值不同紫→红 统计数字差异14/3/2 vs 28/3/25。这种主题复刻是系列化 demo 的极致表现——同套代码生成不同主题的应用。十、模板复用的工程价值App 13紫→ App 17红的换肤是 5 分钟的工程复制13-music-playlist/→17-volunteer/改 Theme.ets 的 3 个颜色primary/accent/primarySoft改 README 的 bundleName、文案、Tab 命名改 ProfileTab 的小明同学→小志愿者、Lv 数字、Slogan重新构建部署5 步换肤 5 分钟生成一个新主题应用——这是 ArkUI 模板设计的最高境界。真实项目套用这个方法企业品牌定制客户 A 要绿色、客户 B 要蓝色——同一个产品代码Theme.ets 改 3 个颜色 两个品牌版节日活动春节红、圣诞绿、中秋金——换主题色配合节日营销暗色/亮色双模式light: {...} dark: {...} 配合系统主题自动切换主题与代码分离是 ArkUI 工程的最佳实践——App 13/17 就是这个实践的标准示范。十一、记录数据的生命周期管理App 17 记录页当前是内存数据——5 条硬编码的 records。真实项目应考虑记录的完整生命周期1. 创建用户在活动 Tab 创建 → 后端 API 生成记录 → 状态进行中status12. 进行中志愿者开始活动 → 记录显示已参与 X 人可能从后端实时拉取3. 完成活动结束 → 状态转已完成status2→ 自动发放志愿时长/证书4. 评价用户打分/评论 → 记录 desc 字段更新5. 归档超过 90 天 → 移入历史区不再显示在主列表状态机驱动 UI——record 的 status 字段1/2/3...决定显示什么文字进行中/已完成显示什么颜色warn/ok/danger显示什么操作按钮取消/再次/查看决定是否进首页推荐**数据生命周期 状态机 UI 联动**是真实记录页的核心模型App 17 的简化版是 2 状态进行中/已完成真实项目通常 5 状态。十二、状态色 状态文字双通道的极致应用App 17 状态标签的色 文双通道设计值得深入分析Text(item.status).fontSize(10).fontColor(#FFFFFF) .backgroundColor(item.statusColor) .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(D.rSm)色 文双通道的 5 个好处色弱用户友好约 8% 人口色弱靠文字识别暗色模式兼容某些深色背景下颜色对比度不够文字兜底国际化不同国家颜色语义不同红色在西方危险在中国吉祥可访问性屏幕阅读器读状态进行中不靠颜色打印场景黑白打印只剩文字依然能识别色 文双通道是 ArkUI 状态显示的最佳实践。真实项目应该无脑套用——所有用颜色区分状态的地方都该配文字兜底。反例警示只用颜色区分状态没有文字色弱用户完全无法使用——这是移动端的可访问性死穴。十三、总结App 17 活动记录页解析完毕。3 栏彩色统计 下划线 Tab 状态色标签 进度条 双按钮是核心组件。与 App 13 的模板复刻展示了 ArkUI 主题与代码分离的工程价值——同一套 UI 框架换 Theme.ets 3 个颜色 全新应用。色 文双通道 状态机 UI 联动是真实记录页的核心——读者在抄模板时记得补齐。