Introduction
UILabel does not support animating its text content directly with UIView animations. To animate a number counting up or down (e.g., from 0 to 100), you need a timer-based approach that updates the label text at regular intervals. The most common methods are CADisplayLink (synced to the screen refresh rate), Timer, or a custom UIViewPropertyAnimator with a progress callback.
CADisplayLink Approach (Recommended)
CADisplayLink fires on every screen refresh (60 or 120 Hz), producing the smoothest animation:
1class CountingLabel: UILabel {
2 private var startValue: Double = 0
3 private var endValue: Double = 0
4 private var duration: TimeInterval = 1.0
5 private var startTime: Date?
6 private var displayLink: CADisplayLink?
7 private var formatter: ((Double) -> String)?
8
9 func count(from start: Double, to end: Double,
10 duration: TimeInterval = 1.0,
11 formatter: @escaping (Double) -> String = { String(format: "%.0f", $0) }) {
12 self.startValue = start
13 self.endValue = end
14 self.duration = duration
15 self.formatter = formatter
16 self.startTime = Date()
17
18 displayLink?.invalidate()
19 displayLink = CADisplayLink(target: self, selector: #selector(updateValue))
20 displayLink?.add(to: .main, forMode: .common)
21 }
22
23 @objc private func updateValue() {
24 guard let startTime = startTime else { return }
25
26 let elapsed = Date().timeIntervalSince(startTime)
27 let progress = min(elapsed / duration, 1.0)
28
29 // Ease-out curve for natural feel
30 let easedProgress = 1 - pow(1 - progress, 3)
31
32 let currentValue = startValue + (endValue - startValue) * easedProgress
33 text = formatter?(currentValue) ?? "\(Int(currentValue))"
34
35 if progress >= 1.0 {
36 displayLink?.invalidate()
37 displayLink = nil
38 text = formatter?(endValue) ?? "\(Int(endValue))"
39 }
40 }
41}
Usage:
1let label = CountingLabel()
2label.count(from: 0, to: 1500, duration: 2.0)
3
4// With custom formatting
5label.count(from: 0, to: 99.99, duration: 1.5) { value in
6 String(format: "$%.2f", value)
7}
Timer Approach (Simpler)
For simple cases where frame-perfect smoothness is not critical:
1func animateLabel(_ label: UILabel, from start: Int, to end: Int, duration: TimeInterval) {
2 let steps = abs(end - start)
3 let interval = duration / Double(steps)
4 var current = start
5
6 Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { timer in
7 current += (end > start) ? 1 : -1
8 label.text = "\(current)"
9
10 if current == end {
11 timer.invalidate()
12 }
13 }
14}
15
16// Usage
17animateLabel(scoreLabel, from: 0, to: 100, duration: 2.0)
This ticks once per integer step. For large ranges (0 to 10,000), the interval becomes very small and the timer may not fire fast enough. Use CADisplayLink for large ranges.
UIView Transition Animation
To add a visual transition effect (crossfade) when the number changes:
1func updateWithCrossfade(_ label: UILabel, newText: String) {
2 UIView.transition(with: label,
3 duration: 0.3,
4 options: .transitionCrossDissolve,
5 animations: {
6 label.text = newText
7 })
8}
Combine this with either approach for a polished counting effect with fade transitions.
Easing Functions
Different easing curves change the feel of the animation:
1enum EasingFunction {
2 case linear
3 case easeIn
4 case easeOut
5 case easeInOut
6
7 func apply(_ t: Double) -> Double {
8 switch self {
9 case .linear: return t
10 case .easeIn: return t * t * t
11 case .easeOut: return 1 - pow(1 - t, 3)
12 case .easeInOut:
13 return t < 0.5
14 ? 4 * t * t * t
15 : 1 - pow(-2 * t + 2, 3) / 2
16 }
17 }
18}
Apply inside the display link callback:
let easedProgress = easing.apply(progress)
let currentValue = startValue + (endValue - startValue) * easedProgress
1// Integer with thousands separator
2let numberFormatter = NumberFormatter()
3numberFormatter.numberStyle = .decimal
4label.count(from: 0, to: 1500000, duration: 2.0) { value in
5 numberFormatter.string(from: NSNumber(value: value)) ?? ""
6}
7// Shows: 1,500,000
8
9// Currency
10label.count(from: 0, to: 49.99, duration: 1.0) { value in
11 String(format: "$%.2f", value)
12}
13// Shows: $49.99
14
15// Percentage
16label.count(from: 0, to: 87.5, duration: 1.5) { value in
17 String(format: "%.1f%%", value)
18}
19// Shows: 87.5%
SwiftUI Version
In SwiftUI, use withAnimation and the .contentTransition(.numericText()) modifier (iOS 16+):
1struct CounterView: View {
2 @State private var value = 0
3
4 var body: some View {
5 Text("\(value)")
6 .contentTransition(.numericText())
7 .font(.largeTitle)
8
9 Button("Animate") {
10 withAnimation(.easeInOut(duration: 1.0)) {
11 value = 100
12 }
13 }
14 }
15}
For pre-iOS 16, use a TimelineView or Timer publisher:
1struct CounterView: View {
2 @State private var displayValue: Double = 0
3 let timer = Timer.publish(every: 0.016, on: .main, in: .common).autoconnect()
4
5 var body: some View {
6 Text(String(format: "%.0f", displayValue))
7 .onReceive(timer) { _ in
8 if displayValue < 100 {
9 displayValue += 1
10 }
11 }
12 }
13}
Common Pitfalls
Not invalidating CADisplayLink: If you start a new animation without invalidating the previous CADisplayLink, multiple links fire simultaneously, causing erratic values. Always call displayLink?.invalidate() before creating a new one.
Memory leak from CADisplayLink target: CADisplayLink retains its target strongly. If the label is deallocated while the link is active, it crashes. Invalidate the display link in deinit or use a weak proxy object.
Timer precision for large ranges: A Timer with a 0.001s interval does not fire every millisecond — the run loop may coalesce timer events. Use CADisplayLink for ranges over a few hundred steps.
Forgetting to set the final value: Due to floating-point rounding, the last animated value may not exactly equal the target. Always set label.text to the exact end value when the animation completes.
Animating on a background thread: UILabel must be updated on the main thread. Both CADisplayLink (added to .main run loop) and Timer.scheduledTimer (main run loop) handle this automatically, but if you use DispatchQueue.global(), wrap the UI update in DispatchQueue.main.async.
Summary
UILabel text cannot be animated with standard UIView animations
Use CADisplayLink for smooth, frame-synced counting animations
Use Timer for simple integer counting with small ranges
Apply easing functions (ease-out, ease-in-out) for natural-feeling animations
Use NumberFormatter for localized number formatting (commas, currency, percentages)
In SwiftUI (iOS 16+), use .contentTransition(.numericText()) for built-in number animation