Step TrailNew
A liquid glass progress trail for checkout, booking, account setup and verification flows: every step a glass stop joined to the next by a short neck, done steps carrying a check, the current step a red capsule with its number and title, steps people already reached in full text and the rest muted; moving on stretches the red from one stop to the next and gathers it there while the old title blurs into a check, a tap goes back to any step already reached, a tap ahead of that shakes the stop and nothing moves, a sideways drag carries the capsule stop by stop with a tick each and gives at the last step it may reach, and when the titles don't fit the capsule keeps its number, the caption names the step and the stops shrink rather than wrap or clip.
- Type
- StepTrail
- Files
- StepTrail.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 1.0.0
A step progress indicator for multi-step flows that shows where you are, what is done, and lets people go back without letting them jump ahead of what they have filled in. The stops are liquid glass resting joined, so the whole trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title, steps people have already reached beyond the current one show their number in full text, and steps not reached yet show it muted. Moving to another step stretches the red from one stop to the other, its leading edge first, and gathers it there, while the titles and checks blur over. Tap a done or reached step to go there. A tap on a step that hasn't been reached shakes that stop and nothing moves. Drag sideways along the trail to carry the capsule across the reachable steps, with a tick on each; it gives at the last one it may reach and lands where you let go. Vertical swipes still scroll the screen. When the trail doesn't fit, the capsule drops its title (the caption names the step instead), and then the stops shrink. It never wraps and never clips. The trail hugs its content and starts at the leading edge, keeping the width of its widest step so nothing around it moves.
Notes
- Put it at the top of a checkout, booking, account setup or verification flow and bind
selectionto the step your flow shows:StepTrail(steps: ["Cart", "Address", "Payment", "Review"], selection: $step). Your Continue button moves on withstep += 1; the trail never changes steps on its own except when a person taps or drags it. - To let people return forward after going back, keep the furthest step they have filled in and pass it as
reached:.onChange(of: step) { _, value in furthest = max(furthest, value) }. Without it, only earlier steps can be revisited. Lowerfurthestyourself when an edit invalidates later steps. - Every stop is liquid glass, resting joined, so the trail reads as one shape with short necks. Done steps carry a check, the current step is a red capsule with its number and title in dark ink, steps already reached beyond the current one show their number in full text and steps not reached yet show it muted. Red marks the current step and nothing else.
- Moving to another step closes one capsule and opens the other while the red stretches across: the edge facing the move leads and the far edge lets go a beat later, so the red reaches over the neck and gathers on the new stop. The old title blurs out as its capsule shrinks into a circle and its check blurs in; the new title blurs in as the capsule opens. A move made while another is still landing picks up from where everything is.
- Tap a done or reached step to go there, with a selection tick. A tap on a step that hasn't been reached shakes that stop with a firm tick and nothing moves. A tap on the current step does nothing.
- Drag sideways along the trail to carry the capsule across the reachable steps. It lifts under the finger, moves to a stop once the finger is a little past the midpoint (so it never flickers between two), ticks on each, and between stops its facing edge reaches toward the finger with rubber-band resistance. At the last step it may reach it only gives, with one firm tick, and let go it lands where it is: the binding changes once, on release. The drag starts only when it runs sideways, so vertical swipes that begin on the trail still scroll the screen or move a sheet.
- Set
selectiontosteps.countwhen the flow completes: every stop shows a check, the red drains from the last capsule and the trail lands with one small beat. The caption and VoiceOver read "Complete". Tapping a done step from there goes back to it, like any other done step; disable the trail if the flow is final. - When the titles don't fit, the capsule keeps only its number and the caption names the step ("ID photo Step 4 of 6"); when the numbers don't fit either, the stops shrink to fit. It never wraps and never clips. The trail hugs its content, starts at the leading edge and keeps the width of its widest step, so nothing around it moves as the capsule changes. Whether titles show is decided by the longest title, so the form never flips between steps. Titles longer than
Style.maximumTitleWidthtruncate. - An empty
stepsdraws nothing; a single step shows its capsule. Values ofselectionoutside0...steps.countare drawn clamped, and a change tostepsputs the trail on the selection without motion. - VoiceOver reads the trail as one adjustable element: the label "Progress" and a value such as "Step 2 of 4, Address". Swipe up and down to move within the reached steps, or use the Go to actions for each reachable step. With a hardware keyboard, focus the trail and use the left and right arrow keys (mirrored right to left).
- Reduce Motion: the red stays in the stops and crossfades from one to the other with no stretch, the capsules resize on a short spring with no overshoot, the checks, numbers and titles fade, the shake and the lift stay still, and a press shades instead of sinking.
- Dynamic Type grows the stops, numbers and titles until they are a little over twice their size, and the caption all the way; when they no longer fit, the title moves to the caption and the stops shrink to fit. Right to left mirrors the trail: it starts at the right, the number sits at the capsule's right end and a drag runs the other way.
- Haptics: a selection tick when a tap, a key or a drag lands on a step and for each stop a drag crosses, and a firm tick for a refused tap or a drag pushed against its last step. Changes your app makes stay silent, so the trail never doubles your own feedback.
- On iOS 26 the stops are Liquid Glass and merge through their necks; before that, and under Reduce Transparency, they draw as frosted or solid stops on their own.
.disabled(true)shows the progress dimmed and ignores touches. Stylesets the accent (the current capsule) and the ink on it, the label and secondary label colors, the stop height (34 by default, at least 28) and the longest title.Messagesreplaces any line of copy: the VoiceOver label, the caption, the spoken value, the finished state and the Go to action, for example.init(label: "Checkout progress"). All of them are localizable through your String Catalog.
Usage
StepTrailExample()Parameters
| Parameter | Description |
|---|---|
steps | The step titles, in order, such as ["Cart", "Address", "Payment", "Review"]. They show in the current step's capsule and are read by VoiceOver. An empty list draws nothing. Titles longer than Style.maximumTitleWidth truncate in the capsule. |
selection | The index of the current step. A tap, a drag or VoiceOver writes it; set it yourself to move on (a Continue button). steps.count shows every step done, for the moment a flow completes. Values outside 0...steps.count are drawn clamped. |
reached | The furthest step people have filled in, so they can return forward to it after going back. Steps up to it stay tappable and show their number in full text. nil (the default) is the selection, so only earlier steps can be revisited. Keep it in your own state, for example furthest = max(furthest, step). |
showsCaption | Shows a quiet caption under the trail, such as "Step 2 of 4". When the titles don't fit in the trail, the caption names the current step too. |
messages | The built-in copy: the VoiceOver label ("Progress"), the caption, the spoken value, the finished state and the VoiceOver actions. Defaults are localizable through your String Catalog. |
style | Colors and size. Defaults to the SwiftPieces house palette (a red current step on neutral glass), adapting to light and dark. |
Source
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
import UIKit
import UIKit.UIGestureRecognizerSubclass
/// A step progress indicator for multi-step flows that shows where you are, what is done, and lets people go back
/// without letting them jump ahead of what they have filled in.
///
/// The stops are liquid glass resting joined, so the whole trail reads as one shape with short necks. Done steps
/// carry a check, the current step is a red capsule with its number and title, steps people have already reached
/// beyond the current one show their number in full text, and steps not reached yet show it muted. Moving to another
/// step stretches the red from one stop to the other, its leading edge first, and gathers it there, while the titles
/// and checks blur over.
///
/// Tap a done or reached step to go there. A tap on a step that hasn't been reached shakes that stop and nothing
/// moves. Drag sideways along the trail to carry the capsule across the reachable steps, with a tick on each; it
/// gives at the last one it may reach and lands where you let go. Vertical swipes still scroll the screen.
///
/// When the trail doesn't fit, the capsule drops its title (the caption names the step instead), and then the stops
/// shrink. It never wraps and never clips. The trail hugs its content and starts at the leading edge, keeping the
/// width of its widest step so nothing around it moves.
///
/// - Parameters:
/// - steps: The step titles, in order, such as `["Cart", "Address", "Payment", "Review"]`. They show in the current step's capsule and are read by VoiceOver. An empty list draws nothing. Titles longer than `Style.maximumTitleWidth` truncate in the capsule.
/// - selection: The index of the current step. A tap, a drag or VoiceOver writes it; set it yourself to move on (a Continue button). `steps.count` shows every step done, for the moment a flow completes. Values outside `0...steps.count` are drawn clamped.
/// - reached: The furthest step people have filled in, so they can return forward to it after going back. Steps up to it stay tappable and show their number in full text. `nil` (the default) is the selection, so only earlier steps can be revisited. Keep it in your own state, for example `furthest = max(furthest, step)`.
/// - showsCaption: Shows a quiet caption under the trail, such as "Step 2 of 4". When the titles don't fit in the trail, the caption names the current step too.
/// - messages: The built-in copy: the VoiceOver label ("Progress"), the caption, the spoken value, the finished state and the VoiceOver actions. Defaults are localizable through your String Catalog.
/// - style: Colors and size. Defaults to the SwiftPieces house palette (a red current step on neutral glass), adapting to light and dark.
public struct StepTrail: View {
/// Colors and size. `.standard` is the house palette.
public struct Style: Sendable {
/// The current step's capsule, painted inside its glass. Red in the house palette, and the only red.
public var accent: Color
/// The number and title on the accent.
public var accentInk: Color
/// Checks on done steps, numbers of reached steps and the caption's step title.
public var label: Color
/// Numbers of steps not reached yet, and the caption.
public var secondaryLabel: Color
/// The stop diameter and capsule height at the default text size, at least 28. It follows Dynamic Type.
public var height: CGFloat
/// The longest a title runs in the capsule before it truncates, at the default text size.
public var maximumTitleWidth: CGFloat
/// Pass only what you want to change; `nil` keeps the house palette value.
public init(accent: Color? = nil, accentInk: Color? = nil, label: Color? = nil, secondaryLabel: Color? = nil, height: CGFloat = 34, maximumTitleWidth: CGFloat = 160) {
self.accent = accent ?? Color(red: 1, green: 0, blue: 0)
self.accentInk = accentInk ?? stepTrailColor(light: 0x141414, dark: 0x141414)
self.label = label ?? stepTrailColor(light: 0x141414, dark: 0xF4F3EF)
self.secondaryLabel = secondaryLabel ?? stepTrailColor(light: 0x5C5A56, dark: 0xA6A49F)
self.height = max(height, 28)
self.maximumTitleWidth = max(maximumTitleWidth, 40)
}
public static let standard = Style()
}
/// The trail's copy. Every default goes through `String(localized:)`, so it can be translated in your String
/// Catalog, and any line can be replaced: `.init(label: "Checkout progress")`.
public struct Messages: Sendable {
/// What VoiceOver calls the trail.
public var label: String
/// The caption under the trail, such as "Step 2 of 4".
public var caption: @Sendable (_ position: Int, _ count: Int) -> String
/// What VoiceOver reads as the trail's value, such as "Step 2 of 4, Address".
public var value: @Sendable (_ position: Int, _ count: Int, _ title: String) -> String
/// The caption and the spoken value once every step is done.
public var complete: String
/// The VoiceOver action that goes to a step, such as "Go to Cart".
public var goTo: @Sendable (_ title: String) -> String
/// Pass only the lines you want to change.
public init(
label: String = String(localized: "Progress", comment: "Step trail: what VoiceOver calls the trail"),
caption: @escaping @Sendable (_ position: Int, _ count: Int) -> String = { position, count in
String(localized: "Step \(position) of \(count)", comment: "Step trail caption, such as Step 2 of 4")
},
value: @escaping @Sendable (_ position: Int, _ count: Int, _ title: String) -> String = { position, count, title in
String(localized: "Step \(position) of \(count), \(title)", comment: "Step trail value read by VoiceOver, such as Step 2 of 4, Address")
},
complete: String = String(localized: "Complete", comment: "Step trail caption and value once every step is done"),
goTo: @escaping @Sendable (_ title: String) -> String = { title in
String(localized: "Go to \(title)", comment: "Step trail VoiceOver action that goes to a step, such as Go to Cart")
}
) {
self.label = label
self.caption = caption
self.value = value
self.complete = complete
self.goTo = goTo
}
public static var standard: Messages { Messages() }
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.layoutDirection) private var layoutDirection
@Environment(\.isEnabled) private var isEnabled
/// How far the stops and their text follow Dynamic Type.
@ScaledMetric(relativeTo: .subheadline) private var textScale: CGFloat = 1
@ScaledMetric(relativeTo: .footnote) private var captionSize: CGFloat = 13
@Binding private var selection: Int
private let steps: [String]
private let reached: Int?
private let showsCaption: Bool
private let messages: Messages
private let style: Style
/// The step drawn as current: the selection, or the stop a drag is over. `steps.count` once every step is done.
@State private var shown: Int
/// How open each stop is, 0 a circle and 1 the capsule with its title. Animated on its own spring.
@State private var open: StepTrailOpenness
/// The red puck's two edges as step positions, each sprung on its own so the puck stretches between stops.
@State private var lowerEdge: Double
@State private var upperEdge: Double
/// How far, in points, a dragging finger pulls the puck's facing edge. Set directly under the finger.
@State private var pull: Double = 0
/// The stop a drag is over, while one is.
@State private var scrubbing: Int?
@State private var grabbed = false
@State private var pressed: Int?
@State private var pushing = false
/// Each refused stop's shake count.
@State private var shakes: [Int: Int] = [:]
@State private var titleWidths: [Int: CGFloat] = [:]
@State private var landings = 0
@State private var refusals = 0
@State private var completions = 0
public init(steps: [String], selection: Binding<Int>, reached: Int? = nil, showsCaption: Bool = true, messages: Messages = .standard, style: Style = .standard) {
self.steps = steps
self._selection = selection
self.reached = reached
self.showsCaption = showsCaption
self.messages = messages
self.style = style
let count = steps.count
let start = min(max(selection.wrappedValue, 0), count)
self._shown = State(initialValue: start)
self._open = State(initialValue: StepTrailOpenness(open: start, count: count))
let edge = Double(min(start, max(count - 1, 0)))
self._lowerEdge = State(initialValue: edge)
self._upperEdge = State(initialValue: edge)
}
public var body: some View {
if steps.isEmpty {
EmptyView()
} else {
trail
}
}
private var trail: some View {
let rtl = layoutDirection == .rightToLeft
return ViewThatFits(in: .horizontal) {
block(metrics(titled: true, rtl: rtl))
block(metrics(titled: false, rtl: rtl))
}
.fontWeight(.semibold)
.background { titleProbe }
.saturation(isEnabled ? 1 : 0)
.opacity(isEnabled ? 1 : 0.45)
.sensoryFeedback(.selection, trigger: landings)
.sensoryFeedback(.impact(flexibility: .rigid), trigger: refusals)
.accessibilityElement(children: .ignore)
.accessibilityLabel(messages.label)
.accessibilityValue(spokenValue)
.accessibilityAdjustableAction { direction in
switch direction {
case .increment: step(by: 1, haptic: false)
case .decrement: step(by: -1, haptic: false)
@unknown default: break
}
}
.accessibilityActions {
ForEach(actionIndices, id: \.self) { index in
Button(messages.goTo(steps[index])) { go(to: index) }
}
}
.focusable(isEnabled)
.onKeyPress(.leftArrow) { step(by: rtl ? 1 : -1, haptic: true) ? .handled : .ignored }
.onKeyPress(.rightArrow) { step(by: rtl ? -1 : 1, haptic: true) ? .handled : .ignored }
.onChange(of: selection) { _, value in
// A drag owns the trail until it lands; the landing reconciles with whatever the selection is then.
guard scrubbing == nil else { return }
move(to: clamped(value))
}
.onChange(of: steps) { _, _ in place(clamped(selection)) }
}
// MARK: Layout
/// The trail and its caption in one size: titled, or numbers only (shrinking further when it must).
private func block(_ metrics: StepTrailMetrics) -> some View {
VStack(alignment: .leading, spacing: (10 * metrics.scale).rounded()) {
track(metrics)
if showsCaption {
caption(titled: metrics.titled)
.frame(maxWidth: metrics.naturalWidth, alignment: .leading)
}
}
}
@ViewBuilder private func track(_ metrics: StepTrailMetrics) -> some View {
if metrics.titled {
trackView(metrics)
} else {
// Numbers only: as wide as the stops at full size, and narrower when that's all there is, the stops
// shrinking to fit rather than wrapping or running off the edge.
GeometryReader { proxy in
trackView(metrics.fitted(to: proxy.size.width))
.frame(width: proxy.size.width, height: proxy.size.height)
}
.frame(idealWidth: metrics.naturalWidth, maxWidth: metrics.naturalWidth)
.frame(height: metrics.rowHeight)
}
}
private func trackView(_ metrics: StepTrailMetrics) -> some View {
StepTrailTrack(
steps: steps,
metrics: metrics,
shown: shown,
furthest: furthest,
open: open,
lower: lowerEdge,
upper: upperEdge,
pull: pull,
grabbed: grabbed,
pressed: pressed,
shakes: shakes,
completions: completions,
style: style,
isEnabled: isEnabled,
onTouch: handle
)
}
/// Sizes for one form of the trail, at the current text size.
private func metrics(titled: Bool, rtl: Bool) -> StepTrailMetrics {
// Past the second accessibility size the stops stop growing: the caption carries the larger text.
let scale = min(max(textScale, 1), 2.2)
let d = (style.height * scale).rounded()
let label = 15 * scale
let lead = (d * 0.85).rounded()
let tail = (d * 0.41).rounded()
let cap = style.maximumTitleWidth * scale
let titles = steps.indices.map { index in
min(titleWidths[index] ?? CGFloat(steps[index].count) * label * 0.56, cap).rounded(.up)
}
let widths = titled ? titles.map { lead + $0 + tail } : Array(repeating: d, count: steps.count)
let gap = PieceLiquid.joined
let rest = CGFloat(steps.count - 1) * (d + gap)
let natural = rest + (widths.max() ?? d)
return StepTrailMetrics(d: d, gap: gap, label: label, titleLead: lead, titled: titled, rtl: rtl, openWidths: widths, titleWidths: titles, naturalWidth: natural, frameWidth: natural, scale: scale)
}
/// Measures every title once at the capsule's font, so the trail can size its capsule and reserve its widest form.
private var titleProbe: some View {
let size = 15 * min(max(textScale, 1), 2.2)
return ZStack {
ForEach(Array(steps.enumerated()), id: \.offset) { index, title in
Text(title)
.font(.system(size: size, weight: .semibold))
.fixedSize()
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) { titleWidths[index] = width }
}
}
}
.hidden()
.accessibilityHidden(true)
}
// MARK: Caption
@ViewBuilder private func caption(titled: Bool) -> some View {
let count = steps.count
let font = Font.system(size: captionSize, weight: .semibold)
if shown >= count {
Text(messages.complete)
.font(font)
.foregroundStyle(style.secondaryLabel)
.transition(PieceMotion(reduceMotion: reduceMotion).swap)
} else if titled {
position(font: font)
.transition(PieceMotion(reduceMotion: reduceMotion).swap)
} else {
// The capsule has no room for its title here, so the caption names the step.
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
PieceMorphText(text: steps[shown], font: font)
.foregroundStyle(style.label)
position(font: font)
}
VStack(alignment: .leading, spacing: 2) {
Text(steps[shown])
.font(font)
.foregroundStyle(style.label)
.fixedSize(horizontal: false, vertical: true)
position(font: font)
}
}
}
}
private func position(font: Font) -> some View {
let position = min(shown, steps.count - 1) + 1
return Text(messages.caption(position, steps.count))
.font(font)
.foregroundStyle(style.secondaryLabel)
.fixedSize(horizontal: false, vertical: true)
.contentTransition(reduceMotion ? .opacity : .numericText(value: Double(position)))
// A count people read: it rolls without overshoot, whatever spring the trail moves on.
.animation(PieceMotion(reduceMotion: reduceMotion).value, value: position)
}
// MARK: State
private func clamped(_ value: Int) -> Int { min(max(value, 0), steps.count) }
/// The last step people may go to: the furthest reached, never past the last step.
private var furthest: Int {
let current = clamped(selection)
return min(max(current, reached ?? current), steps.count - 1)
}
private var actionIndices: [Int] {
let current = clamped(selection)
return (0...furthest).filter { $0 != current }
}
private var spokenValue: String {
guard shown < steps.count else { return messages.complete }
return messages.value(shown + 1, steps.count, steps[shown])
}
/// Draws `target` as current. The capsule closes on one stop and opens on the other on the morph spring, while
/// the puck's edge facing the target leads on the snap spring and the far edge follows a beat later on a looser
/// one, so the red stretches toward the target and gathers in on it. Under Reduce Motion the puck stays out of it:
/// the stops carry the tint themselves and it crossfades.
private func move(to target: Int) {
let count = steps.count
let from = shown
guard target != from, count > 0 else { return }
let motion = PieceMotion(reduceMotion: reduceMotion)
withAnimation(motion.morph) {
shown = target
open = StepTrailOpenness(open: target, count: count)
}
let edge = Double(min(target, count - 1))
if reduceMotion {
withTransaction(Self.instant) {
lowerEdge = edge
upperEdge = edge
}
} else {
let forward = edge >= (lowerEdge + upperEdge) / 2
// The far edge waits a beat and follows on a looser spring, so the red reaches across the neck before it
// lets go of the stop it leaves.
let lead = motion.snap, trail = motion.follow(PieceMotion.responsive, rank: 4).delay(0.05)
withAnimation(forward ? lead : trail) { upperEdge = edge }
withAnimation(forward ? trail : lead) { lowerEdge = edge }
}
if target == count, from < count { completions += 1 }
}
/// Puts the trail on `index` with no motion: new steps, or a size change.
private func place(_ index: Int) {
let count = steps.count
let edge = Double(min(index, max(count - 1, 0)))
withTransaction(Self.instant) {
shown = index
open = StepTrailOpenness(open: index, count: count)
lowerEdge = edge
upperEdge = edge
pull = 0
scrubbing = nil
grabbed = false
pressed = nil
}
}
/// A tap, a key or a VoiceOver action going to a step. Only steps up to `furthest` can be chosen.
private func go(to index: Int) {
guard index >= 0, index <= furthest, index != clamped(selection) else { return }
selection = index
}
/// One step back or on, within what has been reached. Returns whether it moved.
@discardableResult
private func step(by delta: Int, haptic: Bool) -> Bool {
let current = clamped(selection)
let next = min(current, steps.count - 1) + (current >= steps.count && delta < 0 ? 0 : delta)
guard next >= 0, next <= furthest, next != current else { return false }
selection = next
if haptic { landings += 1 }
return true
}
private func handle(_ touch: StepTrailGesture) {
guard isEnabled else { return }
switch touch {
case .press(let index):
pressed = index.flatMap { $0 <= furthest ? $0 : nil }
case .tap(let index):
pressed = nil
tap(index)
case .scrubBegan(let index):
scrubBegan(at: index)
case .scrubMoved(let position, let pitch, let limit):
scrubMoved(to: position, pitch: pitch, limit: limit)
case .scrubEnded(let velocity, let pitch, let limit):
scrubEnded(velocity: velocity, pitch: pitch, limit: limit)
case .scrubCancelled:
scrubEnded(velocity: 0, pitch: 1, limit: 1, cancelled: true)
}
}
/// A step that can be reached goes there with a tick; one that can't shakes, and nothing moves.
private func tap(_ index: Int) {
guard steps.indices.contains(index) else { return }
if index <= furthest {
guard index != clamped(selection) else { return }
selection = index
landings += 1
} else {
shakes[index, default: 0] += 1
refusals += 1
}
}
private func scrubBegan(at index: Int) {
guard furthest > 0 else { return }
let start = min(max(index, 0), furthest)
pressed = nil
pushing = false
scrubbing = start
withAnimation(PieceMotion(reduceMotion: reduceMotion).press) { grabbed = true }
if start != shown {
move(to: start)
landings += 1
}
}
/// `position` is where the finger holds the capsule's number, in steps (1.5 is halfway from the second stop to
/// the third). The capsule moves to a stop once the finger is a little past the midpoint, so it never flickers
/// between two, and between stops the puck's facing edge reaches toward the finger with rubber-band resistance,
/// which is all it does past the last step it may reach.
private func scrubMoved(to position: Double, pitch: CGFloat, limit: CGFloat) {
guard let current = scrubbing else { return }
var next = current
let nearest = Int(position.rounded())
if nearest != current, abs(position - Double(nearest)) < 0.35 { next = nearest }
next = min(max(next, 0), furthest)
if next != current {
scrubbing = next
move(to: next)
landings += 1
}
let reach = (position - Double(next)) * Double(pitch)
withTransaction(Self.instant) {
pull = reduceMotion ? 0 : Double(PieceMotion.rubberBand(CGFloat(reach), limit: limit))
}
// One rigid tick as the capsule is pushed against the last step it may reach, or the first; it rearms once
// the finger comes back.
let against = (next == furthest && position > Double(next) + 0.45) || (next == 0 && position < -0.45)
if against, !pushing {
pushing = true
refusals += 1
} else if !against, abs(position - Double(next)) < 0.3 {
pushing = false
}
}
/// The finger lifts: the capsule lands where it is and the binding changes once. The pull springs back at the
/// speed the finger left it.
private func scrubEnded(velocity: CGFloat, pitch: CGFloat, limit: CGFloat, cancelled: Bool = false) {
guard let landing = scrubbing else { return }
let motion = PieceMotion(reduceMotion: reduceMotion)
scrubbing = nil
pushing = false
withAnimation(motion.release) { grabbed = false }
let raw = CGFloat(pull)
let frame: CGFloat = 1 / 120
let speed = (PieceMotion.rubberBand(raw + velocity * frame, limit: limit) - raw) / frame
withAnimation(motion.settle(velocity: cancelled ? 0 : speed, from: raw, to: 0)) { pull = 0 }
if !cancelled, landing != clamped(selection) {
selection = landing
} else {
move(to: clamped(selection))
}
}
private static var instant: Transaction {
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
return transaction
}
}
// MARK: - Track
/// What a touch on the trail means, already resolved to stops.
private enum StepTrailGesture {
/// A finger is down on a stop, or lifted (`nil`).
case press(Int?)
case tap(Int)
case scrubBegan(Int)
/// Where the finger holds the capsule's number, in steps; the stop pitch and the pull's limit, in points.
case scrubMoved(position: Double, pitch: CGFloat, limit: CGFloat)
case scrubEnded(velocity: CGFloat, pitch: CGFloat, limit: CGFloat)
case scrubCancelled
}
/// Sizes for one form of the trail. Lengths run from the trail's leading edge.
private struct StepTrailMetrics {
var d: CGFloat
var gap: CGFloat
var label: CGFloat
var titleLead: CGFloat
var titled: Bool
var rtl: Bool
/// Each stop's width while it is the open capsule (the diameter when titles don't show).
var openWidths: [CGFloat]
var titleWidths: [CGFloat]
/// The widest the trail gets at full size: every other stop a circle, the widest capsule open.
var naturalWidth: CGFloat
/// The width it is drawn in. The trail starts at its leading edge.
var frameWidth: CGFloat
var scale: CGFloat
var rowHeight: CGFloat { max(d, 44) }
var pitch: CGFloat { d + gap }
/// Numbers only, shrunk to `width` when the stops at full size don't fit, never below 22 points.
func fitted(to width: CGFloat) -> StepTrailMetrics {
let count = CGFloat(openWidths.count)
guard count > 0, width > 0 else { return self }
let room = (width - (count - 1) * gap) / count
let size = min(d, max(room, 22)).rounded(.down)
var fitted = self
fitted.label = label * size / d
fitted.d = size
fitted.titleLead = (size * 0.85).rounded()
fitted.openWidths = Array(repeating: size, count: openWidths.count)
fitted.frameWidth = min(count * size + (count - 1) * gap, max(width, 0))
return fitted
}
}
/// Where everything sits for one frame of the openness.
private struct StepTrailGeometry {
let metrics: StepTrailMetrics
/// Leading edge and width of each stop.
let x: [CGFloat]
let w: [CGFloat]
let openness: [Double]
init(metrics: StepTrailMetrics, open: [Double]) {
self.metrics = metrics
self.openness = open
var x: [CGFloat] = [], w: [CGFloat] = []
var cursor: CGFloat = 0
for (index, wide) in metrics.openWidths.enumerated() {
let amount = CGFloat(index < open.count ? max(open[index], 0) : 0)
let width = metrics.d + (wide - metrics.d) * amount
x.append(cursor)
w.append(width)
cursor += width + metrics.gap
}
self.x = x
self.w = w
}
/// How open a stop is this frame.
func open(_ index: Int) -> Double { index < openness.count ? min(max(openness[index], 0), 1) : 0 }
/// A stop's left edge on screen.
func screenX(_ index: Int) -> CGFloat {
metrics.rtl ? metrics.frameWidth - x[index] - w[index] : x[index]
}
/// An edge at a step position: between stops it runs from one stop's edge to the next, and past the ends it
/// carries on at the last spacing, so a spring's give reads past the first or last stop.
func edge(at position: Double, trailing: Bool) -> CGFloat {
let count = x.count
func at(_ index: Int) -> CGFloat { trailing ? x[index] + w[index] : x[index] }
guard count > 1 else { return at(0) + CGFloat(position) * metrics.pitch }
let index = min(max(Int(position.rounded(.down)), 0), count - 2)
let t = CGFloat(position - Double(index))
return at(index) + (at(index + 1) - at(index)) * t
}
/// The puck between its two edges, with a finger's pull on the edge facing it. Never narrower than a stop.
func puck(lower: Double, upper: Double, pull: Double) -> ClosedRange<CGFloat> {
var low = edge(at: min(lower, upper), trailing: false)
var high = edge(at: max(lower, upper), trailing: true)
if pull < 0 { low += CGFloat(pull) } else { high += CGFloat(pull) }
if high - low < metrics.d {
let mid = (low + high) / 2
low = mid - metrics.d / 2
high = mid + metrics.d / 2
}
return low...high
}
/// The puck on screen.
func screen(_ range: ClosedRange<CGFloat>) -> ClosedRange<CGFloat> {
metrics.rtl ? (metrics.frameWidth - range.upperBound)...(metrics.frameWidth - range.lowerBound) : range
}
/// The stop under a point on the trail, measured from its leading edge. The neck between two stops splits.
func stop(at leading: CGFloat) -> Int {
for index in x.indices where leading < x[index] + w[index] + metrics.gap / 2 { return index }
return max(x.count - 1, 0)
}
}
private enum StepTrailStatus { case done, current, ahead, unreached }
/// The trail itself: glass stops joined by necks, and the red puck over them carrying every face in its ink, cut
/// to its outline, while each stop's own face is cut away under it, so a face turns exactly under the puck's edge.
/// Everything is laid out from three animated values (the openness and the puck's two edges), so the puck stays
/// glued to the stops while they change width.
private struct StepTrailTrack: View {
let steps: [String]
let metrics: StepTrailMetrics
let shown: Int
let furthest: Int
let open: StepTrailOpenness
let lower: Double
let upper: Double
let pull: Double
let grabbed: Bool
let pressed: Int?
let shakes: [Int: Int]
let completions: Int
let style: StepTrail.Style
let isEnabled: Bool
let onTouch: (StepTrailGesture) -> Void
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Where in the capsule the finger took hold, from its number, so the capsule never jumps to the finger.
@State private var grabOffset: CGFloat = 0
var body: some View {
StepTrailAnimatedOpenness(value: open) { open in
StepTrailAnimatedNumber(value: lower) { lower in
StepTrailAnimatedNumber(value: upper) { upper in
StepTrailAnimatedNumber(value: pull) { pull in
drawing(open: open.values, lower: lower, upper: upper, pull: pull)
}
}
}
}
.frame(width: metrics.frameWidth, height: metrics.rowHeight)
// Laid out on screen coordinates; the stops and faces mirror themselves for right to left.
.environment(\.layoutDirection, .leftToRight)
.overlay {
StepTrailTouchSurface(isEnabled: isEnabled, scrubs: furthest > 0) { event in interpret(event) }
}
}
/// Under Reduce Motion the stops carry the red themselves and it crossfades; the puck stays home.
private var usesPuck: Bool { !reduceMotion }
private var complete: Bool { shown >= steps.count }
private func status(_ index: Int) -> StepTrailStatus {
if index < shown { return .done }
if index == shown { return .current }
return index <= furthest ? .ahead : .unreached
}
private func drawing(open: [Double], lower: Double, upper: Double, pull: Double) -> some View {
let geometry = StepTrailGeometry(metrics: metrics, open: open)
let puck = geometry.screen(geometry.puck(lower: lower, upper: upper, pull: pull))
return PieceLiquidGroup {
ZStack(alignment: .leading) {
ForEach(steps.indices, id: \.self) { index in
stop(index, geometry: geometry, puck: puck)
}
puckView(geometry: geometry, puck: puck)
}
.frame(width: metrics.frameWidth, height: metrics.d, alignment: .leading)
// The flow completing lands as one beat across every stop.
.pieceLiquidPop(trigger: completions, amount: 0.06)
}
}
private func stop(_ index: Int, geometry: StepTrailGeometry, puck: ClosedRange<CGFloat>) -> some View {
let status = status(index)
let left = geometry.screenX(index)
let width = geometry.w[index]
let carriesRed = !usesPuck && status == .current
let color: Color = switch status {
case .done, .ahead: style.label
case .current: carriesRed ? style.accentInk : style.label
case .unreached: style.secondaryLabel
}
let cut = usesPuck && puck.upperBound > left && puck.lowerBound < left + width
return face(index, status: status, width: width, openness: geometry.open(index))
.foregroundStyle(color)
.mask {
if cut {
StepTrailCutout(lower: puck.lowerBound - left, upper: puck.upperBound - left, height: metrics.d)
.fill(style: FillStyle(eoFill: true))
} else {
Rectangle()
}
}
.clipShape(.capsule)
.pieceLiquid(.capsule, tint: carriesRed ? style.accent : nil, interactive: false)
.pieceLiquidPress(pressed == index && status != .current, depth: 2)
.pieceShake(trigger: shakes[index] ?? 0, distance: 6)
.offset(x: left)
}
/// The red puck: every face in the accent's ink, laid out exactly as the stops lay theirs out, cut to its outline.
/// Once every step is done it is neutral glass over the last stop, drawing the same check the stop does.
private func puckView(geometry: StepTrailGeometry, puck: ClosedRange<CGFloat>) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let ink = complete ? style.label : style.accentInk
return ZStack(alignment: .leading) {
ForEach(steps.indices, id: \.self) { index in
face(index, status: status(index), width: geometry.w[index], openness: geometry.open(index))
.offset(x: geometry.screenX(index) - puck.lowerBound)
}
}
.foregroundStyle(ink)
.frame(width: puck.upperBound - puck.lowerBound, height: metrics.d, alignment: .leading)
.clipShape(.capsule)
.pieceLiquid(.capsule, tint: complete ? nil : style.accent, interactive: false)
.pieceLiquidPress(pressed == shown && !complete, depth: 2)
// Grabbed, it lifts a little; let go, it lands with a little give.
.pieceLiquidScale(grabbed && !reduceMotion ? 1.06 : 1)
.animation(grabbed ? motion.press : motion.release, value: grabbed)
// Inside a group glass ignores opacity, so under Reduce Motion the puck closes to nothing instead.
.pieceLiquidScale(usesPuck ? 1 : 0.001)
.offset(x: puck.lowerBound)
.allowsHitTesting(false)
}
private func face(_ index: Int, status: StepTrailStatus, width: CGFloat, openness: Double) -> some View {
StepTrailFace(
number: index + 1,
title: steps[index],
done: status == .done,
openness: openness,
width: width,
titleWidth: index < metrics.titleWidths.count ? metrics.titleWidths[index] : 0,
metrics: metrics
)
}
// MARK: Touch
/// Turns touches on screen into stops. Taps hit the stop under the finger as the trail is drawn now; a drag
/// holds the capsule where it was taken, and reports where that point is in steps, at an even pitch: with every
/// stop before the capsule a circle, its number always sits a whole pitch from the next.
private func interpret(_ event: StepTrailTouchSurface.Event) {
let target = StepTrailGeometry(metrics: metrics, open: StepTrailOpenness(open: shown, count: steps.count).values)
let leading = { (x: CGFloat) in metrics.rtl ? metrics.frameWidth - x : x }
let limit = metrics.d * 0.35
switch event {
case .down(let x):
onTouch(.press(target.stop(at: leading(x))))
case .up:
onTouch(.press(nil))
case .tap(let x):
onTouch(.tap(target.stop(at: leading(x))))
case .began(let x):
let point = leading(x)
let index = min(target.stop(at: point), furthest)
grabOffset = point - (CGFloat(index) * metrics.pitch + metrics.d / 2)
onTouch(.scrubBegan(index))
case .moved(let x):
let position = (leading(x) - grabOffset - metrics.d / 2) / metrics.pitch
onTouch(.scrubMoved(position: Double(position), pitch: metrics.pitch, limit: limit))
case .ended(let velocity):
onTouch(.scrubEnded(velocity: metrics.rtl ? -velocity : velocity, pitch: metrics.pitch, limit: limit))
case .cancelled:
onTouch(.scrubCancelled)
}
}
}
/// One stop's content: its number or check centred in the leading circle, and, in the titled form, its title after
/// it. The title's presence follows the stop's openness frame by frame, so it blurs in as the capsule opens and out
/// as it closes, identically in the stop and in the puck's copy; the number and the check blur over.
private struct StepTrailFace: View {
let number: Int
let title: String
let done: Bool
/// How open the stop is this frame: 0 a circle, 1 the capsule.
let openness: Double
let width: CGFloat
let titleWidth: CGFloat
let metrics: StepTrailMetrics
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let edge: Alignment = metrics.rtl ? .trailing : .leading
// The title arrives over the last two thirds of the opening and is gone in the first third of the closing.
let shows = min(max((openness - 0.35) / 0.65, 0), 1)
ZStack(alignment: edge) {
ZStack {
if done {
Image(systemName: "checkmark")
.font(.system(size: metrics.label * 0.9, weight: .semibold))
.transition(motion.swap)
} else {
Text(number, format: .number)
.font(.system(size: metrics.label, weight: .semibold).monospacedDigit())
.lineLimit(1)
.minimumScaleFactor(0.6)
.transition(motion.swap)
}
}
.frame(width: metrics.d, height: metrics.d)
if metrics.titled {
Text(title)
.font(.system(size: metrics.label, weight: .semibold))
.lineLimit(1)
.frame(width: titleWidth, alignment: edge)
.blur(radius: reduceMotion ? 0 : (1 - shows) * 5)
.opacity(shows)
.padding(metrics.rtl ? .trailing : .leading, metrics.titleLead)
}
}
.frame(width: max(width, 0), height: metrics.d, alignment: edge)
}
}
/// Everything in a stop but the puck's stretch over it, for an even-odd fill: the stop's own face shows only
/// where the puck is not.
private struct StepTrailCutout: Shape {
var lower: CGFloat
var upper: CGFloat
var height: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path(rect)
let width = max(upper - lower, 0)
path.addRoundedRect(in: CGRect(x: rect.minX + lower, y: rect.midY - height / 2, width: width, height: height), cornerSize: CGSize(width: height / 2, height: height / 2), style: .continuous)
return path
}
}
// MARK: - Animation
/// How open each stop is, as one value SwiftUI can animate: every stop's openness springs on its own from wherever it
/// is, so a step chosen while another is still opening retargets smoothly.
#if compiler(>=6.2)
nonisolated private struct StepTrailOpenness: VectorArithmetic, Sendable {
var values: [Double]
}
#else
private struct StepTrailOpenness: VectorArithmetic, Sendable {
var values: [Double]
}
#endif
extension StepTrailOpenness {
/// Stop `open` open, every other one a circle. Past the last step, none.
nonisolated init(open: Int, count: Int) {
values = (0..<max(count, 0)).map { $0 == open ? 1 : 0 }
}
nonisolated static var zero: StepTrailOpenness { StepTrailOpenness(values: []) }
nonisolated static func + (lhs: StepTrailOpenness, rhs: StepTrailOpenness) -> StepTrailOpenness {
combine(lhs, rhs, +)
}
nonisolated static func - (lhs: StepTrailOpenness, rhs: StepTrailOpenness) -> StepTrailOpenness {
combine(lhs, rhs, -)
}
nonisolated mutating func scale(by rhs: Double) {
values = values.map { $0 * rhs }
}
nonisolated var magnitudeSquared: Double {
values.reduce(0) { $0 + $1 * $1 }
}
/// Element by element; a shorter list counts as zeros, so a change in the number of steps still adds up.
nonisolated private static func combine(_ lhs: StepTrailOpenness, _ rhs: StepTrailOpenness, _ operation: (Double, Double) -> Double) -> StepTrailOpenness {
let count = max(lhs.values.count, rhs.values.count)
return StepTrailOpenness(values: (0..<count).map { index in
operation(index < lhs.values.count ? lhs.values[index] : 0, index < rhs.values.count ? rhs.values[index] : 0)
})
}
}
/// The openness, animated: SwiftUI interpolates it frame by frame and rebuilds the trail with it.
private struct StepTrailAnimatedOpenness<Content: View>: View, Animatable {
var value: StepTrailOpenness
@ViewBuilder let content: (StepTrailOpenness) -> Content
nonisolated var animatableData: StepTrailOpenness {
get { value }
set { value = newValue }
}
var body: some View { content(value) }
}
/// One number animated on its own spring. Nested, each of the puck's edges keeps its own.
private struct StepTrailAnimatedNumber<Content: View>: View, Animatable {
var value: Double
@ViewBuilder let content: (Double) -> Content
nonisolated var animatableData: Double {
get { value }
set { value = newValue }
}
var body: some View { content(value) }
}
// MARK: - Touch surface
/// UIKit's recognizers under the trail, because SwiftUI can't start a drag only when it runs sideways: a tap, a
/// sideways pan that leaves vertical swipes to the scroll view or sheet around it, and a touch-down observer that
/// never claims the touch, so a press shows at once without blocking a scroll.
private struct StepTrailTouchSurface: UIViewRepresentable {
enum Event {
case down(CGFloat), up, tap(CGFloat), began(CGFloat), moved(CGFloat), ended(velocity: CGFloat), cancelled
}
var isEnabled: Bool
var scrubs: Bool
var onEvent: (Event) -> Void
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.backgroundColor = .clear
view.isAccessibilityElement = false
view.accessibilityElementsHidden = true
let coordinator = context.coordinator
let down = StepTrailTouchDown()
down.onChange = { [weak coordinator] point in coordinator?.onEvent(point.map { .down($0.x) } ?? .up) }
down.delegate = coordinator
let tap = UITapGestureRecognizer(target: coordinator, action: #selector(Coordinator.tap(_:)))
let pan = UIPanGestureRecognizer(target: coordinator, action: #selector(Coordinator.pan(_:)))
pan.delegate = coordinator
for recognizer in [down, tap, pan] { view.addGestureRecognizer(recognizer) }
coordinator.pan = pan
return view
}
func updateUIView(_ view: UIView, context: Context) {
context.coordinator.onEvent = onEvent
context.coordinator.pan?.isEnabled = scrubs
view.isUserInteractionEnabled = isEnabled
}
@MainActor
final class Coordinator: NSObject, UIGestureRecognizerDelegate {
var onEvent: (Event) -> Void = { _ in }
weak var pan: UIPanGestureRecognizer?
@objc func tap(_ recognizer: UITapGestureRecognizer) {
if recognizer.state == .ended { onEvent(.tap(recognizer.location(in: recognizer.view).x)) }
}
@objc func pan(_ recognizer: UIPanGestureRecognizer) {
let x = recognizer.location(in: recognizer.view).x
switch recognizer.state {
case .began: onEvent(.began(x))
case .changed: onEvent(.moved(x))
case .ended: onEvent(.ended(velocity: recognizer.velocity(in: recognizer.view).x))
case .cancelled, .failed: onEvent(.cancelled)
default: break
}
}
/// The pan starts only for a mostly sideways drag, so vertical swipes stay with the screen.
func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
guard let pan = recognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: pan.view)
return abs(velocity.x) > abs(velocity.y)
}
/// The touch-down observer watches alongside everything else.
func gestureRecognizer(_ recognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
recognizer is StepTrailTouchDown || other is StepTrailTouchDown
}
}
}
/// Reports a finger arriving and leaving without ever recognizing, so it never takes a touch from anything else.
private final class StepTrailTouchDown: UIGestureRecognizer {
var onChange: ((CGPoint?) -> Void)?
private var start: CGPoint?
init() {
super.init(target: nil, action: nil)
cancelsTouchesInView = false
delaysTouchesEnded = false
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
guard let touch = touches.first, start == nil else { return }
start = touch.location(in: view)
onChange?(start)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesMoved(touches, with: event)
// A finger that travels lets its stop go: a drag or a scroll takes over from here.
if let touch = touches.first, let start, hypot(touch.location(in: view).x - start.x, touch.location(in: view).y - start.y) > 12 {
finish()
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesEnded(touches, with: event)
finish()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesCancelled(touches, with: event)
finish()
}
private func finish() {
if start != nil { onChange?(nil) }
start = nil
state = .failed
}
override func reset() {
super.reset()
start = nil
}
}
/// A house-palette color that follows the interface style.
private func stepTrailColor(light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { @Sendable traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
})
}
// MARK: - Example
/// A checkout people went back in (Payment still reachable), and a six-step verification compressed to numbers.
private struct StepTrailExample: View {
@State private var checkout = 1
@State private var furthest = 2
@State private var verification = 3
var body: some View {
VStack(alignment: .leading, spacing: 36) {
StepTrail(steps: ["Cart", "Address", "Payment", "Review"], selection: $checkout, reached: furthest)
.onChange(of: checkout) { _, step in furthest = max(furthest, step) }
StepTrail(steps: ["Email", "Phone", "Address", "ID photo", "Selfie", "Review"], selection: $verification)
.frame(maxWidth: 240, alignment: .leading)
Button("Continue") { checkout = min(checkout + 1, 4) }
.fontWeight(.semibold)
.tint(Color(red: 1, green: 0, blue: 0))
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(stepTrailColor(light: 0xF3F2EE, dark: 0x121212))
}
}
#Preview("Light") {
StepTrailExample()
}
#Preview("Dark") {
StepTrailExample()
.preferredColorScheme(.dark)
}
// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, follow, momentum, rubberBand, pressMath, shake)
/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
var reduceMotion = false
// Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
/// No overshoot, 90% in about 90ms. A press arriving under the finger.
static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
/// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
/// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
/// About 15% overshoot. A resolved action landing. At most once per interaction.
static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
/// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }
/// Reduce Motion: settles become this short spring with no overshoot.
private static var still: Animation { .spring(duration: 0.25, bounce: 0) }
// Roles.
/// Touch-down. Starts on the same frame and never bounces under the finger.
var press: Animation { .spring(Self.tight) }
/// The finger lifts off a pressed surface, which springs back through rest.
var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// Lands on a detent, page or segment.
var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
/// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
/// never shows a value that isn't true.
var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
/// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
/// A shape or container changes size, corner radius or form, with a little give at the end.
var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
/// Something appears, opens or expands. Opening is a little slower than closing.
var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
/// Something leaves, closes or collapses. Quick and firm, out of the way.
var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
/// A resolved action lands: a check, a sent state, a reaction.
var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
/// A refused action. Firm, no wobble; `pieceShake` adds the movement.
var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
/// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }
/// Loops, drifts, idle breathing and parallax run only when this is true.
var allowsAmbient: Bool { !reduceMotion }
/// A moving transition, or a plain fade under Reduce Motion.
func transition(_ transition: AnyTransition) -> AnyTransition {
reduceMotion ? .opacity : transition
}
/// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
/// blurs out as the new one sharpens in. A fade under Reduce Motion.
@MainActor var swap: AnyTransition {
reduceMotion ? .opacity : AnyTransition(.blurReplace)
}
}
extension PieceMotion {
/// Follow-through: rank 0 leads, each later rank arrives a beat later on a slightly looser spring. Safe to reverse.
func follow(_ spring: Spring = PieceMotion.elastic, rank: Int) -> Animation {
guard !reduceMotion else { return .spring(duration: 0.25, bounce: 0) }
let k = Double(min(max(rank, 0), 6))
return .spring(duration: spring.duration + 0.04 * k, bounce: min(spring.bounce + 0.02 * k, 0.55))
}
/// One-shot entrances only: item `index` waits 30ms per place, capped at the seventh. Exits go together.
func cascade(_ animation: Animation, index: Int, step: Double = 0.03) -> Animation {
guard !reduceMotion, index > 0 else { return animation }
return animation.delay(step * Double(min(index, 7)))
}
}
extension PieceMotion {
/// Where a flick at `velocity` (pt/s) coasts to. 0.998 coasts like a scroll view; 0.99 suits detents.
nonisolated static func project(_ position: CGFloat, velocity: CGFloat, decelerationRate: CGFloat = 0.99) -> CGFloat {
position + velocity / 1000 * decelerationRate / (1 - decelerationRate)
}
/// The candidate closest to `value`.
nonisolated static func nearest(_ value: CGFloat, in candidates: [CGFloat]) -> CGFloat {
candidates.min { abs($0 - value) < abs($1 - value) } ?? value
}
/// A settle that leaves at the finger's speed (pt/s). One per axis, each on its own `.offset(x:)` / `.offset(y:)`:
/// a spring takes one velocity. SwiftUI's own velocity carry-over is unreliable; this always carries it.
func settle(velocity: CGFloat, from current: CGFloat, to target: CGFloat, spring: Spring = PieceMotion.elastic) -> Animation {
let spring = reduceMotion ? Spring(duration: 0.25, bounce: 0) : spring
let distance = target - current
guard abs(distance) >= 1 else { return .spring(spring) }
// In whole distances per second, capped near the spring's frequency: a hard flick adds give, not a slingshot.
// At exactly the frequency a critically damped spring cannot pass its target, so Reduce Motion stops there.
let cap = 2 * Double.pi / spring.duration * (reduceMotion ? 1 : 1.5)
let relative = min(max(Double(velocity / distance), -cap), cap)
return .interpolatingSpring(spring, initialVelocity: relative)
}
}
extension PieceMotion {
/// A scroll view's edge resistance for a pull `overshoot` points past a limit; never reaches `limit`.
/// About 24 to 40 for thumbs and toggles, 60 to 120 for cards and sheets. Band the total pull, not deltas.
nonisolated static func rubberBand(_ overshoot: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
guard limit > 0, overshoot != 0 else { return 0 }
let banded = (1 - 1 / (abs(overshoot) * coefficient / limit + 1)) * limit
return overshoot < 0 ? -banded : banded
}
/// `value` inside `range` passes through unchanged; past either end it moves with rubber-band resistance.
nonisolated static func rubberBand(_ value: CGFloat, in range: ClosedRange<CGFloat>, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
if value < range.lowerBound { return range.lowerBound + rubberBand(value - range.lowerBound, limit: limit, coefficient: coefficient) }
if value > range.upperBound { return range.upperBound + rubberBand(value - range.upperBound, limit: limit, coefficient: coefficient) }
return value
}
}
extension PieceMotion {
/// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
return min(max(1 - depth * 2 / side, 0.92), 0.985)
}
/// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
guard let touch, size.width > 0, size.height > 0 else { return .center }
let x = min(max(touch.x / size.width, 0), 1)
let y = min(max(touch.y / size.height, 0), 1)
return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
}
}
/// A short decaying side-to-side shake for refused input, each time `trigger` changes. Under Reduce Motion it stays
/// still (same view, no identity change): pair it with a color or message change and the error haptic.
private struct PieceShake: ViewModifier {
let trigger: AnyHashable
var distance: CGFloat = 8
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
let distance = reduceMotion ? 0 : distance
content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, x in
view.offset(x: x)
} keyframes: { _ in
KeyframeTrack {
CubicKeyframe(-distance, duration: 0.06)
CubicKeyframe(distance * 0.75, duration: 0.07)
CubicKeyframe(-distance * 0.5, duration: 0.07)
CubicKeyframe(distance * 0.25, duration: 0.06)
SpringKeyframe(0, duration: 0.12, spring: Spring(duration: 0.18, bounce: 0))
}
}
}
}
private extension View {
/// Shakes this view side to side once each time `trigger` changes, for input that was refused.
func pieceShake(trigger: some Hashable & Sendable, distance: CGFloat = 8) -> some View {
modifier(PieceShake(trigger: AnyHashable(trigger), distance: distance))
}
}
// swiftpieces-motion: end
// MARK: - Piece liquid
//
// The SwiftPieces liquid glass language: glass shapes that merge through a neck, bubbles that bud out of
// and melt back into each other, and the frosted fallback before iOS 26. Each piece carries a copy of only
// the parts it uses, so this file stands alone. Generated from registry/foundation/PieceLiquid.swift in the
// SwiftPieces repo; edit it there, not here. The rules are in LIQUID_GLASS.md.
// swiftpieces-liquid: 1.7.1 (liquid, liquidPress, liquidPop, morphText)
/// The liquid glass language: one merge distance, two rest gaps, and the springs a bubble leaves and comes home on.
///
/// Glass shapes inside one `PieceLiquidGroup` melt into each other through a neck when they come within `merge`
/// points. Parts of one control rest `joined`, inside that distance, so the neck holds; separate actions rest
/// `apart`, outside it, so they only goo while one buds out of, or melts back into, another.
private enum PieceLiquid {
/// Glass shapes closer than this share a neck.
static let merge: CGFloat = 20
/// The gap between parts of one control (a stepper's buttons, a progress pill and its stop): the neck holds,
/// short and smooth, about two thirds of the shapes' height at its waist. Joined parts read best at one height.
static let joined: CGFloat = 4
/// The gap between separate actions (menu items, confirm and cancel, chips): they rest as their own bubbles.
static let apart: CGFloat = 26
/// How far a bubble shrinks while it is home inside its parent.
static let homeScale: CGFloat = 0.72
/// A bubble leaving its parent: slow enough that the neck's stretch and snap read.
static func split(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.62, bounce: 0.22)
}
/// A bubble going home. No bounce: a bounce would carry it out through the far side of its parent.
static func home(reduceMotion: Bool) -> Animation {
reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.5, bounce: 0)
}
}
/// A group of glass shapes that merge into one liquid surface. On iOS 26 it is a `GlassEffectContainer`; before
/// that, and under Reduce Transparency, the shapes draw on their own and simply don't merge. `lift` adds the soft
/// shadow liquid glass floats on in light mode.
private struct PieceLiquidGroup<Content: View>: View {
var spacing: CGFloat = PieceLiquid.merge
var lift = true
@ViewBuilder var content: Content
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorScheme) private var colorScheme
var body: some View {
container
.shadow(color: .black.opacity(lift && colorScheme == .light ? 0.07 : 0), radius: 18, y: 8)
}
@ViewBuilder private var container: some View {
#if compiler(>=6.2)
if #available(iOS 26, *), !reduceTransparency {
GlassEffectContainer(spacing: spacing) { content }
} else {
content
}
#else
content
#endif
}
}
/// One liquid glass shape: Liquid Glass on iOS 26, carrying `tint` as a solid colour and swelling under a press when
/// `interactive`; a frosted Material with a light rim and a soft shadow before that; a solid fill under Reduce
/// Transparency.
///
/// The tint is painted inside clear glass rather than tinting the glass. Tinted glass in a group bleeds its colour
/// through every neck as a smear, so a red button would glow into the white pill it is joined to; painted inside,
/// the colour stays crisp to the shape's edge, the necks between shapes are clear glass, and a tint change animates
/// like any colour (tinted glass snaps).
private struct PieceLiquidSurface<S: Shape>: ViewModifier {
var shape: S
var tint: Color?
var interactive: Bool
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
@Environment(\.colorScheme) private var colorScheme
@Environment(\.pieceLiquidScale) private var scale
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26, *), !reduceTransparency {
// Scaled as two parts, content and outline, about the same centre. A scaleEffect on a glass view inside a
// GlassEffectContainer shrinks the glass but leaves what it carries full size, pinned to its corner.
content
.background { shape.fill(tint ?? .clear) }
.scaleEffect(scale)
.glassEffect(glass, in: shape.scale(scale))
} else {
fallback(content).scaleEffect(scale)
}
#else
fallback(content).scaleEffect(scale)
#endif
}
#if compiler(>=6.2)
@available(iOS 26, *)
private var glass: Glass {
interactive ? Glass.regular.interactive() : .regular
}
#endif
private func fallback(_ content: Content) -> some View {
let dark = colorScheme == .dark
return content
.background {
if reduceTransparency {
shape.fill(tint ?? (dark ? Color(white: 0.17) : Color(white: 0.97)))
} else {
ZStack {
shape.fill(.regularMaterial)
if let tint { shape.fill(tint.opacity(0.88)) }
}
}
}
.overlay { shape.stroke(Color.white.opacity(dark ? 0.14 : 0.7), lineWidth: 0.5) }
.shadow(color: .black.opacity(dark ? 0.32 : 0.08), radius: 10, y: 5)
}
}
private struct PieceLiquidScaleKey: EnvironmentKey {
static let defaultValue: CGFloat = 1
}
private extension EnvironmentValues {
/// How much the liquid shapes below are scaled, about their own centres. Nested scales multiply.
var pieceLiquidScale: CGFloat {
get { self[PieceLiquidScaleKey.self] }
set { self[PieceLiquidScaleKey.self] = newValue }
}
}
private extension View {
/// Draws this view on a liquid glass `shape`. Put it inside a `PieceLiquidGroup` so it can merge with its neighbours.
func pieceLiquid<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = true) -> some View {
modifier(PieceLiquidSurface(shape: shape, tint: tint, interactive: interactive))
}
/// Scales the liquid glass shapes in this view, content and outline together, about their own centres. Use it
/// instead of `scaleEffect` on a glass view (a press, a lift, a swell): inside a group a plain `scaleEffect` shrinks
/// the glass but leaves its content full size and off centre. Animates like any other value.
func pieceLiquidScale(_ scale: CGFloat) -> some View {
transformEnvironment(\.pieceLiquidScale) { $0 *= scale }
}
}
/// The press for a glass control: the same size-aware sink and lean as `piecePress`, applied through
/// `pieceLiquidScale` so the glass and what it carries sink together (a plain scaleEffect on glass leaves the content
/// behind). Put the glass inside what it presses: the label of a button, the view this modifies. Under Reduce Motion
/// it shades instead of moving.
private struct PieceLiquidPress: ViewModifier {
let pressed: Bool
var touch: CGPoint?
var depth: CGFloat = 2.5
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
@State private var size: CGSize = .zero
@State private var anchor: UnitPoint = .center
func body(content: Content) -> some View {
let motion = PieceMotion(reduceMotion: reduceMotion)
let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
// A scale about `anchor` is a scale about the centre plus this shift toward the anchor.
let lean = CGSize(width: (anchor.x - 0.5) * size.width * (1 - scale), height: (anchor.y - 0.5) * size.height * (1 - scale))
content
.onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
.brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
.pieceLiquidScale(scale)
.offset(lean)
.animation(pressed ? motion.press : motion.release, value: pressed)
.onChange(of: pressed) { _, isPressed in
if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
.onChange(of: touch) { _, touch in
if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
}
}
}
/// `PiecePressStyle` for glass buttons: the label (with its `.pieceLiquid` inside) sinks as one.
private struct PieceLiquidPressStyle: ButtonStyle {
var depth: CGFloat = 2.5
func makeBody(configuration: Configuration) -> some View {
configuration.label.pieceLiquidPress(configuration.isPressed, depth: depth)
}
}
private extension View {
/// Sinks this view's glass while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
func pieceLiquidPress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
modifier(PieceLiquidPress(pressed: pressed, touch: touch, depth: depth))
}
}
/// `piecePop` for glass: dips, swells past full size and lands each time `trigger` changes, on the same timings,
/// through `pieceLiquidScale` so the glass and what it carries pop together. Driven from state rather than a
/// keyframe animator, since the liquid scale can't be set from inside one. Still under Reduce Motion.
private struct PieceLiquidPop: ViewModifier {
let trigger: AnyHashable
var amount: CGFloat = 0.08
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var scale: CGFloat = 1
@State private var run = 0
func body(content: Content) -> some View {
content
.pieceLiquidScale(scale)
.onChange(of: trigger) { _, _ in
guard !reduceMotion else { return }
run += 1
let mine = run
withAnimation(.easeInOut(duration: 0.08)) { scale = 1 - amount * 0.4 }
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(80))
guard run == mine else { return }
withAnimation(.spring(duration: 0.18, bounce: 0)) { scale = 1 + amount }
try? await Task.sleep(for: .milliseconds(140))
guard run == mine else { return }
withAnimation(.spring(PieceMotion.expressive)) { scale = 1 }
}
}
}
}
private extension View {
/// Pops this view's glass each time `trigger` changes. Use a counter, never a Bool that can flip back before it fires.
func pieceLiquidPop(trigger: some Hashable & Sendable, amount: CGFloat = 0.08) -> some View {
modifier(PieceLiquidPop(trigger: AnyHashable(trigger), amount: amount))
}
}
/// A label that changes letter by letter: letters both strings share hold still, the rest blur out and the new ones
/// blur in a few milliseconds apart. Under Reduce Motion it cross-fades. VoiceOver reads the whole string.
///
/// Only text whose letters stand on their own is cut into letters: Latin, Greek, Cyrillic, CJK, digits and symbols.
/// Those letters are laid out left to right whatever the layout direction, the order they read in, so "1.5×" stays
/// "1.5×" in an Arabic or Hebrew interface instead of coming out backwards. Text in a right-to-left script, or in one
/// whose letters join or reorder (Arabic, Hebrew, Devanagari, Thai and their neighbours), can't be cut apart without
/// breaking it, so it changes as one: the old string blurs out where it stood while the new one blurs in.
private struct PieceMorphText: View {
var text: String
var font: Font = .body.weight(.semibold)
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Group {
if Self.splits(text) {
let glyphs = Array(text)
HStack(spacing: 0) {
ForEach(glyphs.indices, id: \.self) { i in
Text(String(glyphs[i]))
.id("\(i)\(glyphs[i])")
.transition(transition(i, scale: 0.6))
}
}
// The letters' reading order, not the layout's: an HStack lays its children out backwards under a
// right-to-left layout.
.environment(\.layoutDirection, .leftToRight)
} else {
ZStack(alignment: .leading) {
Text(text)
.id(text)
.transition(transition(0, scale: 0.9))
}
}
}
.font(font)
.fixedSize()
.accessibilityElement(children: .ignore)
.accessibilityLabel(text)
}
private func transition(_ i: Int, scale: CGFloat) -> AnyTransition {
guard !reduceMotion else { return .opacity }
return AnyTransition(.blurReplace(.downUp)).combined(with: .scale(scale: scale, anchor: .bottom))
.animation(.spring(duration: 0.42, bounce: 0.3).delay(Double(i) * 0.022))
}
/// Whether `text` can be cut into letters that each read left to right on their own: no right-to-left script, no
/// script that joins or reorders its letters, and no bidirectional control characters.
private static func splits(_ text: String) -> Bool {
!text.unicodeScalars.contains { scalar in
switch scalar.value {
case 0x0590...0x08FF, // Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic
0x0900...0x0DFF, // Devanagari to Sinhala
0x0E00...0x0FFF, // Thai, Lao, Tibetan
0x1000...0x109F, // Myanmar
0x1780...0x17FF, // Khmer
0xA8E0...0xA8FF, // Devanagari Extended
0xFB1D...0xFDFF, // Hebrew and Arabic presentation forms
0xFE70...0xFEFF, // Arabic presentation forms B
0x200E...0x200F, 0x202A...0x202E, 0x2066...0x2069, // bidirectional marks, embeddings and isolates
0x10800...0x10FFF, // historic right-to-left scripts
0x1E800...0x1EFFF: // Adlam and other right-to-left scripts
return true
default:
return false
}
}
}
}
// swiftpieces-liquid: endInstall
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first →
Building a whole app? See SwiftPieces Pro →
More SwiftUI navigation
All navigation →Build it yourself: SwiftUI animations guide
Scroll IslandNew
A floating liquid glass island for long screens such as articles, recipes, docs, settings and product pages: it names the section in view and rings it in red with how much of the page has been read, morphs the title letter by letter as the page scrolls, buds a joined back-to-top bubble once the reader leaves the first section, scrubs sections under a sideways drag with a tick for each, and on a tap or a swipe up grows the same glass into a contents panel where a red puck stretches from row to row under the finger and the pick scrolls the page straight there.
Stretch Header
A hero header for your own ScrollView and NavigationStack: a semibold display title and uppercase eyebrow over a solid block hero that stretches on overscroll, a title that shrinks toward the real navigation bar, a row of liquid glass stat pills that pins under the bar, and a solid bar that fades in.