软件 · 文章

用 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。只有面板首次打开或用户切换统计周期时,才重新播放加载效果。

这类动画应该服务于理解和体验,而不是抢走注意力.喧宾夺主。

版权声明

本文内容版权归作者或相关权利人所有。转载、引用或其他使用请遵循相应授权条款,并保留本文链接。

本文链接:https://xuyi.dev/2026-07-01-lrarri