一、引言1.1 什么是时间轴Timeline时间轴Timeline是一种按时间顺序展示事件的 UI 组件。它在移动应用中无处不在订单状态已下单 → 已支付 → 已发货 → 已签收项目进度启动 → 设计 → 开发 → 测试 → 上线消息历史聊天记录按时间分组展示操作日志系统操作的时间线回顾时间轴的核心特征是一条垂直的连接线串联起所有节点每个节点包含一个圆点指示器和一段内容卡片。1.2 本文要解决的问题ArkUI 并没有内置的 Timeline 组件。但通过 Column 自定义组合我们可以从零构建一个功能完整的时间轴不使用任何第三方库只用 Column、Row、Stack、Text、Divider这些最基础的组件组合出一个可交互的时间轴 UI。1.3 本文核心内容知识点 说明Column 纵向骨架 Column 垂直排列所有时间节点Row 左右分栏 每个节点左侧圆点线 右侧内容卡片自定义连接线 用窄背景 Column 模拟垂直线条三种状态设计 已完成(绿) / 进行中(蓝) / 待处理(灰)数据驱动 State 驱动时间轴状态推进Builder 组件化 将圆点、标签、按钮拆分为可复用组件二、时间轴布局设计2.1 整体结构┌─────────────────────────────────┐│ 垂直时间轴组件 ││ Column 自定义连线实现 Timeline │├─────────────────────────────────┤│ [ 显示连接线] [↻ 重置状态] │ ← 控制栏├─────────────────────────────────┤│ ││ ●── 项目启动 2025-07-01 │ ← 已完成│ │ ││ ●── 需求分析 2025-07-03 │ ← 已完成│ │ ││ ●── UI 设计 2025-07-05 │ ← 已完成│ │ ││ ●── 前端开发 2025-07-08 │ ← 进行中│ │ ││ ○── 后端联调 2025-07-15 │ ← 待处理│ │ ││ ○── 测试验收 2025-07-20 │ ← 待处理│ │ ││ ○── 上线发布 2025-07-25 │ ← 待处理无连接线│ │├─────────────────────────────────┤│ ● 已完成 ● 进行中 ○ 待处理 │ ← 图例└─────────────────────────────────┘2.2 每个时间节点的内部结构每个节点 Row水平分层├── 左侧 Column30vp 宽│ ├── 圆点Stack 层叠│ │ ├── 外圈光晕背景色半透明│ │ └── 实心圆或空心环│ └── 垂直连接线2px 宽 Column│ └── layoutWeight(1) 撑满剩余高度│└── 右侧 ColumnlayoutWeight 弹性├── Row标题 状态标签├── Text描述文字2行/全部└── Row时间 展开/收起按钮2.3 三种状态的设计规范状态 圆点 卡片背景 连接线颜色 语义已完成 绿色实心 对勾 #1a3a2e 深绿 绿色半透明 已经完成的事件进行中 蓝色实心 高光 #1a2a4e 深蓝 蓝色半透明 当前正在推进的事件待处理 ⚪ 灰色空心环 #1a1a2e 深灰 灰色浅色 尚未开始的事件三、Demo 代码逐层剖析3.1 项目结构与路由{“src”: [“pages/TimelineDemo”]}TimelineDemo.ets 共 519 行完整结构如下TimelineDemo.ets (519行)├── enum TimelineStatus ← COMPLETED / ACTIVE / PENDING├── interface TimelineItem ← id / title / description / time / status├── Component TimelineDemo│ ├── State 变量3个 ← timelineData / showLines / expandedId│ ├── 颜色常量3色 ← 绿/蓝/灰│ ├── aboutToAppear() ← 初始化 7 条数据│ ├── build()│ │ ├── 标题区│ │ ├── 控制栏2个按钮│ │ ├── Scroll Column ← 时间轴主体│ │ │ └── ForEach → timelineNode(item, index)│ │ └── 底部图例3色│ ├── Builder 方法6个│ │ ├── timelineNode() ← 核心每个时间节点│ │ ├── completedDot() ← ✅ 已完成圆点│ │ ├── activeDot() ← 进行中圆点│ │ ├── pendingDot() ← ⚪ 待处理圆点│ │ ├── statusBadge() ← 状态标签徽章│ │ ├── configButton() ← 控制按钮│ │ └── legendDot() ← 图例小点│ └── 私有方法│ ├── initTimelineData() ← 数据初始化│ ├── advanceTimeline() ← 状态推进│ ├── resetAllToPending() ← 全部重置│ └── 颜色/文字工具3.2 数据模型设计enum TimelineStatus {COMPLETED, // 已完成ACTIVE, // 进行中PENDING // 待处理}interface TimelineItem {id: number;title: string;description: string;time: string;status: TimelineStatus;}7 条示例数据模拟了一个项目研发时间轴项目启动 → COMPLETED需求分析 → COMPLETEDUI 设计 → COMPLETED前端开发 → ACTIVE ← 当前阶段后端联调 → PENDING测试验收 → PENDING上线发布 → PENDING ← 最后一项无连接线3.3 时间轴核心布局Scroll ColumnScroll() {Column() {ForEach(this.timelineData, (item: TimelineItem, index: number) {this.timelineNode(item, index)}, (item: TimelineItem) item.id.toString())}.width(‘100%’).padding({ left: 16, right: 16, top: 8, bottom: 16 })}.layoutWeight(1)为什么用 Scroll 包裹—— 当时间轴节点超过屏幕高度时底部的节点会被截断。Scroll 让用户可以上下滚动查看所有节点。3.4 核心每个时间节点的实现BuildertimelineNode(item: TimelineItem, index: number) {Row() {// 左侧时间轴指示器 Column() {// 圆点根据状态不同if (item.status COMPLETED) {this.completedDot() // 绿色实心 对勾} else if (item.status ACTIVE) {this.activeDot() // 蓝色实心 高光} else {this.pendingDot() // 灰色空心环}// 垂直连接线最后一项不显示 if (this.showLines index this.timelineData.length - 1) { Column() .width(2) .layoutWeight(1) .backgroundColor(this.getLineColor(item.status)) } } .width(30) .alignItems(HorizontalAlign.Center) // 右侧内容卡片 Column() { // 标题 状态标签 Row() { Text(item.title).fontSize(15).fontColor(Color.White) this.statusBadge(item.status) } // 描述展开/收起控制行数 Text(item.description) .maxLines(this.expandedId item.id ? 10 : 2) .textOverflow({ overflow: TextOverflow.Ellipsis }) // 时间 展开按钮 Row() { Text(item.time) Text(this.expandedId item.id ? 收起 ▲ : 展开 ▼) } } .layoutWeight(1) .backgroundColor(this.getCardBg(item.status)) .borderRadius(10) .padding(12) .gesture(TapGesture().onAction(() { // 点击切换展开 推进状态 if (this.expandedId item.id) { this.expandedId -1; } else { this.expandedId item.id; if (item.status ACTIVE) { this.advanceTimeline(item.id); } } }))}.alignItems(VerticalAlign.Top)}关键设计细节左侧 Column 宽度固定 30vp确保所有圆点在同一垂直线上.layoutWeight(1) 撑满连接线连接线 Column 的高度由父容器决定maxLines 控制展开收起时 2 行展开时 10 行textOverflow(Ellipsis)超出时显示省略号alignItems(Top)圆点和内容从顶部对齐3.5 三种状态圆点的 Stack 实现三个圆点都用 Stack层叠布局 实现——底层是光晕或外圈上层是实心圆或标记。已完成圆点BuildercompletedDot() {Stack() {// 底层外圈光晕半透明绿色Text().width(18).height(18).backgroundColor(‘#2ECC7133’).borderRadius(9)// 中层实心圆 Text().width(12).height(12) .backgroundColor(#2ECC71).borderRadius(6) // 上层对勾标记 Text(✓).fontSize(8).fontColor(Color.White)}.width(22).height(22)}三层叠加以 22×22 为容器三种元素在 Z 轴上依次叠加。进行中圆点BuilderactiveDot() {Stack() {Text().width(20).height(20).backgroundColor(‘#4A90D922’).borderRadius(10)Text().width(12).height(12).backgroundColor(‘#4A90D9’).borderRadius(6)Text().width(4).height(4).backgroundColor(Color.White).borderRadius(2) // 高光}.width(24).height(24)}进行中圆点比已完成的大 2px24 vs 22视觉上更突出。待处理圆点BuilderpendingDot() {Stack() {Text().width(12).height(12).border({ width: 2, color: ‘#95A5A6’ }).borderRadius(6)}.width(22).height(22)}待处理是空心圆环——只有边框border没有背景色。3.6 垂直连接线的实现连接线是时间轴最关键的视觉元素。Demo 中使用了一个窄背景色的 Columnif (this.showLines index this.timelineData.length - 1) {Column().width(2) // 2vp 宽的垂直线.layoutWeight(1) // 撑满剩余垂直空间.backgroundColor(this.getLineColor(item.status)) // 颜色随状态变化}为什么不用 Divider 因为 Divider 是水平分割线而我们需要的是垂直连接线。用 Column 背景色可以精确控制宽度、颜色和高度。连接线颜色的计算private getLineColor(status: TimelineStatus): string {switch (status) {case COMPLETED: return ‘#2ECC7144’; // 绿色半透明case ACTIVE: return ‘#4A90D944’; // 蓝色半透明case PENDING: return ‘#95A5A622’; // 灰色浅透明}}颜色跟随当前节点的状态已完成的节点下方是绿色线进行中节点下方是蓝色线待处理节点下方是灰色线。3.7 状态推进逻辑private advanceTimeline(currentId: number): void {const updated this.timelineData.map((item) {const result { …item }; // 复制if (item.id currentId) {result.status COMPLETED; // 当前 → 已完成}if (item.id currentId 1 item.status PENDING) {result.status ACTIVE; // 下一个 → 进行中}return result;});this.timelineData updated;}注意ArkTS 中不允许使用 { …item, status: … } 对象展开语法。必须显式构建完整对象。推进规则点击前端开发(ACTIVE) →前端开发 → COMPLETED后端联调 → ACTIVE3.8 状态标签徽章BuilderstatusBadge(status: TimelineStatus) {Text(this.getStatusLabel(status)).fontSize(9).backgroundColor(this.getStatusColor(status) ‘66’).border({ width: 1, color: this.getStatusColor(status) ‘99’ }).borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })}效果半透明背景 同色边框 → 胶囊形状的标签。3.9 图例BuilderlegendDot(color: string, label: string) {Row() {Text().width(8).height(8).backgroundColor(color).borderRadius(4)Text(label).fontSize(11).fontColor(Color.Gray)}.alignItems(VerticalAlign.Center)}底部图例用三个小圆点 文字说明方便用户理解三种状态的颜色含义。四、时间轴的变体与扩展4.1 水平时间轴Scroll() {Row() {ForEach(this.timelineData, (item, index) {Column() {// 上内容卡片// 中连接线// 下圆点}})}}.scrollable(ScrollDirection.Horizontal)只需将 Scroll 设置为水平滚动内部改为 Row 排列连接线改为水平方向。4.2 可交互的拖拽时间轴结合第二篇文章的 PanGesture可以让用户通过拖拽来调整节点的顺序。4.3 自定义节点图标BuildercustomNode(icon: string, color: string) {Stack() {Text().width(28).height(28).backgroundColor(color ‘33’).borderRadius(14)Text(icon).fontSize(14)}}用 Emoji 或图标代替圆点让每个节点有不同的视觉标识。4.4 时间轴动画// 节点进入动画Text(item.title).transition({type: TransitionType.Insert,opacity: 0,translate: { x: -20 }})4.5 懒加载时间轴对于超长时间轴几十个节点可以使用 LazyForEach 替代 ForEach只渲染可见区域的节点。五、常见问题与坑点5.1 连接线高度不准确现象连接线没有撑满两个节点之间的空间。原因父 Column 没有明确的高度。解决方案Column().width(2).layoutWeight(1) // ← 关键撑满剩余空间同时左侧 Column 需要设置固定高度或 layoutWeightColumn().width(30).height(this.expandedId item.id ? 130 : 90) // ← 固定高度5.2 最后一项多余连接线现象时间轴最后一个节点下面还有一段连接线。解决方案// 最后一项index length - 1不渲染连接线if (this.showLines index this.timelineData.length - 1) {// 渲染连接线}5.3 状态标签颜色不同步现象标签文字颜色与背景颜色不匹配。解决方案使用统一的颜色获取函数// 颜色保持一致backgroundColor(this.getStatusColor(status) ‘66’)border({ color: this.getStatusColor(status) ‘99’ })5.4 卡片展开后内容溢出现象描述文字过长时点击展开后超出卡片边界。解决方案Text(item.description).maxLines(this.expandedId item.id ? 10 : 2)// ↑ 设置最大行数超出用省略号5.5 Builder 中的条件渲染在 Builder 中使用 if/else 时需要注意 ArkTS 的限制// ✅ 正确Builder timelineNode(item, index) {if (conditionA) {this.componentA()} else {this.componentB()}}// ❌ 错误不能直接在 Builder 中声明变量Builder timelineNode() {const x 1; // 编译错误}变量声明应该在普通方法中完成Builder 只做 UI 编排。六、最佳实践清单6.1 时间轴组件的标准模板Componentstruct Timeline {Prop data: TimelineItem[];build() {Scroll() {Column() {ForEach(this.data, (item, index) {this.timelineNode(item, index)})}}}Builder timelineNode(item, index) {Row() {// 左侧指示器Column() {this.dot(item.status)if (index this.data.length - 1) {Column().width(2).layoutWeight(1).backgroundColor(this.lineColor)}}.width(30).alignItems(HorizontalAlign.Center)// 右侧内容 Column() { Text(item.title) Text(item.description) Text(item.time) } .layoutWeight(1) } .alignItems(VerticalAlign.Top)}}6.2 数据与 UI 分离// 数据层独立文件// timelineData.etsexport const projectTimeline: TimelineItem[] [ /* …/ ];export const orderTimeline: TimelineItem[] [ /… */ ];// 展示层// TimelinePage.etsState private data: TimelineItem[] projectTimeline;6.3 响应式状态推进// 使用状态机模式管理推进逻辑private getNextStatus(current: TimelineStatus): TimelineStatus {switch (current) {case PENDING: return ACTIVE;case ACTIVE: return COMPLETED;case COMPLETED: return COMPLETED; // 已完成不再变化}}6.4 可定制化配置interface TimelineConfig {dotSize?: number; // 圆点大小lineWidth?: number; // 连接线宽度cardRadius?: number; // 卡片圆角colors?: {completed: string; // 完成色active: string; // 进行中色pending: string; // 待处理色};}6.5 无障碍支持Text(‘✓’).accessibilityText(‘已完成’).accessibilityLevel(‘auto’)七、总结与展望7.1 核心回顾Column 实现时间轴的公式时间轴 Column(垂直骨架) Row(每个节点: 左侧指示器 右侧内容) Column(左侧: 圆点 连接线) Column(右侧: 标题 描述 时间) State(数据驱动状态变化)7.2 从本 demo 学到的布局技巧技巧 实现方式 用途垂直连接线 Column().width(2).layoutWeight(1) 窄背景 Column 模拟线条圆点 光晕 Stack { 光晕 → 实心圆 → 标记 } 多层叠加实现精致图标展开/收起 maxLines(2) ↔ maxLines(10) 控制文本行数状态颜色 switch(status) → color alpha 统一的颜色管理骨架 组件 Column Builder 拆分 可维护的代码结构7.3 下一步探索LazyForEach大数据量时间轴的性能优化动画节点出现/状态变化时的过渡动画拖拽排序PanGesture 实现节点重排嵌套时间轴每个节点内部再嵌套子时间轴无障碍为时间轴添加完整无障碍支持附录 A完整 Demo 代码/*TimelineDemo.ets —— 鸿蒙原生 ArkTS 布局方式之 Column 实现垂直时间轴组件 核心技术 Column() —— 作为时间轴的外骨架垂直排列各个时间节点自定义连接线 —— 用 Container 背景色绘制垂直连接线Builder 组件化 —— 将时间轴节点抽象为可复用的组件*/enum TimelineStatus { COMPLETED, ACTIVE, PENDING }interface TimelineItem {id: number;title: string;description: string;time: string;status: TimelineStatus;}EntryComponentstruct TimelineDemo {State private timelineData: TimelineItem[] [];State private showLines: boolean true;State private expandedId: number -1;private readonly COLOR_COMPLETED: string ‘#2ECC71’;private readonly COLOR_ACTIVE: string ‘#4A90D9’;private readonly COLOR_PENDING: string ‘#95A5A6’;aboutToAppear(): void { this.initTimelineData(); }build() {Column() {Text(‘垂直时间轴组件’).fontSize(20).fontWeight(FontWeight.Bold).fontColor(Color.White).textAlign(TextAlign.Center).width(‘100%’).padding({ top: 14, bottom: 2 })// 控制栏 Row() { this.configButton(this.showLines ? 隐藏连接线 : 显示连接线, this.showLines ? #ffffff22 : #333, () { this.showLines !this.showLines; }) this.configButton(↻ 重置状态, #E74C3C, () { this.resetAllToPending(); }) }.width(100%).padding({ left: 12, right: 12, bottom: 4 }) // Scroll Column 时间轴主体 Scroll() { Column() { ForEach(this.timelineData, (item, index) { this.timelineNode(item, index) }, (item) item.id.toString()) }.width(100%).padding({ left: 16, right: 16, top: 8, bottom: 16 }) }.layoutWeight(1).width(100%) // 图例 Row() { this.legendDot(this.COLOR_COMPLETED, 已完成) this.legendDot(this.COLOR_ACTIVE, 进行中) this.legendDot(this.COLOR_PENDING, 待处理) }.width(100%).justifyContent(FlexAlign.SpaceEvenly) .padding({ top: 8, bottom: 10 }).backgroundColor(#1a1a3e) }.width(100%).height(100%).backgroundColor(#0f3460)}// — 核心时间轴节点 —BuildertimelineNode(item: TimelineItem, index: number) {Row() {// 左侧指示器Column() {if (item.status TimelineStatus.COMPLETED) this.completedDot()else if (item.status TimelineStatus.ACTIVE) this.activeDot()else this.pendingDot()if (this.showLines index this.timelineData.length - 1) {Column().width(2).layoutWeight(1).backgroundColor(this.getLineColor(item.status))}}.width(30).alignItems(HorizontalAlign.Center).height(this.expandedId item.id ? 130 : 90)// 右侧内容卡片 Column() { Row() { Text(item.title).fontSize(15).fontColor(Color.White) .fontWeight(FontWeight.Bold) this.statusBadge(item.status) }.width(100%).alignItems(VerticalAlign.Center) Text(item.description).fontSize(12).fontColor(Color.Gray) .width(100%).margin({ top: 4 }) .maxLines(this.expandedId item.id ? 10 : 2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { Text(item.time).fontSize(11).fontColor(Color.White).opacity(0.6) Text(this.expandedId item.id ? 收起 ▲ : 展开 ▼) .fontSize(11).fontColor(#00B4D8) }.width(100%).justifyContent(FlexAlign.SpaceBetween).margin({ top: 6 }) } .layoutWeight(1).backgroundColor(this.getCardBg(item.status)) .borderRadius(10).padding(12).margin({ left: 8, bottom: 4 }) .gesture(TapGesture().onAction(() { if (this.expandedId item.id) this.expandedId -1; else { this.expandedId item.id; if (item.status TimelineStatus.ACTIVE) this.advanceTimeline(item.id); } })) }.width(100%).alignItems(VerticalAlign.Top)}// — 三种圆点 —Builder completedDot() {Stack() {Text().width(18).height(18).backgroundColor(this.COLOR_COMPLETED‘33’).borderRadius(9)Text().width(12).height(12).backgroundColor(this.COLOR_COMPLETED).borderRadius(6)Text(‘✓’).fontSize(8).fontColor(Color.White).fontWeight(FontWeight.Bold)}.width(22).height(22)}Builder activeDot() {Stack() {Text().width(20).height(20).backgroundColor(this.COLOR_ACTIVE‘22’).borderRadius(10)Text().width(12).height(12).backgroundColor(this.COLOR_ACTIVE).borderRadius(6)Text().width(4).height(4).backgroundColor(Color.White).borderRadius(2)}.width(24).height(24)}Builder pendingDot() {Stack() {Text().width(12).height(12).border({ width: 2, color: this.COLOR_PENDING }).borderRadius(6)}.width(22).height(22)}Builder statusBadge(status: TimelineStatus) {Text(this.getStatusLabel(status)).fontSize(9).fontColor(Color.White).backgroundColor(this.getStatusColor(status)‘66’).border({ width: 1, color: this.getStatusColor(status)‘99’ }).borderRadius(8).padding({ left: 6, right: 6, top: 2, bottom: 2 }).margin({ left: 8 })}Builder configButton(label: string, color: string, action: () void) {Button(label).height(30).fontSize(11).backgroundColor(color).fontColor(Color.White).borderRadius(6).layoutWeight(1).margin({ left: 3, right: 3 }).gesture(TapGesture().onAction(() action()))}Builder legendDot(color: string, label: string) {Row() {Text().width(8).height(8).backgroundColor(color).borderRadius(4).margin({ right: 4 })Text(label).fontSize(11).fontColor(Color.Gray)}.alignItems(VerticalAlign.Center)}// — 数据 —private initTimelineData(): void {this.timelineData [{ id:1, title:‘项目启动’, description:‘确定项目目标与范围…’, time:‘2025-07-01’, status:TimelineStatus.COMPLETED },{ id:2, title:‘需求分析’, description:‘深入调研用户需求…’, time:‘2025-07-03’, status:TimelineStatus.COMPLETED },{ id:3, title:‘UI 设计’, description:‘基于 PRD 完成原型设计…’, time:‘2025-07-05’, status:TimelineStatus.COMPLETED },{ id:4, title:‘前端开发’, description:‘基于 ArkTS 开发全部页面…’, time:‘2025-07-08’, status:TimelineStatus.ACTIVE },{ id:5, title:‘后端联调’, description:‘前后端接口联调…’, time:‘2025-07-15’, status:TimelineStatus.PENDING },{ id:6, title:‘测试验收’, description:‘功能测试、性能测试…’, time:‘2025-07-20’, status:TimelineStatus.PENDING },{ id:7, title:‘上线发布’, description:‘生产环境部署…’, time:‘2025-07-25’, status:TimelineStatus.PENDING }];}private advanceTimeline(currentId: number): void {const updated: TimelineItem[] this.timelineData.map((item) {const r: TimelineItem { id:item.id, title:item.title,description:item.description, time:item.time, status:item.status };if (item.id currentId) r.status TimelineStatus.COMPLETED;if (item.id currentId1 item.status TimelineStatus.PENDING)r.status TimelineStatus.ACTIVE;return r;});this.timelineData updated;}private resetAllToPending(): void {const updated: TimelineItem[] this.timelineData.map((item): TimelineItem {return { id:item.id, title:item.title,description:item.description, time:item.time,status:TimelineStatus.PENDING };});if (updated.length 0) {updated[0] { id:updated[0].id, title:updated[0].title,description:updated[0].description, time:updated[0].time,status:TimelineStatus.ACTIVE };}this.timelineData updated;this.expandedId -1;}// — 工具 —private getStatusColor(s: TimelineStatus): string {return [this.COLOR_COMPLETED, this.COLOR_ACTIVE, this.COLOR_PENDING][s];}private getStatusLabel(s: TimelineStatus): string {return [‘已完成’, ‘进行中’, ‘待处理’][s];}private getCardBg(s: TimelineStatus): string {return [‘#1a3a2e’, ‘#1a2a4e’, ‘#1a1a2e’][s];}private getLineColor(s: TimelineStatus): string {return [this.COLOR_COMPLETED‘44’, this.COLOR_ACTIVE‘44’, this.COLOR_PENDING‘22’][s];}}附录 B参考资料HarmonyOS NEXT 开发者文档 — Column 布局HarmonyOS NEXT 开发者文档 — Stack 布局HarmonyOS NEXT 开发者文档 — Scroll 滚动HarmonyOS NEXT 开发者文档 — Builder 装饰器版权声明本文为 HarmonyOS NEXT 技术分享系列的第七篇遵循 CC BY-NC 4.0 协议。欢迎转载但请注明出处。系列文章第一篇TapGesture 点击手势布局第二篇PanGesture 拖拽手势布局第三篇GestureGroup 组合手势布局第四篇Column 垂直排列入门第五篇Column Scroll 可滚动列表第六篇Column Flex 弹性混合布局第七篇Column 垂直时间轴组件本文