
Build State
From syxUI — written for both platforms, not translated between them.
A CI badge cycling queued, running, and passed, with a calm live dot and a width that follows.
Badges · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- badge
- status
- ci
- build
- pill
- live
- state
The actual source
BuildStateBadge.swift
// Build State · syxUI · https://syxui.dev/components/build-state-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 BuildBadgeFont = UIFont
#else
import AppKit
private typealias BuildBadgeFont = NSFont
#endif
/// Dashes in the queued ring. Three reads as "waiting" at 20pt; six reads as a
/// texture.
private let buildDashes = 3
/// Whole dash pitches the queued ring turns per cycle. Two is 0.67 of a turn in
/// a 6 s loop — a full revolution every 9 s, near enough the 8 s the design
/// asks for. It has to be a whole multiple of the dash pitch: anything else
/// leaves the ring a few degrees out of place at the loop's seam, and the badge
/// visibly jumps once per cycle.
private let buildDashTurns = 2.0
/// The loop, as fractions of `period`. Calibrated so that at the default
/// `period: 6` they land on the milliseconds each stage was designed in:
/// a 1.3 s wait, a 0.36 s morph, 2.5 s of running, 0.36 s to settle, 0.76 s
/// green, and a 0.72 s reset. Every sub-timing below is a fraction too, so the
/// slider scales the whole cycle without changing any relationship inside it.
private let buildMorph = 0.06 // 0.36 s
private let buildReset = 0.12 // 0.72 s — twice a morph, because it undoes two
private let buildQueuedHold = 0.216_667 // 1.30 s
private let buildRunHold = 0.416_667 // 2.50 s — exactly two breaths
private let buildPassHold = 0.126_667 // 0.76 s
// Boundaries, named once so the reader can follow the drive without arithmetic.
private let buildRunMorph = buildQueuedHold // 0.2167
private let buildRunStart = buildRunMorph + buildMorph // 0.2767
private let buildEndMorph = buildRunStart + buildRunHold // 0.6933
private let buildEndStart = buildEndMorph + buildMorph // 0.7533
private let buildResetAt = buildEndStart + buildPassHold // 0.8800
/// Where the colour finishes, as a fraction of the morph it belongs to.
///
/// This is the whole reason the colour is a separate track. Slate → amber and
/// amber → green both pass through a muddy midpoint — half green, half amber is
/// olive — so the colour is given 72% of the shape's window and an ease whose
/// speed *peaks* at the midpoint. It arrives while the mark is still moving:
/// the colour announces the state, the shape confirms it. Run them on one
/// timing and the badge sits in olive for the whole morph.
private let buildColourTrack = 0.72
/// The label rolls over between 18% and 36% of the morph — ahead of the colour,
/// which finishes at 72%, and well ahead of the shape, which finishes last.
/// Word, then colour, then mark.
///
/// All three of those numbers came out of rendered frames rather than out of
/// code, and each fixed something the code could not show:
///
/// - The alphas are **complements**, so they always sum to 1. Sequencing them
/// out-then-in leaves a window where neither is drawn, and that window lands
/// on the beat where the dot has collapsed and the check has not started, so
/// the pill emptied for about 70 ms twice a cycle.
/// - The window carries a **counter-roll** of a third of the height, so the two
/// words pass each other rather than blending. Two different words at half
/// opacity on one baseline reads as a double exposure.
/// - It **starts at 18%, not at 0**. Swapped any earlier the badge said
/// "Running" in slate with the dashed ring still open, which reads for two
/// frames as a mislabelled badge rather than as an early announcement.
private let buildLabelStart = 0.18
private let buildLabelSpan = 0.18
private let buildLabelRoll = 0.34
/// A build badge that cycles queued → running → passed as one lerped view.
///
/// The indicator is a single `Shape` whose `animatableData` is
/// `AnimatablePair(ringClose, tickDraw)`: a dashed slate ring closes into a
/// filled dot, the dot collapses to nothing, and a check draws out of the point
/// it vanished at. One path throughout — three icons crossfading is the thing
/// this replaces, and at 22pt a crossfade reads as a double exposure.
///
/// `PhaseAnimator` is the obvious tool and the wrong one: it cannot be pinned
/// to a `Double`, so the badge could never be rendered at a chosen frame, and a
/// component with no still is a component nobody clicks. Instead the whole
/// cycle is a pure function of one number and `phase` overrides it.
///
/// Four tracks, deliberately out of step, and that is the component: the
/// **label** has rolled over by 36% of the morph, the **colour** lands at 72%,
/// the **shape** finishes last, and the **width** runs
/// `Spring(response:dampingRatio:)` in real seconds rather than in fractions of
/// the loop — so the pill's trailing edge is still settling after the mark has
/// arrived. `pillWidth` goes on the badge's own frame, so a sibling in the row
/// slides on that spring instead of jumping when it ends. `AnimatedSize` and a
/// bare `.animation(_:value:)` on the frame both take a curve, and a
/// curve-driven width beside a spring-driven glyph is what reads as broken.
struct BuildStateBadge: View {
/// Which way the build ends. `.failed` draws a cross — two strokes, the
/// second 60 ms behind the first — and shakes 3pt when it lands.
enum Outcome { case passed, failed }
var height: CGFloat = 22
/// Seconds for one queued → running → passed → queued cycle.
var period: Double = 6
var outcome: Outcome = .passed
var queued: Color = Color(red: 0.545, green: 0.553, blue: 0.596)
var running: Color = Color(red: 0.961, green: 0.647, blue: 0.141)
var passed: Color = Color(red: 0.188, green: 0.643, blue: 0.424)
/// Preview override. When set, the badge renders that point of one cycle
/// instead of driving its own clock. 0 is queued, 1 is queued again.
var phase: Double? = nil
/// The width spring, as plain stored properties so the drive can be retuned
/// from the call site rather than by editing this file. Written inline
/// inside `Spring(...)` neither of these could be reached at all.
///
/// Damped to 0.85 on purpose: the pill's edge is 8pt from a neighbour's
/// leading edge, and a width that visibly wobbles past its target reads as
/// a layout bug rather than as a spring.
var widthResponse: Double = 0.32
var widthDamping: Double = 0.85
// Chosen, not exposed. The track is one colour for all four states so the
// state colour is the only thing that moves; the label is that same colour
// lifted toward white, which is what keeps it readable on the track at
// every state instead of only on the dark ones.
private let track = Color(red: 0.086, green: 0.094, blue: 0.114)
private let failedTint = Color(red: 0.898, green: 0.267, blue: 0.243)
@Environment(\.self) private var env
@Environment(\.accessibilityReduceMotion) private var reduceMotion
// The live clock, and a spring that a tap adds to it. Keeping them separate
// is what makes a tap mid-transition retarget: the spring is re-launched
// from its own current value *and velocity*, so a fast triple-tap slides
// through the palette in one motion instead of queueing three.
@State private var epoch = Date()
@State private var skipFrom: Double = 0
@State private var skipTo: Double = 0
@State private var skipVelocity: Double = 0
@State private var skipAt: Date? = nil
@State private var struck: Int = -1
var body: some View {
Group {
if let phase {
pill(at: frame(at: phase))
} else if reduceMotion {
// A live indicator pinned at its zero crossing looks switched
// off, so Reduce Motion lands on the frame that carries the
// meaning: settled, green, check drawn.
pill(at: frame(at: buildEndStart + buildPassHold / 2))
} else {
TimelineView(.animation) { timeline in
pill(at: frame(at: drive(at: timeline.date)))
}
}
}
.sensoryFeedback(trigger: struck) { _, step in
// Nothing on queued: waiting should not buzz.
switch step {
case 1: return .impact(weight: .light)
case 2: return .success
case 3: return .error
default: return nil
}
}
}
// MARK: - One frame
@ViewBuilder
private func pill(at frame: BuildBadgeFrame) -> some View {
let mark = height * 0.48
let radius = mark / 2
let markCentre = CGPoint(x: height * 0.32 + radius, y: height / 2)
let tint = frame.fill
let ink = lift(tint, toward: 0.55)
ZStack(alignment: .topLeading) {
Capsule(style: .continuous)
.fill(blend(track.resolve(in: env), tint, 0.13))
Capsule(style: .continuous)
.strokeBorder(alpha(tint, 0.34), lineWidth: max(0.8, height * 0.028))
// The shed ring. Clipped inside the pill rather than drawn outside
// it: on a dark track a ring crossing the rim at a fifth of full
// opacity reads as a clipping fault, not as an emanation, and it
// never grows past 0.42 * height so the capsule never cuts it.
if frame.shed > 0 {
// Starts just outside the dot at its fullest breath, so it is
// never born inside the thing it came off.
let travel = radius * (0.95 + 0.80 * frame.shed)
Circle()
.strokeBorder(
alpha(tint, 0.32 * pow(1 - frame.shed, 0.9) * min(1, frame.shed / 0.08)),
lineWidth: mark * 0.12
)
.frame(width: travel * 2, height: travel * 2)
.offset(x: markCentre.x - travel, y: markCentre.y - travel)
}
BuildMark(
ringClose: frame.ringClose,
tickDraw: frame.tickDraw,
breath: frame.breath,
spin: frame.spin,
crossed: outcome == .failed
)
.fill(tint)
.frame(width: mark, height: mark)
.offset(x: markCentre.x - radius, y: markCentre.y - radius)
label(frame.outLabel, ink: ink, mark: mark)
.opacity(frame.outAlpha)
.offset(y: frame.outLift)
label(frame.inLabel, ink: ink, mark: mark)
.opacity(frame.inAlpha)
.offset(y: frame.inLift)
}
.frame(width: CGFloat(frame.width), height: height)
.clipShape(Capsule(style: .continuous))
.offset(x: frame.shake)
.contentShape(Capsule(style: .continuous))
.onTapGesture(perform: advance)
.accessibilityElement()
.accessibilityLabel(Text("Build status"))
.accessibilityValue(Text(frame.spoken))
}
private func label(_ string: String, ink: Color.Resolved, mark: CGFloat) -> some View {
Text(string)
.font(.system(size: height * 0.58, weight: .semibold))
.foregroundStyle(ink)
.lineLimit(1)
.fixedSize()
.frame(height: height, alignment: .center)
.offset(x: height * 0.32 + mark + height * 0.20, y: opticalNudge)
}
/// Text sits high in its line box, so a geometrically centred label reads
/// low. This puts the midpoint between baseline and cap height on the
/// pill's centre instead, computed from the font rather than guessed.
private var opticalNudge: CGFloat {
let font = platformFont
return (font.capHeight - font.ascender - font.descender) / 2
}
// MARK: - Drive
/// Unwrapped position on the cycle. The linear clock plus whatever a tap
/// has added to it.
private func drive(at date: Date) -> Double {
date.timeIntervalSince(epoch) / max(0.5, period) + skipped(at: date)
}
private func skipped(at date: Date) -> Double {
guard let skipAt else { return skipTo }
return skipSpring.value(
fromValue: skipFrom,
toValue: skipTo,
initialVelocity: skipVelocity,
time: date.timeIntervalSince(skipAt)
)
}
private var skipSpring: Spring { Spring(response: 0.42, dampingRatio: 1.0) }
/// The whole visual state at one point of the cycle.
///
/// Three morphs bracket the three holds, so any position resolves to "which
/// morph was last, and how long ago". A hold is simply a morph whose `u`
/// has reached 1 — which is what lets the width spring keep settling after
/// the shape has arrived, rather than being cut off at the boundary.
private func frame(at raw: Double) -> BuildBadgeFrame {
let position = raw - raw.rounded(.down)
let last = morph(before: position)
let elapsed = (position - last.start + (position < last.start ? 1 : 0))
let seconds = elapsed * max(0.5, period)
let u = min(1, elapsed / last.span)
let from = last.from, to = last.to
let colour = smooth(min(1, u / buildColourTrack))
let tint = blend(resolved(from), resolved(to), colour)
// The mark as one number: 0 ring, 1 dot, 2 check. A lerp of *that*
// gives the reverse sequence for free on the reset — check retracts,
// dot re-forms, dot opens — which is why the cycle can close on itself
// without a cut.
let travelled = last.kind.shaped(u)
let position2 = from.mark + (to.mark - from.mark) * travelled
var breath = 1.0
var shed = 0.0
if position >= buildRunStart && position < buildEndMorph {
let v = (position - buildRunStart) / buildRunHold
// Two breaths in the window, so the dot is at rest at both ends and
// the loop closes. The ring is shed off the *first* one only, which
// is what stops the rhythm reading as a metronome.
breath = 1 + 0.11 * (1 - cos(4 * .pi * v))
if v > 0.22 && v < 0.50 { shed = (v - 0.22) / 0.28 }
}
var shake: CGFloat = 0
if outcome == .failed, last.to == .failed {
let s = (seconds - last.span * max(0.5, period)) / 0.42
if s > 0 && s < 1 {
shake = height * 0.136 * CGFloat(sin(4 * .pi * s) * (1 - s))
}
}
let swap = smooth((u - buildLabelStart) / buildLabelSpan)
return BuildBadgeFrame(
width: Spring(response: widthResponse, dampingRatio: widthDamping)
.value(
fromValue: pillWidth(from),
toValue: pillWidth(to),
initialVelocity: 0,
time: seconds
),
fill: tint,
ringClose: min(1, position2),
tickDraw: max(0, position2 - 1),
breath: breath,
spin: position * buildDashTurns * 360 / Double(buildDashes),
shed: shed,
outLabel: from.label,
outAlpha: 1 - swap,
outLift: -height * buildLabelRoll * swap,
inLabel: to.label,
inAlpha: swap,
inLift: height * buildLabelRoll * (1 - swap),
shake: shake,
spoken: (swap < 0.5 ? from : to).spoken
)
}
/// The morph a position sits in or after, and the two states it brackets.
private func morph(before position: Double) -> BuildBadgeMorph {
let ending: BuildBadgeStep = outcome == .failed ? .failed : .passed
if position >= buildRunMorph && position < buildEndMorph {
return .init(start: buildRunMorph, span: buildMorph, from: .queued, to: .running, kind: .close)
}
if position >= buildEndMorph && position < buildResetAt {
return .init(start: buildEndMorph, span: buildMorph, from: .running, to: ending, kind: .draw)
}
return .init(start: buildResetAt, span: buildReset, from: ending, to: .queued, kind: .rewind)
}
/// Jump to the next state.
///
/// The tap moves the *clock*, not a separate state machine, so every
/// re-timing above applies to a tapped transition exactly as it does to an
/// automatic one. Landing 2% inside the next hold rather than at its middle
/// leaves room for the linear clock, which keeps running underneath while
/// the spring travels.
private func advance() {
guard phase == nil, !reduceMotion else { return }
let now = Date()
let here = drive(at: now)
let position = here - here.rounded(.down)
let gates = [0.0, buildRunStart, buildEndStart].map { $0 + 0.02 }
let index = gates.firstIndex { $0 > position + 0.01 } ?? 0
var delta = gates[index] - position
if delta <= 0 { delta += 1 }
skipFrom = skipped(at: now)
skipVelocity = skipAt.map { at in
skipSpring.velocity(
fromValue: skipFrom,
toValue: skipTo,
initialVelocity: skipVelocity,
time: now.timeIntervalSince(at)
)
} ?? 0
skipTo = skipFrom + delta
skipAt = now
// Struck at the tap, not on arrival: feedback that trails the finger by
// the spring's 0.42 s reads as lag, and the tap is what the hand did.
switch index {
case 1: struck = 1
case 2: struck = outcome == .failed ? 3 : 2
default: struck = 0
}
}
// MARK: - Geometry and colour
private func resolved(_ step: BuildBadgeStep) -> Color.Resolved {
switch step {
case .queued: return queued.resolve(in: env)
case .running: return running.resolve(in: env)
case .passed: return passed.resolve(in: env)
case .failed: return failedTint.resolve(in: env)
}
}
/// The pill is its label plus fixed chrome, measured rather than guessed.
///
/// `NSString.size(withAttributes:)` is exact for the descriptor `Text` draws
/// with, and synchronous, which is what the Flutter side gets from
/// `TextPainter`. At 22pt tall that is 77.5 / 80.1 / 73.8 / 65.9 points for
/// the four words — so the row *opens* 2.6pt into running and *closes* 6.2pt
/// into passed, or 14.1pt into failed. The contraction is the interesting
/// half: a list of builds tightens as they finish.
private func pillWidth(_ step: BuildBadgeStep) -> Double {
let text = (step.label as NSString)
.size(withAttributes: [.font: platformFont]).width
return Double(text + height * 0.32 * 2 + height * 0.48 + height * 0.20)
}
private var platformFont: BuildBadgeFont {
.systemFont(ofSize: height * 0.58, weight: .semibold)
}
}
// MARK: - The indicator
/// Ring → dot → check as one filled path.
///
/// `ringClose` closes a dashed annulus into a solid dot: the gaps shut first,
/// then the hole. `tickDraw` spends its first third collapsing that dot — the
/// anticipation before the result — and its remaining two thirds drawing the
/// mark out of the point the dot vanished at, short leg first. At the handover
/// both are zero-sized, which is why there is never a frame with two shapes in
/// it.
///
/// The path is *filled*, not stroked, because the ring's own thickness has to
/// animate and a `StrokeStyle` interpolates between endpoint widths instead of
/// being evaluated per frame. `strokedPath` turns the tick's centreline into a
/// fillable outline, so one `fill` covers every state.
private struct BuildMark: Shape {
var ringClose: Double
var tickDraw: Double
/// Not part of `animatableData`: the whole badge body is re-evaluated every
/// frame from one drive, so this arrives already correct and has nothing to
/// interpolate.
var breath: Double
var spin: Double
var crossed: Bool
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(ringClose, tickDraw) }
set {
ringClose = newValue.first
tickDraw = newValue.second
}
}
func path(in rect: CGRect) -> Path {
let side = min(rect.width, rect.height)
let centre = CGPoint(x: rect.midX, y: rect.midY)
let radius = side / 2
let stroke = radius * 0.34
let close = min(max(ringClose, 0), 1)
let draw = min(max(tickDraw, 0), 1)
// First third of `tickDraw` is the dot's collapse, eased in so it
// gathers speed as it goes; the rest is the mark drawing.
let collapse = min(1, draw * 3)
let drawn = ease(max(0, (draw - 1.0 / 3) / (2.0 / 3)))
if drawn > 0.001 {
return tick(side: side, origin: rect.origin, stroke: stroke, drawn: drawn)
}
// The dot drifts toward the point the mark starts from as it shrinks,
// so the check appears to be pulled out of what is left of it.
let target = crossed
? CGPoint(x: 0.5, y: 0.5)
: CGPoint(x: 0.13, y: 0.53)
let pull = collapse * collapse
let hub = CGPoint(
x: centre.x + (rect.minX + target.x * side - centre.x) * pull,
y: centre.y + (rect.minY + target.y * side - centre.y) * pull
)
let grown = 1 + (breath - 1) * close
let outer = radius * (1 - 0.28 * close) * grown * (1 - collapse)
let inner = max(0, (outer - stroke * (1 - collapse)) * (1 - close))
guard outer > 0.01 else { return Path() }
// Each dash is an arc centreline stroked to the annulus's own thickness
// with round caps, so the gaps have soft ends rather than radial saw
// cuts. It also degenerates correctly: at `close == 1` the inner radius
// is zero, so the centreline sits at half the outer radius and the
// stroke is the full diameter — a solid dot, out of the same expression.
//
// The gaps shut by the time the ring is half closed. Three abutting
// stroked arcs union under a non-zero fill, so no seam can appear at
// the moment they meet.
let filled = smooth(min(1, close / 0.5))
let pitch = 360.0 / Double(buildDashes)
let span = pitch * (0.62 + 0.38 * filled)
let mid = (outer + inner) / 2
let style = StrokeStyle(lineWidth: outer - inner, lineCap: .round)
var path = Path()
for index in 0..<buildDashes {
let start = spin + Double(index) * pitch
var dash = Path()
dash.addArc(
center: hub,
radius: mid,
startAngle: .degrees(start),
endAngle: .degrees(start + span),
clockwise: false
)
path.addPath(dash.strokedPath(style))
}
return path
}
/// A check, or a cross whose second stroke is 60 ms behind the first —
/// a quarter of the 240 ms draw, which is what makes a failure read as two
/// deliberate strokes rather than an X stamped on.
private func tick(side: CGFloat, origin: CGPoint, stroke: CGFloat, drawn: Double) -> Path {
// A shade heavier than the ring's own thickness: a check is a thin
// diagonal where the ring was a closed curve, and matched stroke for
// stroke it reads lighter than the dot it replaced.
let style = StrokeStyle(lineWidth: stroke * 1.18, lineCap: .round, lineJoin: .round)
func at(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
CGPoint(x: origin.x + x * side, y: origin.y + y * side)
}
var path = Path()
if crossed {
var first = Path()
first.move(to: at(0.22, 0.22))
first.addLine(to: at(0.78, 0.78))
path.addPath(first.trimmedPath(from: 0, to: min(1, drawn / 0.75))
.strokedPath(style))
let second = min(1, max(0, (drawn - 0.25) / 0.75))
if second > 0.001 {
var line = Path()
line.move(to: at(0.78, 0.22))
line.addLine(to: at(0.22, 0.78))
path.addPath(line.trimmedPath(from: 0, to: second).strokedPath(style))
}
return path
}
// Short leg first: it is 34% of the polyline's length, so the trim
// draws it in the first third of the window without being told to.
var line = Path()
line.move(to: at(0.13, 0.53))
line.addLine(to: at(0.40, 0.79))
line.addLine(to: at(0.88, 0.24))
path.addPath(line.trimmedPath(from: 0, to: drawn).strokedPath(style))
return path
}
}
// MARK: - Model
private enum BuildBadgeStep {
case queued, running, passed, failed
/// 0 ring, 1 dot, 2 mark. One number, so a lerp between any two states
/// walks the indicator through the shapes in between in the right order.
var mark: Double {
switch self {
case .queued: return 0
case .running: return 1
case .passed, .failed: return 2
}
}
var label: String {
switch self {
case .queued: return "Queued"
case .running: return "Running"
case .passed: return "Passed"
case .failed: return "Failed"
}
}
/// What VoiceOver says as the badge's value. Derived, so the spoken state
/// and the drawn one cannot drift apart.
var spoken: String { label.lowercased() }
}
private enum BuildBadgeKind {
/// Ring to dot: one eased move, nothing sequenced.
case close
/// Dot to mark: 120 ms of collapse, then 240 ms of drawing.
case draw
/// The loop's reset. The forward morph in reverse, half each — the only way
/// the cycle gets back to queued without a cut.
case rewind
func shaped(_ u: Double) -> Double {
switch self {
case .close:
return smooth(u)
case .draw:
if u <= 1.0 / 3 { return (1.0 / 3) * (3 * u) * (3 * u) }
return 1.0 / 3 + (2.0 / 3) * ease((3 * u - 1) / 2)
case .rewind:
if u <= 0.5 { return 0.5 * smooth(2 * u) }
return 0.5 + 0.5 * smooth(2 * u - 1)
}
}
}
private struct BuildBadgeMorph {
var start: Double
var span: Double
var from: BuildBadgeStep
var to: BuildBadgeStep
var kind: BuildBadgeKind
}
private struct BuildBadgeFrame {
var width: Double
var fill: Color.Resolved
var ringClose: Double
var tickDraw: Double
var breath: Double
var spin: Double
var shed: Double
var outLabel: String
var outAlpha: Double
var outLift: CGFloat
var inLabel: String
var inAlpha: Double
var inLift: CGFloat
var shake: CGFloat
var spoken: String
}
// MARK: - Curves and colour
private func smooth(_ t: Double) -> Double {
let x = min(max(t, 0), 1)
return x * x * (3 - 2 * x)
}
private func ease(_ t: Double) -> Double {
let x = min(max(t, 0), 1)
return 1 - (1 - x) * (1 - x)
}
/// Interpolated on the sRGB components, which is what `Color.lerp` does on the
/// Flutter side — so both files pass through the same midpoint.
private func blend(_ from: Color.Resolved, _ to: Color.Resolved, _ t: Double) -> Color.Resolved {
let f = Float(min(max(t, 0), 1))
return Color.Resolved(
red: from.red + (to.red - from.red) * f,
green: from.green + (to.green - from.green) * f,
blue: from.blue + (to.blue - from.blue) * f,
opacity: from.opacity + (to.opacity - from.opacity) * f
)
}
private func lift(_ colour: Color.Resolved, toward white: Float) -> Color.Resolved {
Color.Resolved(
red: colour.red + (1 - colour.red) * white,
green: colour.green + (1 - colour.green) * white,
blue: colour.blue + (1 - colour.blue) * white,
opacity: colour.opacity
)
}
private func alpha(_ colour: Color.Resolved, _ value: Double) -> Color.Resolved {
Color.Resolved(
red: colour.red,
green: colour.green,
blue: colour.blue,
opacity: Float(min(max(value, 0), 1))
)
}
#Preview {
// The first is live — tap it to jump to the next state.
VStack(alignment: .leading, spacing: 18) {
BuildStateBadge(height: 52)
BuildStateBadge(height: 52, phase: 0.06)
BuildStateBadge(height: 52, phase: 0.395)
BuildStateBadge(height: 52, phase: 0.80)
BuildStateBadge(height: 52, outcome: .failed, phase: 0.80)
}
.padding(24)
}
iOS 17 · No dependencies
SwiftUI note. The animated width sits on the badge's own frame, so a commit message dropped to its right closes in on the same spring as the build finishes. Each badge drives its own TimelineView(.animation); in a list of forty rows, hoist one timeline into the list and pass the phase down instead.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


