鸿蒙原生应用实战: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+3栏彩色统计+下划线Tab+项目一+二+三完整呈现

项目源码开源: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.lengthRecordItem[] 数组长度(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 }
];
  • 总数 28C.primary 红色(中性聚合)
  • 进行中 3C.warn 橙色(警示待办)
  • 已完成 25C.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 橙色)。"颜色 = 状态"原则一致

潜在 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: 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 个 @StateactiveTab: number = 0recordsprivate 不可变数据。

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 唯一不变)。

志愿服务记录页下部 · 项目三+四+五+进度条+双按钮+底部Tab栏

九、与 App 13 歌单记录页的对比总结

维度App 13 歌单记录App 17 志愿记录
主题色紫 #8B5CF6红 #EF4444
Tab 状态全部/进行中/已完成全部/进行中/已完成
统计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 的极致表现——同套代码生成不同主题的应用。

志愿服务记录页底部 · 项目三+四+五+安全区留白+底部Tab栏

十、模板复用的工程价值

App 13(紫)→ App 17(红)的"换肤"是 5 分钟的工程

  1. 复制 13-music-playlist/17-volunteer/
  2. 改 Theme.ets 的 3 个颜色(primary/accent/primarySoft)
  3. 改 README 的 bundleName、文案、Tab 命名
  4. 改 ProfileTab 的 小明同学小志愿者、Lv 数字、Slogan
  5. 重新构建部署

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 个好处

  1. 色弱用户友好:约 8% 人口色弱,靠文字识别
  2. 暗色模式兼容:某些深色背景下颜色对比度不够,文字兜底
  3. 国际化:不同国家"颜色语义"不同(红色在西方=危险,在中国=吉祥)
  4. 可访问性:屏幕阅读器读"状态:进行中",不靠颜色
  5. 打印场景:黑白打印只剩文字,依然能识别

"色 + 文"双通道是 ArkUI 状态显示的最佳实践真实项目应该无脑套用——所有"用颜色区分状态"的地方都该配文字兜底。

反例警示:只用颜色区分状态(没有文字),色弱用户完全无法使用——这是移动端的"可访问性死穴"。

十三、总结

App 17 活动记录页解析完毕。3 栏彩色统计 + 下划线 Tab + 状态色标签 + 进度条 + 双按钮是核心组件。与 App 13 的"模板复刻"展示了 ArkUI 主题与代码分离的工程价值——同一套 UI 框架,换 Theme.ets 3 个颜色 = 全新应用。"色 + 文"双通道 + "状态机 + UI 联动"是真实记录页的核心——读者在抄模板时记得补齐。

Logo

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

更多推荐