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.