iOS development
UILabel
animation
Swift
user interface

Animate UILabel text between two numbers?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

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 fires on every screen refresh (60 or 120 Hz), producing the smoothest animation:

swift
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:

swift
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:

swift
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:

swift
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:

swift
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:

swift
let easedProgress = easing.apply(progress)
let currentValue = startValue + (endValue - startValue) * easedProgress

Formatting Options

swift
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+):

swift
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:

swift
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

Course illustration
Course illustration

All Rights Reserved.