软件 · 文章
用 SwiftUI 做一个有质感的统计面板数值加载动画
clibo.us 也上线一个月了,我想通过博客的方式,陆续将自己对产品开发的一些心得、想法和经验分享出来,虽然现在是VB时代,很多东西可能对于AI来说不过是一句prompt的事,但我始终还是认为,分享记录是有意义的。
在开发Clibo这款产品的时候,其中有一个统计面板的功能,里面需要显示各种统计数据,那么最简单也是最直接的方式,就是衣服打开面板的时候,直接显示没有任何动画的数值。
这样写虽然没有什么问题,但是在视觉上会显得比较生硬。由于所有数据都是在打开面板的瞬间直接静态显示在界面的,所以会却少正在加载数据的反馈,也少了一点产品层面的精致感。可能每个人对产品的审美不一样,我个人是比较喜欢研究一些细节上的动画效果的,所以才有了这篇分析的文章。
在数值加载动画这块,我也是参考和查阅了一些优秀的产品和相关的资料,最终实现了一版我很满意的效果。
简单来说,就是给统计面板加上一个轻量的数值加载动画,面板打开时,所有指标从0开始平滑增长到真实值;当然,由于这里的统计数据包含了各种tondi图表,所以图表柱状高度、横向条形宽度也同步展开。这个效果实现起来并不复杂,关键是引入一个统一的渲染进度。
核心思路
不需要也不建议给每一个数字单独写一次动画,我的做法是定义一个全局的renderProgress 。
@State private var renderProgress = 0.0它的取值范围是0.....1,当统计面板打开时,把它从0动画到1:
private func restartRenderAnimation() {
renderProgress = 0
withAnimation(.easeOut(duration: 0.75)) {
renderProgress = 1
}
}然后给所有需要动画的数值都乘上质感进度:
private func animatedDouble(_ value: Int) -> Double {
Double(value) * renderProgress
}比如真实的数据是120,当renderProgress是0.5时,界面显示就是60。 随着renderProgress从0变成1,数字自然就从0滚动到了120。
统计面板打开时启动动画。
.onAppear {
restartRenderAnimation()
}如果面板已打开,只是后台数据刷新,就不一定要重新播放动画。否则每次复制内容都会让整个统计面板从0开始跳动,反而干扰用户的阅读体验。所以,针对这个问题,我的做法是将刷新分成两种:
func refresh(restartsAnimation: Bool) {
snapshot = try store.statistics(period: .week)
if restartsAnimation {
animationToken = UUID()
}
}面板第一次展示时:
viewModel.refresh(restartsAnimation: true)数据变化时:
viewModel.refresh(restartsAnimation: false)这样可以避免动画被频繁的重制。
数字本身的过渡
为了让数字过渡变化更自然,我又封装了一个 Animatable 的数字组件:
private struct AnimatedMetricValue: View, Animatable {
var value: Double
var formatter: (Double) -> String
var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View {
if #available(macOS 14.0, *) {
Text(formatter(value))
.contentTransition(.numericText(value: value))
.animation(.snappy, value: value)
.monospacedDigit()
} else {
Text(formatter(value))
.monospacedDigit()
}
}
}Animatable 让 SwiftUI 知道 value 是可以被插值的。
contentTransition(.numericText(value: value)) 是 macOS 14 之后很好用的数字过渡效果,数字变化时不是简单替换文本,而是更像计数器滚动。
monospacedDigit() 可以避免数字宽度变化导致布局抖动。比如 111 和 888 在普通字体里宽度可能不同,数字跳动时会让整个组件左右晃动。
使用时只需要传入数值和格式化函数:
AnimatedMetricValue(
value: animatedDouble(snapshot.totalClips),
formatter: formatMetricInteger
)图表的处理(选读)
这篇文章主要是针对数字类型的,对于图表的处理,也顺带说一下我的做法。本来,最初的设计上,这个动画效果是只针对面板中的数字的,但是只让数字动,图表不动。界面会显得割裂。
所以对图表也做了一些简单的处理:
// 这柱状图
BarMark(
x: .value("Date", item.date, unit: .day),
y: .value("Clips", animatedDouble(item.count))
)
// 横向进度条
let ratio = maximum > 0 ? animatedDouble(item.count) / Double(maximum) : 0
Capsule()
.fill(Color.teal.gradient)
.frame(width: ratio > 0 ? max(4, proxy.size.width * ratio) : 0)这样面板打开时,数字、柱状图、条形图会在同一个节奏里出现,看起来就像一个完整的加载过程,而不是几个独立组件各自动画。
格式化显示
动画过程中数值是 Double,但展示时通常需要转换成用户可读的文本。因为我界面中显示的数值几乎都是Int类型。所以这里需要格式化处理。
整数
private func formatMetricInteger(_ value: Double) -> String {
let rounded = Int(value.rounded())
return numberFormatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)"
}文件大小
private func formatBytes(_ value: Double) -> String {
ByteCountFormatter.string(
fromByteCount: Int64(value.rounded()),
countStyle: .file
)
}金额
private func formatCurrency(_ value: Double) -> String {
String(format: "$%.4f", value)
}这样
AnimatedMetricValue不关心具体业务,只负责动画和渲染。不同指标通过 formatter 控制展示方式。
为什么不用Timer?
这个效果也可以用 Timer 每隔几毫秒手动累加数值,但没有必要。
SwiftUI 已经有动画系统,只要把状态从 0 改到 1,并包在 withAnimation 里,框架就会处理插值、刷新和节奏控制。
renderProgress = 0
withAnimation(.easeOut(duration: 0.75)) {
renderProgress = 1
}为什么这个动画我选择控制在 0.75s 左右而不是其他值?
太短的话,用户几乎感知不到,和直接出现差别不大。太长的话,统计面板会显得拖沓,用户想快速扫一眼数据时反而被动画打断。
另外,数据刷新时我不会默认重启动画,而是直接把 renderProgress 保持为 1。只有面板首次打开或用户切换统计周期时,才重新播放加载效果。
这类动画应该服务于理解和体验,而不是抢走注意力.喧宾夺主。
版权声明
本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。