Expanding Track
A thumbless glass slider that swells under the finger and buds a readout bubble that rides the edge, for a value or a range.
Live preview
// 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
/// Thumbless liquid glass slider that swells under the finger. Bind a `Double` for a single value or a `ClosedRange<Double>` for a range.
///
/// The bar is a glass shape with the fill inside it. While you drag an untitled track, the value buds up out of the
/// bar on a glass bubble that rides the held edge, joined to the bar by a liquid neck, and melts back in on release.
///
/// - Parameters:
/// - value: Bound single value (single-value init).
/// - range: Bound selected range (range init). The two handles never cross.
/// - bounds: Minimum and maximum selectable values.
/// - step: Optional snapping increment. Also sets the spacing of the selection ticks and detent dots; without it, ticks fire every tenth of the span.
/// - minimumDistance: Smallest gap the range handles keep between them (range init only).
/// - symbol: Optional leading SF Symbol that plays a variable-color pulse each time the value rises past a tick.
/// - title: Optional label. Shows an uppercase caption with the value as a big numeral above the track (decimals dimmed), and becomes the VoiceOver label. A titled track reads its value there, so it buds no readout bubble.
/// - style: Colors and track metrics. Defaults to the SwiftPieces house palette, adapting to light and dark.
/// - format: Formats the readout and the VoiceOver value. Defaults to up to two decimals.
public struct ExpandingTrack: View {
/// Colors and metrics for the track. `.standard` is the house palette: a brand red fill with dark ink in a neutral glass bar.
public struct Style: Sendable {
/// The glass tint of the bar. `.clear`, the default, leaves it neutral glass.
public var trough: Color
/// The filled part of the track, a solid block inside the glass.
public var fill: Color
/// Detent dots and handles drawn on top of `fill`.
public var ink: Color
/// Primary text: the numeral, the readout bubble and the detent dots over the glass.
public var label: Color
/// Caption, symbol and disabled fill.
public var secondaryLabel: Color
/// Track thickness at rest.
public var restHeight: CGFloat
/// Track thickness while dragging.
public var activeHeight: CGFloat
/// Largest corner radius; thinner states round fully.
public var cornerRadius: CGFloat
/// Pass only what you want to change; `nil` keeps the house palette value.
public init(
trough: Color? = nil,
fill: Color? = nil,
ink: Color? = nil,
label: Color? = nil,
secondaryLabel: Color? = nil,
restHeight: CGFloat = 8,
activeHeight: CGFloat = 32,
cornerRadius: CGFloat = 12
) {
self.trough = trough ?? .clear
self.fill = fill ?? adaptive(light: 0xFF0000, dark: 0xFF0000)
self.ink = ink ?? adaptive(light: 0x141414, dark: 0x141414)
self.label = label ?? adaptive(light: 0x141414, dark: 0xF4F3EF)
self.secondaryLabel = secondaryLabel ?? adaptive(light: 0x5C5A56, dark: 0xA6A49F)
self.restHeight = restHeight
self.activeHeight = activeHeight
self.cornerRadius = cornerRadius
}
public static let standard = Style()
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
@Environment(\.isEnabled) private var isEnabled
@ScaledMetric(relativeTo: .largeTitle) private var numeralSize: CGFloat = 40
@ScaledMetric(relativeTo: .caption2) private var captionSize: CGFloat = 11
@ScaledMetric(relativeTo: .subheadline) private var readoutSize: CGFloat = 15
@GestureState private var isTouching = false
@State private var isDragging = false
/// The bar is swollen: set on its own spring, in its own transaction, so the swell never carries the fill edge,
/// the squish or the landing along with it.
@State private var swollen = false
@State private var activeHandle: Handle = .upper
/// Where the held edge is drawn while dragging: under the finger, between steps too. Nil at rest, where every
/// edge sits on its value.
@State private var heldX: CGFloat?
/// How far the bar gives, in points, while the finger pushes past an end. Only the drawing; the value stays clamped.
@State private var squish: CGFloat = 0
/// The far end the bar gives toward. Kept after release, so the bar springs back from the end it was pushed at.
@State private var squishAnchor: UnitPoint = .leading
/// Releases whose squish has not landed yet. One a grab takes over counts until the grab's spring lands too.
@State private var rebounds = 0
/// Until when the squish eases onto the finger rather than following it directly, set by a push that catches a rebound.
@State private var blendsUntil = Date.distantPast
@State private var edgeTick = 0
@State private var stepTick = 0
@State private var riseTick = 0
/// The readout bubble: out above the bar while an untitled track is held, melted into the bar otherwise.
@State private var buds = PieceBuds()
@State private var readoutWidth: CGFloat = 0
private let single: Binding<Double>?
private let range: Binding<ClosedRange<Double>>?
private let bounds: ClosedRange<Double>
private let step: Double?
private let minimumDistance: Double
private let symbol: String?
private let title: String?
private let style: Style
private let format: (Double) -> String
/// The most the bar gives past either end: rubber-band resistance never quite reaches it.
private let squishLimit: CGFloat = 24
private enum Handle { case lower, upper }
private var motion: PieceMotion { PieceMotion(reduceMotion: reduceMotion) }
public init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0...1, step: Double? = nil, symbol: String? = nil, title: String? = nil, style: Style = .standard, format: ((Double) -> String)? = nil) {
self.single = value
self.range = nil
self.bounds = bounds
self.step = step
self.minimumDistance = 0
self.symbol = symbol
self.title = title
self.style = style
self.format = format ?? { $0.formatted(.number.precision(.fractionLength(0...2))) }
}
public init(range: Binding<ClosedRange<Double>>, in bounds: ClosedRange<Double>, step: Double? = nil, minimumDistance: Double = 0, symbol: String? = nil, title: String? = nil, style: Style = .standard, format: ((Double) -> String)? = nil) {
self.single = nil
self.range = range
self.bounds = bounds
self.step = step
self.minimumDistance = minimumDistance
self.symbol = symbol
self.title = title
self.style = style
self.format = format ?? { $0.formatted(.number.precision(.fractionLength(0...2))) }
}
private var span: Double { max(bounds.upperBound - bounds.lowerBound, .ulpOfOne) }
private var detent: Double { step.map { $0 > 0 ? $0 : span / 10 } ?? span / 10 }
private var lowerValue: Double { range?.wrappedValue.lowerBound ?? bounds.lowerBound }
private var upperValue: Double { single?.wrappedValue ?? range?.wrappedValue.upperBound ?? bounds.lowerBound }
private var activeValue: Double { activeHandle == .lower ? lowerValue : upperValue }
private func fraction(_ value: Double) -> CGFloat { CGFloat(min(max((value - bounds.lowerBound) / span, 0), 1)) }
/// The bar's glass tint: none for `.clear`, so the house bar stays neutral glass.
private var troughTint: Color? { style.trough == .clear ? nil : style.trough }
/// The readout bubble's height, from its text size.
private var readoutHeight: CGFloat { (readoutSize * 2).rounded() }
public var body: some View {
// Read here, so this body observes the bubbles: the reads below sit in closures (the track's geometry reader) that are not re-run on
// their own when a bubble changes, which would let one appear without its bud.
let _ = (buds.present, buds.out)
VStack(alignment: .leading, spacing: 6) {
if let title { header(title) }
HStack(spacing: 12) {
if let symbol {
Image(systemName: symbol)
.font(.body.weight(.semibold))
.foregroundStyle(isDragging ? style.label : style.secondaryLabel)
// Lights a beat behind the bar it labels.
.animation(motion.follow(PieceMotion.tight, rank: 1), value: isDragging)
.symbolEffect(.variableColor, value: riseTick)
.frame(width: 24)
.accessibilityHidden(true)
}
track
}
.frame(height: 44)
}
.fontWeight(.semibold)
.sensoryFeedback(.impact(flexibility: .rigid), trigger: edgeTick)
.sensoryFeedback(.selection, trigger: stepTick)
// A drag the system cancels never reaches onEnded, so the bar is let go here too.
.onChange(of: isTouching) { _, touching in
if !touching { endDrag() }
}
.onChange(of: upperValue) { old, new in
// The symbol pulses on any rise past a tick, including programmatic changes; haptics stay gesture-only.
if ((new - bounds.lowerBound) / detent).rounded(.down) > ((old - bounds.lowerBound) / detent).rounded(.down) { riseTick += 1 }
}
.modifier(Accessibility(label: title, lower: range == nil ? nil : lowerValue, upper: upperValue, format: format, increment: detent) { handle, delta in
set(handle == 0 ? .lower : .upper, to: (handle == 0 ? lowerValue : upperValue) + delta)
})
}
// MARK: Header
/// Uppercase caption and the value as a big numeral with dimmed decimals.
private func header(_ title: String) -> some View {
HStack(alignment: .lastTextBaseline, spacing: 12) {
Text(title.uppercased())
.font(.system(size: captionSize, weight: .semibold))
.tracking(1)
.foregroundStyle(style.secondaryLabel)
.lineLimit(1)
Spacer(minLength: 8)
Text(range != nil ? numeral(format(lowerValue)) + dimmed(" – ") + numeral(format(upperValue)) : numeral(format(upperValue)))
.font(.system(size: numeralSize, weight: .semibold))
.tracking(-0.5)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.6)
.contentTransition(.numericText(value: upperValue))
// Rolls for a programmatic or VoiceOver change, without overshoot, so the digits never pass the true value.
// Under the finger it just updates, so it stays legible.
.animation(isDragging || reduceMotion ? nil : motion.value, value: upperValue)
.animation(isDragging || reduceMotion ? nil : motion.value, value: lowerValue)
}
.accessibilityHidden(true)
}
/// Splits the formatted value at the locale's decimal separator so the fraction reads quieter.
private func numeral(_ string: String) -> AttributedString {
let separator = Locale.current.decimalSeparator ?? "."
guard let cut = string.range(of: separator, options: .backwards),
string[cut.upperBound...].first?.isNumber == true else {
var whole = AttributedString(string)
whole.foregroundColor = style.label
return whole
}
var whole = AttributedString(string[..<cut.lowerBound])
whole.foregroundColor = style.label
return whole + dimmed(String(string[cut.lowerBound...]))
}
private func dimmed(_ string: String) -> AttributedString {
var part = AttributedString(string)
part.foregroundColor = style.secondaryLabel
return part
}
// MARK: Track
private var track: some View {
GeometryReader { proxy in
let width = max(proxy.size.width, 1)
let thick = swollen && !reduceMotion
let height = thick ? style.activeHeight : style.restHeight
let lowerX = range == nil ? 0 : (activeHandle == .lower ? heldX : nil) ?? fraction(lowerValue) * width
let upperX = (activeHandle == .upper ? heldX : nil) ?? fraction(upperValue) * width
let fillColor = isEnabled ? style.fill : style.secondaryLabel.opacity(0.45)
// Pushed past an end, the bar gives toward the far end and bulges a little across, like silicone on a stop.
let length = max(1 - squish / width, 0.5)
let bulge = 1 + min(squish / width, 0.08)
let toward: Alignment = squishAnchor == .trailing ? .trailing : .leading
let follower = motion.follow(PieceMotion.tight, rank: 1)
let brightness = swollen && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0
let shape = RoundedRectangle(cornerRadius: style.cornerRadius, style: .continuous)
PieceLiquidGroup {
ZStack(alignment: .leading) {
// Under the bar, so the bubble at home sits inside it and melts into it, never over the fill.
if buds.contains("readout") {
readout(edge: activeHandle == .lower ? lowerX : upperX, width: width, height: height, length: length, bulge: bulge)
}
// The glass bar, carrying the fill, dots and handles as its own content: glass drawn over them as
// a separate layer would blur them. A drag moves offsets and masks rather than resizing the fill.
ZStack(alignment: .leading) {
dots(width: width, color: style.label.opacity(0.22))
.animation(follower) { $0.opacity(thick ? 1 : 0) }
block(from: lowerX, to: upperX, width: width, color: fillColor)
// Dots switch to ink where the fill passes under them, so they stay legible at 100%.
dots(width: width, color: style.ink.opacity(0.35))
.animation(follower) { $0.opacity(thick ? 1 : 0) }
.mask(alignment: .leading) { block(from: lowerX, to: upperX, width: width, color: .black) }
if range != nil {
handle(.lower, at: lowerX + 7, height: height)
handle(.upper, at: upperX - 7, height: height)
}
}
// A VoiceOver step or a change from your code glides the fill and handles onto the new value as a
// detent does, while the numeral rolls. Under the finger it never animates.
.animation(isDragging ? nil : motion.snap, value: upperValue)
.animation(isDragging ? nil : motion.snap, value: lowerValue)
// Reduce Motion: no swell, so the held fill darkens a step instead, as a press does.
.brightness(brightness)
.frame(width: width, height: height)
// The squish: the content gives with the bar (a scale on the content is fine; on the glass it is
// not), and the glass takes the squished size as its frame, against the end it was pushed from.
.scaleEffect(x: length, y: bulge, anchor: squishAnchor)
.frame(width: width * length, height: height * bulge, alignment: toward)
.clipShape(shape)
.pieceLiquid(shape, tint: troughTint, interactive: false)
.frame(width: width, alignment: toward)
}
.frame(width: width, height: proxy.size.height)
}
.accessibilityHidden(true)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.updating($isTouching) { _, touching, _ in touching = true }
.onChanged { drag in
let x = drag.location.x
if !isDragging {
isDragging = true
// The bar inflates under the finger with no overshoot.
withAnimation(motion.press) { swollen = true }
if range != nil {
activeHandle = abs(x - lowerX) < abs(x - upperX) || (lowerX == upperX && x < lowerX) ? .lower : .upper
}
showReadout(true)
}
// Never animated: the fill edge is under the finger from the first touch, and the value snaps
// to its step beneath it.
set(activeHandle, to: bounds.lowerBound + Double(min(max(x / width, 0), 1)) * span)
heldX = held(at: x, width: width)
if x > width { squishAnchor = .leading } else if x < 0 { squishAnchor = .trailing }
// The squish follows the finger directly too, except while a release is still springing back:
// a push then eases on over 100ms on the press spring, which takes that rebound over where it
// is drawn instead of cutting it short. Direct tracking resumes once it has landed.
let next = give(at: x, width: width)
if next != squish && rebounds > 0 { blendsUntil = drag.time.addingTimeInterval(0.1) }
if drag.time < blendsUntil {
withAnimation(motion.press) { squish = next }
} else {
squish = next
}
}
.onEnded { drag in
// The squish springs back at the speed the finger was pushing it.
let x = drag.location.x, frame: CGFloat = 1 / 120
endDrag(velocity: (give(at: x + drag.velocity.width * frame, width: width) - give(at: x, width: width)) / frame)
}
)
}
}
/// The value on a glass bubble, out above the bar while an untitled track is held. It rides the held edge, kept
/// over the bar, a liquid neck's width above it, so the two stay joined. Home is a sliver on the edge at the bar's
/// centre line, shrunk to fit inside the thin bar it melts back into.
private func readout(edge x: CGFloat, width: CGFloat, height: CGFloat, length: CGFloat, bulge: CGFloat) -> some View {
let out = buds.isOut("readout")
// Where the bar and its held edge are drawn while the bar gives past an end.
let left = squishAnchor == .leading ? 0 : width * (1 - length)
let right = squishAnchor == .leading ? width * length : width
let drawn = squishAnchor == .leading ? x * length : width - (width - x) * length
let half = readoutWidth / 2
let center = right - left > readoutWidth ? min(max(drawn, left + half), right - half) : (left + right) / 2
let rise = height * bulge / 2 + PieceLiquid.joined + readoutHeight / 2
// The foundation's home shrink leaves the bubble taller than a resting bar, so it shrinks further on the way in,
// and stays a sliver inside the bar's ends, so a value at a bound never pokes out past them.
let fit = min(1, style.restHeight * 0.9 / (readoutHeight * PieceLiquid.homeScale))
let homeHalf = half * PieceLiquid.homeScale * fit
let homeCenter = right - left > homeHalf * 2 ? min(max(drawn, left + homeHalf), right - homeHalf) : (left + right) / 2
return Text(format(activeValue))
.font(.system(size: readoutSize, weight: .semibold, design: .rounded).monospacedDigit())
.foregroundStyle(style.label)
.lineLimit(1)
.pieceBudContent(out: out)
.padding(.horizontal, 12)
.frame(minWidth: readoutHeight * 1.5)
.frame(height: readoutHeight)
.fixedSize()
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { readoutWidth = $0 }
.pieceLiquid(.capsule, interactive: false)
.pieceLiquidScale(out || reduceMotion ? 1 : fit)
.pieceBud(out: out, rest: CGSize(width: center - half, height: -rise), home: CGSize(width: homeCenter - half, height: 0))
.allowsHitTesting(false)
}
/// Buds the readout out of the bar on touch-down, and melts it back in on release. A titled track reads its value
/// in the header, so it has none.
private func showReadout(_ shows: Bool) {
guard title == nil else { return }
Task {
if shows {
await buds.bloom(["readout"], reduceMotion: reduceMotion)
} else {
await buds.gather(["readout"], reduceMotion: reduceMotion)
}
}
}
/// How far the bar gives for a finger at `x`: nothing over the track, and past either end rubber-band resistance
/// on the whole distance past it. Off under Reduce Motion, where the end simply stops the bar.
private func give(at x: CGFloat, width: CGFloat) -> CGFloat {
guard !reduceMotion else { return 0 }
return PieceMotion.rubberBand(max(x - width, -x, 0), limit: squishLimit)
}
/// Where the held edge is drawn for a finger at `x`: under it, as far as the held value can go.
private func held(at x: CGFloat, width: CGFloat) -> CGFloat {
var reach = bounds
if let current = range?.wrappedValue {
reach = activeHandle == .lower
? bounds.lowerBound...max(bounds.lowerBound, current.upperBound - minimumDistance)
: min(bounds.upperBound, current.lowerBound + minimumDistance)...bounds.upperBound
}
return min(max(x, fraction(reach.lowerBound) * width), fraction(reach.upperBound) * width)
}
/// Lets go: the bar deflates, the readout melts back in and any squish springs back, leaving at `velocity` (pt/s).
/// Runs again when the gesture state resets, which is the only signal a cancelled drag sends; by then there is
/// nothing left to do.
private func endDrag(velocity: CGFloat = 0) {
guard isDragging else { return }
isDragging = false
// The bar settles back with a little give.
withAnimation(motion.release) { swollen = false }
showReadout(false)
// A stepped edge lands on its step from where the finger left it, as a detent does.
withAnimation(motion.snap) { heldX = nil }
guard squish != 0 else { return }
// Counted until it lands, so a grab during it knows to blend.
rebounds += 1
withAnimation(motion.settle(velocity: velocity, from: squish, to: 0), completionCriteria: .removed) {
squish = 0
} completion: {
rebounds -= 1
}
}
/// The filled span from `lowerX` to `upperX`: a full-width block moved by an offset and cut by a mask, so a drag
/// moves it without laying anything out.
private func block(from lowerX: CGFloat, to upperX: CGFloat, width: CGFloat, color: Color) -> some View {
color
.offset(x: lowerX)
.mask(alignment: .leading) { Rectangle().offset(x: upperX - width) }
}
/// Detent dots across the swollen track, skipped when they would crowd closer than 8pt.
private func dots(width: CGFloat, color: Color) -> some View {
let count = Int((span / detent).rounded())
return Canvas { context, size in
guard count > 1, width / CGFloat(count) >= 8 else { return }
for i in 1..<count {
let x = size.width * CGFloat(i) / CGFloat(count)
context.fill(Path(ellipseIn: CGRect(x: x - 1.5, y: size.height / 2 - 1.5, width: 3, height: 3)), with: .color(color))
}
}
}
/// Range handle: an ink notch that sinks into the track while it is the one being dragged.
private func handle(_ which: Handle, at x: CGFloat, height: CGFloat) -> some View {
let sunk = isDragging && activeHandle == which
return Capsule()
.fill(style.ink)
.frame(width: 3, height: max(height * 0.5, 4))
.scaleEffect(sunk ? 0.5 : 1)
.opacity(sunk ? 0.4 : 0.85)
// Grows and sinks on the band's own beat. Scoped above the offset, so the notch never trails the fill edge.
.animation(isDragging ? motion.press : motion.release, value: isDragging)
.offset(x: x - 1.5)
}
private func set(_ handle: Handle, to raw: Double) {
var next = raw
if let step, step > 0 {
next = bounds.lowerBound + ((raw - bounds.lowerBound) / step).rounded() * step
}
let previous: Double
if let single {
next = min(max(next, bounds.lowerBound), bounds.upperBound)
previous = single.wrappedValue
guard next != previous else { return }
single.wrappedValue = next
} else if let range {
let current = range.wrappedValue
switch handle {
case .lower:
let ceiling = max(bounds.lowerBound, current.upperBound - minimumDistance)
next = min(max(next, bounds.lowerBound), ceiling)
previous = current.lowerBound
guard next != previous else { return }
range.wrappedValue = next...current.upperBound
case .upper:
let floor = min(bounds.upperBound, current.lowerBound + minimumDistance)
next = max(min(next, bounds.upperBound), floor)
previous = current.upperBound
guard next != previous else { return }
range.wrappedValue = current.lowerBound...next
}
} else {
return
}
// One haptic per change: arriving at a bound plays the firmer impact in place of that step's tick.
let before = ((previous - bounds.lowerBound) / detent).rounded(.down)
let after = ((next - bounds.lowerBound) / detent).rounded(.down)
if next == bounds.lowerBound || next == bounds.upperBound {
edgeTick += 1
} else if before != after {
stepTick += 1
}
}
/// Single value: one adjustable element. Range: a "Minimum" and a "Maximum" element, each adjustable.
private struct Accessibility: ViewModifier {
let label: String?
let lower: Double?
let upper: Double
let format: (Double) -> String
let increment: Double
let adjust: (Int, Double) -> Void
func body(content: Content) -> some View {
if let lower {
content
.accessibilityElement(children: .contain)
.overlay {
HStack(spacing: 0) {
Color.clear
.accessibilityElement(children: .ignore)
.accessibilityLabel(label.map { "\($0), minimum" } ?? "Minimum")
.accessibilityValue(format(lower))
.accessibilityAdjustableAction { adjust(0, $0 == .increment ? increment : -increment) }
Color.clear
.accessibilityElement(children: .ignore)
.accessibilityLabel(label.map { "\($0), maximum" } ?? "Maximum")
.accessibilityValue(format(upper))
.accessibilityAdjustableAction { adjust(1, $0 == .increment ? increment : -increment) }
}
.allowsHitTesting(false)
}
} else if let label {
content
.accessibilityElement(children: .ignore)
.accessibilityLabel(label)
.accessibilityValue(format(upper))
.accessibilityAdjustableAction { adjust(1, $0 == .increment ? increment : -increment) }
} else {
content
.accessibilityElement(children: .ignore)
.accessibilityValue(format(upper))
.accessibilityAdjustableAction { adjust(1, $0 == .increment ? increment : -increment) }
}
}
}
}
/// A house-palette color that follows the interface style.
private func adaptive(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
/// The track in its three shapes: an untitled value with a leading symbol, which buds its readout above the held
/// edge, a titled stepped value, and a titled range.
private struct ExpandingTrackExample: View {
@State private var volume = 0.62
@State private var warmth = 3400.0
@State private var price = 40.0...160.0
var body: some View {
VStack(alignment: .leading, spacing: 30) {
ExpandingTrack(value: $volume, symbol: "speaker.wave.3.fill") { ($0 * 100).formatted(.number.precision(.fractionLength(0))) }
.accessibilityLabel("Volume")
ExpandingTrack(value: $warmth, in: 2700...6500, step: 100, symbol: "sun.max.fill", title: "Warmth", style: .init(fill: adaptive(light: 0xFFD976, dark: 0xFFD976))) { "\(Int($0))K" }
ExpandingTrack(range: $price, in: 0...200, step: 5, minimumDistance: 10, title: "Price per night", style: .init(fill: adaptive(light: 0xA9DCB7, dark: 0xA9DCB7))) { "$\(Int($0))" }
}
.padding(.horizontal, 34)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(adaptive(light: 0xF3F2EE, dark: 0x121212))
}
}
#Preview("Light") {
ExpandingTrackExample()
}
#Preview("Dark") {
ExpandingTrackExample()
.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)
/// 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
}
}
// 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.8.0 (liquid, bud)
/// 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 bud: how a bubble leaves and rejoins its parent, driven explicitly so every bubble shows the whole cycle.
///
/// A bubble is born at `home`, inside its parent, where the two glass shapes are one. It springs out to `rest`, and
/// while it is inside the merge distance a neck holds it to the parent, thinning as it goes, until it snaps free.
/// Going home it springs back on a spring with no bounce, the neck reaches out and re-forms, and only once it has
/// melted all the way in is it removed. Both offsets are relative to where the bubble is laid out. Under Reduce
/// Motion it stays at `rest`: its content fades and its glass closes in place.
private struct PieceBud: ViewModifier {
var out: Bool
var rest: CGSize
var home: CGSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
if reduceMotion {
// No travel. Inside a group glass ignores opacity, so the glass closes to nothing in place on the short
// Reduce Motion ease while its content fades; the opacity covers a bubble outside a group.
content
.pieceLiquidScale(out ? 1 : 0.001)
.opacity(out ? 1 : 0)
.offset(rest)
} else {
// The glass shrinks through `pieceLiquidScale`, never a plain scaleEffect (see there), then moves.
content
.pieceLiquidScale(out ? 1 : PieceLiquid.homeScale)
.offset(out ? rest : home)
}
}
}
/// A bubble's own content, on its own clock: gone the moment the bubble heads home, so it never rides over the
/// parent's content, and arriving just after the bubble leaves.
private struct PieceBudContent: ViewModifier {
var out: Bool
func body(content: Content) -> some View {
content
.blur(radius: out ? 0 : 6)
.opacity(out ? 1 : 0)
.animation(out ? .easeOut(duration: 0.3).delay(0.1) : .easeOut(duration: 0.14), value: out)
}
}
private extension View {
/// Places a bubble at `rest` while `out`, and at `home` (inside its parent, shrunk) while not.
func pieceBud(out: Bool, rest: CGSize = .zero, home: CGSize) -> some View {
modifier(PieceBud(out: out, rest: rest, home: home))
}
/// Hides a bubble's icon or label while it is home. Put it on the content, inside the glass.
func pieceBudContent(out: Bool) -> some View {
modifier(PieceBudContent(out: out))
}
}
/// Which bubbles exist and which are out. A bubble is added home with no animation, sent out on the next frame,
/// and called home before it is removed, so it always melts in rather than fading. Keep one in `@State`.
@MainActor @Observable
private final class PieceBuds {
private(set) var present: [String] = []
private(set) var out: Set<String> = []
/// The latest call for each bubble. A bloom or gather that has been overtaken (a bubble sent home while it was
/// still waiting to go out, or called out again while melting) leaves that bubble alone.
@ObservationIgnored private var turn: [String: Int] = [:]
func contains(_ id: String) -> Bool { present.contains(id) }
func isOut(_ id: String) -> Bool { out.contains(id) }
private func claim(_ ids: [String]) -> [String: Int] {
var mine: [String: Int] = [:]
for id in ids {
let next = (turn[id] ?? 0) + 1
turn[id] = next
mine[id] = next
}
return mine
}
/// Puts bubbles straight out at rest with no motion: a view's first frame, or a state restored.
func place(_ ids: [String]) {
_ = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
out.formUnion(ids)
}
}
/// Adds bubbles home, then sends each out, `stagger` seconds apart, after an optional `delay`.
func bloom(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.05, delay: Double = 0) async {
let mine = claim(ids)
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) {
for id in ids where !present.contains(id) { present.append(id) }
}
try? await Task.sleep(for: .milliseconds(24 + Int(max(delay, 0) * 1000)))
let split = PieceLiquid.split(reduceMotion: reduceMotion)
for (i, id) in ids.enumerated() where turn[id] == mine[id] {
withAnimation(split.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.insert(id) }
}
}
/// Calls bubbles home, last first, then removes them once they have melted in.
func gather(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.04) async {
let mine = claim(ids)
let home = PieceLiquid.home(reduceMotion: reduceMotion)
for (i, id) in ids.reversed().enumerated() {
withAnimation(home.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.remove(id) }
}
try? await Task.sleep(for: .milliseconds(Int((0.52 + Double(ids.count) * stagger) * 1000)))
var quiet = Transaction()
quiet.disablesAnimations = true
withTransaction(quiet) { present.removeAll { ids.contains($0) && !out.contains($0) && turn[$0] == mine[$0] } }
}
}
// swiftpieces-liquid: end- Type
- ExpandingTrack
- Files
- ExpandingTrack.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.2.0
Thumbless liquid glass slider that swells under the finger. Bind a Double for a single value or a ClosedRange<Double> for a range. The bar is a glass shape with the fill inside it. While you drag an untitled track, the value buds up out of the bar on a glass bubble that rides the held edge, joined to the bar by a liquid neck, and melts back in on release.
Notes
- There is no thumb: the whole 44pt row is the hit area and the value follows the finger's x position directly, so the bar can rest at 8pt and still be easy to grab. Tracking is never animated: from the first touch, including a tap far from the current value, the edge of the fill sits under the finger. With a
step, the value snaps from step to step beneath it while the edge stays with the finger, and on release the edge lands on its step with a little give. A VoiceOver step or a change from your code glides the fill (and the range handles) onto the new value on the same spring. - The bar is liquid glass carrying the fill as a solid block, with the detent dots and handles. While held it inflates to 32pt (
Style.activeHeight) on a quick spring with no overshoot, and the dots at eachstepfade in a beat behind it, so they never show through a thin bar. On release they fade first and the bar settles back to 8pt with a little give. The swell runs on its own spring, apart from the fill edge, the squish and the landing, and a drag moves offsets and masks rather than resizing the fill. - Without a
title, the value buds up out of the bar on a glass bubble the moment you touch it. The bubble rides the held edge, kept over the bar, and rests a liquid neck's width above it, so the two stay joined while you drag. Let go and it melts back into the bar, its number blurring away as it heads home. The bubble draws above the track's frame, so leave a little room above an untitled track. - Pass
titlefor the labeled layout: an uppercase caption with the value as a big numeral above the track, decimals dimmed at the locale's separator. The numeral rolls to a new value after a programmatic or VoiceOver change, without overshooting it, and updates directly while you drag, so it stays legible under the finger. A titled track reads its value there, so it buds no bubble. - Pushing past either end, the bar gives toward the far end with rubber-band resistance, never more than about 24pt, and bulges slightly across, like silicone pressed against a stop. The value stays at the bound, and the readout bubble stays over the end of the bar as it gives. On release it springs back at the speed the finger was pushing. Push past the end again while it is still springing back and it eases onto the finger from where it is drawn instead of jumping.
- Haptics: a selection tick per
stepcrossing (every tenth of the span without a step), and a rigid impact when the value reaches a bound, in place of that step's tick. No haptic is added for the press or release itself; a touch-down that moves the value plays that change's tick or impact. The optional symbol lights while held and plays.variableColorwhen the value rises past a tick. - The range init keeps the handles apart by
minimumDistanceand lets the active ink handle sink into the fill; VoiceOver gets separate "Minimum" and "Maximum" adjustable elements, prefixed with the title when there is one. - A drag the system cancels lets go exactly like a release, so the bar never stays swollen and the bubble always melts back in.
Stylecarries the bar's glass tint (trough, neutral glass by default), the fill block, ink, labels and track metrics..standardis the house palette (a brand red fill in neutral glass); pass.init(fill:)to use another block. All text is semibold..disabled(true)greys the fill.- Reduce Motion: the bar stays at 8pt and its fill darkens a step while held (brightens in dark mode) instead of swelling, the readout bubble opens and closes in place above the held edge instead of budding, the ends stop the bar without any give, the edge lands on its step and glides to a new value on a short spring with no overshoot, and the numeral changes without rolling. Haptics, the symbol tint and its pulse are unchanged. Reduce Transparency draws the glass as solid shapes.
- The drag starts on touch-down so the track responds to taps; inside a vertical
ScrollViewor sheet, a drag that begins on the track moves the value rather than scrolling.
Usage
ExpandingTrackExample()Parameters
| Parameter | Description |
|---|---|
value | Bound single value (single-value init). |
range | Bound selected range (range init). The two handles never cross. |
bounds | Minimum and maximum selectable values. |
step | Optional snapping increment. Also sets the spacing of the selection ticks and detent dots; without it, ticks fire every tenth of the span. |
minimumDistance | Smallest gap the range handles keep between them (range init only). |
symbol | Optional leading SF Symbol that plays a variable-color pulse each time the value rises past a tick. |
title | Optional label. Shows an uppercase caption with the value as a big numeral above the track (decimals dimmed), and becomes the VoiceOver label. A titled track reads its value there, so it buds no readout bubble. |
style | Colors and track metrics. Defaults to the SwiftPieces house palette, adapting to light and dark. |
format | Formats the readout and the VoiceOver value. Defaults to up to two decimals. |
Install
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 inputs and forms
All inputs and forms →Build it yourself: SwiftUI haptics guide