HarmonyOS ArkTS实战:一条竖线串记录 —— 校园快递取件页手绘时间轴与状态分类筛选

应用背景:11 校园快递(campus-express)第三个 Tab 是取件记录页,对应源码 Func2Tab.ets(共 208 行)。它是用户查看"我的快递都到哪了、该去哪取"的流水账,橙色主题 #EA580C。本文逐行拆解 TabBar 分类标签与计数徽标OverviewCard 统计概览TimelineList 时间轴列表 三大模块,重点讲解"如何用一条竖线 + 圆形节点把一组离散记录串成时间轴",以及"状态分类标签如何驱动列表筛选"。

取件页在三个内容 Tab 里最"重数据":它不像首页那样铺品牌、也不像寄件页那样收输入,而是把"历史 + 当前"的快递记录按时间顺序摊开。用户在寄件之后最关心的就是"东西到没到、去哪取",取件页正是这个问题的答案页。读完它,你会掌握 ArkUI 里"时间轴"这一高频 UI 模式的纯声明式实现。

为什么取件页偏要用"时间轴"而不是普通列表?因为取件记录天然带有"先后到达"的时序语义:驿站签收 → 短信通知 → 你去取 → 完成,是一条有方向的流。用一条竖线把这些节点串起来,用户一眼就能读出"进度走到哪了",比把记录平铺成网格更贴合心智模型。这也是为什么物流、订单、审批这类"带状态推进"的场景,几乎都偏爱时间轴——它把"状态"翻译成了"空间位置",可读性最好。

一、数据模型:三套结构

取件页只定义了三个接口,比首页的六个还少,因为时间轴的数据形状很单一。注意 TimelineItemstatus + statusColor 两个字段组合表达状态,颜色直接由数据携带而非在 UI 里三元判断——这是取件页与首页 Express 的关键架构区别。

interface TimelineItem {
  id: number; emoji: string; title: string; date: string;
  code: string; status: string; statusColor: string; progress: number;
}
interface StatCard { label: string; value: string; emoji: string; }
interface TabItem { label: string; count: number; }

TimelineItem 是时间轴的一条记录:图标、标题(公司名+状态)、日期、单号、状态文字、状态颜色、进度。与首页的 Express 不同,这里用 status + statusColor 两个字段组合表达状态,颜色直接由数据携带而非在 UI 里三元判断。StatCard 是概览卡里的单格:标签 + 数值 + 图标。TabItem 是顶部分类标签:文字 + 数量徽标。

这种"同一份数据、多份裁剪视图"的架构,是大型应用必须早做好的功课。如果在首页、取件页各维护一份快递数组,两边数据一旦不一致(比如首页显示"已签收"、取件页还显示"运输中"),用户立刻失去信任。正确做法是:全局只持有一份 deliveries,首页取"最近的 N 条 + 进度",取件页取"全部 + 时间排序",各自 map 成自己的接口形状。这样数据源唯一,UI 怎么裁剪都不会打架。取件页的 TimelineItem 额外携带了 statusColor 字段,把"状态→颜色"的映射固化在数据层,UI 只需读取——这在状态类型较多时比在 UI 里写三元表达式更清晰。

项目源码开源:https://gitee.com/codenestFlow/HarmonyOSHub

配图

二、页面骨架:Header + TabBar + Scroll

build() {
  Column() {
    this.Header()
    this.TabBar()
    Scroll() {
      Column({ space: 14 }) {
        this.OverviewCard()
        this.TimelineList()
      }
      .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)
}

骨架依旧是 Header + Scroll 的二分结构,但中间多塞了一个 TabBar()——它是吸顶的分类筛选条,永远停在 Header 正下方、滚动区正上方。这种"Header / 筛选条 / 滚动内容"的三段式,在带筛选的列表页里极为常见(商品列表、订单列表、消息列表都是这个套路)。滚动区里直接放 OverviewCardTimelineList 两块,没有额外的 SectionTitle 区块标题——取件页的时间轴是完整呈现的,不需要跳转二级页。

"筛选条插在 Header 和 Scroll 之间"这个位置很有讲究:它不参与滚动,所以用户无论滚到多深,筛选标签始终可见、随时可切。如果你把 TabBar 也塞进 Scroll 里,滚下去就找不到筛选器了,体验会断。记住一条经验——凡是"控制下方内容的开关类 UI"(标签页、筛选条、分段控件),都应放在滚动容器之外、紧贴其上方,保证"开关永远在视线内"。

三、Header 标题栏

// Header — 取件页标题
@Builder 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 一字不差:.height(this.safeTop + 56) + padding({ top: safeTop }) + alignItems(VerticalAlign.Bottom),标题精确贴底。三页共用同一套 Header 写法,是"统一组件库"的最直接证据——你甚至可以把 Header 抽成一个独立 @Builder 或公共组件,三页直接复用,避免三处各写一遍、将来改一处要改三处。

四、TabBar 分类标签与计数徽标

这是取件页的"筛选大脑"。四个标签(全部 / 待取 / 已取 / 退回)各带一个数量徽标,选中项用橙色高亮。

// TabBar — 分类标签
@Builder TabBar() {
  Row({ space: 6 }) {
    ForEach(this.tabs, (t: TabItem, idx: number) => {
      Row({ space: 4 }) {
        Text(t.label).fontSize(13).fontColor(this.tabIdx === idx ? '#FFFFFF' : C.textSub)
        Text(t.count.toString()).fontSize(10).fontColor(this.tabIdx === idx ? '#FFFFFF' : C.textDim)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .backgroundColor(this.tabIdx === idx ? '#33FFFFFF' : C.cardSoft)
          .borderRadius(8)
      }
      .padding({ left: 14, right: 14, top: 8, bottom: 8 })
      .backgroundColor(this.tabIdx === idx ? C.primary : C.card)
      .borderRadius(18)
      .border({ width: 1, color: this.tabIdx === idx ? C.primary : C.stroke })
      .onClick(() => { this.tabIdx = idx; })
    }, (t: TabItem) => t.label)
  }
  .width('100%')
  .padding({ left: D.pad, right: D.pad, top: 12, bottom: 12 })
  .backgroundColor(C.card)
}

这段有两个值得拆解的点。其一是选中态的双重强调:未选中时文字是 C.textSub(灰)、背景 C.card(白)、描边 C.stroke;选中时文字变 #FFFFFF(白)、背景变 C.primary(橙)、描边也变 C.primary。文字色 + 背景色 + 描边三处同时变化,比寄件页只改背景色更醒目——因为这里是"筛选入口",需要让用户明确感知"当前看的是哪一类"。其二是计数徽标的半透明背景:选中时徽标背景用 #33FFFFFF(20% 透明白),叠在橙色底上形成微妙的层次感;未选中时用 C.cardSoft(浅灰),与白底卡片区分。每个标签内部是 Row({ space: 4 }) 包了"标签文字 + 计数"两段,紧凑排列。

计数徽标的产品意义不容小觑:它把"你有多少待办"直接预演在入口上,用户不用点进去就能感知"我有 3 个待取件",这是降低"未读焦虑"和"提升点击率"的通用手段。徽标和标签文字同处一个胶囊里,选中态整体变橙、未选中保持白底灰字,视觉上非常干净。

和寄件页的 TypeSelector 对比会很有趣:两者都是"下标存状态 + ForEach 渲染 + 点击改下标",但寄件页用 backgroundColor 反差表示单选,取件页额外叠加了"计数徽标"和"描边色变化"。可见同一套状态模式,可以演化出不同视觉强度,全看该控件在页面里的重要性。

五、OverviewCard 统计概览

概览卡用一行三列把三项关键指标摆出来,下方用 Divider 分隔再补一行更细的统计。

// OverviewCard — 统计概览
@Builder OverviewCard() {
  Column({ space: 14 }) {
    Row() {
      ForEach(this.statCards, (s: StatCard) => {
        Column({ space: 4 }) {
          Text(s.emoji).fontSize(22)
          Text(s.value).fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.text)
          Text(s.label).fontSize(11).fontColor(C.textDim)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }, (s: StatCard) => s.label)
    }
    .width('100%')

    Divider().color(C.stroke).strokeWidth(0.5)

    Row({ space: 12 }) {
      Column({ space: 2 }) {
        Text('平均取件').fontSize(11).fontColor(C.textDim)
        Text('2.3 天').fontSize(14).fontColor(C.text).fontWeight(FontWeight.Medium)
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Column({ space: 2 }) {
        Text('代取费').fontSize(11).fontColor(C.textDim)
        Text('¥35').fontSize(14).fontColor(C.text).fontWeight(FontWeight.Medium)
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Column({ space: 2 }) {
        Text('退回率').fontSize(11).fontColor(C.textDim)
        Text('11%').fontSize(14).fontColor(C.text).fontWeight(FontWeight.Medium)
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
    }
    .width('100%')
  }
  .width('100%')
  .padding(16)
  .backgroundColor(C.card)
  .borderRadius(D.rLg)
  .border({ width: 1, color: C.stroke })
}

第一层用 Row + ForEach + layoutWeight(1) 做一行三列:每个 StatCardlayoutWeight(1) 均分宽度,emoji + 数值 + 标签 三段式纵向排列,居中对齐。注意它没用 GridFlex 组件,而是用 Row + layoutWeight 均分——在只有 3 格、且永远 1 行的简单场景里,这比 Grid 更轻。emoji 用 22 号、数值用 18 号粗体、标签用 11 号灰字,层级清晰。

第二层用 Divider().color(C.stroke).strokeWidth(0.5) 做了一条极细分隔线,把上方"核心指标"和下方"辅助统计"在视觉上分开。下方是 平均取件 / 代取费 / 退回率 三项,同样 layoutWeight(1) 均分三列、各含"小灰标题 + 粗体数值"。这组数据回答了用户更深层的疑问:"我一般多久能取、找人代取贵不贵、出错概率大不大"——把运营指标前置,是取件页"服务感"的来源。

校园快递取件页底部 · 完整时间轴收尾

六、TimelineList 时间轴列表(核心)

这是全页最硬核的部分:用"竖线 + 圆形节点"把记录串成一条时间轴。

// TimelineList — 时间轴
@Builder TimelineList() {
  Column({ space: 0 }) {
    ForEach(this.timeline, (item: TimelineItem, idx: number) => {
      Column() {
        Row({ space: 12 }) {
          Column({ space: 0 }) {
            Row() {
              Text(item.emoji).fontSize(18)
            }
            .width(36).height(36)
            .backgroundColor(C.primarySoft).borderRadius(18)
            .justifyContent(FlexAlign.Center)

            if (idx < this.timeline.length - 1) {
              Column()
                .width(2).height(28)
                .backgroundColor(C.stroke)
            }
          }
          .alignItems(HorizontalAlign.Center)

          Column({ space: 8 }) {
            Row() {
              Text(item.title).fontSize(14).fontColor(C.text).fontWeight(FontWeight.Medium).layoutWeight(1)
              Text(item.code).fontSize(11).fontColor(C.primary).fontWeight(FontWeight.Medium)
            }
            .width('100%')

            Row() {
              Text(item.date).fontSize(11).fontColor(C.textDim).layoutWeight(1)
              Text(item.status).fontSize(10).fontColor(item.statusColor)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(C.cardSoft)
                .borderRadius(4)
            }
            .width('100%')

            Progress({ value: item.progress, total: 100 }).color(item.statusColor).width('100%')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
        }
        .width('100%')
        .alignItems(VerticalAlign.Top)
      }
      .width('100%')
      .padding({ bottom: 4 })
      .onClick(() => { promptAction.showToast({ message: item.title }); })
    }, (item: TimelineItem) => item.id.toString())
  }
  .width('100%')
  .padding(14)
  .backgroundColor(C.card)
  .borderRadius(D.rLg)
  .border({ width: 1, color: C.stroke })
}

时间轴的精髓在左侧那一列:每个节点是一个 36×36、圆角 18(即正圆)的 C.primarySoft 圆底,里面放 emoji;节点下方用 if (idx < this.timeline.length - 1) 条件渲染一条 width(2).height(28) 的竖线。于是"圆 → 竖线 → 圆 → 竖线 ……"一路连下去,最后一条因为 idx 等于末位不画竖线,正好收尾。这就是纯声明式时间轴的经典写法:节点 + 条件竖线,零自定义绘制、零第三方库。

右侧信息区是"标题 + 单号"一行、"日期 + 状态标签"一行、再压一条 Progress。标题用 layoutWeight(1) 撑满左侧、单号用 C.primary 橙色靠右;日期用 layoutWeight(1) 靠左、状态标签用 item.statusColor 着色靠右。状态颜色直接来自数据字段 item.statusColor——这是取件页与首页的关键区别:首页在 UI 里用三元表达式判断颜色,取件页把颜色存进数据里,UI 只负责读取。Progress 的颜色也用 item.statusColor,进度条和状态标签来自同一颜色源,绝不会出现"条是橙色但标签是绿色"的矛盾——再次印证"单一数据源"原则。整行可点弹标题 Toast,符合"时间轴项即入口"。

校园快递取件页中部 · 时间轴节点与状态标签

七、交互:状态筛选与状态驱动

取件页当前最关键的 @StatetabIdx——它只控制 TabBar 的选中高亮。源码里 timeline 数组是写死的,所以点击不同标签时列表并不会真正过滤。但这恰恰是留给读者的"接口":只要在 TabBaronClick 里,除了 this.tabIdx = idx,再加一句 this.timeline = this.allTimeline.filter(...),就能让"点标签 → 列表实时筛选"活起来。

这个"留白"很有教学意义:demo 先保证结构和视觉正确,把"数据联动"作为可扩展点。在真实项目里,allTimeline 来自网络请求,过滤逻辑就是 filter(item => item.status === tabKey)。ArkUI 里只要 @State 数组被赋了新引用,列表自动重绘——你几乎不用写额外的刷新代码。为了更稳,建议在组件里额外维护 private keyword: string,把"分类标签"和"搜索关键词"两种筛选合并成一个 computed 风格的 get filtered() 函数,UI 永远读 filtered,状态变更全自动反映——这是把"筛选"做扎实的标准姿势。

校园快递取件页顶部 · 分类标签与统计概览

八、与首页、寄件页的设计对比

把三页横向排开,能清晰看到"信息 / 表单 / 记录"三种典型页面的分工:

  • 首页:信息总览,重品牌与高频提醒,几乎无状态;
  • 寄件页:表单录入,重状态与交互,四个状态变量驱动五块 UI;
  • 取件页:历史记录,重时间顺序与状态分类,一个 tabIdx 驱动筛选高亮。

三页共享同一套 Header 写法、C/D 双类主题、Progress/Divider/Blank 等内置控件,但各自用不同的"状态复杂度"讲不同的故事。学会从这三页里抽象出"骨架 + 积木 + 状态"三层,你就拥有了写任意 Tab 页的通用能力。如果再叠上"我的"页,四种页面形态齐活,几乎能覆盖一个中型 App 的全部静态页面类型。

校园快递取件页下部 · 运输进度与已送达状态

九、本章小结与工程经验

模块核心技术学习价值
TabBar选中态橙底白字 + 计数徽标带计数的分类筛选条
OverviewCardRow + layoutWeight 三列均分 + Divider 分隔轻量统计概览
TimelineList圆节点 + if 条件竖线 + statusColor 数据驱动纯声明式时间轴
状态筛选tabIdx 驱动高亮可扩展的列表过滤接口

取件页最大的收获是"时间轴三件套":一个圆形 emoji 节点 + 一条条件渲染的竖线(idx < length-1 才画)+ 竖线 layoutWeight(1) 跟随内容高度。这三样组合,能应付 90% 的"步骤条 / 物流轨迹 / 聊天时间线"需求。其次,if 条件渲染让"数量角标""末尾不画线"这类逻辑变得极其自然,彻底告别命令式的 setVisibility。最后,筛选只改 @State 下标、数据过滤留作扩展,是 demo 阶段"先稳结构、后接数据"的务实范式。

如果你要把取件页接成真实业务,记住一句话:把写死的 timeline 改成 private allTimeline + private timeline(后者由 tabIdx 过滤得到),其余 UI 一行都不用动——这正是声明式"数据驱动视图"最迷人的地方。另外,时间轴组件通用性极强,强烈建议把它抽成 @Component struct Timeline,把"节点样式 / 竖线颜色 / 是否显示日期"都做成参数,这样物流页、订单页、审批流都能直接复用同一份时间轴,省下的重复代码相当可观。

十、状态标签的三元色可以再进化

取件页的状态着色用的是 item.statusColor——颜色直接从数据字段读取,而非在 UI 里做三元判断。TimelineItem 接口里有专门的 statusColor: string 字段,数据定义时就把"待取件 → C.warn、已取件 → C.ok、已退回 → C.danger"的映射写死了。这在 demo 里够用,但真实物流有更多状态:待取件、运输中、已签收、已退回、异常滞留、拒收……一旦状态超过三种,在数据层维护颜色映射也会变得繁琐。

更工程化的做法是把"状态 → 颜色"的映射抽成一个字典/映射对象,例如 const STATUS_COLOR: Record<string, string> = { 待取件: C.warn, 已签收: C.ok, 已退回: C.danger, 运输中: C.primary },渲染时一行 fontColor(STATUS_COLOR[t.tag] ?? C.textDim) 搞定。这样新增状态只需往字典里加一条,不用改任何渲染逻辑,而且状态与颜色的对应关系被集中到一处,方便统一维护和给设计稿对色。这个"映射表代替多分支"的技巧,在主题色、状态色、图标映射里都通用,值得和"选择器三板斧""时间轴三件套"一起记进你的 ArkUI 工具箱。

十一、性能与可访问性的两个提醒

最后补两个容易被忽略的工程点。其一,ForEach 的 key 推荐稳定:时间轴用 item.id.toString() 做 key 是正确的做法,因为 id 唯一且不随排序变化;如果哪天你要"按日期倒序重排时间轴",key 仍指向同一 id,ArkUI 才能正确复用节点、避免闪烁。千万别用数组下标当 key——一旦插入或排序,下标全乱,会导致节点错位、动画错乱。其二,时间轴节点别只靠颜色传信息:状态标签除了用颜色(item.statusColor),还同时写了文字("待取件"/"已取件"/"已退回"),这对色弱用户很友好。记住"颜色 + 文字双通道"这条无障碍原则,任何"靠颜色区分"的状态,都该配一段文字兜底。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐