HarmonyOS 应用 · 校园宿舍拼单订单页深度解析:状态机驱动的列表与时间线设计

本文以 HarmonyOS 6.0(API 23)+ ArkTS 声明式 UI 为技术底座,逐行拆解「校园宿舍拼单」应用"订单"Tab(Func2Tab)的搭建思路——从统计概览到订单列表,从三步时间线到头像组叠加。所有截图均来自本人电脑正在运行的 HarmonyOS 模拟器真机画面。


一、项目缘起:为什么订单页是"留存"的关键

首页让用户"逛",拼单页让用户"建",而订单页让用户"管"——管的过程是否顺畅,决定了用户会不会再次打开这款 App。

在拼单场景下,订单的状态远比普通电商复杂:发起拼单 → 等待加入 → 已满员 → 配送中 → 已完成,每一步都会触发不同的交互(催单 / 确认收货 / 评价)。如果订单页只是简单地把所有订单堆在一起,用户每次都要手动筛选、查找,体验会非常糟糕。

Func2Tab 的解法是"三层过滤 + 卡片自描述":

  1. 顶层 StatOverview:4 个数字一眼看清全局状态分布
  2. 中层 TabBar + FilterRow:按状态 + 时间双维度筛选
  3. 底层 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)
}

这种结构的原因是:

  1. Header + StatOverview + TabBar + FilterRow 都需要长期可见——用户翻看订单时,可能想随时切换状态。
  2. 订单列表才是滚动区域,因为订单可能很多。

下半部分用 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)
}

HeaderFunc1Tab 完全同构——同样的"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 同样严格遵循项目规范:

  1. 接口先行OrderItemStatCell 都用 interface 显式声明。
  2. ForEach 第三参数必填:4 处 ForEach 都传了 keyGenerator,注意头像组的 keyGenerator 用了 (a: string, idx: number) => idx.toString()——同一个 emoji 可能在不同订单里重复出现,用 idx 索引更可靠。
  3. 无类型断言:全文没有 as any 等逃生舱口。
  4. if (idx < xxx.length - 1) 边界判断:多处使用这种"最后一项不画分隔符/连接符"的模式,避免边界元素出现孤立的装饰。

特别值得一提的是 OrderCard@Builder 参数化:与首页的 GroupCard(g: GroupItem) 同构,传入一个 OrderItem 实例。OrderCard 内部的所有逻辑都基于 o.xxx 访问,没有引入任何额外状态——这让卡片成为"纯函数式组件",渲染结果只依赖入参,未来接入真实数据时只需替换 orders 数组即可。


十一、可演进方向

Func2Tab 已经覆盖了订单管理的主要交互,但还有几处可演进:

  1. 真实筛选生效:当前 activeTabfilterStatus 只是 UI 选中状态,没有真正过滤订单。后续接入 computed 派生数据或后端分页查询即可。
  2. 时间线点击回放:点击"发起拼单 ✓"可以弹层展示该步骤的详情(如"今天 12:30 发起,3 人加入")。
  3. 操作按钮加二次确认:特别是"确认收货"这种状态翻转操作,应该加 AlertDialog 二次确认。
  4. 空状态:当某个 Tab 下没有订单时,显示"暂无订单"的空状态卡。
  5. 下拉刷新:用 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 的指示器,通过 offsetmargin 切换位置。但这种实现需要算每个 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 个好处:

  1. 可测试:传入不同 OrderItem,断言渲染结果。可以完全脱离模拟器做单元测试。
  2. 可缓存:ArkUI 框架可以根据参数判断是否需要重渲染。
  3. 可复用:未来"个人中心"页面想展示"最近一笔订单"时,可以直接调用 OrderCard,无需复制代码。

如果未来需要新增"团队订单卡片"或"团购订单卡片",可以基于 OrderItem 派生新接口(如 TeamOrderItem extends OrderItem),传入新卡片组件即可——这是 ArkUI 声明式范式的"组合优于继承"思想。


十二、写在最后

回到开篇的两张真机截图——

订单页首屏

订单页底部

从顶部 Header 到 4 栏统计、状态 Tab、时间筛选,再到下方的订单卡片(emoji 头像 + 订单信息 + 状态标签 + 渐变进度条 + 三步时间线 + 头像组叠加 + 动态操作按钮),整张订单页用约 250 行 ArkTS 代码,构建出了一个"信息密度极高但仍清晰可读"的列表。

它最值得借鉴的设计有 3 处:

  1. 状态机驱动的卡片:用 o.status 决定按钮的文案和视觉强度,用 o.step 决定时间线的符号,让一张卡片能自动适配不同订单的不同状态。
  2. 头像组叠加:用负 margin + 白边实现"成员头像重叠"效果,比传统头像列表节省 50% 横向空间。
  3. 轻量筛选 + 重量筛选并存:4 栏统计(重量,一眼看清全貌)+ TabBar + FilterRow(轻量,按需筛选),形成"全局 → 分类 → 个体"的三层导航结构。

如果你也在做"列表 + 状态"的 HarmonyOS 页面,不妨从"卡片承载全部信息"的思路开始——不要让用户跳转到详情页才能完成操作,而是把"操作"塞进卡片本身。配合"按状态配色 / 按重要性分级按钮"这两条原则,整个列表会既专业又高效。

Logo

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

更多推荐