← All shorts
14iOS 16+2 min read

contentTransition(.numericText()): Odometer-Style Numbers

Roll digits up or down like an odometer when a number changes, with one modifier and no custom digit views.

A score or price counting up should feel like digits rolling into place, not a hard cut or a flat crossfade. Building that by hand meant slicing a number into individual digit views and animating each one's vertical offset separately.

#The old way

struct ScoreCounter: View {
    @State private var score = 0

    var body: some View {
        VStack(spacing: 20) {
            Text(score, format: .number)
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .id(score)
                .transition(.opacity)
                .animation(.easeInOut, value: score)

            Button("Add 10") {
                score += 10
            }
        }
    }
}

That fades the whole number out and back in — a jump cut with a blur on it, not a roll.

#The new way

import SwiftUI

struct ScoreCounter: View {
    @State private var score = 0

    var body: some View {
        VStack(spacing: 20) {
            Text(score, format: .number)
                .font(.system(size: 48, weight: .bold, design: .rounded))
                .monospacedDigit()
                .contentTransition(.numericText(value: Double(score)))
                .animation(.snappy, value: score)

            Button("Add 10") {
                score += 10
            }
        }
    }
}

#Why it matters

  • Each digit that changes rolls independently in the direction the value moved — up when increasing, down when decreasing — matching what users expect from a native counter or stopwatch.
  • Digits that don't change (the leading "1" in 19 → 10) stay put instead of the whole string re-animating.
  • No per-digit view decomposition, no manual offset math — it's one modifier on a plain Text.

#Gotcha

.contentTransition(.numericText()) does nothing on its own — it only defines how to animate a change, not that a change should animate. The mutation still has to happen inside withAnimation or alongside an .animation(_:value:) modifier, exactly as shown above; skip that and the number jumps instantly. Also always pair it with .monospacedDigit() — without fixed-width digits, the string's width changes as digits roll, and the whole Text visibly jitters horizontally mid-animation.

swiftuiios16animation

Related shorts