Skip to content
Digit Roll preview
An animated render of the SwiftUI source on this page.

Digit Roll

From syxUI — written for both platforms, not translated between them.

An odometer whose digit columns roll up and settle with a spring.

Text Effects · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • text
  • number
  • odometer
  • counter
  • digits
  • stats
  • animation

The actual source

DigitRoll.swift
// Digit Roll · syxUI · https://syxui.dev/components/digit-roll
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

#if os(macOS)
import AppKit
private typealias PlatformFont = NSFont
#else
import UIKit
private typealias PlatformFont = UIFont
#endif

/// Which end of the figure leads the cascade.
enum DigitRollOrder: Sendable {
    /// Least-significant column first — the way a real odometer trips.
    case rtl
    /// Most-significant column first. Reads better on a headline figure.
    case ltr
    /// Every column together.
    case all
}

/// An odometer: one vertical 0-9 strip per digit, clipped to a single row.
///
/// Every column is a real strip rather than a cross-faded label, so you see
/// the neighbouring digits travel past and you see the spring carry the strip
/// a whisker too far before it settles. That overshoot is the mechanical tell.
/// The prefix and the group separators are outside the clips and never move.
struct DigitRoll: View {
    var value: Int = 12480
    /// Columns to draw. Leading zeros are dropped unless `leadingZeros` is on.
    var digits: Int = 6
    var prefix: String = "$"
    /// Milliseconds between neighbouring columns starting their roll.
    var stagger: Double = 60
    var direction: DigitRollOrder = .rtl
    var leadingZeros: Bool = false

    var fontSize: CGFloat = 46
    var color: Color = Color(white: 0.08)
    var separator: String = ","
    /// Digit every strip starts from before it rolls.
    var start: Int = 0
    /// Settle spring. At 0.82 damping the strip overshoots by about a percent,
    /// which is the difference between mechanical and merely eased.
    var response: Double = 0.55
    var damping: Double = 0.82

    /// Preview override. When set, the component renders that point of the
    /// roll instead of running its own spring.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var landed = false

    var body: some View {
        HStack(alignment: .center, spacing: 0) {
            if !prefix.isEmpty {
                Text(prefix)
                    .frame(height: rowHeight)
            }

            ForEach(slots) { slot in
                if let digit = slot.digit {
                    strip(to: digit, delay: Double(slot.order) * stagger)
                } else {
                    Text(separator)
                        .frame(height: rowHeight)
                }
            }
        }
        .font(.system(size: fontSize, weight: .semibold))
        .monospacedDigit()
        .foregroundStyle(color)
        .onAppear { landed = true }
        // VoiceOver wants the figure, not ten digits per column.
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(spoken)
    }

    /// One column: the full 0-9 strip, translated so the wanted digit sits in
    /// the single row the clip leaves visible.
    private func strip(to digit: Int, delay: Double) -> some View {
        let shown = landed ? digit : start

        return VStack(spacing: 0) {
            ForEach(0..<10, id: \.self) { row in
                Text(String(row))
                    .frame(height: rowHeight)
            }
        }
        .offset(y: offset(to: digit, delay: delay))
        .frame(height: rowHeight, alignment: .top)
        .clipped()
        .animation(roll(delay: delay), value: shown)
    }

    /// Live rolls run on a real spring, so a `value` change mid-flight
    /// retargets from wherever the strip has got to instead of restarting.
    private func roll(delay: Double) -> Animation? {
        guard progress == nil, !reduceMotion else { return nil }
        return .spring(response: response, dampingFraction: damping)
            .delay(delay / 1000)
    }

    private func offset(to digit: Int, delay: Double) -> CGFloat {
        guard let progress else {
            return -CGFloat(landed ? digit : start) * rowHeight
        }
        let settle = springSettle(progress * duration - delay / 1000)
        let travelled = Double(start) + (Double(digit) - Double(start)) * settle
        return -CGFloat(travelled) * rowHeight
    }

    /// Unit step response of `.spring(response:dampingFraction:)`, evaluated
    /// in closed form. Only needed when `progress` is sampling frames: there
    /// is no animation running then, and the overshoot has to come from
    /// somewhere or the preview reads as a linear slide.
    private func springSettle(_ t: Double) -> Double {
        guard t > 0 else { return 0 }
        let omega = 2 * .pi / max(response, 0.01)
        let zeta = min(max(damping, 0.05), 1)
        if zeta >= 1 { return 1 - exp(-omega * t) * (1 + omega * t) }

        let damped = omega * (1 - zeta * zeta).squareRoot()
        let decay = exp(-zeta * omega * t)
        return 1 - decay * (cos(damped * t) + (zeta * omega / damped) * sin(damped * t))
    }

    /// Seconds for the whole cascade: last column's delay plus its settle.
    private var duration: Double {
        let columns = shownDigits.count - firstShown
        let last = direction == .all ? 0 : Double(max(columns - 1, 0)) * stagger
        return last / 1000 + response * 2.6
    }

    /// `NSFont` has no `lineHeight`, so use the metric sum that both platforms
    /// spell the same way. Deriving the row from the font is what keeps the
    /// clip aligned at large Dynamic Type sizes instead of near enough.
    private var rowHeight: CGFloat {
        let font = PlatformFont.systemFont(ofSize: fontSize, weight: .semibold)
        return font.ascender - font.descender + font.leading
    }

    private var shownDigits: [Int] {
        var out: [Int] = []
        var remainder = max(value, 0)
        for _ in 0..<min(max(digits, 1), 9) {
            out.append(remainder % 10)
            remainder /= 10
        }
        return out.reversed()
    }

    /// Index of the first column that gets drawn. Dropping the leading zeros
    /// rather than blanking them keeps the figure optically centred.
    private var firstShown: Int {
        guard !leadingZeros else { return 0 }
        let list = shownDigits
        var index = 0
        while index < list.count - 1 && list[index] == 0 { index += 1 }
        return index
    }

    private var slots: [Slot] {
        let list = shownDigits
        let first = firstShown
        let columns = list.count - first
        var out: [Slot] = []
        var position = 0

        for index in first..<list.count {
            let place = list.count - 1 - index
            out.append(Slot(id: out.count, digit: list[index], order: order(position, of: columns)))
            position += 1
            if place > 0 && place.isMultiple(of: 3) {
                out.append(Slot(id: out.count, digit: nil, order: 0))
            }
        }
        return out
    }

    private func order(_ position: Int, of columns: Int) -> Int {
        switch direction {
        case .ltr: return position
        case .rtl: return columns - 1 - position
        case .all: return 0
        }
    }

    private var spoken: String {
        slots.reduce(prefix) { text, slot in
            text + (slot.digit.map(String.init) ?? separator)
        }
    }

    /// One position in the line: a rolling column, or a separator that does not.
    private struct Slot: Identifiable {
        let id: Int
        let digit: Int?
        let order: Int
    }
}

#Preview {
    DigitRoll()
        .padding(30)
}
iOS 17 · No dependencies

SwiftUI note. Row height comes from the font's ascender/descender/leading, not a guess, so the clip holds at large Dynamic Type. `progress` samples a closed-form spring for the preview; the live path always runs a real `.spring`, and a `value` change mid-roll retargets it rather than restarting.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27