Article

Building a Polished Number-Loading Animation for a Stats Panel in SwiftUI

clibo.us has also been live for a month now, and I'd like to share my thoughts, ideas, and experience in product development through this blog over time. Although it's the vibe-coding era and a lot of things may be just a prompt away for an AI, I still believe that sharing and recording is meaningful.

While developing Clibo, one feature was a stats panel that needs to display all sorts of statistics. The simplest and most direct approach is to show the numbers with no animation at all the moment the panel opens.

There's nothing wrong with writing it this way, but visually it feels rather stiff. Because all the data is shown statically on the interface the instant the panel opens, there's no feedback that data is loading, and a bit less of the polish that makes a product feel refined. Everyone's taste in products is different; I personally enjoy studying animation details, which is why this analysis article exists.

For the number-loading animation, I also referred to and studied some excellent products and related material, and finally implemented a version I'm very happy with.

Your browser does not support video playback

Simply put, it adds a lightweight number-loading animation to the stats panel: when the panel opens, every metric grows smoothly from 0 to its real value. Of course, since the statistics here include various charts, the bar heights and horizontal bar widths also expand in sync. The effect isn't complicated to implement; the key is to introduce one unified render progress.


The Core Idea

There's no need, and it isn't advisable, to write a separate animation for every single number. My approach is to define one global renderProgress.

SWIFT

复制

@State private var renderProgress = 0.0

Its range is 0...1. When the stats panel opens, animate it from 0 to 1:

SWIFT

复制

private func restartRenderAnimation() {
    renderProgress = 0

    withAnimation(.easeOut(duration: 0.75)) {
        renderProgress = 1
    }
}

Then multiply every value that needs animating by the render progress:

SWIFT

复制

private func animatedDouble(_ value: Int) -> Double {
    Double(value) * renderProgress
}

For example, if the real data is 120, when renderProgress is 0.5 the interface shows 60. As renderProgress goes from 0 to 1, the number naturally rolls from 0 up to 120.

The animation starts when the stats panel opens.

SWIFT

复制

.onAppear {
    restartRenderAnimation()
}

If the panel is already open and only a background data refresh happens, there's no need to replay the animation. Otherwise every refresh would make the whole stats panel jump up from 0 again, which actually disrupts the user's reading. So to address this, my approach is to split refreshing into two cases:

SWIFT

复制

func refresh(restartsAnimation: Bool) {
    snapshot = try store.statistics(period: .week)

    if restartsAnimation {
        animationToken = UUID()
    }
}
  • When the panel is shown for the first time:

SWIFT

复制

viewModel.refresh(restartsAnimation: true)
  • When the data changes:

SWIFT

复制

viewModel.refresh(restartsAnimation: false)

This avoids the animation being reset too frequently.


The Number Transition Itself

To make number transitions feel more natural, I also wrapped an Animatable number component:

SWIFT

复制

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 lets SwiftUI know that value can be interpolated.

contentTransition(.numericText(value: value)) is a very handy number transition effect available since macOS 14. When the number changes, the text isn't simply replaced; it behaves more like a counter rolling.

monospacedDigit() avoids layout jitter caused by changing digit widths. For example, 111 and 888 may have different widths in a normal font, and when the numbers jump, the whole component would sway left and right.

To use it, you only need to pass in the value and a formatting function:

SWIFT

复制

AnimatedMetricValue(
    value: animatedDouble(snapshot.totalClips),
    formatter: formatMetricInteger
)

Handling Charts (Optional)

This article is mainly about numbers, but I'll also mention my approach to charts in passing. Originally, the design had this animation apply only to the numbers in the panel, but animating only the numbers while the charts stay still makes the interface feel disjointed.

So I also gave the charts some simple handling:

SWIFT

复制

// This bar chart
BarMark(
    x: .value("Date", item.date, unit: .day),
    y: .value("Clips", animatedDouble(item.count))
)
// Horizontal progress bar
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)

This way, when the panel opens, the numbers, bar chart, and horizontal bars appear in the same rhythm, looking like one complete loading process rather than several independent components each animating on their own.


Formatted Display

During the animation the value is a Double, but for display it usually needs to be converted into user-readable text. Because almost all the numbers shown in my interface are of type Int, formatting is needed here.

  • Integers

SWIFT

复制

private func formatMetricInteger(_ value: Double) -> String {
    let rounded = Int(value.rounded())
    return numberFormatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)"
}
  • File sizes

SWIFT

复制

private func formatBytes(_ value: Double) -> String {
    ByteCountFormatter.string(
        fromByteCount: Int64(value.rounded()),
        countStyle: .file
    )
}
  • Amounts

SWIFT

复制

private func formatCurrency(_ value: Double) -> String {
    String(format: "$%.4f", value)
}

This way AnimatedMetricValue doesn't care about the specific business logic and is only responsible for animation and rendering. Different metrics control how they're displayed through a formatter.


Why Not Use a Timer?

This effect could also be done by manually accumulating the value every few milliseconds with a Timer, but there's no need.

SwiftUI already has an animation system. As long as you change the state from 0 to 1 and wrap it in withAnimation, the framework handles interpolation, refreshing, and pacing.

SWIFT

复制

renderProgress = 0

withAnimation(.easeOut(duration: 0.75)) {
    renderProgress = 1
}

Why did I choose to keep this animation at around 0.75s rather than some other value?

If it's too short, users hardly notice it, and it's little different from appearing directly. If it's too long, the stats panel feels sluggish, and users who want to take a quick glance at the data get interrupted by the animation instead.

Also, when data refreshes I don't restart the animation by default, but keep renderProgress at 1. The loading effect is replayed only when the panel is opened for the first time or the user switches the stats period.

This kind of animation should serve understanding and experience, not steal the attention and overshadow the content.

Copyright

Copyright of this article belongs to its author or rights holder. When reposting or quoting, please follow the applicable license terms and keep the link to this article.

Link to this article: https://xuyi.dev/en/2026-07-01-lrarri