HarmonyOS 应用 · 校园宿舍拼单订单页深度解析:状态机驱动的列表与时间线设计
HarmonyOS 应用 · 校园宿舍拼单订单页深度解析:状态机驱动的列表与时间线设计
本文以 HarmonyOS 6.0(API 23)+ ArkTS 声明式 UI 为技术底座,逐行拆解「校园宿舍拼单」应用"订单"Tab(
Func2Tab)的搭建思路——从统计概览到订单列表,从三步时间线到头像组叠加。所有截图均来自本人电脑正在运行的 HarmonyOS 模拟器真机画面。
一、项目缘起:为什么订单页是"留存"的关键
首页让用户"逛",拼单页让用户"建",而订单页让用户"管"——管的过程是否顺畅,决定了用户会不会再次打开这款 App。
在拼单场景下,订单的状态远比普通电商复杂:发起拼单 → 等待加入 → 已满员 → 配送中 → 已完成,每一步都会触发不同的交互(催单 / 确认收货 / 评价)。如果订单页只是简单地把所有订单堆在一起,用户每次都要手动筛选、查找,体验会非常糟糕。
Func2Tab 的解法是"三层过滤 + 卡片自描述":
- 顶层 StatOverview:4 个数字一眼看清全局状态分布
- 中层 TabBar + FilterRow:按状态 + 时间双维度筛选
- 底层 OrderCard:每张卡片自带状态标签、进度条、时间线、动态操作按钮
整个 Func2Tab 位于 entry/src/main/ets/pages/Func2Tab.ets,约 250 行 ArkTS 代码,由 1 个 Header + 4 个 @Builder 区块(StatOverview / TabBar / FilterRow / OrderCard)组成。
二、整体架构:固定头 + 滚动列表
Func2Tab 的结构与 Func1Tab 不同——它的"上半部分是不滚动的":
build() {
Column() {
this.Header()
this.StatOverview()
this.TabBar()
this.FilterRow()
Scroll() {
Column({ space: 12 }) {
ForEach(this.orders, (o: OrderItem) => {
this.OrderCard(o)
}, (o: OrderItem) => o.id.toString())
}
.width('100%')
.padding({ left: D.pad, right: D.pad, top: 8, bottom: D.pad + this.safeBottom + 20 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
}
.width('100%').height('100%').backgroundColor(C.bg)
}
这种结构的原因是:
- Header + StatOverview + TabBar + FilterRow 都需要长期可见——用户翻看订单时,可能想随时切换状态。
- 订单列表才是滚动区域,因为订单可能很多。
下半部分用 ForEach(this.orders, ...) 遍历 5 个 mock 订单,每张订单通过 this.OrderCard(o) 渲染一个独立的 @Builder。
下面这张图,是 HarmonyOS 模拟器里订单页首屏的真实运行效果:

可以看到 4 栏统计(5 进行中 / 2 待收货 / 18 已完成 / 1 已取消)、4 个状态 Tab(全部 / 进行中 / 待收货 / 已完成)+ 4 个筛选 Chip + 排序入口,下方是 3 张订单卡片(奶茶拼单进行中、炸鸡全家桶待收货、麻辣烫拼单配送中)。
三、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 与 Func1Tab 完全同构——同样的"safeTop + 56"高度,同样的 20 号加粗主色文字,只是文案从"发起拼单"换成了"我的订单"。这种统一的 Header 结构让 4 个 Tab 之间切换时没有"跳戏"感。
四、StatOverview:4 栏统计概览
@Builder StatOverview() {
Row() {
ForEach(this.stats, (s: StatCell, idx: number) => {
Column({ space: 4 }) {
Text(s.value).fontSize(24).fontWeight(FontWeight.Bold).fontColor(s.color)
Text(s.label).fontSize(11).fontColor(C.textDim)
}
.layoutWeight(1)
if (idx < this.stats.length - 1) {
Divider().vertical(true).height(30).color(C.stroke)
}
}, (s: StatCell) => s.label)
}
.width('100%').padding({ top: 16, bottom: 16 })
.backgroundColor(C.card)
.border({ width: 0, color: C.stroke })
}
数据由 StatCell 接口驱动:
private stats: StatCell[] = [
{ label: '进行中', value: '5', color: C.primary },
{ label: '待收货', value: '2', color: C.warn },
{ label: '已完成', value: '18', color: C.ok },
{ label: '已取消', value: '1', color: C.textDim }
];
4 个统计单元格,每个由"大数字 + 小标签"组成。4 种不同的字色是关键——主色橙(进行中)、警告黄(待收货)、成功绿(已完成)、暗淡灰(已取消)。这种"按状态配色"的处理,让用户一眼区分哪些状态是"重要且需要处理"的(橙、黄),哪些是"已完成可忽略"的(绿),哪些是"已废弃无需关注"的(灰)。
每两个单元格之间用 Divider().vertical(true).height(30).color(C.stroke) 画一根垂直分隔线——这是 ArkUI 里少见的"垂直 Divider"用法,需要显式调用 vertical(true),否则默认是水平 Divider。
if (idx < this.stats.length - 1) 控制只在"非最后一个"单元格后加分隔线,避免最右边出现一根悬空的竖线。这种"边界判断 + 不画最后一项"是处理"项间分隔符"的通用写法。
五、TabBar:4 个状态 Tab 切换
@Builder TabBar() {
Row() {
ForEach(this.tabs, (t: string, idx: number) => {
Column({ space: 6 }) {
Text(t).fontSize(14)
.fontColor(this.activeTab === idx ? C.primary : C.textSub)
.fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
Row()
.width(this.activeTab === idx ? 24 : 0)
.height(3).borderRadius(2)
.backgroundColor(C.primary)
}
.layoutWeight(1)
.onClick(() => { this.activeTab = idx; })
}, (t: string) => t)
}
.width('100%').height(48)
.backgroundColor(C.card)
}
4 个 Tab:['全部', '进行中', '待收货', '已完成']。每个 Tab 由"文字 + 下划线指示器"组成。未选中的 Tab 下划线宽度为 0,选中的 Tab 下划线宽度 24:
Row()
.width(this.activeTab === idx ? 24 : 0)
.height(3).borderRadius(2)
.backgroundColor(C.primary)
这是一个非常优雅的"动画替代"实现——通过 width 变化模拟"下划线滑入"效果。在 ArkUI 里没有声明式动画时,用状态驱动尺寸变化是最直接的过渡方式。height(3).borderRadius(2) 让下划线呈短粗的胶囊形,视觉上比细线更有质感。
整个 TabBar 高度 48vp,正好放下"文字 + 6vp 间距 + 3vp 下划线 + 上下 padding"。
六、FilterRow:时间筛选 + 排序入口
@Builder FilterRow() {
Row() {
Text('筛选:').fontSize(12).fontColor(C.textDim)
ForEach(this.filterOpts, (opt: string) => {
Text(opt).fontSize(12)
.fontColor(this.filterStatus === opt ? '#FFFFFF' : C.textSub)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(this.filterStatus === opt ? C.primary : C.cardSoft)
.borderRadius(10)
.margin({ left: 6 })
.onClick(() => { this.filterStatus = opt; })
}, (opt: string) => opt)
Blank()
Text('⌄ 排序').fontSize(12).fontColor(C.textSub)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(C.cardSoft).borderRadius(10)
}
.width('100%')
.padding({ left: D.pad, right: D.pad, top: 10, bottom: 10 })
.backgroundColor(C.bg)
}
4 个时间筛选 ['全部', '最近7天', '最近30天', '本月'] + 一个"⌄ 排序"入口。
注意 FilterRow 的背景色用的是 C.bg(浅蓝灰),与上方 TabBar 的白底形成轻微的"分级"视觉——筛选是次级筛选,应该在视觉上"弱化"于主 Tab。Row 内置间距处理:“筛选:” 标签 → 4 个筛选 Chip 用 margin({ left: 6 }) 撑开 → Blank() 推到最右 → "⌄ 排序"在末尾。
Chip 的选中态是"主色背景 + 白字"——比截止时间的"轻量选中"更强,因为这是主筛选维度。这种"按重要性分级视觉"的处理,与首页的 ChipRow 设计一脉相承。
七、OrderCard:每张订单的"全能卡片"
OrderCard 是整个 Func2Tab 的核心,每张卡片承载 6 个信息维度:
@Builder OrderCard(o: OrderItem) {
Column({ space: 12 }) {
Row({ space: 10 }) {
Row() { Text(o.emoji).fontSize(32) }
.width(56).height(56).backgroundColor(C.cardSoft).borderRadius(D.rMd)
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row() {
Text(o.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor(C.text)
.layoutWeight(1)
Text('¥' + o.price).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.danger)
}.width('100%')
Row({ space: 8 }) {
Text(o.shop).fontSize(11).fontColor(C.textDim)
Text(o.time).fontSize(11).fontColor(C.textDim)
}.width('100%')
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(o.status).fontSize(11).fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(o.statusColor).borderRadius(8)
}.width('100%')
Stack({ alignContent: Alignment.Start }) {
Column().width('100%').height(5).backgroundColor(C.cardSoft).borderRadius(3)
Column()
.width(o.step / o.totalStep * 100 + '%')
.height(5).borderRadius(3)
.linearGradient({ angle: 0, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}.width('100%')
Row() {
ForEach(o.timeline, (step: string, idx: number) => {
Row({ space: 4 }) {
if (idx < o.step) {
Text('✓').fontSize(10).fontColor(C.ok)
} else if (idx === o.step) {
Text('●').fontSize(10).fontColor(C.primary)
} else {
Text('○').fontSize(10).fontColor(C.textDim)
}
Text(step).fontSize(10).fontColor(idx <= o.step ? C.textSub : C.textDim)
}
if (idx < o.timeline.length - 1) {
Text('—').fontSize(10).fontColor(C.textDim).margin({ left: 4, right: 4 })
}
}, (step: string) => step)
}.width('100%')
Row() {
Row({ space: 0 }) {
ForEach(o.avatars, (a: string, idx: number) => {
Text(a).fontSize(14)
.width(24).height(24).textAlign(TextAlign.Center)
.backgroundColor(C.cardSoft).borderRadius(12)
.margin({ left: idx === 0 ? 0 : -6 })
.border({ width: 2, color: C.card })
}, (a: string, idx: number) => idx.toString())
Text('+' + o.joined).fontSize(10).fontColor(C.textDim)
.height(24).textAlign(TextAlign.Center)
.margin({ left: 4 })
}
Blank()
if (o.status === '已完成') {
Text('评价').fontSize(12).fontColor(C.primary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primarySoft).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '去评价' }); })
} else if (o.status === '待收货') {
Text('确认收货').fontSize(12).fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primary).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '已确认收货' }); })
} else {
Text('催单').fontSize(12).fontColor(C.primary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primarySoft).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '已催单' }); })
}
}.width('100%')
}
.width('100%').padding(14).backgroundColor(C.card)
.borderRadius(D.rLg).border({ width: 1, color: C.stroke })
}
整张卡片可以拆成 5 个区域来理解。
7.1 顶部:订单概要
[emoji 头像 56×56] [订单名 ¥12] [进行中]
[店铺 时间]
订单名占满剩余空间 (layoutWeight(1)),价格固定在右侧(红色加粗强调)。状态标签在最右边(backgroundColor(o.statusColor) 用接口字段控制),4 种状态对应 4 种颜色:
private orders: OrderItem[] = [
{ id: 1, emoji: '🧋', name: '奶茶拼单', shop: '一点点', price: 12, status: '进行中', statusColor: C.primary, ... },
{ id: 2, emoji: '🍟', name: '炸鸡全家桶', shop: '肯德基', price: 39, status: '待收货', statusColor: C.warn, ... },
{ id: 3, emoji: '🍜', name: '麻辣烫拼单', shop: '张亮', price: 22, status: '配送中', statusColor: C.accent, ... },
{ id: 4, emoji: '🧁', name: '蛋挞一盒', shop: '烘焙坊', price: 15, status: '已完成', statusColor: C.ok, ... },
...
];
5 种状态:进行中(橙)、待收货(黄)、配送中(金黄)、已完成(绿)、已取消(灰)。
7.2 中部:进度条
Stack({ alignContent: Alignment.Start }) {
Column().width('100%').height(5).backgroundColor(C.cardSoft).borderRadius(3)
Column()
.width(o.step / o.totalStep * 100 + '%')
.height(5).borderRadius(3)
.linearGradient({ angle: 0, colors: [[C.primary, 0.0], [C.accent, 1.0]] })
}.width('100%')
与首页 GroupCard 的进度条同构:浅色轨道 + 渐变进度。o.step / o.totalStep 计算当前进度比例(1/3 = 33%、2/3 = 67%、3/3 = 100%)。这种"百分比宽度 + 渐变"的实现极简,没有用任何 Canvas 或进度条组件。
7.3 时间线:三步状态可视化
Row() {
ForEach(o.timeline, (step: string, idx: number) => {
Row({ space: 4 }) {
if (idx < o.step) {
Text('✓').fontSize(10).fontColor(C.ok)
} else if (idx === o.step) {
Text('●').fontSize(10).fontColor(C.primary)
} else {
Text('○').fontSize(10).fontColor(C.textDim)
}
Text(step).fontSize(10).fontColor(idx <= o.step ? C.textSub : C.textDim)
}
if (idx < o.timeline.length - 1) {
Text('—').fontSize(10).fontColor(C.textDim).margin({ left: 4, right: 4 })
}
}, (step: string) => step)
}.width('100%')
时间线是订单页的"灵魂设计"。每张订单有 3 步时间线(如 ['发起拼单 ✓', '等待加入', '成团配送']),通过当前 step 索引和循环索引 idx 的比较,用 3 种符号区分状态:
- 已完成(
idx < o.step):绿色 ✓ - 当前进行中(
idx === o.step):橙色实心 ● - 未开始(
idx > o.step):灰色空心 ○
— 短横线作为步骤之间的连接符。idx <= o.step ? C.textSub : C.textDim 让已完成和进行中的步骤文字用次文色,未完成的用暗淡色——进一步强化状态对比。
7.4 头像组叠加:成员可视化
Row({ space: 0 }) {
ForEach(o.avatars, (a: string, idx: number) => {
Text(a).fontSize(14)
.width(24).height(24).textAlign(TextAlign.Center)
.backgroundColor(C.cardSoft).borderRadius(12)
.margin({ left: idx === 0 ? 0 : -6 })
.border({ width: 2, color: C.card })
}, (a: string, idx: number) => idx.toString())
Text('+' + o.joined).fontSize(10).fontColor(C.textDim)
.height(24).textAlign(TextAlign.Center)
.margin({ left: 4 })
}
这是整张卡片最有"技术感"的设计——头像组叠加。每个 emoji 头像 24×24vp 圆形,第一个头像 margin left = 0,后续头像 margin left = -6(负值让头像向左偏移 6vp,与前一个头像重叠 6vp)。border({ width: 2, color: C.card }) 给每个头像加 2vp 白边,让叠加看起来像"重叠的卡片"而非"模糊的色块"。
后面跟一个"+" + 总人数的文字提示,告诉用户总共有多少成员。这种"头像组 + 总数"是社交 App 的经典模式(如微信群成员头像),把它搬到订单页能瞬间提升"团队感"。
7.5 操作按钮:动态适配状态
if (o.status === '已完成') {
Text('评价').fontSize(12).fontColor(C.primary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primarySoft).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '去评价' }); })
} else if (o.status === '待收货') {
Text('确认收货').fontSize(12).fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primary).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '已确认收货' }); })
} else {
Text('催单').fontSize(12).fontColor(C.primary)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(C.primarySoft).borderRadius(D.rSm)
.onClick(() => { promptAction.showToast({ message: '已催单' }); })
}
根据订单状态动态切换操作按钮——这是 ArkUI “数据驱动 UI” 的最佳体现:
| 状态 | 按钮文案 | 视觉强度 |
|---|---|---|
| 已完成 | 评价 | 轻量(浅橙底 + 主色字) |
| 待收货 | 确认收货 | 重量(主色底 + 白字) |
| 其他 | 催单 | 轻量(浅橙底 + 主色字) |
为什么"确认收货"是重量按钮?因为它是这个状态下唯一"必需"的操作(其他都是可选的反馈动作)。这种"按操作重要性区分视觉强度"的设计,让用户能在 0.5 秒内定位"该点什么"。
下图是订单页底部,展示两张已完成订单的卡片(蛋挞、可乐),都有"评价"按钮:

可以看到已完成订单的进度条完全填满,时间线全部绿色 ✓,头像组叠加(蛋挞有 5 个,可乐有 3 个),右侧"评价"按钮都是浅橙底主色字。
八、OrderItem 数据结构:13 个字段
OrderItem 接口定义了订单的全部数据维度:
interface OrderItem {
id: number;
emoji: string;
name: string;
shop: string;
price: number;
status: string;
statusColor: string;
time: string;
joined: number;
need: number;
step: number;
totalStep: number;
timeline: string[];
avatars: string[];
}
13 个字段,按业务维度分组:
- 基础信息:
id/emoji/name/shop/price/time - 状态信息:
status/statusColor/step/totalStep/timeline - 成员信息:
joined/avatars
这种"按维度分字段"的接口设计,让前端能精准控制每个订单的视觉呈现。后续接入后端时,只需把 joined 从接口读出来、把 timeline 由后端按业务规则生成即可,UI 不用动。
九、状态管理:2 个 @State 字段
整个 Func2Tab 只有 2 个 @State:
@State activeTab: number = 0;
@State filterStatus: string = '全部';
activeTab 控制状态 Tab 选中下标(0=全部 / 1=进行中 / 2=待收货 / 3=已完成)。filterStatus 控制时间筛选值。
这与首页的 3 个 @State、拼单页的 11 个 @State 形成鲜明对比——订单页的重心是"展示"而非"编辑",所以状态字段少。这是设计表单类页面与列表类页面的关键差异。
十、ArkTS 工程化细节
Func2Tab 同样严格遵循项目规范:
- 接口先行:
OrderItem、StatCell都用interface显式声明。 - ForEach 第三参数必填:4 处
ForEach都传了 keyGenerator,注意头像组的 keyGenerator 用了(a: string, idx: number) => idx.toString()——同一个 emoji 可能在不同订单里重复出现,用idx索引更可靠。 - 无类型断言:全文没有
as any等逃生舱口。 if (idx < xxx.length - 1)边界判断:多处使用这种"最后一项不画分隔符/连接符"的模式,避免边界元素出现孤立的装饰。
特别值得一提的是 OrderCard 的 @Builder 参数化:与首页的 GroupCard(g: GroupItem) 同构,传入一个 OrderItem 实例。OrderCard 内部的所有逻辑都基于 o.xxx 访问,没有引入任何额外状态——这让卡片成为"纯函数式组件",渲染结果只依赖入参,未来接入真实数据时只需替换 orders 数组即可。
十一、可演进方向
Func2Tab 已经覆盖了订单管理的主要交互,但还有几处可演进:
- 真实筛选生效:当前
activeTab和filterStatus只是 UI 选中状态,没有真正过滤订单。后续接入computed派生数据或后端分页查询即可。 - 时间线点击回放:点击"发起拼单 ✓"可以弹层展示该步骤的详情(如"今天 12:30 发起,3 人加入")。
- 操作按钮加二次确认:特别是"确认收货"这种状态翻转操作,应该加
AlertDialog二次确认。 - 空状态:当某个 Tab 下没有订单时,显示"暂无订单"的空状态卡。
- 下拉刷新:用 ArkUI 的
Refresh组件包住 Scroll,实现"下拉刷新订单列表"。
十一+、再深入:几个值得记住的小技巧
11+1. TabBar 下划线宽度替代动画
ArkUI 的 Tabs 组件原生支持下划线动画,但那是"组件级"的能力。当我们需要自绘 TabBar 时,可以用"宽度变化"模拟下划线滑入:
Row()
.width(this.activeTab === idx ? 24 : 0)
.height(3).borderRadius(2)
.backgroundColor(C.primary)
未选中时下划线宽度 0(不可见),选中时宽度 24(可见)。这种"用尺寸变化模拟状态切换"是 ArkUI 里最常用的"轻量动画"技巧——不需要 @AnimatableExtend 或显式 animation() API,状态变化自然驱动尺寸更新。
如果要更细腻的滑动效果,可以用 Row() 替代宽度变化,做一个固定宽度 24 的指示器,通过 offset 或 margin 切换位置。但这种实现需要算每个 Tab 的具体偏移量,性价比不高。宽度变化的方案在 80% 场景下已经够用。
11+2. 头像组叠加的"白边"原理
负 margin 让头像重叠,但仅靠负 margin 会让两张头像"糊"在一起。解决办法是给每个头像加白边:
.margin({ left: idx === 0 ? 0 : -6 })
.border({ width: 2, color: C.card })
border({ width: 2, color: C.card }) 是 2vp 的卡片色(白)边框。叠加效果是:第一个头像的右边缘被第二个头像的 2vp 白边"切"出一条干净的分割线。这种"用 border 模拟分隔线"的方式,比给每个头像加 z-index 排序或画 Canvas 简单得多。
类似的小技巧还有:“用 padding 模拟内边距”、“用空白 Text 模拟占位”、“用 Stack 模拟图层”——ArkUI 的核心组件组合能力,能覆盖 90% 的设计需求。
11+3. 状态机的"视觉权重"分配
Func2Tab 的卡片设计里有一个很值得借鉴的模式——按状态优先级分配视觉强度:
| 元素 | 状态 | 视觉强度 |
|---|---|---|
| 状态标签 | 已完成 | 绿色底白字(成功态) |
| 状态标签 | 待收货 | 黄色底白字(警告态) |
| 状态标签 | 配送中 | 金黄色底白字(进行态) |
| 操作按钮 | 待收货 → 确认收货 | 主色实心(重量级) |
| 操作按钮 | 已完成 → 评价 | 主色描边(轻量级) |
| 时间线 | 已完成步骤 | 绿色 ✓ |
| 时间线 | 当前步骤 | 橙色 ● |
| 时间线 | 未完成步骤 | 灰色 ○ |
这种"全页面共享一套状态颜色映射"的设计,让用户形成"看到橙色就是当前在看、看到绿色就是已完成、看到黄色就是待处理"的视觉肌肉记忆,整个订单页因此变得"自解释"。
11+4. 滚动列表的 padding 陷阱
整个 Func2Tab 的滚动区域 padding 设置为:
.padding({ left: D.pad, right: D.pad, top: 8, bottom: D.pad + this.safeBottom + 20 })
底部 padding = 16 + safeBottom + 20。为什么要 +20?这是为了在最后一张订单卡片和 Tab 栏之间留出"手指可点击区域",避免用户想点最后一张订单卡片时不小心点到 Tab 栏。这种"额外 20vp 留白"是移动端列表的标准实践。
+ safeBottom 则是为了避开 HarmonyOS 的底部导航条指示器。如果漏了 safeBottom,最后一张卡片可能被系统的"Home Indicator"遮住,造成误操作。
11+5. "@Builder 参数化"与可复用性
OrderCard 接收 o: OrderItem 参数,整个卡片内部不依赖任何外部状态(除了状态机的视觉判断)。这种"纯函数式组件"的设计有 3 个好处:
- 可测试:传入不同
OrderItem,断言渲染结果。可以完全脱离模拟器做单元测试。 - 可缓存:ArkUI 框架可以根据参数判断是否需要重渲染。
- 可复用:未来"个人中心"页面想展示"最近一笔订单"时,可以直接调用
OrderCard,无需复制代码。
如果未来需要新增"团队订单卡片"或"团购订单卡片",可以基于 OrderItem 派生新接口(如 TeamOrderItem extends OrderItem),传入新卡片组件即可——这是 ArkUI 声明式范式的"组合优于继承"思想。
十二、写在最后
回到开篇的两张真机截图——


从顶部 Header 到 4 栏统计、状态 Tab、时间筛选,再到下方的订单卡片(emoji 头像 + 订单信息 + 状态标签 + 渐变进度条 + 三步时间线 + 头像组叠加 + 动态操作按钮),整张订单页用约 250 行 ArkTS 代码,构建出了一个"信息密度极高但仍清晰可读"的列表。
它最值得借鉴的设计有 3 处:
- 状态机驱动的卡片:用
o.status决定按钮的文案和视觉强度,用o.step决定时间线的符号,让一张卡片能自动适配不同订单的不同状态。 - 头像组叠加:用负 margin + 白边实现"成员头像重叠"效果,比传统头像列表节省 50% 横向空间。
- 轻量筛选 + 重量筛选并存:4 栏统计(重量,一眼看清全貌)+ TabBar + FilterRow(轻量,按需筛选),形成"全局 → 分类 → 个体"的三层导航结构。
如果你也在做"列表 + 状态"的 HarmonyOS 页面,不妨从"卡片承载全部信息"的思路开始——不要让用户跳转到详情页才能完成操作,而是把"操作"塞进卡片本身。配合"按状态配色 / 按重要性分级按钮"这两条原则,整个列表会既专业又高效。
更多推荐




所有评论(0)