☰
iOS时间轴(Timeline)实现全解析:从选型到避坑
2026/10/13 10:37:00 网站建设 项目流程

简介:这是一份iOS时间轴(Timeline)功能实现示例工程,适合有一定iOS基础、想在App中集成日程、历史记录或动态信息流等时间线界面的开发者。示例完整覆盖了数据模型搭建、自定义UITableViewCell绘制、NSDateFormatter时间格式化、基于Core Graphics/UIKit的时间线段与节点绘制,并针对大量条目滚动时的cell复用、延迟加载和性能优化给出了处理思路,覆盖了从数据到界面的完整实现链路。同时兼顾横竖屏适配、响应式布局、动画效果以及下拉刷新、上拉加载更多等交互能力,让读者能系统掌握时间轴的各类实现细节。压缩包共20个文件、仅37KB,内含可运行的Xcode工程与单元测试目标,以Objective-C源码(.h/.m)、XIB/Storyboard界面布局和plist配置为主,另有工程配置文件,便于直接打开运行。已有1336人学习下载,适合对照Demo代码练习,或在业务中灵活扩展自定义排序、多语言与实时同步等需求,而不必从零开始搭建基础框架。

1. iOS时间轴实现(Timeline):一张表能画完的组件,为什么你越写越复杂

如果你接手过订单物流页、会员成长记录或者版本发布历史,你大概率见过这么一条线:左侧一根竖线串起圆点和时间,右侧是卡片文案。这个界面在需求文档里叫时间轴,在 iOS 里的实现方式五花八门,有人用自定义 View 重写 draw,有人用 StackView 硬排,最后都在滚动流畅度和数据刷新上翻车。iOS时间轴实现(Timeline) 的本质不是画一条竖线,而是把结构相同的数据用列表组件稳定地渲染出来。这篇笔记会把选型、数据模型、核心代码、避坑和进阶一次讲透,适合正在做或准备做时间轴类界面的 iOS 开发者。

我见过太多把时间轴做复杂的人:节点要动画、连线要渐变、卡片要圆角阴影,结果滚动时掉帧、复用错位、日期分组混乱。实际上 90% 的业务时间轴用 UITableView 就能扛住,剩下的 10% 才需要 UICollectionView 自定义布局。关键不在于你用哪种视图,而在于你先把数据模型和绘制策略分开,让 UI 成为数据的投影,而不是让数据迁就 UI。

下文按一条完整的时间轴交付路径来讲:先选骨架,再定数据模型,接着贴出可运行的实现代码,然后列出我踩过的坑,最后给一个能提升体验的进阶技巧。你可以直接照着落代码,也可以先读完避坑章节再动手,少走弯路。

2. 先定骨架再写代码:UITableView与UICollectionView的选型依据

时间轴的 UI 形态千变万化,但骨架只有两种:纵向滚动的列表,和可以横向分栏的网格。骨架选错,后面所有代码都在打补丁。这一章不讲高深理论,只讲我怎么在接到需求时快速锁定方案,以及两个方案各自的适用边界。

2.1 UITableView方案:节点固定、内容垂直展开的首选

UITableView 是最贴合时间轴直觉的方案:一个 section 是一天,一个 row 是一个节点,左侧画时间、右侧画内容。它对 cell 复用的支持最成熟,动态高度计算也最省心,只要约束闭合,系统能自动算出每一行的高度。

适合 UITableView 的典型场景是:节点数量相对固定、顺序不会乱、内容以文本和图片为主。订单物流轨迹、操作日志、版本发布记录都属于这一类。这类时间轴的共同点是用户关心“我走到哪一步了”,而不是“时间线本身的视觉效果有多炫”。

一个判断技巧:如果你的时间轴去掉那条竖线后,剩下的内容仍然能像普通列表一样按时间排序阅读,那 UITableView 就是答案。你需要的只是给 cell 加一个左侧对齐的圆点视图,再在 tableView 的背景上画一条竖线,而不是去自定义一套渲染引擎。

final class TimelineViewController: UITableViewController { private var sections: [TimelineSection] = [] override func viewDidLoad() { super.viewDidLoad() tableView.register(TimelineCell.self, forCellReuseIdentifier: "TimelineCell") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 72 tableView.separatorStyle = .none } }

这段代码定义了时间轴页面的基础:自动计算行高,取消系统分割线,因为时间轴的分割线由自定义竖线承担。注册 cell 用类名而不是 xib,方便后面直接改代码而不维护额外文件。

参数说明:automaticDimension让 cell 高度由 Auto Layout 约束推导,estimatedRowHeight是滚动条的估算基准,后面避坑章节会细讲。

2.2 UICollectionView方案:多列、分组与复杂布局再出手

当时间轴出现“横向时间维度”或“多轨并行”时,UITableView 就不够用了。典型场景包括:同一时间段内有多个并行任务需要分轨展示,或者时间轴需要支持横向滑动查看不同时段。这时候 UICollectionView 配合自定义 Layout 是正解。

UICollectionView 的优势在于它把布局逻辑从数据源里剥离出来,你可以自己控制每个节点的 frame 或者用 compositional layout 描述分区规则。缺点是代码量明显增加,你需要额外处理 Layout 的 invalidate 时机,稍不注意就会出现滚动后布局乱跳的玄学问题。

我一般只有在两种情况下才选 UICollectionView:一是节点需要自由定位,比如按小时分布在 24 小时横轴上;二是一个 cell 里要承载嵌套的时间子列表,用 List 或者 StackView 嵌套会导致性能问题。如果只是纵向一条线,别犹豫,用 UITableView。

let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(80)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(80)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.interGroupSpacing = 12 return section }

用 compositional layout 的好处是 spacing 和 section 边距由配置驱动,不用在 cell 里手动加约束。estimated(80)是预估高度,实际高度由 cell 内部约束决定,和 UITableView 的 estimatedRowHeight 同一个思路。

需要提醒的是:compositional layout 对复杂时间轴的支撑上限很高,但调试成本也高。如果你发现自己在 Layout 子类里重写了一堆prepare和layoutAttributesForElements,先停下来想想是不是选型过重了。

2.3 选型自检清单:三个问题帮你锁定方案

我在动手前会问自己三个问题,回答完基本就知道该用哪个组件了。

第一问:内容是纯纵向展开吗?如果是,UITableView。如果还需要横向维度,比如同时展示多个时间段,UICollectionView。第二问:节点数量是可枚举的吗?像物流轨迹这种下单、出库、派送、签收的固定步骤,UITableView 足够;像日志系统里可能追加几十万条记录的,也还是 UITableView,因为它复用机制更成熟。第三问:我需要做自定义交互动画吗?比如节点缩放、连线生长这类效果,UICollectionView 对属性动画的控制更精细,UITableView 也能做但受限更多。

这三个问题不是拍脑袋,而是基于一个原则:能用系统列表表达的结构,就不要自定义绘制。自定义绘制意味着你要自己处理复用、触摸、动画和命中测试,这些恰恰是系统组件已经解决好的问题。把精力留在业务逻辑上,时间轴的 UI 只是最后一个渲染步骤。

3. 时间轴的数据模型:把日期、节点与显示状态分开存

很多时间轴代码写乱,不是因为 UI 复杂,而是数据模型没有设计好。常见错误是把“显示文案”和“业务数据”混在一起存,导致改一个时间格式要动整个模型层。这一章给出一个我验证过多次的模型结构,以及日期分组时最容易踩的时区问题。

3.1 TimelineNode与分区模型:一个节点只归一个分区

时间轴的每个节点应该有独立 ID、时间、标题、详情和状态。状态不要用 String,用枚举,这样 UI 才能根据状态做颜色和图标映射,而不是到处比较字符串。

enum TimelineStatus { case completed case processing case pending } struct TimelineNode { let id: String let date: Date let title: String let content: String? let status: TimelineStatus } struct TimelineSection { let day: Date let nodes: [TimelineNode] }

模型层只存数据不存展示字段,比如不要存timeText,因为“今天”“昨天”“6月12日”这些是格式化出来的,应该由 UI 层根据date现场生成。这样同一个节点在列表页显示相对时间,在详情页显示完整时间,不需要改模型。

分区的目的是把同一天的节点聚合到同一个 section,而不是简单地遍历数组。分区的 key 必须是日历意义上的“天”,而不是时间戳本身,否则同一天的节点会因为时分秒不同被拆成多个分区。

func groupNodes(_ nodes: [TimelineNode], calendar: Calendar) -> [TimelineSection] { var groups: [Date: [TimelineNode]] = [:] for node in nodes { let day = calendar.startOfDay(for: node.date) groups[day, default: []].append(node) } return groups.keys .sorted() .map { day in TimelineSection(day: day, nodes: groups[day]!.sorted { $0.date < $1.date }) } }

这段代码的核心是calendar.startOfDay(for:),它把任意时间点归一到当天零点,作为分区的 key。calendar由外部传入,默认用Calendar.current,但如果你需要固定展示某个时区的时间轴,就传入一个指定时区的 Calendar。

参数说明:sorted()对日期 key 升序排列,保证时间轴从早到晚;分区内的 nodes 按date再次排序,因为原始数据可能乱序。这里要注意的是 dict 的default: []写法需要 Swift 4 以上,老项目如果还在用 Swift 3,要改成先判断再插入的写法。

3.2 日期分组与相对时间:格式化是时间轴的隐藏主角

时间轴最容易出 bug 的地方不是布局,而是日期。我第一次做时间轴时直接用DateFormatter把服务器返回的字符串转成 Date,再按字符串前缀分组,结果同一批数据在不同设备上分区数量不一样,排查到最后发现是时区不同。

正确的做法是:服务器返回的时间字符串如果带时区,转 Date 时用 ISO8601DateFormatter 并指定timeZone;如果不带时区,要明确它是 UTC 还是本地时间,否则分组会整体偏差。分区这一层统一用 Calendar 的startOfDay,不要自己拼接“年-月-日”字符串。

相对时间格式化是另一个隐藏细节。时间轴的节点通常要显示“刚刚”“5分钟前”“昨天”这类文案,直接用DateFormatter做会显得生硬。我一般会写一个小的相对时间函数,按时间差选择单位。

func relativeTimeText(from date: Date, to now: Date = Date()) -> String { let interval = now.timeIntervalSince(date) if interval < 60 { return "刚刚" } if interval < 3600 { return "\(Int(interval / 60))分钟前" } if interval < 86400 { return "\(Int(interval / 3600))小时前" } if interval < 172800 { return "昨天" } let formatter = DateFormatter() formatter.dateFormat = "M月d日" return formatter.string(from: date) }

这段代码的时间阈值可以根据业务调整,比如任务类时间轴可能更关注“逾期 X 天”,那就可以加上超过 30 天显示绝对日期的分支。注意DateFormatter创建成本较高,不要在 cell 里频繁创建,要么做一个静态实例,要么用上面的简单计算优先,只在跨天时走 Formatter。

还有一个容易忽略的点:服务器返回的时间常常是字符串,比如"2024-06-12T08:00:00Z",如果你直接用Date(string:)这种方法解析,很有可能会得到 nil。常见做法是先用 ISO8601DateFormatter 解析,失败再退回手动指定格式的 DateFormatter。这一步不做,后面的分区和相对时间全是空的。

4. 从零跑通一条时间轴:核心代码与参数调优

这一章给出一套可以直接落地的实现。我不贴完整工程,只贴关键路径:cell 的布局、竖线的绘制策略、以及控制器的数据接入。你照着把这三块拼起来,就能跑出一条基础时间轴。

4.1 最小可运行实现:TableView + TimelineCell

先从 cell 开始。TimelineCell 的布局分成两个区域:左侧时间列和右侧内容区。时间列固定宽度,中心放一个圆点;右侧放标题和详情。

final class TimelineCell: UITableViewCell { private let dotView = UIView() private let titleLabel = UILabel() private let contentLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none backgroundColor = .clear contentView.backgroundColor = .clear dotView.translatesAutoresizingMaskIntoConstraints = false dotView.layer.cornerRadius = 5 dotView.layer.masksToBounds = true contentView.addSubview(dotView) titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) contentLabel.translatesAutoresizingMaskIntoConstraints = false contentLabel.font = .systemFont(ofSize: 13) contentLabel.numberOfLines = 0 contentLabel.textColor = .secondaryLabel contentView.addSubview(titleLabel) contentView.addSubview(contentLabel) NSLayoutConstraint.activate([ dotView.centerXAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), dotView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), dotView.widthAnchor.constraint(equalToConstant: 10), dotView.heightAnchor.constraint(equalToConstant: 10), titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 44), titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), contentLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), contentLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), contentLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), contentLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) ]) } }

布局的关键是:dotView的 centerX 固定在对左侧 24pt 的位置,而不是用 leading 加宽度的方式。这样当 cell 宽度变化时,圆点位置不变。contentLabel的bottomAnchor闭合了 cell 的高度链,系统才能自动算出行高。

时间列宽度我固定为 44pt,也就是圆点中心在 24pt 处。如果设计稿要求更宽的时间文字,比如左侧要显示“06-12 08:00”,那就把时间列加宽到 80pt,圆点中心改成 56pt,同时把 titleLabel 的 leading 改成 76pt。这些数值不是玄学,是保证视觉比例的常用间距。

控制器接入数据时,只需要把分组后的 sections 赋值给 controller,再实现三个数据源方法即可。这里不重复贴完整代码,只强调一点:cellForRowAt里获取节点后,把状态映射到 dotView 的背景色,ttle 和 content 赋值。状态映射要用枚举的 switch,不要用 if 链。

4.2 竖线与圆点的绘制策略:背景图层还是Cell内绘制

竖线是时间轴最容易做崩的部分。很多人第一反应是在每个 cell 里画一条从顶部到底部的竖线,然后用约束让它填满 cell。这个方案在静态页面没问题,但一旦 cell 复用,就会出现线断断续续、节点与线错位的情况,原因是 cell 的 frame 在复用瞬间还没有更新到最终位置。

我验证过多次的稳定方案是:把竖线放在 tableView 的 backgroundView 上,只画一条贯穿整个滚动区域的长线。cell 内部只画圆点和内容,不再画竖线。这样竖线永远不会断,因为它是背景的一部分,跟随内容滚动。

private func setupTimelineLine() { let background = UIView() let line = UIView() line.translatesAutoresizingMaskIntoConstraints = false line.backgroundColor = UIColor.separator background.addSubview(line) NSLayoutConstraint.activate([ line.leadingAnchor.constraint(equalTo: background.leadingAnchor, constant: 23.5), line.topAnchor.constraint(equalTo: background.topAnchor), line.bottomAnchor.constraint(equalTo: background.bottomAnchor), line.widthAnchor.constraint(equalToConstant: 1) ]) tableView.backgroundView = background }

这里 line 的 leading 是 23.5pt,对应 cell 里 dotView 中心在 24pt 的位置,1pt 宽的线左边缘是 23.5,圆点中心是 24,视觉上线正好从圆点中间穿过。tableView.backgroundView有一个容易被忽略的特性:它的 frame 会随着 contentSize 变化而拉伸,所以从顶部到底部的约束能让线覆盖整个内容区。

这个方案的另一个好处是首尾处理变简单了。如果你希望第一条线从第一个节点的圆点开始,而不是从屏幕最顶部开始,可以在顶部加一个 tableHeaderView,把 header 高度设为第一个节点的中垂线位置到顶部的距离。需要节点间距特别大时,这个方案的性能优势更明显,因为竖线只需要绘制一次。

4.3 三个关键参数:estimatedRowHeight、间距与时间格式

第一个参数是estimatedRowHeight。它直接影响滚动条的平滑度和 cell 出现前的高度计算。我见过设置成 44 的,结果实际行高 120,滚动时 cell 高度剧烈跳动。正确的做法是取一个接近真实高度的值,比如内容最多两行时设 72,比真实值略小,让系统在计算时往上调整,视觉上比往下收缩更自然。

第二个参数是 cell 内容间距。titleLabel 顶部 12pt、底部 12pt、内容间隔 4pt,这组数值是时间轴的呼吸感来源。调大顶部间距会让时间轴显得松散,适合少节点的场景;调小则紧凑,适合日志流。不要每个 cell 都设不同间距,保持统一,否则竖线上圆点的节奏会忽快忽慢。

第三个参数是时间格式。时间轴左侧或标题区域的时间文案,要区分“列表态”和“详情态”。列表态用相对时间“3小时前”,点击进入详情后用绝对时间“2024年6月12日 08:30”。这个切换建议在展示层处理,不要同时存两个字段。

代码里对应的是relativeTimeText(from:to:)函数和详情页里的 DateFormatter。需要注意:相对时间在数据不变时可能过期,比如用户停在列表页 1 小时后,原本“3小时前”应该变成“4小时前”。如果业务要求严格,要在viewWillAppear或定时器里刷新可见 cell;如果只是展示型页面,通常不需要实时刷新。

5. 时间轴开发避坑指南:5个让界面翻车的真实问题

这一章是血泪经验。以下问题我一个不落都踩过,每个都按“现象 → 原因 → 解决”整理。你在开发中遇到类似表现,可以直接对照排查。

5.1 竖线断裂、圆点错位:Cell复用与图层Frame的冲突

现象:快速滚动时间轴后,竖线在某些位置断开,圆点忽左忽右,截图看像一条虚线。

原因:cell 复用时,之前 cell 里的竖线约束和图层 frame 还残留着,新 cell 出现时其 frame 尚未更新到最终位置,导致竖线偏移。尤其是在 cell 里用layoutIfNeeded或者在cellForRowAt里手动设置 layer 的 frame 时最明显。

解决:把竖线从 cell 移到 tableView 的 backgroundView,cell 里只保留圆点和内容视图。圆点的约束用 centerX 固定到时间列中心,而不是用 frame 直接赋值。这样竖线只绘制一次,cell 复用完全不参与竖线的渲染。如果你因为某些原因必须在 cell 内画线,那就在layoutSubviews里根据最终 bounds 更新 layer frame,切不要在cellForRowAt里设置 frame。

5.2 状态颜色串了:复用Cell没有清理旧状态

现象:第一个节点是完成状态显示绿色,滚动后另一个节点变成待处理,但圆点还是绿色。更诡异的是,等到 cell 再次滚出屏幕再滚回来,颜色又正常了。

原因:cell 被复用时,上一个节点的状态还留在 dotView 上。如果状态映射只写在cellForRowAt里,一旦赋值链路被提前 return,或者数据源返回的节点状态与上一次一致导致 UI 没有刷新,就会看到残留颜色。

解决:在prepareForReuse里把 dotView 重置为默认颜色,再把状态绑定的逻辑收敛到一个方法里,例如func configure(with node: TimelineNode)。在这个方法内部用 switch 对状态做完整映射,不要用 if 加 return 的形式。这样每次复用都会先重置再赋值,颜色不可能串。

override func prepareForReuse() { super.prepareForReuse() dotView.backgroundColor = .clear titleLabel.text = nil contentLabel.text = nil }

5.3 快速滑动掉帧:离屏渲染与图片同步加载

现象:时间轴一屏十来个节点,快速滑动时明显卡顿,FPS 掉到 40 以下。用 Instruments 的 Core Animation 调试一看,大量离屏渲染标记。

原因:两个制造麻烦的元凶。第一是圆点和卡片用了cornerRadius加masksToBounds,这会产生离屏渲染。第二是内容图片在主线程同步 data 转 UIImage,滑到哪张卡到哪张。

解决:圆点只有 10pt 大小,用UIView加cornerRadius还能接受;如果是大面积圆角卡片,就用UIGraphicsImageRenderer预渲染圆角图片,不要用masksToBounds裁。图片加载一律异步,配合占位图或骨架屏。阴影不要直接加在 cell 的 layer 上,改用shadowPath指定路径,能省掉大部分离屏渲染。

5.4 动态高度跳动:预估行高与实际行高差距过大

现象:滚动时 cell 高度跳动,有的行文字被截断,有的行下面一大片空白。reloadData 后高度又正常,滚动后再次异常。

原因:estimatedRowHeight与实际约束推导的高度偏差超过 2 倍时,系统在滚动过程中先按预估高度计算滚动条和可见区域,再逐行修正实际高度,这个修正过程在界面上表现为跳动。

解决:先量出真实行高的合理区间,预估高度取中间偏小的值。另外检查 cell 布局约束是否闭合:contentLabel的bottomAnchor有没有连到contentView.bottomAnchor,如果漏了这条约束,系统无法推导高度,只能退回使用预估高度。

5.5 日期分组跨时区错乱:Calendar与时区没有统一

现象:同一批数据,同事的设备上显示“6月12日”有 5 条,你的设备上分成两天,每条日期都偏移了 8 小时。

原因:分组用的 Calendar 是Calendar.current,它跟随系统时区。如果服务器返回的时间是 UTC,你直接用这个时间戳的startOfDay分组,在 UTC+8 的设备上就会把当地时间凌晨 8 点前的事件归到前一天。

解决:明确你的时间轴要按哪个时区展示。如果是本地时间,就统一Calendar.current,同时确保服务器时间字符串转 Date 时带对时区;如果是业务统一时间,就创建一个固定时区的 Calendar 传入分组函数,比如var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(identifier: "Asia/Shanghai")。不要在模型层混用,所有日期计算走同一个 Calendar 实例。

我的习惯是在 App 启动时建一个全局的appCalendar,所有startOfDay、DateFormatter的 timeZone 都从它取,避免各个页面各自为政。

6. 进阶技巧:让时间轴节点“点一下”展开的转场与定位

基础时间轴跑通后,最值得投入的体验优化是节点到详情页的转场。默认 push 动画太生硬,尤其是用户点击的是某个圆点,结果整个页面平移了,视觉上节点和详情页之间没有关联。我会用自定义转场,以点击的圆点为锚点放大到详情页。

具体做法是:在didSelectRowAt里拿到点击 cell 的 dotView,用convert方法把它在 window 上的 frame 算出来,作为动画起点。详情页的 UIView 从这个 frame 放大到全屏,透明度从 0.3 过渡到 1,转场结束后详情页内部再执行内容渐显。这个转场用UIViewControllerTransitioningDelegate实现,注意要设置modalPresentationStyle = .custom,否则系统不会调用自定义转场代理。

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { guard let cell = tableView.cellForRow(at: indexPath) as? TimelineCell else { return } let dotFrame = cell.convert(cell.dotFrameInWindow, to: nil) let detailVC = TimelineDetailViewController() detailVC.modalPresentationStyle = .custom detailVC.transitioningDelegate = self present(detailVC, animated: true) }

这里的关键是把 dotView 的 frame 在点击前算好并暴露出来,避免在动画过程中再去读 view 的 frame,因为转场开始后 view 的层级变化会导致 frame 不准确。

另一个高频需求是时间轴定位到指定节点,比如“回到今天”或者“跳到上次未读位置”。常见的做法是tableView.scrollToRow(at:at:animated:),但如果行高是动态的,滚动位置经常偏差几十像素。我一般先reloadData,等布局稳定后再在viewDidLayoutSubviews里执行一次定位,这样位置更可靠。如果时间轴很长,可以先算好目标节点的 y 坐标,用scrollRectToVisible直接滚到那个区域,性能更好。

我现在做任何时间轴需求都会先过三个问题:节点会持续增长吗?日期需要按天分组吗?用户会不会高频点击节点?答案落进数据模型和转场策略里,UI 反而是最后画的部分。这套流程帮我少踩了很多坑,也让我在接新需求时不用推翻重来,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询