Skip to content
Count Widen preview
An animated render of the SwiftUI source on this page.

Count Widen

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

A count badge whose pill widens first, then lets the new digit column drop into the space.

Badges · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • badge
  • count
  • notification
  • digits
  • width
  • spring
  • unread

The actual source

CountWidenBadge.swift
// Count Widen · syxUI · https://syxui.dev/components/count-widen-badge
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

#if canImport(UIKit)
import UIKit
private typealias BadgeFont = UIFont
#else
import AppKit
private typealias BadgeFont = NSFont
#endif

/// Ten digits per wheel.
private let wheelDigits = 10

/// Cells in one strip: the ten digits, plus a repeat of `0` and `9` underneath
/// so a roll through 9 → 0 continues downward instead of jumping back to the
/// top of the strip.
private let stripCells = 12

/// A count badge whose pill opens before the arriving digit lands in it.
///
/// Two springs, deliberately out of step. The width leads on
/// `spring(response: 0.34, dampingFraction: 0.78)`, so the pill makes room
/// first; `widthLead` milliseconds later the digit strips fall by one row on
/// `spring(duration: 0.42, bounce: digitBounce)`, which kicks about 7% past and
/// returns. Width first reads as the badge making space — a width that snaps
/// while the digit springs reads as broken, and one curve driving the width
/// beside a spring driving the digit reads as unwell.
///
/// The strips are real: a `VStack` of digit cells clipped to one row, so *when*
/// a column arrives is a number this component owns.
/// `.contentTransition(.numericText)` cross-fades a formatted string and would
/// hand that timing to the framework.
struct CountWidenBadge: View {
    var count: Int = 9
    var height: CGFloat = 20
    /// How far the width leads the digit, in milliseconds. This is the effect.
    var widthLead: Double = 70
    /// Overshoot on the digit spring. 0 lands flat; 0.38 kicks past and back.
    var digitBounce: Double = 0.38
    var fill: Color = Color(red: 1.0, green: 0.231, blue: 0.188)
    var text: Color = Color(white: 1.0)

    /// Preview override. When set, the badge renders one increment
    /// (`count` → `count + 1`) at that point of the drive instead of answering
    /// taps. 0 is before, 1 is settled.
    var progress: Double? = nil

    // Chosen, not exposed. A pill that wobbles wider than its neighbour reads
    // as a wobble rather than as a spring, so the width is damped to within a
    // couple of percent — and the *lead* is the number worth a slider.
    private let widthResponse: Double = 0.34
    private let widthDamping: Double = 0.78
    private let digitDuration: Double = 0.42
    private let plusFade: Double = 0.14

    /// Seconds of simulated time one increment spans. Both springs are inside
    /// half a percent of their target by here, so `progress: 1` is settled.
    private let driveSpan: Double = 0.70

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// Taps accumulate here rather than overwriting `count`, so the badge still
    /// retargets when `count` changes underneath it.
    @State private var bumps: Int = 0
    @State private var rolled: Double? = nil
    @State private var opened: CGFloat? = nil
    @State private var plusShown: Double? = nil
    @State private var measuredDigit: CGFloat? = nil
    @State private var measuredPlus: CGFloat? = nil

    var body: some View {
        let scrubbed = scrubbedFrame
        let width = scrubbed?.width ?? opened ?? pillWidth(for: target)
        let roll = scrubbed?.roll ?? rolled ?? capped(target)
        let plusAmount = scrubbed?.plus ?? plusShown ?? plus(target)

        // Everything hangs off the pill's trailing inner edge, so the slack the
        // leading width spring opens shows up on the *leading* side — which is
        // exactly where the next column lands.
        let digitsRight = width - sidePadding - plusWidth * plusAmount

        ZStack(alignment: .leading) {
            Capsule(style: .continuous).fill(fill)

            wheel(place: 0, roll: roll)
                .offset(x: digitsRight - digitWidth)
            wheel(place: 1, roll: roll)
                .offset(x: digitsRight - digitWidth * 2)

            // Past the cap the third column never comes; the strips hold at 99
            // and this fades in while the width opens one glyph for it.
            glyph("+")
                .frame(width: plusWidth, height: rowHeight)
                .opacity(plusAmount)
                .offset(x: digitsRight)
        }
        .frame(width: width, height: height)
        .clipShape(Capsule(style: .continuous))
        .contentShape(Capsule(style: .continuous))
        .background(alignment: .topLeading) { ruler }
        .onPreferenceChange(DigitAdvanceKey.self) { value in
            if value > 0 { measuredDigit = value }
        }
        .onPreferenceChange(PlusAdvanceKey.self) { value in
            if value > 0 { measuredPlus = value }
        }
        .onAppear(perform: pin)
        .onTapGesture { step(by: 1) }
        .onLongPressGesture(minimumDuration: 0.4) { step(by: -1) }
        .onChange(of: target, initial: false) { _, value in settle(on: value) }
        .onChange(of: digitWidth, initial: false) { _, _ in
            // Type changed size under us; the spring's stored end point is now
            // a stale number of points.
            if opened != nil { opened = pillWidth(for: target) }
        }
        .sensoryFeedback(.increase, trigger: target)
        .sensoryFeedback(.impact(weight: .light), trigger: columnCount)
        .accessibilityElement()
        .accessibilityLabel(Text("\(target) unread"))
    }

    // MARK: - Drive

    /// The whole visual state at one instant of `progress`.
    ///
    /// `Spring` evaluates the same maths `.spring(…)` runs, so a scrubbed frame
    /// and a live frame are the same frame — the preview is not an impression
    /// of the animation, it is the animation.
    private var scrubbedFrame: (width: CGFloat, roll: Double, plus: Double)? {
        guard let progress else { return nil }

        let time = min(max(progress, 0), 1) * driveSpan
        let lead = max(0, widthLead) / 1000
        let from = target
        let to = min(999, target + 1)

        let widthPart = Spring(response: widthResponse, dampingRatio: widthDamping)
            .value(target: 1.0, time: time)
        let digitPart = Spring(duration: digitDuration, bounce: bounce)
            .value(target: 1.0, time: max(0, time - lead))
        let fade = min(1, max(0, (time - lead) / plusFade))

        return (
            width: pillWidth(for: from)
                + (pillWidth(for: to) - pillWidth(for: from)) * widthPart,
            roll: capped(from) + (capped(to) - capped(from)) * digitPart,
            plus: plus(from) + (plus(to) - plus(from)) * (fade * fade * (3 - 2 * fade))
        )
    }

    /// Where the springs start.
    ///
    /// Without this the first change would land instantly rather than
    /// animating: the fallbacks in `body` already read the *new* count, so
    /// there would be nothing left between the two frames.
    private func pin() {
        guard progress == nil, opened == nil else { return }
        opened = pillWidth(for: target)
        rolled = capped(target)
        plusShown = plus(target)
    }

    /// Width first, digit `widthLead` later.
    ///
    /// A second tap mid-flight retargets both springs rather than queueing:
    /// each keeps its velocity and re-aims, so a rapid climb is one continuous
    /// motion rather than a queue of pops.
    private func settle(on value: Int) {
        guard progress == nil else { return }

        let width = pillWidth(for: value)
        let roll = capped(value)
        let plusAmount = plus(value)

        guard !reduceMotion else {
            opened = width
            rolled = roll
            plusShown = plusAmount
            return
        }

        let lead = max(0, widthLead) / 1000
        withAnimation(.spring(response: widthResponse, dampingFraction: widthDamping)) {
            opened = width
        }
        withAnimation(.spring(duration: digitDuration, bounce: bounce).delay(lead)) {
            rolled = roll
        }
        withAnimation(.easeInOut(duration: plusFade).delay(lead)) {
            plusShown = plusAmount
        }
    }

    /// Increment on tap, decrement on long press, so the badge demonstrates
    /// itself. Both fire on the gesture's end, so a cancelled press does
    /// nothing, and the clamp keeps `bumps` from drifting past the ends.
    private func step(by delta: Int) {
        bumps = min(max(count + bumps + delta, 0), 999) - count
    }

    // MARK: - Digits

    /// One digit strip.
    ///
    /// Cells run top to bottom in *descending* order, so an increment slides
    /// the strip downward and the arriving digit enters from above — it drops
    /// into the space the width just made. Every column moves the same way, so
    /// the whole number reads as one wheel rather than two.
    private func wheel(place: Int, roll: Double) -> some View {
        let value = wheelValue(place: place, roll: roll)
            .truncatingRemainder(dividingBy: Double(wheelDigits))
        let row = Double(wheelDigits) - value

        return VStack(spacing: 0) {
            ForEach(0..<stripCells, id: \.self) { cell in
                glyph(face(cell: cell, place: place))
                    .frame(width: digitWidth, height: rowHeight)
            }
        }
        .offset(y: -row * rowHeight)
        .frame(width: digitWidth, height: rowHeight, alignment: .top)
        .clipped()
        .mask(alignment: .top) { aperture }
    }

    /// The window's edges, softened.
    ///
    /// A hard clip puts a cut line in mid-air a few points inside the pill, and
    /// mid-roll you see a glyph sliced by nothing. The band is 16% of the row
    /// at each end — outside the resting glyph's own box, so a settled digit is
    /// untouched and only a moving one fades as it leaves.
    private var aperture: some View {
        LinearGradient(
            stops: [
                .init(color: .white.opacity(0), location: 0),
                .init(color: .white, location: 0.16),
                .init(color: .white, location: 0.84),
                .init(color: .white.opacity(0), location: 1)
            ],
            startPoint: .top,
            endPoint: .bottom
        )
        .frame(height: rowHeight)
    }

    /// A geared odometer: the tens wheel only turns while the ones wheel is
    /// crossing its own 9 → 0. Without the gearing a count of 9 would sit its
    /// tens wheel nine tenths of the way round, and the badge would read "09".
    private func wheelValue(place: Int, roll: Double) -> Double {
        let scale = pow(10, Double(place))
        let turns = (roll / scale).rounded(.down)
        let within = roll - turns * scale
        return turns + min(1, max(0, within - (scale - 1)))
    }

    private func face(cell: Int, place: Int) -> String {
        let digit = ((wheelDigits - cell) % wheelDigits + wheelDigits) % wheelDigits
        // A leading zero is not drawn, which is what makes a single-digit count
        // a circle instead of "09" — and what the tens column falls out of.
        if digit == 0 && place > 0 { return "" }
        return String(digit)
    }

    private func glyph(_ string: String) -> some View {
        Text(string)
            .font(.system(size: fontSize, weight: .semibold))
            .monospacedDigit()
            .foregroundStyle(text)
    }

    // MARK: - Geometry

    /// Derived, not exposed: a badge whose text sizes independently of its box
    /// stops being a badge at the first setting a reader tries.
    private var fontSize: CGFloat { height * 0.58 }
    private var sidePadding: CGFloat { height * 0.34 }
    private var bounce: Double { min(max(digitBounce, 0), 0.9) }

    /// `NSFont` has no `lineHeight`, so both platforms build the row pitch from
    /// the three metrics that make one.
    private var rowHeight: CGFloat {
        let font = platformFont
        return font.ascender - font.descender + font.leading
    }

    /// Measured, never guessed. Tabular figures give every digit one advance,
    /// so any label's width is a count of them — and a ratio of the point size
    /// would break the moment the type scales.
    private var digitWidth: CGFloat { measuredDigit ?? advance(of: "0") }
    private var plusWidth: CGFloat { measuredPlus ?? advance(of: "+") }

    private var platformFont: BadgeFont {
        .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
    }

    /// Frame-one seed only. The hidden `Text` pair below is the authority,
    /// because it is measured in the environment the badge actually draws in.
    private func advance(of string: String) -> CGFloat {
        (string as NSString).size(withAttributes: [.font: platformFont]).width
    }

    private var target: Int { max(0, min(999, count + bumps)) }
    private var columnCount: Int { digitCount(target) + (target > 99 ? 1 : 0) }

    private func digitCount(_ value: Int) -> Int { value > 9 ? 2 : 1 }
    private func capped(_ value: Int) -> Double { Double(min(value, 99)) }
    private func plus(_ value: Int) -> Double { value > 99 ? 1 : 0 }

    private func pillWidth(for value: Int) -> CGFloat {
        let content = digitWidth * CGFloat(digitCount(value))
            + plusWidth * CGFloat(plus(value))
        return max(height, content + sidePadding * 2)
    }

    /// Two hidden glyphs, measured where they will be drawn.
    private var ruler: some View {
        HStack(spacing: 0) {
            Text("0").background { report(DigitAdvanceKey.self) }
            Text("+").background { report(PlusAdvanceKey.self) }
        }
        .font(.system(size: fontSize, weight: .semibold))
        .monospacedDigit()
        .fixedSize()
        .hidden()
    }

    private func report<K: PreferenceKey>(_ key: K.Type) -> some View
    where K.Value == CGFloat {
        GeometryReader { geometry in
            Color.clear.preference(key: key, value: geometry.size.width)
        }
    }
}

private struct DigitAdvanceKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

private struct PlusAdvanceKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

#Preview {
    // The first is live — tap it to increment, long-press to go back.
    HStack(spacing: 14) {
        CountWidenBadge(count: 9, height: 48)
        CountWidenBadge(count: 9, height: 48, progress: 0.12)
        CountWidenBadge(count: 9, height: 48, progress: 1)
        CountWidenBadge(count: 120, height: 48)
    }
    .padding(22)
}
iOS 17 · No dependencies

SwiftUI note. The animated width sits on the badge's own frame, so drop it straight into the row: the enclosing HStack re-lays-out every frame and the neighbour slides on the same spring. The tap is a self-demo — inside a row that is already a button, add .accessibilityHidden(true) and let the row speak the count.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27