鸿蒙原生应用实战:HarmonyOS 6.0 进度条双按钮驱动志愿活动记录卡状态设计
鸿蒙原生应用实战:HarmonyOS 6.0 进度条双按钮驱动志愿活动记录卡状态设计
App 17「志愿服务」记录 Tab(Func2Tab),是志愿活动的历史记录页。整页用 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 歌单记录页完全同源——只有主题色不同(紫→红)。


二、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——"轻反馈"策略。
三、StatsCard:3 栏彩色统计(本页特色)
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(视觉隔开),左右各 16vp(D.pad)。注意 StatsCard 用 margin 而非外层 padding——因为它在父 Column 内独立占位。
四、TabBar:3 段下划线指示器
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 个 Tab),App 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 橙色)。"颜色 = 状态"原则一致。
潜在 bug:width('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: string + statusColor: 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 个 @State:activeTab: number = 0。records 是 private 不可变数据。
4 个 Tab 的 @State 分布:
| 页面 | @State 数量 | 类型 |
|---|---|---|
| 首页 | 2(activeCat/selectedBar) | 可交互 |
| 活动 | 5(inputTitle/inputDesc/selectedType/selectedPriority/remindOn) | 表单 |
| 记录 | 1(activeTab) | 筛选 |
| 我的 | 0 | 纯展示("小明同学 Lv.4"无 @State) |
"@State 数量 = 交互点数" 规律延续:表单 5、列表 1-2、纯展示 0。
注意:App 17 我的页(ProfileTab)0 个 @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 接收参数 vs @Component 接收 props:
@Builder接收参数:轻量、无生命周期@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() 是稳定 key(id 唯一不变)。

九、与 App 13 歌单记录页的对比总结
| 维度 | App 13 歌单记录 | App 17 志愿记录 |
|---|---|---|
| 主题色 | 紫 #8B5CF6 | 红 #EF4444 |
| Tab 状态 | 全部/进行中/已完成 | 全部/进行中/已完成 |
| 统计 | 14/3/2(彩) | 28/3/25(彩) |
| 记录 | 5 条(项目一~五) | 5 条(项目一~五,完全同构) |
| 状态操作 | 详情+更多 | 详情+更多 |
| 进度条 | 进行中显 | 进行中显 |
| 状态色 | warn/ok | warn/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 生成记录 → 状态"进行中"(status=1)
2. 进行中:志愿者开始活动 → 记录显示"已参与 X 人"(可能从后端实时拉取)
3. 完成:活动结束 → 状态转"已完成"(status=2)→ 自动发放志愿时长/证书
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 联动"是真实记录页的核心——读者在抄模板时记得补齐。
更多推荐





所有评论(0)