Watch a score count up. It should feel like digits rolling into place. Not a hard cut, not a flat crossfade.
Building that by hand used to mean slicing the 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's a crossfade. The whole number fades out, the whole number fades back in. It's 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
- Every digit that changes rolls on its own, in the direction the value moved: up when it increases, down when it decreases. That's the behavior users already know from a native counter or a stopwatch.
- Digits that don't change stay put. The leading "1" in 19 → 10 holds still instead of the whole string re-animating.
- One modifier on a plain
Text. No per-digit view decomposition, no manual offset math.
#Gotcha
.contentTransition(.numericText()) does nothing on its own. It 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.
Pair it with .monospacedDigit(), always. Without fixed-width digits the string's width changes as digits roll, and the whole Text visibly jitters horizontally mid-animation.