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

发布时间:2026/8/18 19:58:10
HarmonyOS 应用 · 校园宿舍拼单订单页深度解析:状态机驱动的列表与时间线设计 HarmonyOS 应用 · 校园宿舍拼单订单页深度解析状态机驱动的列表与时间线设计本文以 HarmonyOS 6.0API 23 ArkTS 声明式 UI 为技术底座逐行拆解「校园宿舍拼单」应用订单TabFunc2Tab的搭建思路——从统计概览到订单列表从三步时间线到头像组叠加。所有截图均来自本人电脑正在运行的 HarmonyOS 模拟器真机画面。一、项目缘起为什么订单页是留存的关键首页让用户逛拼单页让用户建而订单页让用户管——管的过程是否顺畅决定了用户会不会再次打开这款 App。在拼单场景下订单的状态远比普通电商复杂发起拼单 → 等待加入 → 已满员 → 配送中 → 已完成每一步都会触发不同的交互催单 / 确认收货 / 评价。如果订单页只是简单地把所有订单堆在一起用户每次都要手动筛选、查找体验会非常糟糕。Func2Tab的解法是三层过滤 卡片自描述顶层 StatOverview4 个数字一眼看清全局状态分布中层 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 之间切换时没有跳戏感。四、StatOverview4 栏统计概览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)控制只在非最后一个单元格后加分隔线避免最右边出现一根悬空的竖线。这种边界判断 不画最后一项是处理项间分隔符的通用写法。五、TabBar4 个状态 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 下划线宽度 24Row() .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 个StateState 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实现下拉刷新订单列表。十一、再深入几个值得记住的小技巧111. 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% 场景下已经够用。112. 头像组叠加的白边原理负 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% 的设计需求。113. 状态机的视觉权重分配Func2Tab的卡片设计里有一个很值得借鉴的模式——按状态优先级分配视觉强度元素状态视觉强度状态标签已完成绿色底白字成功态状态标签待收货黄色底白字警告态状态标签配送中金黄色底白字进行态操作按钮待收货 → 确认收货主色实心重量级操作按钮已完成 → 评价主色描边轻量级时间线已完成步骤绿色 ✓时间线当前步骤橙色 ●时间线未完成步骤灰色 ○这种全页面共享一套状态颜色映射的设计让用户形成看到橙色就是当前在看、看到绿色就是已完成、看到黄色就是待处理的视觉肌肉记忆整个订单页因此变得自解释。114. 滚动列表的 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遮住造成误操作。115. 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 页面不妨从卡片承载全部信息的思路开始——不要让用户跳转到详情页才能完成操作而是把操作塞进卡片本身。配合按状态配色 / 按重要性分级按钮这两条原则整个列表会既专业又高效。