HarmonyOS ArkTS实战:一条竖线串记录 —— 校园快递取件页手绘时间轴与状态分类筛选
HarmonyOS ArkTS实战:一条竖线串记录 —— 校园快递取件页手绘时间轴与状态分类筛选
应用背景:11 校园快递(campus-express)第三个 Tab 是取件记录页,对应源码
Func2Tab.ets(共 208 行)。它是用户查看"我的快递都到哪了、该去哪取"的流水账,橙色主题#EA580C。本文逐行拆解 TabBar 分类标签与计数徽标、OverviewCard 统计概览、TimelineList 时间轴列表 三大模块,重点讲解"如何用一条竖线 + 圆形节点把一组离散记录串成时间轴",以及"状态分类标签如何驱动列表筛选"。
取件页在三个内容 Tab 里最"重数据":它不像首页那样铺品牌、也不像寄件页那样收输入,而是把"历史 + 当前"的快递记录按时间顺序摊开。用户在寄件之后最关心的就是"东西到没到、去哪取",取件页正是这个问题的答案页。读完它,你会掌握 ArkUI 里"时间轴"这一高频 UI 模式的纯声明式实现。
为什么取件页偏要用"时间轴"而不是普通列表?因为取件记录天然带有"先后到达"的时序语义:驿站签收 → 短信通知 → 你去取 → 完成,是一条有方向的流。用一条竖线把这些节点串起来,用户一眼就能读出"进度走到哪了",比把记录平铺成网格更贴合心智模型。这也是为什么物流、订单、审批这类"带状态推进"的场景,几乎都偏爱时间轴——它把"状态"翻译成了"空间位置",可读性最好。
一、数据模型:三套结构
取件页只定义了三个接口,比首页的六个还少,因为时间轴的数据形状很单一。注意 TimelineItem 用 status + 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 里写三元表达式更清晰。

二、页面骨架: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 / 筛选条 / 滚动内容"的三段式,在带筛选的列表页里极为常见(商品列表、订单列表、消息列表都是这个套路)。滚动区里直接放 OverviewCard 和 TimelineList 两块,没有额外的 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) 做一行三列:每个 StatCard 用 layoutWeight(1) 均分宽度,emoji + 数值 + 标签 三段式纵向排列,居中对齐。注意它没用 Grid 或 Flex 组件,而是用 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,符合"时间轴项即入口"。

七、交互:状态筛选与状态驱动
取件页当前最关键的 @State 是 tabIdx——它只控制 TabBar 的选中高亮。源码里 timeline 数组是写死的,所以点击不同标签时列表并不会真正过滤。但这恰恰是留给读者的"接口":只要在 TabBar 的 onClick 里,除了 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 | 选中态橙底白字 + 计数徽标 | 带计数的分类筛选条 |
| OverviewCard | Row + 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),还同时写了文字("待取件"/"已取件"/"已退回"),这对色弱用户很友好。记住"颜色 + 文字双通道"这条无障碍原则,任何"靠颜色区分"的状态,都该配一段文字兜底。
更多推荐


所有评论(0)