An Odometer-Style Number Animation in SwiftUI


Greetings, traveler!

Counters with a rolling, odometer-like feel are a familiar interaction pattern in modern interfaces. You can see this kind of visual treatment in products such as YouTube when numeric values update on screen. In SwiftUI, reaching a similar result is pleasantly straightforward because the framework already includes contentTransition(.numericText()), a transition designed specifically for numeric text updates. From there, the remaining work is about controlling how values move from one state to another. Apple documents numericText as a content transition for views displaying numeric text, and Apple’s SwiftUI and WidgetKit examples use it to give prominence to changing numbers.

What numericText already gives you

A regular Text view in SwiftUI can animate numeric changes with very little code:

Text(value.description)
    .font(.system(size: 64, weight: .bold, design: .rounded))
    .contentTransition(.numericText())
    .animation(.default, value: value)

SwiftUI understands that the content is numeric and applies a specialized transition instead of a generic text fade. That alone already looks much better than a plain value replacement. However, the transition animates from one value to the next, though it does not walk through intermediate values such as 101, 102, 103, and so on.

The idea behind the custom view

To create a more odometer-like effect, the displayed number needs its own internal state. The incoming value becomes the target, while a separate displayedValue moves toward that target one step at a time.

That distinction is the core of the implementation:

public struct OdometerNumberView: View {
    private let value: Int
    private let stepDuration: Double
    private let maxAnimationDuration: Double

    @State private var displayedValue: Int
    @State private var animationTask: Task<Void, Never>?

    public init(
        value: Int,
        stepDuration: Double = 0.1,
        maxAnimationDuration: Double = 0.9
    ) {
        self.value = value
        self.stepDuration = stepDuration
        self.maxAnimationDuration = maxAnimationDuration
        displayedValue = value
    }
}

This structure gives the view two clearly separated responsibilities. The external state tells the component where it should end up. The internal state controls how it gets there.

Rendering the animated number

The body stays compact because most of the logic lives outside rendering:

public var body: some View {
    Text(displayedValue.description)
        .contentTransition(.numericText(value: Double(displayedValue)))
        .animation(.easeInOut(duration: stepDuration), value: displayedValue)
        .onChange(of: value) { _, newValue in
            animateToValue(newValue)
        }
        .onDisappear {
            animationTask?.cancel()
            displayedValue = value
        }
}

There are four important details here.

First, the text is rendered from displayedValue, not from value. That makes the intermediate steps visible.

Second, numericText is still doing valuable work. Even though the component updates one integer at a time, each individual change still benefits from SwiftUI’s dedicated numeric transition. Apple explicitly positions numericText as a specialized transition for numeric content, which makes it a natural fit here.

Third, onChange reacts to external updates and starts a new animation toward the latest target value.

Fourth, onDisappear cancels any running task so the view does not continue mutating state after leaving the screen. After that, it immediately assigns the proper value.

Moving through intermediate values

The custom behavior lives in animateToValue(_:):

private func animateToValue(_ newValue: Int) {
    animationTask?.cancel()
    
    let startValue = displayedValue
    guard startValue != newValue else { return }
    
    let distance = abs(newValue - startValue)
    let effectiveStepDuration = min(
        stepDuration,
        maxAnimationDuration / Double(distance)
    )
    
    animationTask = Task {
        let step = newValue > startValue ? 1 : -1
        var current = startValue
        
        while current != newValue {
            do {
                try await Task.sleep(for: .seconds(effectiveStepDuration))
            } catch {
                break
            }
            
            current += step
            displayedValue = current
        }
    }
}

This method does several useful things in a small amount of code.

The first line cancels any existing animation. That matters when the user taps quickly or when data changes several times in short succession. Without cancellation, multiple tasks could compete to update the same state.

The startValue is captured from the currently displayed number rather than from the previous input. This keeps the animation visually continuous. If the view is halfway through one transition and receives a new target, it resumes from the visible state already on screen.

The step decides direction. A value increasing from 10 to 15 moves by +1, while a value decreasing from 10 to 7 moves by -1.

The loop then advances one integer at a time until the target is reached.

Why the animation duration is capped

A step-by-step animation feels great for small changes, though it can become sluggish for large jumps. Moving from 1 to 4 with a 0.1 second interval takes a comfortable 0.3 seconds. Moving from 1 to 100 with the same interval would take almost ten seconds, which is far too long for a UI counter.

That is why the code introduces this line:

let effectiveStepDuration = min(stepDuration, maxAnimationDuration / Double(distance))

This keeps the default pace for short transitions and speeds up large ones automatically. The result is a component that preserves the odometer feel without becoming impractical when the delta grows.

This is a small addition, though it changes the usability of the component considerably. It keeps the implementation simple while making the behavior much more production-friendly.

Example of usage

A side-by-side comparison makes the benefit easy to see:

struct NumericView: View {
    @State private var value = 1

    var body: some View {
        VStack(spacing: 24) {
            OdometerNumberView(value: value)
                .font(.system(size: 64, weight: .bold, design: .rounded))

            Text(value.description)
                .font(.system(size: 64, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .animation(.default, value: value)

            controls
        }
        .padding()
    }

    private var controls: some View {
        HStack(spacing: 12) {
            Button("-3") {
                value -= 3
            }
            .buttonStyle(.bordered)

            Button("+1") {
                value += 1
            }
            .buttonStyle(.borderedProminent)

            Button("+3") {
                value += 3
            }
            .buttonStyle(.bordered)
        }
    }
}

The lower Text view shows what SwiftUI gives you out of the box. The custom view above it shows what happens once you add intermediate steps and cancellation-aware state progression.

This kind of comparison is useful during development because it helps you decide whether the built-in transition is enough for the screen you are designing. In many cases, it will be. In places where numbers carry more visual weight, the custom version brings more character.

Full implementation

Here is the complete component as a single piece:

import SwiftUI

struct NumericView: View {
    @State private var value = 1

    var body: some View {
        VStack(spacing: 24) {
            OdometerNumberView(value: value)
                .font(.system(size: 64, weight: .bold, design: .rounded))

            Text(value.description)
                .font(.system(size: 64, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .animation(.default, value: value)

            controls
        }
        .padding()
    }

    private var controls: some View {
        HStack(spacing: 12) {
            Button("-3") {
                value -= 3
            }
            .buttonStyle(.bordered)

            Button("+1") {
                value += 1
            }
            .buttonStyle(.borderedProminent)

            Button("+3") {
                value += 3
            }
            .buttonStyle(.bordered)
        }
    }
}

public struct OdometerNumberView: View {
    private let value: Int
    private let stepDuration: Double
    private let maxAnimationDuration: Double

    @State private var displayedValue: Int
    @State private var animationTask: Task<Void, Never>?

    public init(
        value: Int,
        stepDuration: Double = 0.1,
        maxAnimationDuration: Double = 0.9
    ) {
        self.value = value
        self.stepDuration = stepDuration
        self.maxAnimationDuration = maxAnimationDuration
        _displayedValue = State(initialValue: value)
    }

    public var body: some View {
        Text(displayedValue.description)
            .contentTransition(.numericText(value: Double(displayedValue)))
            .animation(.easeInOut(duration: stepDuration), value: displayedValue)
            .onChange(of: value) { _, newValue in
                animateToValue(newValue)
            }
            .onDisappear {
                animationTask?.cancel()
            }
    }

    private func animateToValue(_ newValue: Int) {
        animationTask?.cancel()

        let startValue = displayedValue
        guard startValue != newValue else { return }

        let distance = abs(newValue - startValue)
        let effectiveStepDuration = min(
            stepDuration,
            maxAnimationDuration / Double(distance)
        )

        animationTask = Task {
            let step = newValue > startValue ? 1 : -1
            var current = startValue

            while current != newValue {
                do {
                    try await Task.sleep(for: .seconds(effectiveStepDuration))
                } catch {
                    break
                }

                current += step
                displayedValue = current
            }
        }
    }
}

Conclusion

SwiftUI already solves the hardest part of this problem for you. numericText gives numeric changes an appropriate transition, and a small amount of state-driven logic turns that transition into a richer odometer-style animation.

Available on my GitHub.