# SwiftUI Blocks: every component with its source Copy-paste SwiftUI components for iOS 18 through iOS 27. Swift 6, MIT. A component with a shader has two files: copy both. ## Breathing Guide Circle A circle that expands and contracts through breathe in, hold and breathe out phases, with a label and a countdown. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/BreathingGuideCircle/BreathingGuideCircle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum BreathingGuideCirclePhase: String { case inhale = "Breathe In" case hold = "Hold" case exhale = "Breathe Out" } struct BreathingGuideCircleState: Equatable { let phase: BreathingGuideCirclePhase let secondsLeft: Int /// 0 is fully contracted and 1 is fully expanded. let expansion: Double } enum BreathingGuideCircleMath { /// Where the cycle is after `elapsed` seconds. Invalid durations count as zero. static func state(at elapsed: TimeInterval, inhale: Double, hold: Double, exhale: Double) -> BreathingGuideCircleState { let inhale = sanitized(inhale) let hold = sanitized(hold) let exhale = sanitized(exhale) let total = inhale + hold + exhale guard total > 0, elapsed.isFinite else { return BreathingGuideCircleState(phase: .inhale, secondsLeft: 0, expansion: 0) } var time = elapsed.truncatingRemainder(dividingBy: total) if time < 0 { time += total } if time < inhale { return BreathingGuideCircleState(phase: .inhale, secondsLeft: Int((inhale - time).rounded(.up)), expansion: eased(time / inhale)) } time -= inhale if time < hold { return BreathingGuideCircleState(phase: .hold, secondsLeft: Int((hold - time).rounded(.up)), expansion: 1) } time -= hold let progress = exhale > 0 ? min(time / exhale, 1) : 1 return BreathingGuideCircleState(phase: .exhale, secondsLeft: Int((exhale - time).rounded(.up)), expansion: 1 - eased(progress)) } private static func sanitized(_ duration: Double) -> Double { duration.isFinite ? min(max(duration, 0), 3_600) : 0 } private static func eased(_ progress: Double) -> Double { progress * progress * (3 - 2 * progress) } } /// A circle that expands and contracts through breathing phases, with a label and a countdown. /// /// With Reduce Motion on, the circle stays still and the label keeps guiding. struct BreathingGuideCircle: View { var inhale = 4.0 var hold = 4.0 var exhale = 6.0 var tint: Color = .teal var diameter: CGFloat = 260 @State private var start = Date.now @Environment(\.accessibilityReduceMotion) private var reduceMotion /// How much the scale of every ring grows from contracted to expanded. private let ringGrowth = 0.38 var body: some View { TimelineView(.animation(minimumInterval: reduceMotion ? 0.25 : 1.0 / 30.0)) { timeline in let state = BreathingGuideCircleMath.state( at: timeline.date.timeIntervalSince(start), inhale: inhale, hold: hold, exhale: exhale ) VStack(spacing: 36) { circle(expansion: reduceMotion ? 0.6 : state.expansion) VStack(spacing: 6) { Text(state.phase.rawValue) .font(.system(.title, design: .rounded, weight: .semibold)) .multilineTextAlignment(.center) Text(state.secondsLeft, format: .number) .font(.system(.title2, design: .rounded).monospacedDigit()) .foregroundStyle(.secondary) } } .accessibilityElement(children: .ignore) .accessibilityLabel("Breathing guide") .accessibilityValue(state.phase.rawValue) } } private func circle(expansion: Double) -> some View { let size = max(diameter, 0) return ZStack { Circle() .fill(tint.opacity(0.14)) .scaleEffect(0.62 + ringGrowth * expansion) Circle() .fill(tint.opacity(0.26)) .scaleEffect(0.5 + ringGrowth * expansion) Circle() .fill(.linearGradient(colors: [tint, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)) .scaleEffect(0.38 + ringGrowth * expansion) } .frame(width: size, height: size) } } // MARK: - Demo struct BreathingGuideCircleDemo: View { var body: some View { BreathingGuideCircle(inhale: 1.6, hold: 0.8, exhale: 1.6) .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { BreathingGuideCircleDemo() } ``` ## Keyframe Timeline Showcase A view animated by keyframe tracks for scale, offset and color, with the tracks and a playhead drawn on one shared timeline. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/KeyframeTimelineShowcase/KeyframeTimelineShowcase.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct KeyframeTimelineShowcaseValues { var scale = 1.0 var offset = 0.0 var tint = 0.0 var playhead = 0.0 } /// The length of each keyframe in seconds. Every track adds up to the same total, so the loop closes. private enum KeyframeTimelineShowcaseTiming { static let scale = [0.3, 0.45, 0.35, 0.9] static let offset = [0.3, 0.4, 0.5, 0.8] static let tint = [0.75, 0.5, 0.75] static let total = 2.0 static func ticks(_ durations: [Double]) -> [Double] { var elapsed = 0.0 return durations.map { duration in elapsed += duration return elapsed / total } } } /// Plays scale, offset and color tracks on one shared keyframe timeline, and draws the tracks below. /// /// With Reduce Motion on, the content rests and the timeline stays still. struct KeyframeTimelineShowcase: View { var restColor: Color = .indigo var peakColor: Color = .pink @ViewBuilder var content: () -> Content @ScaledMetric(relativeTo: .largeTitle) private var tileSize = 120.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { stage(KeyframeTimelineShowcaseValues()) } else { KeyframeAnimator(initialValue: KeyframeTimelineShowcaseValues(), repeating: true) { values in stage(values) } keyframes: { _ in KeyframeTrack(\.scale) { CubicKeyframe(0.8, duration: KeyframeTimelineShowcaseTiming.scale[0]) SpringKeyframe(1.25, duration: KeyframeTimelineShowcaseTiming.scale[1], spring: .bouncy) CubicKeyframe(0.92, duration: KeyframeTimelineShowcaseTiming.scale[2]) SpringKeyframe(1, duration: KeyframeTimelineShowcaseTiming.scale[3], spring: .bouncy) } KeyframeTrack(\.offset) { LinearKeyframe(0, duration: KeyframeTimelineShowcaseTiming.offset[0]) CubicKeyframe(-90, duration: KeyframeTimelineShowcaseTiming.offset[1]) CubicKeyframe(0, duration: KeyframeTimelineShowcaseTiming.offset[2]) LinearKeyframe(0, duration: KeyframeTimelineShowcaseTiming.offset[3]) } KeyframeTrack(\.tint) { CubicKeyframe(1, duration: KeyframeTimelineShowcaseTiming.tint[0]) LinearKeyframe(1, duration: KeyframeTimelineShowcaseTiming.tint[1]) CubicKeyframe(0, duration: KeyframeTimelineShowcaseTiming.tint[2]) } KeyframeTrack(\.playhead) { // Each loop starts from the last value, so the playhead has to jump back. MoveKeyframe(0) LinearKeyframe(1, duration: KeyframeTimelineShowcaseTiming.total) } } } } private func stage(_ values: KeyframeTimelineShowcaseValues) -> some View { VStack(spacing: 32) { content() .frame(width: tileSize, height: tileSize) .background( restColor.mix(with: peakColor, by: values.tint), in: RoundedRectangle(cornerRadius: tileSize * 0.26, style: .continuous) ) .scaleEffect(values.scale) .offset(y: values.offset) .frame(height: tileSize + 150, alignment: .bottom) VStack(spacing: 12) { lane("Scale", color: .orange, ticks: KeyframeTimelineShowcaseTiming.ticks(KeyframeTimelineShowcaseTiming.scale)) lane("Offset", color: .teal, ticks: KeyframeTimelineShowcaseTiming.ticks(KeyframeTimelineShowcaseTiming.offset)) lane("Color", color: peakColor, ticks: KeyframeTimelineShowcaseTiming.ticks(KeyframeTimelineShowcaseTiming.tint)) } .overlay(alignment: .leading) { GeometryReader { proxy in Capsule() .fill(.primary) .frame(width: 2) .offset(x: proxy.size.width * values.playhead - 1) } } .accessibilityHidden(true) } } private func lane(_ title: String, color: Color, ticks: [Double]) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Canvas { context, size in let track = CGRect(x: 0, y: size.height / 2 - 2, width: size.width, height: 4) context.fill(Path(roundedRect: track, cornerRadius: 2), with: .color(color.opacity(0.3))) for tick in ticks { // The first and the last dot stay whole inside the lane. let dot = CGRect(x: (size.width - 10) * tick, y: size.height / 2 - 5, width: 10, height: 10) context.fill(Path(ellipseIn: dot), with: .color(color)) } } .frame(height: 12) } } } // MARK: - Demo struct KeyframeTimelineShowcaseDemo: View { var body: some View { KeyframeTimelineShowcase { Image(systemName: "sparkles") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(.white) .accessibilityLabel("Animated tile") } .padding(28) .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { KeyframeTimelineShowcaseDemo() } ``` ## Phase Animator Cycle A card that loops through several phases of scale, rotation and color. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/PhaseAnimatorCycle/PhaseAnimatorCycle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI private enum PhaseAnimatorCyclePhase: CaseIterable { case rest, lift, tilt, shrink var scale: Double { switch self { case .rest: 1 case .lift: 1.14 case .tilt: 1.04 case .shrink: 0.88 } } var rotation: Angle { switch self { case .rest: .zero case .lift: .degrees(-7) case .tilt: .degrees(9) case .shrink: .degrees(-3) } } var color: Color { switch self { case .rest: .indigo case .lift: .purple case .tilt: .pink case .shrink: .orange } } var animation: Animation { switch self { case .rest, .tilt: .spring(duration: 0.6, bounce: 0.4) case .lift, .shrink: .easeInOut(duration: 0.55) } } } /// A card that loops through phases of scale, rotation and color. /// /// With Reduce Motion on, the card stays in its first phase. struct PhaseAnimatorCycle: View { var cornerRadius: CGFloat = 32 @ViewBuilder var content: () -> Content @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { card(.rest) } else { PhaseAnimator(PhaseAnimatorCyclePhase.allCases) { phase in card(phase) } animation: { phase in phase.animation } } } private func card(_ phase: PhaseAnimatorCyclePhase) -> some View { content() .background { RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) .fill(phase.color) .overlay { RoundedRectangle(cornerRadius: max(0, cornerRadius), style: .continuous) .fill(.linearGradient(colors: [.white.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom)) } .shadow(color: phase.color.opacity(0.45), radius: 24, y: 14) } .scaleEffect(phase.scale) .rotationEffect(phase.rotation) } } // MARK: - Demo struct PhaseAnimatorCycleDemo: View { var body: some View { PhaseAnimatorCycle { VStack(spacing: 14) { Image(systemName: "wand.and.stars") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .accessibilityHidden(true) Text("Four phases, one loop") .font(.system(.title3, design: .rounded, weight: .semibold)) .multilineTextAlignment(.center) } .foregroundStyle(.white) .padding(.horizontal, 32) .padding(.vertical, 56) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { PhaseAnimatorCycleDemo() } ``` ## Split Flap Counter A split-flap display where each digit flips down to its new value. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/SplitFlapCounter/SplitFlapCounter.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum SplitFlapCounterMath { /// More places than any `Int` needs, so a huge `minimumDigits` cannot allocate without limit. static let maximumDigits = 20 /// One cell per character, padded with zeros on the left and with a leading minus when negative. static func cells(for value: Int, minimumDigits: Int) -> [String] { let digits = String(value.magnitude).map(String.init) let wanted = min(max(minimumDigits, 0), maximumDigits) let padding = max(0, wanted - digits.count) return (value < 0 ? ["-"] : []) + Array(repeating: "0", count: padding) + digits } } /// A split-flap display where each digit flips down to its new value. /// /// With Reduce Motion on, digits change without flipping. struct SplitFlapCounter: View { let value: Int var minimumDigits = 1 var body: some View { let cells = SplitFlapCounterMath.cells(for: value, minimumDigits: minimumDigits) ViewThatFits(in: .horizontal) { row(cells) ScrollView(.horizontal, showsIndicators: false) { row(cells) } } .accessibilityRepresentation { Text(value.formatted()) } } private func row(_ cells: [String]) -> some View { // Identified by their place from the right, so the units stay put when the number grows. HStack(spacing: 6) { ForEach(Array(cells.indices.reversed()), id: \.self) { place in SplitFlapCounterCell(text: cells[cells.count - 1 - place]) } } } } private struct SplitFlapCounterCell: View { let text: String @State private var shown: String @State private var previous: String @State private var flips = 0.0 @ScaledMetric(relativeTo: .largeTitle) private var height = 92.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion init(text: String) { self.text = text _shown = State(initialValue: text) _previous = State(initialValue: text) } var body: some View { SplitFlapCounterFace(flips: flips, shown: shown, previous: previous, height: height) .onChange(of: text) { _, newText in if reduceMotion { previous = newText shown = newText } else { previous = shown shown = newText withAnimation(.easeIn(duration: 0.42)) { flips += 1 } } } } } /// `flips` grows by one per change. Its fractional part is how far the current flip has gone. private struct SplitFlapCounterFace: View, Animatable { var flips: Double let shown: String let previous: String let height: Double nonisolated var animatableData: Double { get { flips } set { flips = newValue } } var body: some View { let progress = flips - flips.rounded(.down) let isFlipping = progress > 0 let topAngle = isFlipping ? -180 * min(progress, 0.5) : -90 let bottomAngle = isFlipping ? 180 * (1 - max(progress, 0.5)) : 0 ZStack { VStack(spacing: 2) { half(shown, isTop: true) half(isFlipping ? previous : shown, isTop: false) } VStack(spacing: 2) { half(previous, isTop: true) .brightness(-0.25 * min(progress * 2, 1)) .rotation3DEffect(.degrees(topAngle), axis: (x: 1, y: 0, z: 0), anchor: .bottom, perspective: 0.5) .opacity(topAngle > -90 ? 1 : 0) half(shown, isTop: false) .brightness(isFlipping ? -0.25 * (1 - progress) : 0) .rotation3DEffect(.degrees(bottomAngle), axis: (x: 1, y: 0, z: 0), anchor: .top, perspective: 0.5) .opacity(bottomAngle < 90 ? 1 : 0) } } } private func half(_ text: String, isTop: Bool) -> some View { let radius = height * 0.1 return Text(text) .font(.system(size: height * 0.68, weight: .bold, design: .rounded).monospacedDigit()) .foregroundStyle(.white) .frame(width: height * 0.68, height: height) .frame(height: height / 2, alignment: isTop ? .top : .bottom) .clipped() .background( Color(white: isTop ? 0.2 : 0.16), in: UnevenRoundedRectangle( topLeadingRadius: isTop ? radius : 0, bottomLeadingRadius: isTop ? 0 : radius, bottomTrailingRadius: isTop ? 0 : radius, topTrailingRadius: isTop ? radius : 0, style: .continuous ) ) } } // MARK: - Demo struct SplitFlapCounterDemo: View { @State private var value = 1_284 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 24) { Text("Passengers today") .font(.system(.title3, design: .rounded, weight: .semibold)) .foregroundStyle(.white.opacity(0.7)) SplitFlapCounter(value: value, minimumDigits: 5) .padding(.horizontal, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(white: 0.06)) .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) guard !Task.isCancelled else { return } value += 37 } } } } #Preview { SplitFlapCounterDemo() } ``` ## Spring Study Playground Sliders for response and damping that show how a spring moves a view and draw its curve. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/SpringStudyPlayground/SpringStudyPlayground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum SpringStudyPlaygroundMath { static let responseRange = 0.1...1.5 static let dampingRange = 0.1...1.0 static func clamped(_ value: Double, to range: ClosedRange) -> Double { value.isNaN ? range.lowerBound : min(max(value, range.lowerBound), range.upperBound) } /// The position of a spring going from 0 to 1, sampled evenly over `duration` seconds. static func samples(response: Double, damping: Double, count: Int, duration: Double) -> [Double] { guard count > 1, duration > 0, duration.isFinite else { return [] } let spring = Spring( response: clamped(response, to: responseRange), dampingRatio: clamped(damping, to: dampingRange) ) let last = min(count, 512) - 1 return (0...last).map { spring.value(target: 1.0, time: duration * Double($0) / Double(last)) } } } nonisolated private struct SpringStudyPlaygroundCurve: Shape { let samples: [Double] let maximum: Double func path(in rect: CGRect) -> Path { var path = Path() guard samples.count > 1, maximum > 0 else { return path } let points = samples.enumerated().map { index, value in CGPoint( x: rect.minX + rect.width * Double(index) / Double(samples.count - 1), y: rect.maxY - rect.height * value / maximum ) } path.addLines(points) return path } } /// Sliders for response and damping that show how a spring moves a view, with its curve drawn below. /// /// With Reduce Motion on, the ball jumps and the curve still shows what the spring would do. struct SpringStudyPlayground: View { private enum Preset: String, CaseIterable, Identifiable { case smooth = "Smooth" case bouncy = "Bouncy" case stiff = "Stiff" var id: Self { self } var values: (response: Double, damping: Double) { switch self { case .smooth: (0.8, 1) case .bouncy: (0.75, 0.3) case .stiff: (0.2, 0.75) } } } /// The curve's scale. A spring with the lowest damping overshoots to almost this. private static let curveMaximum = 1.9 private static let presetSpacing: CGFloat = 10 @State private var response: Double @State private var damping: Double @State private var isAtEnd = false @ScaledMetric(relativeTo: .title2) private var ballSize = 44.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion init(response: Double = 0.55, damping: Double = 0.55) { _response = State(initialValue: SpringStudyPlaygroundMath.clamped(response, to: SpringStudyPlaygroundMath.responseRange)) _damping = State(initialValue: SpringStudyPlaygroundMath.clamped(damping, to: SpringStudyPlaygroundMath.dampingRange)) } var body: some View { ScrollView { VStack(spacing: 24) { stage curve slider("Response", value: $response, range: SpringStudyPlaygroundMath.responseRange, unit: " s") slider("Damping", value: $damping, range: SpringStudyPlaygroundMath.dampingRange, unit: "") presets } .padding(20) } } private var stage: some View { Circle() .fill(.linearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)) .frame(width: ballSize, height: ballSize) .frame(maxWidth: .infinity, alignment: isAtEnd ? .trailing : .leading) .padding(10) .background(.quaternary, in: Capsule()) .accessibilityHidden(true) } private var curve: some View { let settling = Spring(response: response, dampingRatio: damping).settlingDuration // The curve covers the time the spring takes to settle, so it always reaches the line. let samples = SpringStudyPlaygroundMath.samples(response: response, damping: damping, count: 120, duration: max(settling, 1)) return VStack(alignment: .leading, spacing: 10) { ZStack { GeometryReader { proxy in Path { path in let y = proxy.size.height * (1 - 1 / Self.curveMaximum) path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: proxy.size.width, y: y)) } .stroke(.secondary, style: StrokeStyle(lineWidth: 1, dash: [4, 4])) } SpringStudyPlaygroundCurve(samples: samples, maximum: Self.curveMaximum) .stroke(.pink, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) } .frame(height: 150) .accessibilityHidden(true) Text("Settles in \(settling.formatted(.number.precision(.fractionLength(1)))) s") .font(.footnote) .foregroundStyle(.secondary) } } private func slider(_ title: String, value: Binding, range: ClosedRange, unit: String) -> some View { let text = value.wrappedValue.formatted(.number.precision(.fractionLength(2))) + unit return VStack(spacing: 4) { HStack { Text(title).font(.subheadline.weight(.semibold)) Spacer() Text(text).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary) } .accessibilityHidden(true) Slider(value: value, in: range) { Text(title) } .accessibilityValue(text) .tint(.pink) } } private var presets: some View { ViewThatFits(in: .horizontal) { HStack(spacing: Self.presetSpacing) { presetButtons } VStack(spacing: Self.presetSpacing) { presetButtons } } } @ViewBuilder private var presetButtons: some View { ForEach(Preset.allCases) { preset in Button { response = preset.values.response damping = preset.values.damping play() } label: { // In a narrow row a name shrinks a little before it would break in two. Text(preset.rawValue) .lineLimit(1) .minimumScaleFactor(0.75) .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } Button(action: play) { Text("Play").frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(.pink) } private func play() { withAnimation(reduceMotion ? nil : .spring(response: response, dampingFraction: damping)) { isAtEnd.toggle() } } } // MARK: - Demo struct SpringStudyPlaygroundDemo: View { var body: some View { SpringStudyPlayground() } } #Preview { SpringStudyPlaygroundDemo() } ``` ## Symbol Effect Gallery A gallery of SF Symbol effects, each played on demand and labeled with its name. Category: Animations. Minimum: iOS 18.0. Path: Components/Animations/SymbolEffectGallery/SymbolEffectGallery.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI private enum SymbolEffectGalleryEffect: String, CaseIterable, Identifiable { case bounce = "Bounce" case pulse = "Pulse" case variableColor = "Variable Color" case wiggle = "Wiggle" case rotate = "Rotate" case breathe = "Breathe" var id: Self { self } var symbol: String { switch self { case .bounce: "arrow.down.circle.fill" case .pulse: "heart.fill" case .variableColor: "wifi" case .wiggle: "bell.fill" case .rotate: "gearshape.2.fill" case .breathe: "moon.stars.fill" } } var color: Color { switch self { case .bounce: .blue case .pulse: .pink case .variableColor: .teal case .wiggle: .orange case .rotate: .indigo case .breathe: .purple } } } /// A gallery of SF Symbol effects. Tapping a tile plays its effect once. /// /// With Reduce Motion on, the effects are removed and the symbols stay still. struct SymbolEffectGallery: View { @ScaledMetric(relativeTo: .body) private var minimumColumn = 150.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: minimumColumn), spacing: 14)], spacing: 14) { ForEach(SymbolEffectGalleryEffect.allCases) { effect in SymbolEffectGalleryTile(effect: effect) } } .padding(20) } .symbolEffectsRemoved(reduceMotion) } } private struct SymbolEffectGalleryTile: View { let effect: SymbolEffectGalleryEffect @State private var plays = 0 @ScaledMetric(relativeTo: .largeTitle) private var height = 68.0 var body: some View { Button { plays += 1 } label: { VStack(spacing: 14) { symbol .font(.largeTitle) .foregroundStyle(effect.color) .frame(height: height) Text(effect.rawValue) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, 22) .padding(.horizontal, 12) .background(effect.color.opacity(0.12), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) } .buttonStyle(.plain) .accessibilityLabel(effect.rawValue) .accessibilityHint("Plays the effect") } @ViewBuilder private var symbol: some View { let image = Image(systemName: effect.symbol).accessibilityHidden(true) switch effect { case .bounce: image.symbolEffect(.bounce, value: plays) case .pulse: image.symbolEffect(.pulse, value: plays) case .variableColor: image.symbolEffect(.variableColor.iterative, value: plays) case .wiggle: image.symbolEffect(.wiggle, value: plays) case .rotate: image.symbolEffect(.rotate, value: plays) case .breathe: image.symbolEffect(.breathe, value: plays) } } } // MARK: - Demo struct SymbolEffectGalleryDemo: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Text("Symbol Effects") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .padding(.horizontal, 20) .padding(.top, 12) SymbolEffectGallery() } } } #Preview { SymbolEffectGalleryDemo() } ``` ## Animated Gradient Background A linear gradient that slowly drifts its colors and turns its direction. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/AnimatedGradientBackground/AnimatedGradientBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum AnimatedGradientBackgroundMath { /// The palette index for a position, also when a negative speed runs time backwards. static func index(at position: Double, count: Int) -> Int { (Int(floor(position)) % count + count) % count } } /// A linear gradient whose colors drift along it while its direction slowly turns. /// /// With Reduce Motion on, it shows a still gradient. struct AnimatedGradientBackground: View { var colors: [Color] = [.indigo, .purple, .pink, .orange] /// How many color steps pass each second. var speed: Double = 0.2 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed let angle = time * 0.5 let reach = CGSize(width: cos(angle) * 0.75, height: sin(angle) * 0.75) LinearGradient( colors: driftedColors(at: time), startPoint: UnitPoint(x: 0.5 - reach.width, y: 0.5 - reach.height), endPoint: UnitPoint(x: 0.5 + reach.width, y: 0.5 + reach.height) ) } .ignoresSafeArea() .accessibilityHidden(true) } /// Each stop blends from one palette color to the next, wrapping around so the drift never ends. private func driftedColors(at time: Double) -> [Color] { guard colors.count > 1 else { return colors + colors } return colors.indices.map { index in let position = time + Double(index) let lower = AnimatedGradientBackgroundMath.index(at: position, count: colors.count) let upper = (lower + 1) % colors.count return colors[lower].mix(with: colors[upper], by: position - floor(position)) } } } // MARK: - Demo struct AnimatedGradientBackgroundDemo: View { var body: some View { ZStack { AnimatedGradientBackground() VStack(spacing: 6) { Text("Good Evening") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Colors that never sit still") .font(.headline) .foregroundStyle(.white.opacity(0.8)) } .foregroundStyle(.white) } } } #Preview { AnimatedGradientBackgroundDemo() } ``` ## Aurora Mesh Background An animated aurora background made with a mesh gradient. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/AuroraMeshBackground/AuroraMeshBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A slowly moving aurora built with a mesh gradient. /// /// With Reduce Motion on, it shows a still gradient. struct AuroraMeshBackground: View { var first: Color = .indigo var second: Color = .purple var third: Color = .teal var speed: Double = 0.6 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { context in MeshGradient( width: AuroraMesh.size, height: AuroraMesh.size, points: AuroraMesh.points(at: reduceMotion ? 0 : context.date.timeIntervalSinceReferenceDate * speed), colors: [ first, second, first, third, second, first, second, first, third, ] ) } .ignoresSafeArea() .accessibilityHidden(true) } } enum AuroraMesh { static let size = 3 /// Corners stay fixed and edge points slide along their edge, so the mesh always fills the view. static func points(at time: TimeInterval) -> [SIMD2] { func wave(speed: Double, phase: Double) -> Double { 0.5 + sin(time * speed + phase) * 0.2 } let positions: [(Double, Double)] = [ (0, 0), (wave(speed: 0.8, phase: 2.1), 0), (1, 0), (0, wave(speed: 0.9, phase: 0)), (wave(speed: 1, phase: 0.7), wave(speed: 0.6, phase: 4.2)), (1, wave(speed: 0.7, phase: 1.3)), (0, 1), (wave(speed: 1.1, phase: 3.4), 1), (1, 1), ] return positions.map { SIMD2(Float($0.0), Float($0.1)) } } } // MARK: - Demo struct AuroraMeshBackgroundDemo: View { var body: some View { ZStack { AuroraMeshBackground() Text("Aurora") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(.white) } } } #Preview { AuroraMeshBackgroundDemo() } ``` ## Bubbles Background Translucent bubbles that rise and sway across the screen. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/BubblesBackground/BubblesBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Translucent bubbles that rise and sway. Larger bubbles rise faster. /// /// With Reduce Motion on, the bubbles hold still. struct BubblesBackground: View { var color: Color = .white var count: Int = 36 var speed: Double = 1 @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let margin = 40.0 var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: reduceMotion)) { timeline in Canvas { context, size in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed let tint = Gradient(colors: [color.opacity(0.02), color.opacity(0.26)]) for index in 0.. CGPoint { let travel = size.height + Self.margin * 2 let rise = 35 + scale * 70 let climbed = (noise(index, 2) * travel + time * rise).truncatingRemainder(dividingBy: travel) let sway = sin(time * (0.7 + noise(index, 3)) + noise(index, 4) * 2 * .pi) * (10 + noise(index, 5) * 18) return CGPoint(x: noise(index, 6) * size.width + sway, y: size.height + Self.margin - climbed) } /// A repeatable pseudo-random value in 0..<1 for one bubble and one property. private func noise(_ index: Int, _ salt: Double) -> Double { let value = sin(Double(index) * 127.1 + salt * 311.7) * 43758.5453 return value - floor(value) } } // MARK: - Demo struct BubblesBackgroundDemo: View { var body: some View { ZStack { LinearGradient( colors: [.cyan, .blue, Color(red: 0.03, green: 0.12, blue: 0.4)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() BubblesBackground() Text("Deep Blue") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(.white) } } } #Preview { BubblesBackgroundDemo() } ``` ## Color Orbs Background Large, soft colored orbs drifting slowly over a dark base. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/ColorOrbsBackground/ColorOrbsBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Large, soft colored orbs drifting slowly over a dark base. /// /// With Reduce Motion on, the orbs hold still. struct ColorOrbsBackground: View { /// One orb per color. var colors: [Color] = [.pink, .purple, .blue, .teal, .orange] var base: Color = Color(red: 0.05, green: 0.04, blue: 0.13) var speed: Double = 0.4 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { timeline in Canvas(opaque: true) { context, size in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(base)) context.blendMode = .screen for (index, color) in colors.enumerated() { drawOrb(index: index, color: color, at: time, in: context, size: size) } } } .ignoresSafeArea() .accessibilityHidden(true) } /// A radial falloff to clear looks like a heavy blur and costs far less to redraw each frame. private func drawOrb(index: Int, color: Color, at time: Double, in context: GraphicsContext, size: CGSize) { let center = CGPoint( x: (0.5 + 0.42 * sin(time * (0.5 + noise(index, 1) * 0.5) + noise(index, 2) * 2 * .pi)) * size.width, y: (0.5 + 0.42 * cos(time * (0.4 + noise(index, 3) * 0.5) + noise(index, 4) * 2 * .pi)) * size.height ) let radius = max(size.width, size.height) * (0.34 + noise(index, 5) * 0.14) let falloff = Gradient(stops: [ .init(color: color.opacity(0.9), location: 0), .init(color: color.opacity(0.45), location: 0.5), .init(color: color.opacity(0), location: 1), ]) context.fill( Path(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)), with: .radialGradient(falloff, center: center, startRadius: 0, endRadius: radius) ) } /// A repeatable pseudo-random value in 0..<1 for one orb and one property. private func noise(_ index: Int, _ salt: Double) -> Double { let value = sin(Double(index) * 127.1 + salt * 311.7) * 43758.5453 return value - floor(value) } } // MARK: - Demo struct ColorOrbsBackgroundDemo: View { var body: some View { ZStack { ColorOrbsBackground() VStack(alignment: .leading, spacing: 8) { Image(systemName: "moon.stars.fill") .font(.title) .accessibilityHidden(true) Text("Evening Focus") .font(.system(.title, design: .rounded, weight: .bold)) Text("Soft color moves behind your content without getting in its way.") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } .foregroundStyle(.white) .padding(24) .frame(maxWidth: .infinity, alignment: .leading) .background(.ultraThinMaterial, in: .rect(cornerRadius: 28)) .environment(\.colorScheme, .dark) .padding(24) } } } #Preview { ColorOrbsBackgroundDemo() } ``` ## Grid Pulse Background A grid of dots with a pulse of light spreading out from the center. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/GridPulseBackground/GridPulseBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum GridPulseBackgroundMath { /// How many dots fit on each side of the center, or `nil` when `spacing` cannot lay out a grid. static func dotsPerSide(extent: Double, spacing: Double) -> Int? { guard spacing.isFinite, spacing > 0 else { return nil } return Int(extent / 2 / spacing) + 1 } } /// A grid of dots with a pulse of light spreading from the center. /// /// With Reduce Motion on, it shows one still ring. struct GridPulseBackground: View { var color: Color = .cyan var spacing: Double = 26 /// Seconds for the pulse to travel from the center to the corners. var period: Double = 2.6 @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let dotRadius = 2.0 private static let restingOpacity = 0.32 private static let bandWidth = 80.0 var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in Canvas { context, size in let cycle = timeline.date.timeIntervalSinceReferenceDate / period let progress = reduceMotion ? 0.4 : cycle - floor(cycle) draw(progress: progress, in: context, size: size) } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } private func draw(progress: Double, in context: GraphicsContext, size: CGSize) { guard let columns = GridPulseBackgroundMath.dotsPerSide(extent: size.width, spacing: spacing), let rows = GridPulseBackgroundMath.dotsPerSide(extent: size.height, spacing: spacing) else { return } let center = CGPoint(x: size.width / 2, y: size.height / 2) let front = progress * (hypot(size.width, size.height) / 2 + Self.bandWidth) var resting = Path() for row in -rows...rows { for column in -columns...columns { let dot = CGPoint(x: center.x + Double(column) * spacing, y: center.y + Double(row) * spacing) let gap = (hypot(dot.x - center.x, dot.y - center.y) - front) / Self.bandWidth let glow = exp(-gap * gap) * (1 - pow(progress, 4)) if glow < 0.02 { resting.addEllipse(in: circle(at: dot, radius: Self.dotRadius)) } else { context.fill( Path(ellipseIn: circle(at: dot, radius: Self.dotRadius * (1 + glow * 1.6))), with: .color(color.opacity(Self.restingOpacity + glow * (1 - Self.restingOpacity))) ) } } } context.fill(resting, with: .color(color.opacity(Self.restingOpacity))) } private func circle(at center: CGPoint, radius: Double) -> CGRect { CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2) } } // MARK: - Demo struct GridPulseBackgroundDemo: View { var body: some View { ZStack { Color(red: 0.03, green: 0.05, blue: 0.1) .ignoresSafeArea() GridPulseBackground() VStack(spacing: 10) { Image(systemName: "dot.radiowaves.left.and.right") .font(.largeTitle) .accessibilityHidden(true) Text("Signal") .font(.system(.largeTitle, design: .rounded, weight: .bold)) } .foregroundStyle(.white) .padding(.horizontal, 28) .padding(.vertical, 20) .background(.ultraThinMaterial, in: .rect(cornerRadius: 24)) } } } #Preview { GridPulseBackgroundDemo() } ``` ## Image Derived Gradient Background A full-screen gradient whose colors are sampled from the pixels of an image and cross-fade when the image changes. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/ImageDerivedGradientBackground/ImageDerivedGradientBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct ImageDerivedGradientBackgroundSample: Equatable { let red: Double let green: Double let blue: Double } enum ImageDerivedGradientBackgroundMath { static let bytesPerPixel = 4 /// Averages horizontal bands of premultiplied RGBA pixels, from top to bottom. /// /// Saturated pixels count for more, so a photo gives vivid colors instead of grey. /// Fully transparent bands are left out. static func bands(pixels: [UInt8], width: Int, height: Int, count: Int) -> [ImageDerivedGradientBackgroundSample] { guard width > 0, height > 0, count > 0, pixels.count / bytesPerPixel / width >= height else { return [] } let bandCount = min(count, height) return (0.. 0 else { continue } let r = Double(pixels[offset]) / alpha let g = Double(pixels[offset + 1]) / alpha let b = Double(pixels[offset + 2]) / alpha let weight = alpha / 255 * (0.15 + max(r, g, b) - min(r, g, b)) red += r * weight green += g * weight blue += b * weight total += weight } } guard total > 0 else { return nil } return ImageDerivedGradientBackgroundSample(red: red / total, green: green / total, blue: blue / total) } } } /// A full-screen gradient whose colors are sampled from an image and cross-fade when it changes. /// /// Keep passing the same `Image` value while the picture is the same, or it is sampled again. /// With Reduce Motion on, the colors change without fading. struct ImageDerivedGradientBackground: View { private struct Request: Equatable { let image: Image let bands: Int } let image: Image var bands = 3 var fallback: [Color] = [.indigo, .black] @State private var colors: [Color]? @State private var previous: [Color]? @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let shown = colors ?? fallback ZStack { Color.black // The gradient before stays underneath while the new one fades in over it. // Fading one out as the other fades in would dip through the black. LinearGradient(colors: previous ?? shown, startPoint: .top, endPoint: .bottom) LinearGradient(colors: shown, startPoint: .top, endPoint: .bottom) .id(shown) .transition(.opacity) } .ignoresSafeArea() .accessibilityHidden(true) .task(id: Request(image: image, bands: bands)) { let sampled = sampledColors() guard !Task.isCancelled else { return } previous = colors ?? fallback withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.8)) { colors = sampled } } } /// Draws the image small and reads its pixels. Returns nil when it cannot be rendered. private func sampledColors() -> [Color]? { let renderer = ImageRenderer(content: image.resizable().scaledToFill().frame(width: 24, height: 24).clipped()) renderer.scale = 1 guard let cgImage = renderer.cgImage, let space = CGColorSpace(name: CGColorSpace.sRGB) else { return nil } let width = cgImage.width let height = cgImage.height var pixels = [UInt8](repeating: 0, count: width * height * ImageDerivedGradientBackgroundMath.bytesPerPixel) let didDraw = pixels.withUnsafeMutableBytes { buffer -> Bool in guard let context = CGContext( data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * ImageDerivedGradientBackgroundMath.bytesPerPixel, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return false } context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) return true } guard didDraw else { return nil } let samples = ImageDerivedGradientBackgroundMath.bands(pixels: pixels, width: width, height: height, count: max(bands, 1)) return samples.isEmpty ? nil : samples.map { Color(.sRGB, red: $0.red, green: $0.green, blue: $0.blue) } } } // MARK: - Demo struct ImageDerivedGradientBackgroundDemo: View { @State private var photos = Self.makePhotos() @State private var index = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { if let photo = photos.isEmpty ? nil : photos[index % photos.count] { ImageDerivedGradientBackground(image: photo) photo .resizable() .scaledToFill() .frame(width: 240, height: 240) .clipShape(.rect(cornerRadius: 36, style: .continuous)) .shadow(color: .black.opacity(0.35), radius: 28, y: 16) .id(index) .transition(.opacity) .accessibilityLabel("Placeholder photo") } } .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled else { return } withAnimation(.easeInOut(duration: 0.8)) { index += 1 } } } } /// Stands in for real photos: gradients and symbols rendered into images. private static func makePhotos() -> [Image] { let scenes: [(colors: [Color], symbol: String)] = [ ([.orange, .pink, .purple], "sun.horizon.fill"), ([.teal, .blue, .indigo], "water.waves"), ([.mint, .green, .teal], "leaf.fill"), ] return scenes.compactMap { scene in let content = ZStack { LinearGradient(colors: scene.colors, startPoint: .top, endPoint: .bottom) Image(systemName: scene.symbol) .font(.system(size: 120)) .foregroundStyle(.white.opacity(0.85)) } .frame(width: 240, height: 240) let renderer = ImageRenderer(content: content) renderer.scale = 2 return renderer.cgImage.map { Image(decorative: $0, scale: 2) } } } } #Preview { ImageDerivedGradientBackgroundDemo() } ``` ## Particle Field Background Drifting points that are joined by faint lines when they come close. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/ParticleFieldBackground/ParticleFieldBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Drifting points joined by faint lines when they come close. /// /// With Reduce Motion on, the field holds still. struct ParticleFieldBackground: View { var color: Color = .white var count: Int = 70 var linkDistance: Double = 105 var speed: Double = 1 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: reduceMotion)) { timeline in Canvas { context, size in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed let points = (0.. first { let distance = hypot(points[first].x - points[second].x, points[first].y - points[second].y) guard distance < linkDistance else { continue } let closeness = 1 - distance / linkDistance let level = min(Int(closeness * Double(levels.count)), levels.count - 1) levels[level].move(to: points[first]) levels[level].addLine(to: points[second]) } } for (level, path) in levels.enumerated() { context.stroke(path, with: .color(color.opacity(0.1 * Double(level + 1))), lineWidth: 0.8) } } private func position(of index: Int, at time: Double, in size: CGSize) -> CGPoint { CGPoint( x: bounce(noise(index, 1) + time * (noise(index, 2) - 0.5) * 0.08) * size.width, y: bounce(noise(index, 3) + time * (noise(index, 4) - 0.5) * 0.04) * size.height ) } /// A triangle wave from 0 to 1, so a point turns around at the edge instead of jumping and snapping its links. private func bounce(_ value: Double) -> Double { abs((value - floor(value)) * 2 - 1) } /// A repeatable pseudo-random value in 0..<1 for one point and one property. private func noise(_ index: Int, _ salt: Double) -> Double { let value = sin(Double(index) * 127.1 + salt * 311.7) * 43758.5453 return value - floor(value) } } // MARK: - Demo struct ParticleFieldBackgroundDemo: View { var body: some View { ZStack { LinearGradient( colors: [Color(red: 0.05, green: 0.08, blue: 0.22), Color(red: 0.16, green: 0.06, blue: 0.34)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() ParticleFieldBackground() VStack(spacing: 6) { Text("Particle Field") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Points that find each other") .font(.subheadline) .foregroundStyle(.white.opacity(0.75)) } .foregroundStyle(.white) } } } #Preview { ParticleFieldBackgroundDemo() } ``` ## Snowfall Background Snowflakes falling at different speeds across three layers of depth. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/SnowfallBackground/SnowfallBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Snow in three depths: small and slow far away, large, fast and slightly blurred up close. /// /// With Reduce Motion on, the flakes hold still. struct SnowfallBackground: View { var color: Color = .white /// Multiplies the number of flakes in every depth. var density: Double = 1 var speed: Double = 1 @Environment(\.accessibilityReduceMotion) private var reduceMotion private struct Depth { let count: Int let radius: ClosedRange let fall: Double let sway: Double let opacity: Double let blur: Double } private static let depths = [ Depth(count: 110, radius: 0.8...1.5, fall: 26, sway: 6, opacity: 0.45, blur: 0), Depth(count: 60, radius: 1.8...3, fall: 55, sway: 12, opacity: 0.7, blur: 0), Depth(count: 18, radius: 3.5...6, fall: 105, sway: 22, opacity: 0.9, blur: 1.5), ] var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: reduceMotion)) { timeline in Canvas { context, size in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed for (layer, depth) in Self.depths.enumerated() { context.drawLayer { layerContext in if depth.blur > 0 { layerContext.addFilter(.blur(radius: depth.blur)) } layerContext.fill( flakes(of: depth, layer: layer, at: time, in: size), with: .color(color.opacity(depth.opacity)) ) } } } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } /// One path holds every flake of a depth, so each depth is a single fill. private func flakes(of depth: Depth, layer: Int, at time: Double, in size: CGSize) -> Path { var path = Path() let margin = depth.radius.upperBound * 2 let travel = size.height + margin * 2 for index in 0.. Double { let value = sin(Double(index) * 127.1 + salt * 311.7) * 43758.5453 return value - floor(value) } } // MARK: - Demo struct SnowfallBackgroundDemo: View { var body: some View { ZStack { LinearGradient( colors: [Color(red: 0.07, green: 0.11, blue: 0.27), Color(red: 0.3, green: 0.4, blue: 0.6)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() SnowfallBackground() VStack(spacing: 10) { Image(systemName: "snowflake") .font(.largeTitle) .accessibilityHidden(true) Text("First Snow") .font(.system(.largeTitle, design: .rounded, weight: .bold)) } .foregroundStyle(.white) } } } #Preview { SnowfallBackgroundDemo() } ``` ## Starfield Background Stars streaking toward the viewer with a simple perspective projection. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/StarfieldBackground/StarfieldBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Stars rushing toward the viewer, each one a streak along its line of flight. /// /// With Reduce Motion on, it shows a still field of dots. struct StarfieldBackground: View { var color: Color = .white var count: Int = 320 /// Depth covered per second, where 1 is the whole way from the horizon to the viewer. var speed: Double = 0.22 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in Canvas { context, size in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed let center = CGPoint(x: size.width / 2, y: size.height / 2) let focalLength = max(size.width, size.height) * 0.12 let trail = reduceMotion ? 0.004 : 0.04 for index in 0.. CGPoint { CGPoint( x: center.x + cos(angle) * spread / depth, y: center.y + sin(angle) * spread / depth ) } var streak = Path() streak.move(to: point(atDepth: min(depth + trail, 1))) streak.addLine(to: point(atDepth: depth)) let closeness = 1 - depth context.stroke( streak, with: .color(color.opacity(min(0.15 + closeness * 1.3, 1))), style: StrokeStyle(lineWidth: 0.6 + closeness * 2.2, lineCap: .round) ) } } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } /// A repeatable pseudo-random value in 0..<1 for one star and one property. private func noise(_ index: Int, _ salt: Double) -> Double { let value = sin(Double(index) * 127.1 + salt * 311.7) * 43758.5453 return value - floor(value) } } // MARK: - Demo struct StarfieldBackgroundDemo: View { var body: some View { ZStack { RadialGradient( colors: [Color(red: 0.1, green: 0.1, blue: 0.3), .black], center: .center, startRadius: 0, endRadius: 480 ) .ignoresSafeArea() StarfieldBackground() VStack(spacing: 10) { Image(systemName: "sparkles") .font(.largeTitle) .accessibilityHidden(true) Text("Warp Speed") .font(.system(.largeTitle, design: .rounded, weight: .bold)) } .foregroundStyle(.white) .shadow(color: .black.opacity(0.7), radius: 12) } } } #Preview { StarfieldBackgroundDemo() } ``` ## Synthwave Grid Background A retro perspective grid that scrolls under a gradient sky with a striped sun. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/SynthwaveGridBackground/SynthwaveGridBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum SynthwaveGridBackgroundMath { /// Where the horizontal lines sit, from 0 at the horizon to 1 at the bottom edge. /// /// Each line reaches the next one's place as `phase` goes from 0 to 1, so the motion loops. static func horizontalLines(phase: Double, count: Int) -> [Double] { guard count > 0, phase.isFinite else { return [] } let lines = min(count, 128) let wrapped = phase - phase.rounded(.down) return (0.. 0, size.height > 0 else { return } let horizon = size.height * 0.58 let center = size.width / 2 context.fill( Path(CGRect(origin: .zero, size: CGSize(width: size.width, height: horizon))), with: .linearGradient(Gradient(colors: skyColors), startPoint: .zero, endPoint: CGPoint(x: 0, y: horizon)) ) drawSun(in: context, center: CGPoint(x: center, y: horizon - size.width * 0.14), radius: size.width * 0.3, horizon: horizon) let ground = CGRect(x: 0, y: horizon, width: size.width, height: size.height - horizon) context.fill(Path(ground), with: .color(Color(red: 0.05, green: 0.02, blue: 0.14))) var grid = Path() let spacing = size.width / 4 for column in -12...12 { grid.move(to: CGPoint(x: center + Double(column) * spacing * 0.14, y: horizon)) grid.addLine(to: CGPoint(x: center + Double(column) * spacing, y: size.height)) } for line in SynthwaveGridBackgroundMath.horizontalLines(phase: phase, count: lineCount) { let y = horizon + ground.height * line grid.move(to: CGPoint(x: 0, y: y)) grid.addLine(to: CGPoint(x: size.width, y: y)) } context.stroke(grid, with: .color(lineColor.opacity(0.25)), lineWidth: 5) context.stroke(grid, with: .color(lineColor), lineWidth: 1.5) } private func drawSun(in context: GraphicsContext, center: CGPoint, radius: Double, horizon: Double) { var bands = Path() var y = center.y - radius var gap = 0.0 // The stripes open up toward the bottom of the sun. while y < horizon, radius > 0 { let height = max(radius * 0.22 - gap * 0.7, radius * 0.05) bands.addRect(CGRect(x: center.x - radius, y: y, width: radius * 2, height: height)) gap = radius * 0.03 + max(0, y - center.y) * 0.1 y += height + gap } var clipped = context clipped.clip(to: bands) clipped.fill( Path(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)), with: .linearGradient( Gradient(colors: [.yellow, .orange, .pink]), startPoint: CGPoint(x: center.x, y: center.y - radius), endPoint: CGPoint(x: center.x, y: center.y + radius) ) ) } } // MARK: - Demo struct SynthwaveGridBackgroundDemo: View { var body: some View { ZStack { SynthwaveGridBackground() Text("NIGHT DRIVE") .font(.system(.largeTitle, design: .rounded, weight: .black)) .foregroundStyle(.white) .shadow(color: .pink, radius: 12) .frame(maxHeight: .infinity, alignment: .top) .padding(.top, 72) } } } #Preview { SynthwaveGridBackgroundDemo() } ``` ## Waves Background Layered waves animated with a custom animatable Shape. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/WavesBackground/WavesBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Layered waves, each one an animatable `Shape` driven by a single phase. /// /// With Reduce Motion on, the waves hold still. struct WavesBackground: View { /// One wave per color, from back to front. var colors: [Color] = [.teal, .cyan, .blue, .indigo] var amplitude: Double = 26 /// Seconds for the slowest wave to complete one cycle. var period: Double = 7 @State private var phase = 0.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion nonisolated private struct WaveLayer: Shape { var phase: Double var baseline: Double var amplitude: Double var waves: Double var animatableData: Double { get { phase } set { phase = newValue } } func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) for x in stride(from: rect.minX, through: rect.maxX + 4, by: 4) { let angle = x / max(rect.width, 1) * waves * 2 * .pi let swell = sin(angle + phase) + 0.35 * sin(angle * 2 + phase * 2) path.addLine(to: CGPoint(x: x, y: rect.minY + rect.height * baseline + swell * amplitude)) } path.addLine(to: CGPoint(x: rect.maxX + 4, y: rect.maxY)) path.closeSubpath() return path } } var body: some View { ZStack { ForEach(colors.indices, id: \.self) { index in // A whole number of cycles per loop, so the repeat has no visible seam. let cycles = Double(index % 3 + 1) * (index.isMultiple(of: 2) ? 1 : -1) WaveLayer( phase: phase * cycles + Double(index), baseline: 0.45 + 0.12 * Double(index), amplitude: amplitude * (1 - 0.1 * Double(index)), waves: 1 + 0.5 * Double(index % 2 + 1) ) .fill(colors[index].opacity(0.85)) } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) .task(id: reduceMotion) { updateMotion() } } private func updateMotion() { if reduceMotion { withTransaction(\.disablesAnimations, true) { phase = 0 } } else { withAnimation(.linear(duration: period).repeatForever(autoreverses: false)) { phase = 2 * .pi } } } } // MARK: - Demo struct WavesBackgroundDemo: View { var body: some View { ZStack { LinearGradient(colors: [.orange, .pink, .purple], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() WavesBackground() VStack(spacing: 10) { Image(systemName: "sun.horizon.fill") .font(.largeTitle) .accessibilityHidden(true) Text("High Tide") .font(.system(.largeTitle, design: .rounded, weight: .bold)) } .foregroundStyle(.white) .frame(maxHeight: .infinity, alignment: .top) .padding(.top, 150) } } } #Preview { WavesBackgroundDemo() } ``` ## Weather Effects Background An animated sky for seven kinds of weather, with a turning sun, twinkling stars, drifting clouds, rain with splashes, lightning, swaying snow and fog, that fades from one to the next. Category: Backgrounds. Minimum: iOS 18.0. Path: Components/Backgrounds/WeatherEffectsBackground/WeatherEffectsBackground.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// An animated sky for a kind of weather: sun, stars, clouds, rain, storm, snow or fog. /// /// Changing ``weather`` fades the sky and its effects into the new ones. Lightning follows a /// fixed schedule of at most two flashes every few seconds, never more than three a second. /// With Reduce Motion on, everything holds still and lightning never flashes. The sun sits in /// the top trailing corner, so it moves to the left in a right-to-left layout. /// /// It draws behind content, ignores the safe area and takes no touches, and VoiceOver skips it. struct WeatherEffectsBackground: View { /// The weather to draw. var weather: WeatherEffectsBackgroundWeather @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The weather fading out after a change, drawn under the new one until it is gone. @State private var leaving: WeatherEffectsBackgroundWeather? /// How far the change has gone, from 0 when it starts to 1 when only the new weather shows. @State private var arrival: Double = 1 /// Counts the changes, so the end of an earlier fade does not clear a later one. @State private var change = 0 private let fade: Animation = .easeInOut(duration: 1) var body: some View { ZStack { LinearGradient(colors: weather.skyColors, startPoint: .top, endPoint: .bottom) .animation(fade, value: weather) // Each weather keeps its own clock, so the effects that leave go on moving while they fade. if let leaving { effects(of: leaving) .opacity(1 - arrival) } effects(of: weather) .opacity(arrival) } .onChange(of: weather) { previous, _ in change += 1 let current = change leaving = previous arrival = 0 withAnimation(fade) { arrival = 1 } completion: { if change == current { leaving = nil } } } .ignoresSafeArea() .allowsHitTesting(false) .accessibilityHidden(true) } } /// A kind of weather the background can draw. nonisolated enum WeatherEffectsBackgroundWeather: String, CaseIterable, Sendable { case clearDay, clearNight, cloudy, rain, thunderstorm, snow, fog /// The name of the weather, for a label next to it. var title: LocalizedStringResource { switch self { case .clearDay: "Clear" case .clearNight: "Clear Night" case .cloudy: "Cloudy" case .rain: "Rain" case .thunderstorm: "Thunderstorm" case .snow: "Snow" case .fog: "Fog" } } /// Falling rain and snow need every frame; slow clouds, fog, the sun and the stars do with half. fileprivate var frameInterval: Double { switch self { case .rain, .thunderstorm, .snow: 1.0 / 60.0 case .clearDay, .clearNight, .cloudy, .fog: 1.0 / 30.0 } } fileprivate var skyColors: [Color] { switch self { case .clearDay: [Color(red: 0.11, green: 0.38, blue: 0.80), Color(red: 0.46, green: 0.72, blue: 0.96)] case .clearNight: [Color(red: 0.02, green: 0.03, blue: 0.13), Color(red: 0.10, green: 0.13, blue: 0.34)] case .cloudy: [Color(red: 0.33, green: 0.40, blue: 0.52), Color(red: 0.62, green: 0.68, blue: 0.76)] case .rain: [Color(red: 0.14, green: 0.19, blue: 0.27), Color(red: 0.33, green: 0.40, blue: 0.49)] case .thunderstorm: [Color(red: 0.06, green: 0.07, blue: 0.13), Color(red: 0.20, green: 0.22, blue: 0.34)] case .snow: [Color(red: 0.36, green: 0.44, blue: 0.58), Color(red: 0.70, green: 0.76, blue: 0.85)] case .fog: [Color(red: 0.46, green: 0.49, blue: 0.56), Color(red: 0.70, green: 0.72, blue: 0.75)] } } } // MARK: - Math /// The timing of the effects: where a moving thing wraps around, and when lightning flashes. nonisolated enum WeatherEffectsBackgroundMath { /// The seconds after which the lightning schedule repeats. static let lightningCycle: Double = 6 /// A position moved back inside `0.. Double { guard travel > 0 else { return 0 } let remainder = position.truncatingRemainder(dividingBy: travel) guard remainder < 0 else { return remainder } // A tiny negative remainder rounds up to `travel` itself, which belongs at the start. let wrapped = remainder + travel return wrapped < travel ? wrapped : 0 } /// The brightness of the lightning at a moment, from 0 when dark to 1 at the peak of a flash. static func flash(at time: Double) -> Double { let moment = wrap(time, over: lightningCycle) for strike in strikes where moment >= strike.start && moment < strike.start + strike.duration { return sin(.pi * (moment - strike.start) / strike.duration) } return 0 } /// Two strikes close together, as lightning often comes, early in each cycle. private static let strikes: [(start: Double, duration: Double)] = [(0.4, 0.18), (0.75, 0.22)] } extension WeatherEffectsBackground { private func effects(of weather: WeatherEffectsBackgroundWeather) -> some View { TimelineView(.animation(minimumInterval: weather.frameInterval, paused: reduceMotion)) { timeline in WeatherEffectsBackgroundLayers( weather: weather, time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate, flashes: !reduceMotion ) } .id(weather) } } // MARK: - Layers /// The effects of one weather at one moment. private struct WeatherEffectsBackgroundLayers: View { let weather: WeatherEffectsBackgroundWeather let time: Double let flashes: Bool var body: some View { ZStack { switch weather { case .clearDay: WeatherEffectsBackgroundSun(time: time) case .clearNight: canvas { WeatherEffectsBackgroundStars.draw(in: &$0, size: $1, time: time) } case .cloudy: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .white, opacity: 0.5) } case .rain: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .white, opacity: 0.25) } canvas { WeatherEffectsBackgroundRain.draw(in: &$0, size: $1, time: time, drops: 110) } case .thunderstorm: canvas { WeatherEffectsBackgroundClouds.draw(in: &$0, size: $1, time: time, color: .black, opacity: 0.5) } if flashes { canvas { WeatherEffectsBackgroundLightning.draw(in: &$0, size: $1, time: time) } } canvas { WeatherEffectsBackgroundRain.draw(in: &$0, size: $1, time: time, drops: 170) } case .snow: canvas { WeatherEffectsBackgroundSnow.draw(in: &$0, size: $1, time: time) } case .fog: canvas { WeatherEffectsBackgroundFog.draw(in: &$0, size: $1, time: time) } } } } private func canvas(_ draw: @escaping (inout GraphicsContext, CGSize) -> Void) -> some View { Canvas { context, size in draw(&context, size) } } } /// The property of a particle a pseudo-random value decides. nonisolated private enum WeatherEffectsBackgroundTrait: Double { case x = 1, y, size, speed, phase, offset, extra } /// A repeatable pseudo-random value in 0..<1 for one particle and one of its properties. nonisolated private func weatherEffectsBackgroundNoise(_ index: Int, _ trait: WeatherEffectsBackgroundTrait) -> Double { let value = sin(Double(index) * 127.1 + trait.rawValue * 311.7) * 43_758.5453 return value - floor(value) } // MARK: - Sun /// A glow in the top trailing corner with slow rays turning around it. private struct WeatherEffectsBackgroundSun: View { let time: Double private let diameter: CGFloat = 520 var body: some View { ZStack(alignment: .topTrailing) { Color.clear Canvas { context, size in let center = CGPoint(x: size.width / 2, y: size.height / 2) let radius = size.width / 2 let rays = 14 context.drawLayer { rayContext in rayContext.addFilter(.blur(radius: 12)) rayContext.translateBy(x: center.x, y: center.y) rayContext.rotate(by: .radians(time * 0.05)) for ray in 0.. 0 else { return } context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(.white.opacity(flash * 0.45))) guard flash > 0.3 else { return } let cycle = Int(floor(time / WeatherEffectsBackgroundMath.lightningCycle)) var x = size.width * (0.2 + weatherEffectsBackgroundNoise(cycle, .x) * 0.6) var y: Double = 0 var bolt = Path() bolt.move(to: CGPoint(x: x, y: y)) for step in 0..<9 { x += (weatherEffectsBackgroundNoise(cycle * 10 + step, .offset) - 0.5) * 60 y += size.height * 0.065 bolt.addLine(to: CGPoint(x: x, y: y)) } context.drawLayer { glow in glow.addFilter(.blur(radius: glowWidth)) glow.stroke(bolt, with: .color(Color(red: 0.75, green: 0.8, blue: 1).opacity(flash)), lineWidth: glowWidth) } context.stroke(bolt, with: .color(.white.opacity(flash)), lineWidth: 2) } } // MARK: - Snow nonisolated private enum WeatherEffectsBackgroundSnow { /// Flakes in two depths that fall slowly and sway from side to side. static func draw(in context: inout GraphicsContext, size: CGSize, time: Double) { var near = Path() var far = Path() let travel = size.height + 20 for index in 0..<130 { let isNear = index % 3 == 0 let depth: Double = isNear ? 1 : 0.5 let y = WeatherEffectsBackgroundMath.wrap( weatherEffectsBackgroundNoise(index, .y) * travel + time * 50 * depth, over: travel ) - 10 let sway = sin(time * (0.5 + weatherEffectsBackgroundNoise(index, .speed)) + weatherEffectsBackgroundNoise(index, .phase) * 2 * .pi) * 14 * depth let x = weatherEffectsBackgroundNoise(index, .x) * size.width + sway let radius = (1.3 + weatherEffectsBackgroundNoise(index, .size) * 2) * depth let flake = Path(ellipseIn: CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2)) if isNear { near.addPath(flake) } else { far.addPath(flake) } } context.fill(far, with: .color(.white.opacity(0.55))) context.fill(near, with: .color(.white.opacity(0.9))) } } // MARK: - Fog nonisolated private enum WeatherEffectsBackgroundFog { /// Wide, heavily blurred bands that drift slowly across the lower sky. static func draw(in context: inout GraphicsContext, size: CGSize, time: Double) { let bandWidth = size.width * 1.6 context.drawLayer { layer in layer.addFilter(.blur(radius: 30)) for index in 0..<8 { let travel = size.width + bandWidth let direction: Double = index.isMultiple(of: 2) ? 1 : -1 let x = WeatherEffectsBackgroundMath.wrap( weatherEffectsBackgroundNoise(index, .x) * travel + time * (5 + Double(index) * 2) * direction, over: travel ) - bandWidth let y = size.height * (0.18 + Double(index) * 0.1) let band = Path(ellipseIn: CGRect(x: x, y: y, width: bandWidth, height: size.height * 0.14)) layer.fill(band, with: .color(.white.opacity(0.5))) } } } } // MARK: - Demo struct WeatherEffectsBackgroundDemo: View { /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @State private var weather = WeatherEffectsBackgroundWeather.clearDay /// A choice from the menu also stops the demo, which would otherwise move on from it. private var chosenWeather: Binding { Binding { weather } set: { choice in isAutoPlaying = false weather = choice } } var body: some View { ZStack { WeatherEffectsBackground(weather: weather) // The background takes no touches, so this layer catches the one that stops the demo. Color.clear .contentShape(.rect) .ignoresSafeArea() } .overlay(alignment: .bottom) { Picker("Weather", selection: chosenWeather) { ForEach(WeatherEffectsBackgroundWeather.allCases, id: \.self) { weather in Text(weather.title).tag(weather) } } .pickerStyle(.menu) .tint(.white) .padding(.horizontal, 6) .background(.ultraThinMaterial, in: .capsule) .environment(\.colorScheme, .dark) .padding(.bottom, 40) } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task { while isAutoPlaying { try? await Task.sleep(for: .seconds(1.8)) guard !Task.isCancelled, isAutoPlaying else { return } let all = WeatherEffectsBackgroundWeather.allCases let next = ((all.firstIndex(of: weather) ?? 0) + 1) % all.count weather = all[next] } } } } #Preview { WeatherEffectsBackgroundDemo() } ``` ## Async Action Button A button that runs an async action, swaps its label for a spinner in the label color without changing width and ignores taps until the action ends. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/AsyncActionButton/AsyncActionButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Knows whether the action is running, so a second tap while it runs does nothing. struct AsyncActionButtonRun: Equatable { private(set) var isRunning = false /// Returns `false`, and changes nothing, when the action is already running. mutating func start() -> Bool { guard !isRunning else { return false } isRunning = true return true } mutating func finish() { isRunning = false } } /// A button that runs an async action and shows a spinner in place of its label /// until the action ends. It keeps its width, works with any button style, and /// ignores taps while the action runs instead of looking disabled. /// /// Leaving the screen cancels the action. The swap is a short crossfade, so it /// needs nothing different with Reduce Motion on. struct AsyncActionButton: View { let action: @MainActor () async -> Void let label: Label @State private var run = AsyncActionButtonRun() @State private var runID: UUID? init(action: @escaping @MainActor () async -> Void, @ViewBuilder label: () -> Label) { self.action = action self.label = label() } var body: some View { Button { guard run.start() else { return } runID = UUID() } label: { label .opacity(run.isRunning ? 0 : 1) .overlay { if run.isRunning { AsyncActionButtonSpinner() .transition(.opacity) } } .animation(.easeInOut(duration: 0.15), value: run) } .accessibilityValue(run.isRunning ? Text("In progress") : Text("")) .task(id: runID) { guard runID != nil else { return } await action() run.finish() runID = nil } } } /// Drawn with the label's foreground style, which a `ProgressView` does not /// take, so it is white on a prominent button and tinted on a bordered one. private struct AsyncActionButtonSpinner: View { @ScaledMetric(relativeTo: .body) private var size = 18.0 var body: some View { TimelineView(.animation) { context in let turn = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1) Circle() .trim(from: 0.15, to: 1) .stroke(style: StrokeStyle(lineWidth: size / 7, lineCap: .round)) .frame(width: size, height: size) .rotationEffect(.degrees(turn * 360)) } .accessibilityHidden(true) } } extension AsyncActionButton where Label == Text { init(_ title: LocalizedStringResource, action: @escaping @MainActor () async -> Void) { self.init(action: action) { Text(title) } } } extension AsyncActionButton where Label == SwiftUI.Label { init(_ title: LocalizedStringResource, systemImage: String, action: @escaping @MainActor () async -> Void) { self.init(action: action) { SwiftUI.Label { Text(title) } icon: { Image(systemName: systemImage) } } } } // MARK: - Demo struct AsyncActionButtonDemo: View { @State private var saves = 0 @State private var syncs = 0 var body: some View { VStack(spacing: 24) { VStack(spacing: 6) { Text("Edit Profile") .font(.largeTitle.bold()) Text("Saves: \(saves) · Syncs: \(syncs)") .font(.subheadline) .foregroundStyle(.secondary) .contentTransition(.numericText()) } AsyncActionButton { try? await Task.sleep(for: .seconds(1.5)) saves += 1 } label: { Text("Save Changes") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) HStack(spacing: 12) { AsyncActionButton("Sync", systemImage: "arrow.triangle.2.circlepath") { try? await Task.sleep(for: .seconds(1)) syncs += 1 } .buttonStyle(.bordered) AsyncActionButton("Send Invite") { try? await Task.sleep(for: .seconds(2)) } .buttonStyle(.bordered) .tint(.orange) } } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) } } #Preview { AsyncActionButtonDemo() } ``` ## Badged Icon Button An icon button with a count badge on its corner that pops in, rolls its number, caps at 99+ and bounces the icon when the count goes up. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/BadgedIconButton/BadgedIconButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum BadgedIconButtonBadge { /// The text of the badge: none at zero, and `99+` past the limit. static func text(for count: Int, limit: Int = 99) -> String? { guard count > 0 else { return nil } return count > limit ? "\(limit)+" : "\(count)" } } /// An icon button, like a bell or a cart, with a count in a badge on its corner. /// The badge pops in when the count appears, its number rolls as it changes, /// and the icon bounces when the count goes up. /// /// With Reduce Motion on, the badge fades and the icon stays still. struct BadgedIconButton: View { let title: LocalizedStringResource let systemImage: String let count: Int var limit = 99 var badgeColor: Color = .red let action: () -> Void @State private var bounces = 0 @ScaledMetric(relativeTo: .title2) private var size = 44.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var badgeText: String? { BadgedIconButtonBadge.text(for: count, limit: limit) } var body: some View { Button(action: action) { Image(systemName: systemImage) .font(.title2) .symbolEffect(.bounce, value: bounces) .frame(width: size, height: size) .overlay(alignment: .topTrailing) { if let badgeText { Text(badgeText) .font(.caption2.bold()) .monospacedDigit() .contentTransition(.numericText(value: Double(count))) .foregroundStyle(BadgedIconButtonContrastingForeground(background: badgeColor)) .padding(.horizontal, 5) .frame(minWidth: size * 0.42, minHeight: size * 0.42) .background(badgeColor, in: .capsule) .fixedSize() .alignmentGuide(.top) { $0.height * 0.3 } .alignmentGuide(.trailing) { $0.width * 0.7 } .transition(reduceMotion ? .opacity : .scale.combined(with: .opacity)) } } .contentShape(.rect) } .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.35, bounce: 0.5), value: count) .accessibilityLabel(title) .accessibilityValue(Text(verbatim: badgeText ?? "")) .onChange(of: count) { old, new in if new > old, !reduceMotion { bounces += 1 } } } } nonisolated struct BadgedIconButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct BadgedIconButtonDemo: View { @State private var notifications = 96 @State private var messages = 2 @State private var cartItems = 0 /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true var body: some View { VStack(spacing: 0) { HStack(spacing: 4) { Text("Inbox") .font(.largeTitle.bold()) .lineLimit(1) .minimumScaleFactor(0.6) Spacer() BadgedIconButton(title: "Messages", systemImage: "bubble.left", count: messages) { messages = 0 } BadgedIconButton(title: "Cart", systemImage: "cart", count: cartItems, badgeColor: .yellow) { cartItems = 0 } BadgedIconButton(title: "Notifications", systemImage: "bell", count: notifications) { notifications = 0 } } .padding(.horizontal, 20) .padding(.vertical, 12) Text("Tap an icon to clear its badge.") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task { var step = 0 while isAutoPlaying { try? await Task.sleep(for: .seconds(1.3)) guard !Task.isCancelled, isAutoPlaying else { return } switch step % 3 { case 0: notifications += 2 case 1: cartItems += 1 default: messages += 1 } step += 1 } } } } #Preview { BadgedIconButtonDemo() } ``` ## Button Variant Style A button style with filled, tinted, outlined and plain variants that follows the control size, the disabled state and the destructive role. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ButtonVariantStyle/ButtonVariantStyle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A button style with four variants that reads its size, its enabled state and /// its role from the environment, like the system styles: `.controlSize(.small)` /// makes it small, `.disabled(true)` fades it, and a destructive role turns it red. /// /// With Reduce Motion on, a press dims the button instead of shrinking it. struct ButtonVariantStyle: ButtonStyle { enum Variant { case filled, tinted, outlined, plain } var variant: Variant = .filled var color: Color = .accentColor func makeBody(configuration: Configuration) -> some View { ButtonVariantStyleBody( configuration: configuration, variant: variant, color: configuration.role == .destructive ? .red : color ) } } extension ButtonStyle where Self == ButtonVariantStyle { static func variant(_ variant: ButtonVariantStyle.Variant, color: Color = .accentColor) -> ButtonVariantStyle { ButtonVariantStyle(variant: variant, color: color) } } private struct ButtonVariantStyleBody: View { let configuration: ButtonStyleConfiguration let variant: ButtonVariantStyle.Variant let color: Color @Environment(\.controlSize) private var controlSize @Environment(\.isEnabled) private var isEnabled @Environment(\.accessibilityReduceMotion) private var reduceMotion private var metrics: (font: Font, horizontal: Double, vertical: Double, cornerRadius: Double) { switch controlSize { case .mini, .small: (.subheadline.weight(.semibold), 12, 7, 10) case .large, .extraLarge: (.title3.weight(.semibold), 24, 16, 16) default: (.body.weight(.semibold), 18, 12, 12) } } /// The small size is shorter than 44 points, so only its touch area grows. private var touchSlop: Double { controlSize == .mini || controlSize == .small ? 6 : 0 } private var foreground: AnyShapeStyle { variant == .filled ? AnyShapeStyle(ButtonVariantStyleContrastingForeground(background: color)) : AnyShapeStyle(color) } var body: some View { let shape = RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) let isPressed = configuration.isPressed configuration.label .font(metrics.font) .foregroundStyle(foreground) .padding(.horizontal, metrics.horizontal) .padding(.vertical, metrics.vertical) .background { switch variant { case .filled: shape.fill(color).brightness(isPressed ? -0.08 : 0) case .tinted: shape.fill(color.opacity(isPressed ? 0.25 : 0.15)) case .outlined, .plain: shape.fill(color.opacity(isPressed ? 0.12 : 0)) } } .overlay { if variant == .outlined { shape.strokeBorder(color, lineWidth: 1.5) } } .padding(.vertical, touchSlop) .contentShape(.rect) .padding(.vertical, -touchSlop) .scaleEffect(isPressed && !reduceMotion ? 0.96 : 1) .opacity(isEnabled ? (isPressed && reduceMotion ? 0.7 : 1) : 0.4) .animation(reduceMotion ? nil : .spring(duration: 0.25, bounce: 0.4), value: isPressed) } } nonisolated struct ButtonVariantStyleContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct ButtonVariantStyleDemo: View { @State private var controlSize = ControlSize.regular @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize private var rowLayout: AnyLayout { dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 12)) : AnyLayout(HStackLayout(spacing: 12)) } var body: some View { ScrollView { VStack(spacing: 28) { Picker("Size", selection: $controlSize) { Text("Small").tag(ControlSize.small) Text("Medium").tag(ControlSize.regular) Text("Large").tag(ControlSize.large) } .pickerStyle(.segmented) VStack(spacing: 16) { Button {} label: { Text("Continue").frame(maxWidth: .infinity) } .buttonStyle(.variant(.filled)) rowLayout { Button("Save", systemImage: "bookmark") {} .buttonStyle(.variant(.tinted)) Button("Share", systemImage: "square.and.arrow.up") {} .buttonStyle(.variant(.outlined)) } Button("Not Now") {} .buttonStyle(.variant(.plain)) Divider() rowLayout { Button("Delete", role: .destructive) {} .buttonStyle(.variant(.filled)) Button("Remove", role: .destructive) {} .buttonStyle(.variant(.tinted)) } rowLayout { Button("Follow") {} .buttonStyle(.variant(.filled, color: .orange)) Button("Sold Out") {} .buttonStyle(.variant(.filled)) .disabled(true) } } .controlSize(controlSize) .padding(20) .background(Color(uiColor: .secondarySystemGroupedBackground), in: .rect(cornerRadius: 24, style: .continuous)) } .padding(20) .animation(reduceMotion ? nil : .snappy, value: controlSize) } .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) } } #Preview { ButtonVariantStyleDemo() } ``` ## Elastic Drag Button A round button that stretches toward the finger and springs back on release. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ElasticDragButton/ElasticDragButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A round button that stretches toward your finger and springs back on release. /// A press that does not travel counts as a tap. /// /// With Reduce Motion on, the button stays in place and only dims while pressed. struct ElasticDragButton: View { let systemImage: String let label: LocalizedStringResource var tint: Color = .indigo var reach: Double = 80 let action: () -> Void @State private var pull = CGSize.zero @State private var stretch = 0.0 @State private var angle = Angle.zero @GestureState private var isPressed = false @ScaledMetric(relativeTo: .largeTitle) private var diameter = 112.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection private let tapTolerance = 12.0 /// Gestures report screen points, but layout mirrors in right-to-left layouts: 1 or -1 converts between them. private var forward: CGFloat { layoutDirection == .rightToLeft ? -1 : 1 } var body: some View { ZStack { Circle() .fill(tint.gradient) .scaleEffect(x: 1 + stretch, y: 1 - stretch * 0.45) .rotationEffect(angle) .shadow(color: tint.opacity(0.45), radius: 18, y: 10) Image(systemName: systemImage) .font(.largeTitle.bold()) .foregroundStyle(ElasticDragButtonContrastingForeground(background: tint)) } .frame(width: diameter, height: diameter) .offset(pull) .opacity(isPressed && reduceMotion ? 0.7 : 1) .contentShape(.circle) .gesture(drag) .onChange(of: isPressed) { _, pressed in guard !pressed else { return } withAnimation(.spring(duration: 0.6, bounce: 0.65)) { pull = .zero stretch = 0 } } .accessibilityElement(children: .ignore) .accessibilityLabel(label) .accessibilityAddTraits(.isButton) .accessibilityAction { action() } } private var drag: some Gesture { DragGesture(minimumDistance: 0) .updating($isPressed) { _, state, _ in state = true } .onChanged { value in guard !reduceMotion else { return } let distance = hypot(value.translation.width, value.translation.height) guard distance > 1 else { return } let eased = reach * (1 - exp(-distance / (reach * 2))) let width = value.translation.width * forward pull = CGSize( width: width * eased / distance, height: value.translation.height * eased / distance ) stretch = eased / reach * 0.4 angle = .radians(atan2(value.translation.height, width)) } .onEnded { value in if hypot(value.translation.width, value.translation.height) < tapTolerance { action() } } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct ElasticDragButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct ElasticDragButtonDemo: View { @State private var taps = 0 var body: some View { ElasticDragButton(systemImage: "hand.point.up.left.fill", label: "Stretch") { taps += 1 } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text("Pull me") .font(.largeTitle.bold()) Text(taps == 1 ? "1 tap" : "\(taps) taps") .font(.headline) .foregroundStyle(.secondary) } .padding(.top, 72) } .background( LinearGradient(colors: [.indigo.opacity(0.14), .pink.opacity(0.12)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() ) } } #Preview { ElasticDragButtonDemo() } ``` ## Emoji Reaction Picker A long-press control that reveals a row of reaction emoji, magnifies the one under the finger and selects it on release. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/EmojiReactionPicker/EmojiReactionPicker.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct EmojiReactionPickerOption: Identifiable, Equatable, Sendable { let emoji: String let name: LocalizedStringResource var id: String { emoji } static let defaults = [ EmojiReactionPickerOption(emoji: "👍", name: "Like"), EmojiReactionPickerOption(emoji: "❤️", name: "Love"), EmojiReactionPickerOption(emoji: "😂", name: "Haha"), EmojiReactionPickerOption(emoji: "😮", name: "Wow"), EmojiReactionPickerOption(emoji: "😢", name: "Sad"), ] } enum EmojiReactionPickerMath { /// The cell under a horizontal position, clamped to the row. Nil when there is no row to hit. static func index(atX x: CGFloat, cellWidth: CGFloat, spacing: CGFloat, count: Int) -> Int? { guard count > 0, cellWidth > 0, x.isFinite else { return nil } let position = x / (cellWidth + max(spacing, 0)) return Int(min(max(position, 0), CGFloat(count - 1))) } } /// A button that toggles the first reaction on tap and reveals every reaction on long press. /// Slide along the row to magnify one and lift the finger to pick it. /// /// The row opens above the button's leading edge. Reduce Motion replaces the magnification /// with a highlight, and VoiceOver offers each reaction as an action. struct EmojiReactionPicker: View { private enum Press: Equatable { case idle case active(location: CGPoint?) } @Binding var selection: EmojiReactionPickerOption? var options = EmojiReactionPickerOption.defaults var onSelect: (EmojiReactionPickerOption?) -> Void = { _ in } @GestureState private var press = Press.idle /// Measured from the row, which reads its own size under its text size cap. @State private var cellSize = 46.0 @State private var triggerWidth: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection private let spacing = 6.0 private let padding = 8.0 private let gap = 12.0 /// How far from the row the finger can drift before lifting it cancels. private let tolerance = 70.0 private var isOpen: Bool { press != .idle && !options.isEmpty } private var highlighted: Int? { guard case .active(let location?) = press else { return nil } return index(at: location) } private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.28, bounce: 0.35) } var body: some View { trigger .contentShape(.capsule) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { triggerWidth = $0 } .onTapGesture(perform: toggle) .gesture(longPressAndSlide) .overlay(alignment: .topLeading) { if isOpen { row .padding(.bottom, gap) // A zero-height frame hangs the row above the button, whatever its height. .frame(height: 0, alignment: .bottom) .transition(reduceMotion ? .opacity : .scale(scale: 0.5, anchor: .bottomLeading).combined(with: .opacity)) } } .animation(animation, value: isOpen) .animation(animation, value: highlighted) .sensoryFeedback(.selection, trigger: highlighted) .accessibilityElement(children: .ignore) .accessibilityLabel("Reaction") .accessibilityValue(selection?.name ?? "None") .accessibilityAddTraits(.isButton) .accessibilityAction(.default, toggle) .accessibilityActions { ForEach(options) { option in Button(option.name) { choose(option) } } } } private var trigger: some View { HStack(spacing: 8) { if let selection { Text(selection.emoji) Text(selection.name) } else { Image(systemName: "hand.thumbsup") Text("React") } } .font(.headline) .foregroundStyle(selection == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary)) .padding(.horizontal, 18) .padding(.vertical, 12) .background(.quaternary, in: Capsule()) } private var row: some View { EmojiReactionPickerRow(options: options, highlighted: highlighted, spacing: spacing, padding: padding) // The row cannot wrap under the finger, so it stops growing. .dynamicTypeSize(...DynamicTypeSize.xxxLarge) .fixedSize() .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { cellSize = $0 - padding * 2 } } private var longPressAndSlide: some Gesture { LongPressGesture(minimumDuration: 0.3) .sequenced(before: DragGesture(minimumDistance: 0)) .updating($press) { value, state, _ in switch value { case .first(true): state = .active(location: nil) case .second(true, let drag): state = .active(location: drag?.location) default: state = .idle } } .onEnded { value in guard case .second(true, let drag?) = value, let index = index(at: drag.location), options.indices.contains(index) else { return } choose(options[index]) } } /// Positions are relative to the button, and the row's first cell starts at its leading edge. private func index(at location: CGPoint) -> Int? { let rowHeight = cellSize + padding * 2 guard location.y > -(gap + rowHeight + tolerance), location.y < cellSize + tolerance else { return nil } // Gestures report screen points, but the row mirrors in right-to-left layouts. let x = layoutDirection == .rightToLeft ? triggerWidth - location.x : location.x return EmojiReactionPickerMath.index(atX: x - padding, cellWidth: cellSize, spacing: spacing, count: options.count) } private func toggle() { guard let first = options.first else { return } let new = selection == nil ? first : nil selection = new onSelect(new) } private func choose(_ option: EmojiReactionPickerOption) { selection = option onSelect(option) } } private struct EmojiReactionPickerRow: View { let options: [EmojiReactionPickerOption] let highlighted: Int? let spacing: Double let padding: Double @ScaledMetric(relativeTo: .title) private var cellSize = 46.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { HStack(spacing: spacing) { ForEach(Array(options.enumerated()), id: \.offset) { index, option in let isHighlighted = index == highlighted Text(option.emoji) .font(.title) .frame(width: cellSize, height: cellSize) .background { if isHighlighted && reduceMotion { Circle().fill(.tint.opacity(0.3)) } } .scaleEffect(isHighlighted && !reduceMotion ? 1.55 : 1, anchor: .bottom) .zIndex(isHighlighted ? 1 : 0) } } .padding(padding) .background(.regularMaterial, in: Capsule()) .shadow(color: .black.opacity(0.18), radius: 14, y: 6) } } // MARK: - Demo struct EmojiReactionPickerDemo: View { @State private var reaction: EmojiReactionPickerOption? var body: some View { VStack(alignment: .leading, spacing: 16) { RoundedRectangle(cornerRadius: 22, style: .continuous) .fill(.linearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(height: 280) .overlay { Image(systemName: "sun.haze.fill") .font(.system(.largeTitle, weight: .bold)) .foregroundStyle(.white) } .accessibilityHidden(true) Text("Sunset from the rooftop") .font(.title3.weight(.semibold)) EmojiReactionPicker(selection: $reaction) .padding(.top, 72) } .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { EmojiReactionPickerDemo() } ``` ## Expanding Search Button A round magnifying glass button that opens into a search field. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ExpandingSearchButton/ExpandingSearchButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A round magnifying glass button that opens into a search field. /// Closing it clears the text and dismisses the keyboard. /// /// With Reduce Motion on, the field appears without animating. struct ExpandingSearchButton: View { @Binding var text: String var prompt: LocalizedStringResource = "Search" var onSubmit: () -> Void = {} @State private var isExpanded = false @FocusState private var isFocused: Bool @ScaledMetric(relativeTo: .body) private var height = 54.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var animation: Animation? { reduceMotion ? nil : .spring(duration: 0.45, bounce: 0.25) } var body: some View { HStack(spacing: 0) { Button { open() } label: { Image(systemName: "magnifyingglass") .font(.headline) .frame(width: height, height: height) .contentShape(.circle) } .buttonStyle(.plain) .accessibilityLabel("Search") if isExpanded { TextField(text: $text) { Text(prompt) } .textInputAutocapitalization(.never) .autocorrectionDisabled() .focused($isFocused) .submitLabel(.search) .onSubmit(onSubmit) .onAppear { isFocused = true } .transition(.opacity) Button { close() } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .symbolRenderingMode(.hierarchical) .frame(width: height, height: height) .contentShape(.circle) } .buttonStyle(.plain) .accessibilityLabel("Close") .transition(.opacity) } } .frame(maxWidth: isExpanded ? .infinity : height) .background(.regularMaterial, in: .capsule) .overlay(Capsule().strokeBorder(.quaternary)) } private func open() { if isExpanded { isFocused = true } else { withAnimation(animation) { isExpanded = true } } } private func close() { text = "" isFocused = false withAnimation(animation) { isExpanded = false } } } // MARK: - Demo struct ExpandingSearchButtonDemo: View { private struct Row: Identifiable { let id: String let systemImage: String } @State private var text = "" private let rows = [ Row(id: "Albums", systemImage: "square.stack.fill"), Row(id: "Artists", systemImage: "music.mic"), Row(id: "Playlists", systemImage: "music.note.list"), Row(id: "Podcasts", systemImage: "antenna.radiowaves.left.and.right"), Row(id: "Radio", systemImage: "dot.radiowaves.left.and.right"), ] private var visibleRows: [Row] { text.isEmpty ? rows : rows.filter { $0.id.localizedStandardContains(text) } } var body: some View { VStack(alignment: .leading, spacing: 28) { HStack(spacing: 16) { Text("Library") .font(.largeTitle.bold()) .fixedSize() ExpandingSearchButton(text: $text) .frame(maxWidth: .infinity, alignment: .trailing) } VStack(alignment: .leading, spacing: 22) { ForEach(visibleRows) { row in Label(row.id, systemImage: row.systemImage) .font(.title3) } } if visibleRows.isEmpty { ContentUnavailableView.search(text: text) } Spacer(minLength: 0) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } #Preview { ExpandingSearchButtonDemo() } ``` ## Floating Action Menu A plus button that rotates into a close icon and fans out its actions around it. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/FloatingActionMenu/FloatingActionMenu.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct FloatingActionMenuItem { let title: LocalizedStringResource let systemImage: String var tint: Color = .indigo let action: () -> Void } enum FloatingActionMenuMath { /// The offset of an item on an arc. Angles use screen coordinates: 180 is left and 270 is up. static func offset(index: Int, count: Int, radius: Double, startDegrees: Double, endDegrees: Double) -> CGSize { guard count > 0, (0.. Double { guard count > 1 else { return radius } let step = abs(endDegrees - startDegrees) / Double(count - 1) * .pi / 180 let chordPerRadius = 2 * sin(step / 2) guard chordPerRadius > 0.01 else { return radius } return max(radius, min((itemSize + 6) / chordPerRadius, limit)) } /// The largest radius that keeps every item at least `inset` away from the edges of `container`. static func radiusLimit(center: CGPoint, container: CGSize, inset: Double, count: Int, startDegrees: Double, endDegrees: Double) -> Double { (0.. Double { if direction < -0.001 { return before / -direction } if direction > 0.001 { return after / direction } return .infinity } } /// A plus button that turns into a close icon and fans out its actions around it. /// /// The fan opens up and to the left by default, for a button in the bottom-trailing corner. /// With more than four items it widens so they do not overlap, until it reaches the edge of the screen. /// In the corner of a phone at the default text size, the default fan fits seven actions at 320 points /// wide and nine at 393. Any more stay on screen but overlap, so use a list for longer menus. /// With Reduce Motion on, the actions appear in place without moving. struct FloatingActionMenu: View { let items: [FloatingActionMenuItem] var tint: Color = .indigo var startDegrees = 180.0 var endDegrees = 270.0 var body: some View { FloatingActionMenuStage(items: items, tint: tint, startDegrees: startDegrees, endDegrees: endDegrees) // The buttons only hold icons. The stage reads its sizes under this cap, which keeps the fan on screen. .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } } private struct FloatingActionMenuStage: View { let items: [FloatingActionMenuItem] let tint: Color let startDegrees: Double let endDegrees: Double @State private var isOpen = false @ScaledMetric(relativeTo: .title2) private var buttonSize = 60.0 @ScaledMetric(relativeTo: .title2) private var itemSize = 50.0 @ScaledMetric(relativeTo: .title2) private var radius = 112.0 @State private var containerFrame = CGRect.zero @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { ForEach(Array(items.enumerated()), id: \.offset) { index, item in itemButton(item, index: index) } Button { withAnimation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.3)) { isOpen.toggle() } } label: { Image(systemName: "plus") .font(.title2.weight(.bold)) .rotationEffect(.degrees(isOpen ? 135 : 0)) .foregroundStyle(FloatingActionMenuContrastingForeground(background: tint)) .frame(width: buttonSize, height: buttonSize) .background(tint.gradient, in: Circle()) .shadow(color: tint.opacity(0.4), radius: 12, y: 6) } .buttonStyle(.plain) .accessibilityLabel(isOpen ? "Close menu" : "Open menu") } .background { // Centred on the button and as big as its container, so one frame gives both. Color.clear .containerRelativeFrame([.horizontal, .vertical]) .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { containerFrame = $0 } .allowsHitTesting(false) .accessibilityHidden(true) } .sensoryFeedback(.impact(weight: .light), trigger: isOpen) } private func itemButton(_ item: FloatingActionMenuItem, index: Int) -> some View { let offset = FloatingActionMenuMath.offset( index: index, count: items.count, radius: FloatingActionMenuMath.fittedRadius( radius, itemSize: itemSize, count: items.count, startDegrees: startDegrees, endDegrees: endDegrees, limit: FloatingActionMenuMath.radiusLimit( center: CGPoint(x: containerFrame.midX, y: containerFrame.midY), container: containerFrame.size, inset: itemSize / 2 + 8, count: items.count, startDegrees: startDegrees, endDegrees: endDegrees ) ), startDegrees: startDegrees, endDegrees: endDegrees ) return Button { withAnimation(reduceMotion ? nil : .spring(duration: 0.35)) { isOpen = false } item.action() } label: { Image(systemName: item.systemImage) .font(.title3.weight(.semibold)) .foregroundStyle(FloatingActionMenuContrastingForeground(background: item.tint)) .frame(width: itemSize, height: itemSize) .background(item.tint, in: Circle()) .shadow(color: .black.opacity(0.18), radius: 8, y: 4) } .buttonStyle(.plain) .accessibilityLabel(item.title) .accessibilityHidden(!isOpen) .allowsHitTesting(isOpen) .scaleEffect(isOpen ? 1 : 0.4) .opacity(isOpen ? 1 : 0) .offset(isOpen ? offset : .zero) .animation( reduceMotion ? nil : .spring(duration: 0.42, bounce: 0.32).delay(isOpen ? Double(index) * 0.04 : 0), value: isOpen ) } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct FloatingActionMenuContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct FloatingActionMenuDemo: View { @State private var lastAction = "Tap the plus button" private var items: [FloatingActionMenuItem] { [ FloatingActionMenuItem(title: "Share", systemImage: "square.and.arrow.up", tint: .blue) { lastAction = "Shared" }, FloatingActionMenuItem(title: "Photo", systemImage: "camera.fill", tint: .pink) { lastAction = "Photo added" }, FloatingActionMenuItem(title: "Note", systemImage: "note.text", tint: .orange) { lastAction = "Note added" }, ] } var body: some View { ZStack(alignment: .bottomTrailing) { Text(lastAction) .font(.title3.weight(.semibold)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, maxHeight: .infinity) FloatingActionMenu(items: items) .padding(28) } } } #Preview { FloatingActionMenuDemo() } ``` ## Gradient Border Button A capsule button whose border is an angular gradient that keeps rotating. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/GradientBorderButton/GradientBorderButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A capsule button whose border is an angular gradient that keeps rotating. /// /// With Reduce Motion on, the gradient stays still. struct GradientBorderButton: View { var colors: [Color] = [.pink, .orange, .yellow, .mint, .blue, .purple, .pink] var lineWidth: Double = 3 var period: Double = 3 let action: () -> Void @ViewBuilder let label: () -> Content @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button(action: action) { label() .font(.title3.bold()) .padding(.horizontal, 40) .padding(.vertical, 20) .background(.ultraThinMaterial, in: .capsule) .contentShape(.capsule) } .buttonStyle(.plain) .overlay { TimelineView(.animation(minimumInterval: nil, paused: reduceMotion)) { context in border(at: context.date) } .allowsHitTesting(false) .accessibilityHidden(true) } } private func border(at date: Date) -> some View { let elapsed = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: period) let gradient = AngularGradient( colors: colors, center: .center, angle: .degrees(reduceMotion ? 0 : elapsed / period * 360) ) return ZStack { Capsule() .strokeBorder(gradient, lineWidth: lineWidth) .blur(radius: 12) .opacity(0.8) Capsule() .strokeBorder(gradient, lineWidth: lineWidth) } } } // MARK: - Demo struct GradientBorderButtonDemo: View { @State private var isUpgraded = false var body: some View { GradientBorderButton { isUpgraded.toggle() } label: { Label(isUpgraded ? "Upgraded" : "Upgrade to Pro", systemImage: "crown.fill") .foregroundStyle(.white) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(red: 0.05, green: 0.05, blue: 0.1).ignoresSafeArea()) } } #Preview { GradientBorderButtonDemo() } ``` ## Hamburger Menu Button A three-line menu button whose bars animate into a close cross when toggled. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/HamburgerMenuButton/HamburgerMenuButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct HamburgerMenuButtonBar: Equatable { let start: CGPoint let end: CGPoint } /// Three bars that become a cross as `progress` goes from 0 to 1. nonisolated struct HamburgerMenuButtonShape: Shape { var progress: Double var animatableData: Double { get { progress } set { progress = newValue } } /// The outer bars slide to the middle while turning. The middle bar shrinks away. func bars(in rect: CGRect) -> [HamburgerMenuButtonBar] { let amount = min(max(progress, 0), 1) let halfWidth = rect.width / 2 let gap = rect.height * 0.32 func outerBar(side: Double) -> HamburgerMenuButtonBar { let y = rect.midY + side * gap * (1 - amount) let angle = -side * .pi / 4 * amount let dx = cos(angle) * halfWidth let dy = sin(angle) * halfWidth return HamburgerMenuButtonBar( start: CGPoint(x: rect.midX - dx, y: y - dy), end: CGPoint(x: rect.midX + dx, y: y + dy) ) } var result = [outerBar(side: -1), outerBar(side: 1)] if amount < 1 { let dx = halfWidth * (1 - amount) result.append(HamburgerMenuButtonBar( start: CGPoint(x: rect.midX - dx, y: rect.midY), end: CGPoint(x: rect.midX + dx, y: rect.midY) )) } return result } func path(in rect: CGRect) -> Path { var path = Path() for bar in bars(in: rect) { path.move(to: bar.start) path.addLine(to: bar.end) } return path } } /// A three-line menu button whose bars animate into a close cross. /// /// With Reduce Motion on, the icon changes without morphing. struct HamburgerMenuButton: View { @Binding var isOpen: Bool var color: Color = .primary var openLabel = "Open menu" var closeLabel = "Close menu" @ScaledMetric(relativeTo: .title2) private var size = 24.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button { withAnimation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.25)) { isOpen.toggle() } } label: { HamburgerMenuButtonShape(progress: isOpen ? 1 : 0) .stroke(color, style: StrokeStyle(lineWidth: max(size * 0.12, 2), lineCap: .round)) .frame(width: size, height: size) .frame(minWidth: 44, minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(isOpen ? closeLabel : openLabel) } } // MARK: - Demo struct HamburgerMenuButtonDemo: View { @State private var isOpen = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { HamburgerMenuButton(isOpen: $isOpen) Text("Inbox") .font(.system(.title2, design: .rounded, weight: .bold)) } .padding(.horizontal, 12) if isOpen { VStack(alignment: .leading, spacing: 22) { // One width for every symbol, so the names line up. ForEach([("Inbox", "tray.fill"), ("Starred", "star.fill"), ("Sent", "paperplane.fill"), ("Trash", "trash.fill")], id: \.0) { title, symbol in Label { Text(title) } icon: { Image(systemName: symbol).frame(width: 30) } } } .font(.title3.weight(.medium)) .padding(28) .transition(reduceMotion ? .opacity : .move(edge: .leading).combined(with: .opacity)) } Spacer(minLength: 0) } .padding(.top, 12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } #Preview { HamburgerMenuButtonDemo() } ``` ## Hold To Confirm Button A round button that fires only after a long press fills its progress ring, and cancels if released early. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/HoldToConfirmButton/HoldToConfirmButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A round button that only fires after being held while its ring fills. /// Releasing early drains the ring and cancels. /// /// With Reduce Motion on, the ring still fills, because it carries the meaning, /// but the button does not squeeze. VoiceOver users confirm with a double tap. struct HoldToConfirmButton: View { let title: LocalizedStringResource var systemImage: String = "checkmark" var tint: Color = .red var holdDuration: Double = 1.2 let action: () -> Void @State private var progress = 0.0 @State private var isConfirmed = false @State private var holdTask: Task? @ScaledMetric(relativeTo: .title) private var diameter = 116.0 @ScaledMetric(relativeTo: .title) private var ringWidth = 8.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var isHolding: Bool { progress > 0 && !isConfirmed } var body: some View { VStack(spacing: 14) { ZStack { Circle() .stroke(tint.opacity(0.18), lineWidth: ringWidth) Circle() .trim(from: 0, to: progress) .stroke(isConfirmed ? Color.green : tint, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) Circle() .fill((isConfirmed ? Color.green : tint).gradient) .padding(ringWidth * 1.75) Image(systemName: isConfirmed ? "checkmark" : systemImage) .font(.title.bold()) .foregroundStyle(HoldToConfirmButtonContrastingForeground(background: isConfirmed ? .green : tint)) .contentTransition(reduceMotion ? .identity : .symbolEffect(.replace)) } .frame(width: diameter, height: diameter) .scaleEffect(isHolding && !reduceMotion ? 0.92 : 1) .contentShape(.circle) // The gesture only reports pressing. The task below times the hold, // so the long minimum duration here never completes first. .onLongPressGesture(minimumDuration: 60, perform: {}, onPressingChanged: { isPressing in pressingChanged(isPressing) }) Text(title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) } .sensoryFeedback(.success, trigger: isConfirmed) { _, confirmed in confirmed } .accessibilityElement(children: .ignore) .accessibilityLabel(title) .accessibilityHint("Double tap to confirm") .accessibilityAddTraits(.isButton) .accessibilityAction { confirm() } } private func pressingChanged(_ isPressing: Bool) { guard !isConfirmed else { return } holdTask?.cancel() if isPressing { withAnimation(.linear(duration: holdDuration)) { progress = 1 } holdTask = Task { try? await Task.sleep(for: .seconds(holdDuration)) guard !Task.isCancelled else { return } confirm() } } else { withAnimation(.easeOut(duration: 0.25)) { progress = 0 } } } private func confirm() { guard !isConfirmed else { return } withAnimation(.easeOut(duration: 0.2)) { progress = 1 isConfirmed = true } action() holdTask = Task { try? await Task.sleep(for: .seconds(1.2)) guard !Task.isCancelled else { return } withAnimation(.easeOut(duration: 0.3)) { progress = 0 isConfirmed = false } } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct HoldToConfirmButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct HoldToConfirmButtonDemo: View { @State private var deleted = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { HoldToConfirmButton(title: "Hold to delete", systemImage: "trash.fill") { deleted += 1 } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text(deleted, format: .number) .font(.system(.largeTitle, design: .rounded, weight: .bold)) .contentTransition(reduceMotion ? .identity : .numericText(value: Double(deleted))) .animation(reduceMotion ? nil : .snappy, value: deleted) Text("Files deleted") .font(.headline) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) .padding(.top, 72) } .background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea()) } } #Preview { HoldToConfirmButtonDemo() } ``` ## Like Burst Button A heart button that pops and throws out a burst of small particles when liked. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/LikeBurstButton/LikeBurstButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A heart that pops and throws out a burst of small particles when liked. /// /// With Reduce Motion on, the heart simply fills, with no pop or burst. struct LikeBurstButton: View { @Binding var isLiked: Bool var color: Color = .pink var particleCount: Int = 12 @State private var burstCount = 0 @ScaledMetric(relativeTo: .largeTitle) private var heartSize = 72.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button { isLiked.toggle() if isLiked && !reduceMotion { burstCount += 1 } } label: { ZStack { ForEach(0.. Void @State private var phase = MorphingSubmitButtonPhase.idle @ScaledMetric(relativeTo: .headline) private var height = 64.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var morph: Animation { reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.5, bounce: 0.3) } private var isCollapsed: Bool { phase != .idle && !reduceMotion } private var accessibilityTitle: LocalizedStringResource { switch phase { case .idle: title case .loading: "Submitting" case .success: "Done" } } var body: some View { Button { submit() } label: { ZStack { Text(title) .font(.title3.bold()) .fixedSize() .opacity(phase == .idle ? 1 : 0) if phase == .loading { ProgressView() .tint(MorphingSubmitButtonContrastingForeground(background: phase == .success ? successColor : tint)) .transition(.opacity) } if phase == .success { Image(systemName: "checkmark") .font(.title2.bold()) .transition(reduceMotion ? .opacity : .scale.combined(with: .opacity)) } } .foregroundStyle(MorphingSubmitButtonContrastingForeground(background: phase == .success ? successColor : tint)) .padding(.horizontal, isCollapsed ? 0 : 52) .frame(width: isCollapsed ? height : nil, height: height) .background( phase == .success ? successColor : tint, in: RoundedRectangle(cornerRadius: isCollapsed ? height / 2 : 18, style: .continuous) ) .contentShape(.rect) } .buttonStyle(.plain) .sensoryFeedback(.success, trigger: phase) { _, newPhase in newPhase == .success } .accessibilityLabel(accessibilityTitle) } private func submit() { guard phase == .idle else { return } withAnimation(morph) { phase = .loading } Task { await action() withAnimation(morph) { phase = .success } try? await Task.sleep(for: .seconds(successDuration)) withAnimation(morph) { phase = .idle } } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct MorphingSubmitButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct MorphingSubmitButtonDemo: View { var body: some View { VStack(spacing: 32) { VStack(spacing: 10) { Image(systemName: "paperplane.fill") .font(.largeTitle) .foregroundStyle(.blue) .accessibilityHidden(true) Text("Join the waitlist") .font(.largeTitle.bold()) Text("We will write when your spot opens.") .font(.body) .foregroundStyle(.secondary) } MorphingSubmitButton(title: "Submit") { try? await Task.sleep(for: .seconds(1.4)) } .frame(height: 80) } .multilineTextAlignment(.center) .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { MorphingSubmitButtonDemo() } ``` ## Play Pause Morph Button A play button whose triangle morphs into two pause bars by interpolating its path. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/PlayPauseMorphButton/PlayPauseMorphButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A triangle that morphs into two bars as `progress` goes from 0 to 1. nonisolated struct PlayPauseMorphButtonShape: Shape { var progress: Double var animatableData: Double { get { progress } set { progress = newValue } } private static let iconTop = 0.1 private static let iconBottom = 0.9 /// The x where the triangle is cut in two. private static let triangleCut = 0.57 /// The triangle, cut in two, in a unit square. Corners match one to one with `pauseBars`. private static let playHalves: [[CGPoint]] = [ [CGPoint(x: 0.22, y: iconTop), CGPoint(x: triangleCut, y: 0.3), CGPoint(x: triangleCut, y: 0.7), CGPoint(x: 0.22, y: iconBottom)], [CGPoint(x: triangleCut, y: 0.3), CGPoint(x: 0.92, y: 0.5), CGPoint(x: 0.92, y: 0.5), CGPoint(x: triangleCut, y: 0.7)], ] private static let pauseBars: [[CGPoint]] = [ [CGPoint(x: 0.2, y: iconTop), CGPoint(x: 0.42, y: iconTop), CGPoint(x: 0.42, y: iconBottom), CGPoint(x: 0.2, y: iconBottom)], [CGPoint(x: 0.58, y: iconTop), CGPoint(x: 0.8, y: iconTop), CGPoint(x: 0.8, y: iconBottom), CGPoint(x: 0.58, y: iconBottom)], ] /// The two four-cornered pieces at the current progress, in a unit square. func pieces() -> [[CGPoint]] { let amount = min(max(progress, 0), 1) return zip(Self.playHalves, Self.pauseBars).map { play, pause in zip(play, pause).map { from, to in CGPoint(x: from.x + (to.x - from.x) * amount, y: from.y + (to.y - from.y) * amount) } } } func path(in rect: CGRect) -> Path { var path = Path() for piece in pieces() { path.addLines(piece.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) }) path.closeSubpath() } return path } } /// A play button whose triangle morphs into two pause bars. /// /// With Reduce Motion on, the icon changes without morphing. struct PlayPauseMorphButton: View { @Binding var isPlaying: Bool var colors: [Color] = [.pink, .purple] @ScaledMetric(relativeTo: .largeTitle) private var size = 92.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// The side of the icon as a fraction of the button's. private let iconScale = 0.42 var body: some View { Button { withAnimation(reduceMotion ? nil : .spring(duration: 0.42, bounce: 0.2)) { isPlaying.toggle() } } label: { let shape = PlayPauseMorphButtonShape(progress: isPlaying ? 1 : 0) ZStack { shape.fill(.white) // A stroke of the same color softens the corners. shape.stroke(.white, style: StrokeStyle(lineWidth: size * 0.04, lineJoin: .round)) } .frame(width: size * iconScale, height: size * iconScale) .frame(width: size, height: size) .background(.linearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: Circle()) .shadow(color: (colors.first ?? .black).opacity(0.4), radius: 18, y: 10) } .buttonStyle(.plain) .accessibilityLabel(isPlaying ? "Pause" : "Play") } } // MARK: - Demo struct PlayPauseMorphButtonDemo: View { @State private var isPlaying = false var body: some View { VStack(spacing: 32) { VStack(spacing: 6) { Text("Midnight Drive") .font(.system(.title, design: .rounded, weight: .bold)) Text(isPlaying ? "Now playing" : "Paused") .font(.headline) .foregroundStyle(.secondary) } PlayPauseMorphButton(isPlaying: $isPlaying) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { PlayPauseMorphButtonDemo() } ``` ## Pressable Button Style A button style that shrinks and dims on press, then springs back. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/PressableButtonStyle/PressableButtonStyle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Shrinks and dims a button while it is pressed, then springs back. /// /// With Reduce Motion on, the button only dims. struct PressableButtonStyle: ButtonStyle { var pressedScale: CGFloat = 0.94 var pressedOpacity: Double = 0.8 @Environment(\.accessibilityReduceMotion) private var reduceMotion func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed && !reduceMotion ? pressedScale : 1) .opacity(configuration.isPressed ? pressedOpacity : 1) .animation(.spring(duration: 0.3, bounce: 0.45), value: configuration.isPressed) } } extension ButtonStyle where Self == PressableButtonStyle { static var pressable: PressableButtonStyle { PressableButtonStyle() } } // MARK: - Demo struct PressableButtonStyleDemo: View { @State private var likes = 0 var body: some View { VStack(spacing: 28) { Text(likes == 1 ? "1 like" : "\(likes) likes") .font(.largeTitle.bold()) .contentTransition(.numericText()) Button { withAnimation { likes += 1 } } label: { Label("Like", systemImage: "heart.fill") .font(.title2.bold()) .foregroundStyle(.white) .padding(.horizontal, 44) .padding(.vertical, 20) .background(.pink.gradient, in: .capsule) } .buttonStyle(.pressable) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { PressableButtonStyleDemo() } ``` ## Provider Sign In Button Stack A stack of full-width sign-in buttons, each with a leading icon slot and its own loading state while authenticating. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ProviderSignInButtonStack/ProviderSignInButtonStack.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct ProviderSignInButtonStackOption: Identifiable { let id: String let title: LocalizedStringResource let icon: Image var background: Color = .indigo var foreground: Color = .white let authenticate: @MainActor () async -> Void } /// A stack of full-width sign-in buttons. Each shows its own spinner while it authenticates, /// and the others wait until it is done. /// /// For Sign in with Apple, use the system `SignInWithAppleButton`: Apple's guidelines require it. struct ProviderSignInButtonStack: View { let options: [ProviderSignInButtonStackOption] var spacing: CGFloat = 12 @State private var loadingID: ProviderSignInButtonStackOption.ID? @State private var running: Task? @ScaledMetric(relativeTo: .headline) private var minimumHeight = 54.0 @ScaledMetric(relativeTo: .headline) private var iconSize = 24.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: max(spacing, 0)) { ForEach(options) { option in button(for: option) } } .animation(reduceMotion ? nil : .easeInOut(duration: 0.2), value: loadingID) .onDisappear { running?.cancel() running = nil loadingID = nil } } private func button(for option: ProviderSignInButtonStackOption) -> some View { let isLoading = loadingID == option.id let isWaiting = loadingID != nil && !isLoading return Button { start(option) } label: { HStack(spacing: 14) { ZStack { if isLoading { ProgressView().tint(option.foreground) } else { option.icon .resizable() .scaledToFit() .accessibilityHidden(true) } } .frame(width: iconSize, height: iconSize) Text(option.title) .font(.headline) .multilineTextAlignment(.leading) Spacer(minLength: 0) } .foregroundStyle(option.foreground) .padding(.horizontal, 18) .padding(.vertical, 8) .frame(maxWidth: .infinity, minHeight: minimumHeight) .background(option.background, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .opacity(isWaiting ? 0.5 : 1) } .buttonStyle(.plain) // Not `disabled`, which would also dim the button that is signing in. .allowsHitTesting(loadingID == nil) .accessibilityLabel(option.title) .accessibilityValue(isLoading ? "Signing in" : "") } private func start(_ option: ProviderSignInButtonStackOption) { guard loadingID == nil else { return } loadingID = option.id let authenticate = option.authenticate running = Task { await authenticate() guard !Task.isCancelled else { return } loadingID = nil running = nil } } } // MARK: - Demo struct ProviderSignInButtonStackDemo: View { @State private var status = "Choose how to sign in" private var options: [ProviderSignInButtonStackOption] { [ option("passkey", "Continue with Passkey", symbol: "person.badge.key.fill", color: .indigo), option("email", "Continue with Email", symbol: "envelope.fill", color: .teal), option("phone", "Continue with Phone", symbol: "phone.fill", color: .pink), ] } private func option(_ id: String, _ title: LocalizedStringResource, symbol: String, color: Color) -> ProviderSignInButtonStackOption { ProviderSignInButtonStackOption(id: id, title: title, icon: Image(systemName: symbol), background: color) { do { try await Task.sleep(for: .seconds(1.4)) } catch { return } status = "Signed in with \(id)" } } var body: some View { VStack(spacing: 28) { Image(systemName: "lock.shield.fill") .font(.system(.largeTitle, weight: .bold)) .foregroundStyle(.indigo) .accessibilityHidden(true) Text(status) .font(.system(.title3, design: .rounded, weight: .semibold)) .multilineTextAlignment(.center) ProviderSignInButtonStack(options: options) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { ProviderSignInButtonStackDemo() } ``` ## Pulse Ring Button A round button with rings that keep radiating out from behind it. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/PulseRingButton/PulseRingButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI private enum PulseRingButtonPhase { case rest, expanded } /// A round button with rings that keep radiating out from behind it. /// /// With Reduce Motion on, a single still halo replaces the rings. Set `isPulsing` /// to false to leave the button without them, as when what it signals has stopped. struct PulseRingButton: View { let systemImage: String let label: LocalizedStringResource var tint: Color = .red var ringCount: Int = 3 var ringDuration: Double = 1.6 var stagger: Double = 0.35 var isPulsing = true let action: () -> Void @ScaledMetric(relativeTo: .largeTitle) private var diameter = 104.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { Group { if !isPulsing { EmptyView() } else if reduceMotion { Circle() .fill(tint.opacity(0.2)) .scaleEffect(1.35) } else { ForEach(0.. some View { let startDelay = Double(index) * stagger let endDelay = Double(ringCount - 1 - index) * stagger return Circle() .fill(tint) .phaseAnimator([PulseRingButtonPhase.rest, .expanded]) { content, phase in content .scaleEffect(phase == .expanded ? 2.5 : 1) .opacity(phase == .expanded ? 0 : 0.55) } animation: { phase in phase == .expanded ? .easeOut(duration: ringDuration).delay(startDelay) : .linear(duration: 0.01).delay(endDelay) } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct PulseRingButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct PulseRingButtonDemo: View { @State private var isRecording = true var body: some View { PulseRingButton(systemImage: isRecording ? "mic.fill" : "mic.slash.fill", label: "Record", isPulsing: isRecording) { isRecording.toggle() } .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .top) { VStack(spacing: 6) { Text(isRecording ? "Listening" : "Muted") .font(.largeTitle.bold()) Text("Tap to toggle the microphone") .font(.headline) .foregroundStyle(.white.opacity(0.65)) } .foregroundStyle(.white) .padding(.top, 72) } .background(Color(red: 0.1, green: 0.05, blue: 0.08).ignoresSafeArea()) } } #Preview { PulseRingButtonDemo() } ``` ## Shimmer Button A gradient capsule button with a band of light that sweeps across it in a loop. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ShimmerButton/ShimmerButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A gradient capsule button with a band of light that sweeps across it in a loop. /// /// With Reduce Motion on, the sweep is removed and the button stays still. struct ShimmerButton: View { let title: LocalizedStringResource var systemImage: String? var colors: [Color] = [.indigo, .purple] var sweepDuration: Double = 1.1 var pause: Double = 0.9 let action: () -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button(action: action) { HStack(spacing: 10) { if let systemImage { Image(systemName: systemImage) .accessibilityHidden(true) } Text(title) } .font(.title3.bold()) .foregroundStyle(.white) .padding(.horizontal, 40) .padding(.vertical, 20) .background(LinearGradient(colors: colors, startPoint: .leading, endPoint: .trailing)) .overlay { if !reduceMotion { sweep } } .clipShape(.capsule) .contentShape(.capsule) .shadow(color: (colors.first ?? .clear).opacity(0.5), radius: 18, y: 8) } .buttonStyle(ShimmerButtonPressStyle(reduceMotion: reduceMotion)) } private var sweep: some View { LinearGradient(colors: [.clear, .white.opacity(0.65), .clear], startPoint: .leading, endPoint: .trailing) .scaleEffect(x: 0.4, y: 2.5) .rotationEffect(.degrees(20)) .keyframeAnimator(initialValue: -1.1, repeating: true) { content, position in content.visualEffect { effect, proxy in effect.offset(x: position * proxy.size.width) } } keyframes: { _ in KeyframeTrack { LinearKeyframe(1.1, duration: sweepDuration) LinearKeyframe(1.1, duration: pause) } } .allowsHitTesting(false) .accessibilityHidden(true) } } /// The button dims and shrinks a little while it is pressed. private struct ShimmerButtonPressStyle: ButtonStyle { let reduceMotion: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .opacity(configuration.isPressed ? 0.8 : 1) .scaleEffect(configuration.isPressed && !reduceMotion ? 0.96 : 1) .animation(.snappy(duration: 0.2), value: configuration.isPressed) } } // MARK: - Demo struct ShimmerButtonDemo: View { @State private var isStarted = false var body: some View { VStack(spacing: 36) { VStack(spacing: 10) { Text("Unlock everything") .font(.largeTitle.bold()) Text("Every feature, free for a week.") .font(.body) .foregroundStyle(.white.opacity(0.7)) } ShimmerButton( title: isStarted ? "Trial started" : "Start free trial", systemImage: isStarted ? "checkmark" : "sparkles" ) { isStarted.toggle() } } .foregroundStyle(.white) .multilineTextAlignment(.center) .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(red: 0.07, green: 0.06, blue: 0.16).ignoresSafeArea()) } } #Preview { ShimmerButtonDemo() } ``` ## Shrinking Extended Button An extended floating button that collapses from a labeled pill to a plain icon as the scroll view moves down. Category: Buttons. Minimum: iOS 18.0. Path: Components/Buttons/ShrinkingExtendedButton/ShrinkingExtendedButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum ShrinkingExtendedButtonMath { /// Rubber-banding past the top gives a negative offset, which counts as being at the top. static func shouldExtend(offset: CGFloat, threshold: CGFloat) -> Bool { offset <= max(threshold, 0) } } /// An extended floating button that shrinks from a labeled pill to a plain icon. /// /// Pass `isExtended`, or drive it from a scroll view with `ShrinkingExtendedButtonScrollTracker`. /// With Reduce Motion on, it changes shape without animating. struct ShrinkingExtendedButton: View { let title: LocalizedStringResource let systemImage: String var isExtended = true var tint: Color = .indigo let action: () -> Void @ScaledMetric(relativeTo: .headline) private var height = 58.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button(action: action) { HStack(spacing: 10) { Image(systemName: systemImage) .font(.title3.weight(.semibold)) if isExtended { Text(title) .font(.headline) .transition(.opacity.combined(with: .scale(scale: 0.8, anchor: .leading))) } } .foregroundStyle(ShrinkingExtendedButtonContrastingForeground(background: tint)) .padding(.horizontal, isExtended ? 22 : 0) .frame(minWidth: height, minHeight: height) .background(tint.gradient, in: Capsule()) .shadow(color: tint.opacity(0.35), radius: 12, y: 6) } .buttonStyle(.plain) .accessibilityLabel(title) .animation(reduceMotion ? nil : .spring(duration: 0.35, bounce: 0.15), value: isExtended) } } /// Apply to a scroll view to set `isExtended` from how far it has moved from the top. struct ShrinkingExtendedButtonScrollTracker: ViewModifier { @Binding var isExtended: Bool var threshold: CGFloat = 40 func body(content: Content) -> some View { content.onScrollGeometryChange(for: Bool.self) { geometry in ShrinkingExtendedButtonMath.shouldExtend( offset: geometry.contentOffset.y + geometry.contentInsets.top, threshold: threshold ) } action: { _, shouldExtend in isExtended = shouldExtend } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct ShrinkingExtendedButtonContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct ShrinkingExtendedButtonDemo: View { @State private var isExtended = true var body: some View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack(spacing: 12) { ForEach(0..<30, id: \.self) { index in HStack(spacing: 14) { Circle() .fill(Color(hue: Double(index % 8) / 8, saturation: 0.55, brightness: 0.9)) .frame(width: 44, height: 44) .accessibilityHidden(true) Text("Message \(index + 1)") .font(.headline) Spacer(minLength: 0) } .padding(14) .background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } } .padding(16) } .modifier(ShrinkingExtendedButtonScrollTracker(isExtended: $isExtended)) ShrinkingExtendedButton(title: "New Message", systemImage: "square.and.pencil", isExtended: isExtended) {} .padding(24) } } } #Preview { ShrinkingExtendedButtonDemo() } ``` ## Calendar Month Grid A month calendar for picking one day, with swipe paging between months. Category: Calendars. Minimum: iOS 18.0. Path: Components/Calendars/CalendarMonthGrid/CalendarMonthGrid.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum CalendarMonthGridMath { /// Six weeks, so every month is the same height. static let cellCount = 42 /// The cells of a month: nil before its first day and after its last. static func days(inMonthContaining date: Date, calendar: Calendar) -> [Date?] { guard let first = calendar.dateInterval(of: .month, for: date)?.start else { return Array(repeating: nil, count: cellCount) } let leading = (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7 let dayCount = calendar.range(of: .day, in: .month, for: first)?.count ?? 0 let days = (0.. [String] { let symbols = calendar.veryShortStandaloneWeekdaySymbols guard !symbols.isEmpty else { return [] } let start = min(max(calendar.firstWeekday - 1, 0), symbols.count - 1) return Array(symbols[start...] + symbols[.. String { day.formatted(Date.FormatStyle(date: .complete, time: .omitted, calendar: calendar, timeZone: calendar.timeZone)) } } /// A month calendar for picking one day, with swipe paging between months. /// /// With Reduce Motion on, the arrows change the month without sliding. struct CalendarMonthGrid: View { @Binding var selection: Date? var anchor: Date = .now /// How many months can be reached in each direction. var monthRange = 24 var tint: Color = .indigo var body: some View { CalendarMonthGridStage(selection: $selection, anchor: anchor, monthRange: monthRange, tint: tint) // Seven columns cannot hold accessibility sizes, so growth stops here, row height included. .dynamicTypeSize(...DynamicTypeSize.xxxLarge) } } private struct CalendarMonthGridStage: View { @Binding var selection: Date? let anchor: Date let monthRange: Int let tint: Color @State private var page = 0 @ScaledMetric(relativeTo: .body) private var rowHeight = 44.0 @Environment(\.calendar) private var calendar @Environment(\.accessibilityReduceMotion) private var reduceMotion private let rowSpacing = 4.0 private var reach: Int { min(max(monthRange, 0), 600) } var body: some View { VStack(spacing: 12) { header weekdays TabView(selection: $page) { ForEach(-reach...reach, id: \.self) { offset in grid(for: month(at: offset)) .tag(offset) } } .tabViewStyle(.page(indexDisplayMode: .never)) .frame(height: rowHeight * 6 + rowSpacing * 5) } } private var header: some View { HStack { Text(month(at: page).formatted(monthStyle)) .font(.system(.title3, design: .rounded, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityAddTraits(.isHeader) arrow("chevron.backward", label: "Previous month", to: page - 1) arrow("chevron.forward", label: "Next month", to: page + 1) } } private var monthStyle: Date.FormatStyle { var style = Date.FormatStyle().month(.wide).year() style.calendar = calendar style.timeZone = calendar.timeZone return style } private func arrow(_ systemImage: String, label: String, to target: Int) -> some View { Button { withAnimation(reduceMotion ? nil : .smooth) { page = target } } label: { Image(systemName: systemImage) .font(.body.weight(.semibold)) .frame(minWidth: 44, minHeight: 44) } .tint(tint) .disabled(abs(target) > reach) .accessibilityLabel(label) } private var weekdays: some View { HStack(spacing: 0) { ForEach(Array(CalendarMonthGridMath.weekdaySymbols(calendar: calendar).enumerated()), id: \.offset) { _, symbol in Text(symbol) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) } } .accessibilityHidden(true) } private func grid(for month: Date) -> some View { let cells = CalendarMonthGridMath.days(inMonthContaining: month, calendar: calendar) let columns = Array(repeating: GridItem(.flexible(), spacing: 0), count: 7) return LazyVGrid(columns: columns, spacing: rowSpacing) { ForEach(Array(cells.enumerated()), id: \.offset) { _, day in if let day { dayButton(day) } else { Color.clear .frame(height: rowHeight) .accessibilityHidden(true) } } } } private func dayButton(_ day: Date) -> some View { let isSelected = selection.map { calendar.isDate($0, inSameDayAs: day) } ?? false let isToday = calendar.isDateInToday(day) return Button { selection = day } label: { Text(calendar.component(.day, from: day), format: .number.grouping(.never)) .font(.body.weight(isSelected || isToday ? .semibold : .regular)) .minimumScaleFactor(0.7) .lineLimit(1) .foregroundStyle(isSelected ? AnyShapeStyle(.white) : isToday ? AnyShapeStyle(tint) : AnyShapeStyle(.primary)) .frame(maxWidth: .infinity) .frame(height: rowHeight) .background { if isSelected { Circle().fill(tint) } } .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(CalendarMonthGridMath.dayLabel(for: day, calendar: calendar)) .accessibilityAddTraits(isSelected ? .isSelected : []) } private func month(at offset: Int) -> Date { calendar.date(byAdding: .month, value: offset, to: anchor) ?? anchor } } // MARK: - Demo struct CalendarMonthGridDemo: View { @State private var selection: Date? var body: some View { VStack(spacing: 20) { CalendarMonthGrid(selection: $selection) .padding(16) .background(.background, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) .shadow(color: .black.opacity(0.08), radius: 20, y: 10) Text(selection?.formatted(date: .long, time: .omitted) ?? "Pick a day") .font(.headline) .foregroundStyle(.secondary) } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(.indigo.opacity(0.08)) } } #Preview { CalendarMonthGridDemo() } ``` ## Calendar Range Selector A month calendar that picks a start and an end day and shades the days between them. Category: Calendars. Minimum: iOS 18.0. Path: Components/Calendars/CalendarRangeSelector/CalendarRangeSelector.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A month calendar that picks a start and an end day and shades the days between them. /// /// The first tap sets the start and the second sets the end. Tapping a day before the /// start, or any day once the range is complete, starts a new range. struct CalendarRangeSelector: View { @Binding var start: Date? @Binding var end: Date? var calendar: Calendar var tint: Color @State private var month: Date @ScaledMetric(relativeTo: .body) private var dayHeight = 46.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.locale) private var locale init( start: Binding, end: Binding, month: Date = .now, calendar: Calendar = .current, tint: Color = .indigo ) { _start = start _end = end self.calendar = calendar self.tint = tint _month = State(initialValue: month) } private var selection: CalendarRangeSelectorLogic.Selection { .init(start: start, end: end) } var body: some View { VStack(spacing: 12) { header HStack(spacing: 0) { ForEach(Array(CalendarRangeSelectorLogic.weekdaySymbols(calendar: calendar).enumerated()), id: \.offset) { _, symbol in Text(symbol) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) } } .accessibilityHidden(true) LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 4) { ForEach(0.. some View { Button { guard let next = calendar.date(byAdding: .month, value: offset, to: month) else { return } month = next } label: { Image(systemName: systemImage) .font(.body.weight(.semibold)) .frame(minWidth: 44, minHeight: 44) .contentShape(.rect) } .tint(tint) .accessibilityLabel(label) } private func dayCell(_ day: Date) -> some View { let role = CalendarRangeSelectorLogic.role(of: day, in: selection, calendar: calendar) let isEndpoint = role == .start || role == .end || role == .single let band = tint.opacity(0.18) return Button { choose(day) } label: { ZStack { HStack(spacing: 0) { (role == .middle || role == .end ? band : Color.clear) (role == .middle || role == .start ? band : Color.clear) } .padding(.vertical, 2) if isEndpoint { Circle().fill(tint).padding(2) } Text(verbatim: String(calendar.component(.day, from: day))) .font(.body.weight(isEndpoint ? .semibold : .regular)) .lineLimit(1) .minimumScaleFactor(0.5) .foregroundStyle(isEndpoint ? Color.white : Color.primary) } .frame(height: dayHeight) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(format(day, Date.FormatStyle().weekday(.wide).day().month(.wide).year())) .accessibilityValue(accessibilityValue(for: role)) .accessibilityAddTraits(isEndpoint ? .isSelected : []) } private func choose(_ day: Date) { let next = CalendarRangeSelectorLogic.selecting(day, in: selection, calendar: calendar) withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { start = next.start end = next.end } } private func accessibilityValue(for role: CalendarRangeSelectorLogic.Role) -> String { switch role { case .outside: "" case .start: "Start of range" case .middle: "In range" case .end: "End of range" case .single: "Selected" } } private func format(_ date: Date, _ base: Date.FormatStyle) -> String { var style = base style.calendar = calendar style.timeZone = calendar.timeZone style.locale = locale return date.formatted(style) } } /// The date math of the calendar, apart from the view so it can be tested. enum CalendarRangeSelectorLogic { struct Selection: Equatable { var start: Date? var end: Date? } enum Role: Equatable { case outside, start, middle, end, single } static func selecting(_ day: Date, in selection: Selection, calendar: Calendar) -> Selection { let day = calendar.startOfDay(for: day) guard let start = selection.start.map({ calendar.startOfDay(for: $0) }), selection.end == nil, day >= start else { return Selection(start: day, end: nil) } return Selection(start: start, end: day) } static func role(of day: Date, in selection: Selection, calendar: Calendar) -> Role { let day = calendar.startOfDay(for: day) guard let start = selection.start.map({ calendar.startOfDay(for: $0) }) else { return .outside } guard let end = selection.end.map({ calendar.startOfDay(for: $0) }), end != start else { return day == start ? .single : .outside } let lower = min(start, end) let upper = max(start, end) if day == lower { return .start } if day == upper { return .end } return day > lower && day < upper ? .middle : .outside } static func days(inMonthOf date: Date, calendar: Calendar) -> [Date] { guard let first = calendar.dateInterval(of: .month, for: date)?.start, let range = calendar.range(of: .day, in: .month, for: date) else { return [] } return range.compactMap { calendar.date(byAdding: .day, value: $0 - range.lowerBound, to: first) } } /// The empty cells before day one, given the calendar's first weekday. static func leadingBlanks(inMonthOf date: Date, calendar: Calendar) -> Int { guard let first = calendar.dateInterval(of: .month, for: date)?.start else { return 0 } return (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7 } static func weekdaySymbols(calendar: Calendar) -> [String] { let symbols = calendar.veryShortStandaloneWeekdaySymbols let offset = min(max(calendar.firstWeekday - 1, 0), symbols.count) return Array(symbols[offset...] + symbols[.. 0 else { return "Pick a later day to leave" } return nights == 1 ? "1 night" : "\(nights) nights" } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Book your stay") .font(.largeTitle.bold()) CalendarRangeSelector(start: $start, end: $end, month: firstOfMarch, calendar: calendar) .padding(16) .background(.background, in: .rect(cornerRadius: 24)) Label(summary, systemImage: "moon.stars.fill") .font(.title3.weight(.semibold)) .foregroundStyle(.indigo) } .padding(20) } .background(LinearGradient(colors: [.indigo.opacity(0.25), .pink.opacity(0.15)], startPoint: .top, endPoint: .bottom)) .environment(\.locale, Locale(identifier: "en_US")) } } #Preview { CalendarRangeSelectorDemo() } ``` ## Date Strip Picker A horizontal strip of days that keeps the selected day centered as you scroll. Category: Calendars. Minimum: iOS 18.0. Path: Components/Calendars/DateStripPicker/DateStripPicker.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A horizontal strip of days that keeps the selected day centered as you scroll. /// /// With Reduce Motion on, tapping a day jumps to it without scrolling. struct DateStripPicker: View { @Binding var selection: Date var tint: Color private let days: [Date] private let calendar: Calendar @State private var centeredDay: Date? @ScaledMetric(relativeTo: .title2) private var itemWidth = 58.0 @ScaledMetric(relativeTo: .title2) private var itemHeight = 84.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let spacing = 10.0 init( selection: Binding, center: Date = .now, daysBefore: Int = 30, daysAfter: Int = 30, calendar: Calendar = .current, tint: Color = .indigo ) { _selection = selection self.tint = tint self.calendar = calendar days = DateStripPickerLogic.days(around: center, before: daysBefore, after: daysAfter, calendar: calendar) } var body: some View { GeometryReader { proxy in ScrollView(.horizontal) { LazyHStack(spacing: spacing) { ForEach(days, id: \.self) { dayCell($0) } } .scrollTargetLayout() } .contentMargins(.horizontal, max(0, (proxy.size.width - itemWidth) / 2), for: .scrollContent) .scrollIndicators(.hidden) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $centeredDay, anchor: .center) } .frame(height: itemHeight) .onAppear { centeredDay = closestDay(to: selection) } .onChange(of: centeredDay) { _, day in guard let day, day != calendar.startOfDay(for: selection) else { return } selection = day } .onChange(of: selection) { _, newValue in let day = closestDay(to: newValue) guard day != centeredDay else { return } withAnimation(reduceMotion ? nil : .snappy) { centeredDay = day } } .sensoryFeedback(.selection, trigger: centeredDay) { old, new in old != nil && new != nil } } private func dayCell(_ day: Date) -> some View { let isSelected = day == calendar.startOfDay(for: selection) let isToday = calendar.isDateInToday(day) return Button { withAnimation(reduceMotion ? nil : .snappy) { centeredDay = day } } label: { VStack(spacing: 4) { Text(format(day, Date.FormatStyle().weekday(.abbreviated))) .font(.caption.weight(.medium)) Text(format(day, Date.FormatStyle().day())) .font(.title2.weight(.bold)) Circle() .fill(isToday ? (isSelected ? Color.white : tint) : Color.clear) .frame(width: 5, height: 5) } .lineLimit(1) .minimumScaleFactor(0.7) .foregroundStyle(isSelected ? Color.white : Color.primary) .frame(width: itemWidth, height: itemHeight) .background(isSelected ? AnyShapeStyle(tint.gradient) : AnyShapeStyle(.thinMaterial), in: .rect(cornerRadius: 18)) .scaleEffect(isSelected || reduceMotion ? 1 : 0.92) .animation(reduceMotion ? nil : .snappy, value: isSelected) } .buttonStyle(.plain) .accessibilityLabel(format(day, Date.FormatStyle().weekday(.wide).day().month(.wide))) .accessibilityValue(isToday ? "Today" : "") .accessibilityAddTraits(isSelected ? .isSelected : []) } private func closestDay(to date: Date) -> Date? { DateStripPickerLogic.closestDay(to: date, in: days, calendar: calendar) } private func format(_ date: Date, _ base: Date.FormatStyle) -> String { var style = base style.calendar = calendar style.timeZone = calendar.timeZone return date.formatted(style) } } /// The date math of the strip, apart from the view so it can be tested. enum DateStripPickerLogic { /// Keeps a very wide request from building an enormous array. static let maximumDaysPerSide = 3_650 static func days(around center: Date, before: Int, after: Int, calendar: Calendar) -> [Date] { let before = min(max(before, 0), maximumDaysPerSide) let after = min(max(after, 0), maximumDaysPerSide) let start = calendar.startOfDay(for: center) return (-before...after).compactMap { offset in calendar.date(byAdding: .day, value: offset, to: start).map { calendar.startOfDay(for: $0) } } } /// The same day when it is in the strip, otherwise the nearest end of the strip. static func closestDay(to date: Date, in days: [Date], calendar: Calendar) -> Date? { let day = calendar.startOfDay(for: date) return days.first { $0 >= day } ?? days.last } } // MARK: - Demo struct DateStripPickerDemo: View { @State private var selection = Date.now var body: some View { ZStack { LinearGradient(colors: [.indigo.opacity(0.3), .teal.opacity(0.2)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() DateStripPicker(selection: $selection) VStack { Text("Your agenda") .font(.largeTitle.bold()) Spacer() Text(selection, format: .dateTime.weekday(.wide).day().month(.wide)) .font(.title3.weight(.semibold)) .multilineTextAlignment(.center) } .padding(32) } } } #Preview { DateStripPickerDemo() } ``` ## Day Schedule Timeline The hours of a day down a column, with each event drawn over the time it takes, overlapping events side by side and a line at the current time. Category: Calendars. Minimum: iOS 18.0. Path: Components/Calendars/DayScheduleTimeline/DayScheduleTimeline.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// An event of the day. `start` and `end` are minutes after midnight: 570 is half past nine. struct DayScheduleTimelineEvent: Identifiable { let id: String let title: String let start: Int let end: Int var color: Color = .blue } nonisolated enum DayScheduleTimelineMath { static let minutesPerHour = 60 struct Slot: Equatable, Sendable { /// The column of the event, from the leading edge. let column: Int /// How many columns share the width where the event is. let columns: Int } /// Puts events that overlap side by side. Each takes the first free column, and every /// event of a run of overlaps is as wide as the others, so none hides another. static func slots(for spans: [ClosedRange]) -> [Slot] { var slots = [Slot](repeating: Slot(column: 0, columns: 1), count: spans.count) let order = spans.indices.sorted { (spans[$0].lowerBound, spans[$0].upperBound) < (spans[$1].lowerBound, spans[$1].upperBound) } var run: [Int] = [] var runEnd = Int.min var columnEnds: [Int] = [] func close() { for index in run { slots[index] = Slot(column: slots[index].column, columns: max(columnEnds.count, 1)) } run = [] columnEnds = [] } for index in order { let span = spans[index] if span.lowerBound >= runEnd { close() } let column = columnEnds.firstIndex { $0 <= span.lowerBound } ?? columnEnds.count if column == columnEnds.count { columnEnds.append(span.upperBound) } else { columnEnds[column] = span.upperBound } slots[index] = Slot(column: column, columns: 1) run.append(index) runEnd = max(runEnd, span.upperBound) } close() return slots } /// How far down a minute of the day is, in a column that starts at `firstHour`. static func offset(minute: Int, firstHour: Int, hourHeight: CGFloat) -> CGFloat { CGFloat(minute - firstHour * minutesPerHour) / CGFloat(minutesPerHour) * hourHeight } } /// The hours of a day down a column, with each event drawn over the time it takes. Events /// that overlap share the width, and a line marks the time it is. /// /// A tap on an event sets `selection`. The column starts scrolled to its first event. struct DayScheduleTimeline: View { let events: [DayScheduleTimelineEvent] @Binding var selection: DayScheduleTimelineEvent.ID? var hours: ClosedRange = 7...21 var showsCurrentTime = true @ScaledMetric(relativeTo: .body) private var hourHeight = 88.0 @ScaledMetric(relativeTo: .caption) private var labelWidth = 52.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let calendar = Calendar.current private let gap: CGFloat = 3 private let cornerRadius: CGFloat = 10 private let minutesPerHour = DayScheduleTimelineMath.minutesPerHour private var firstHour: Int { min(max(hours.lowerBound, 0), 23) } private var lastHour: Int { min(max(hours.upperBound, firstHour + 1), 24) } private func date(atMinute minute: Int) -> Date { calendar.date(byAdding: .minute, value: minute, to: calendar.startOfDay(for: .now)) ?? .now } var body: some View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { grid eventLayer if showsCurrentTime { currentTime } } .padding(.vertical, 12) } .onAppear { guard let first = events.map(\.start).min() else { return } proxy.scrollTo(min(max(first / minutesPerHour, firstHour), lastHour - 1), anchor: .top) } } } private var grid: some View { VStack(spacing: 0) { ForEach(firstHour.. some View { let isSelected = selection == event.id return Button { withAnimation(reduceMotion ? nil : .snappy(duration: 0.25)) { selection = isSelected ? nil : event.id } } label: { VStack(alignment: .leading, spacing: 2) { Text(event.title) .font(.subheadline.weight(.semibold)) .lineLimit(2) Text(date(atMinute: event.start)...date(atMinute: event.end)) .font(.caption) .opacity(0.8) .lineLimit(1) } .foregroundStyle(isSelected ? AnyShapeStyle(DayScheduleTimelineContrastingForeground(background: event.color)) : AnyShapeStyle(.primary)) .padding(.horizontal, cornerRadius) .padding(.vertical, 6) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(event.color.opacity(isSelected ? 1 : 0.22), in: .rect(cornerRadius: cornerRadius)) .overlay(alignment: .leading) { UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius, bottomLeading: cornerRadius)) .fill(event.color) .frame(width: 4) } .contentShape(.rect) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } private var currentTime: some View { TimelineView(.everyMinute) { context in let parts = calendar.dateComponents([.hour, .minute], from: context.date) let minute = (parts.hour ?? 0) * minutesPerHour + (parts.minute ?? 0) if minute >= firstHour * minutesPerHour, minute <= lastHour * minutesPerHour { HStack(spacing: 0) { Circle() .fill(.red) .frame(width: 9, height: 9) Rectangle() .fill(.red) .frame(height: 1.5) } .padding(.leading, labelWidth + 3) .offset(y: DayScheduleTimelineMath.offset(minute: minute, firstHour: firstHour, hourHeight: hourHeight) - 4.5) .allowsHitTesting(false) .accessibilityHidden(true) } } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct DayScheduleTimelineContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct DayScheduleTimelineDemo: View { private let events = [ DayScheduleTimelineEvent(id: "standup", title: "Stand-up", start: 9 * 60, end: 9 * 60 + 30, color: .teal), DayScheduleTimelineEvent(id: "review", title: "Design review", start: 10 * 60, end: 11 * 60 + 30, color: .indigo), DayScheduleTimelineEvent(id: "interview", title: "Interview", start: 10 * 60 + 30, end: 11 * 60 + 15, color: .orange), DayScheduleTimelineEvent(id: "lunch", title: "Lunch with Ana", start: 13 * 60, end: 14 * 60, color: .green), DayScheduleTimelineEvent(id: "focus", title: "Focus time", start: 15 * 60, end: 17 * 60, color: .purple), DayScheduleTimelineEvent(id: "gym", title: "Gym", start: 18 * 60 + 30, end: 19 * 60 + 30, color: .pink), ] @State private var selection: String? = "review" var body: some View { VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 2) { Text(Date.now, format: .dateTime.weekday(.wide)) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) Text(Date.now, format: .dateTime.day().month(.wide)) .font(.largeTitle.bold()) } .padding(.horizontal, 20) .padding(.vertical, 12) Divider() DayScheduleTimeline(events: events, selection: $selection) .padding(.trailing, 16) } } } #Preview { DayScheduleTimelineDemo() } ``` ## Avatar Stack Overlapping avatars of initials with a badge counting the rest. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/AvatarStack/AvatarStack.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum AvatarStackMath { /// Splits a number of people into the avatars to draw and the count for the badge. static func visibleAndOverflow(count: Int, maxVisible: Int) -> (visible: Int, overflow: Int) { let visible = min(max(count, 0), max(maxVisible, 0)) return (visible, max(count, 0) - visible) } /// The first letters of the first and last words of a name. static func initials(of name: String) -> String { let words = name.split(whereSeparator: \.isWhitespace) guard let first = words.first?.first else { return "?" } guard words.count > 1, let last = words.last?.first else { return String(first).uppercased() } return "\(first)\(last)".uppercased() } } /// Overlapping avatars of initials, with a +N badge for everyone who does not fit. struct AvatarStack: View { let names: [String] var maxVisible = 4 var diameter: CGFloat = 48 @ScaledMetric(relativeTo: .body) private var textScale = 1.0 private var size: CGFloat { diameter * textScale } private static var palette: [Color] { [.indigo, .pink, .orange, .teal, .purple, .blue] } private var split: (visible: Int, overflow: Int) { AvatarStackMath.visibleAndOverflow(count: names.count, maxVisible: maxVisible) } var body: some View { HStack(spacing: -size * 0.28) { ForEach(Array(names.prefix(split.visible).enumerated()), id: \.offset) { index, name in circle(AvatarStackMath.initials(of: name), fill: color(for: name)) .zIndex(Double(-index)) .transition(.scale.combined(with: .opacity)) } if split.overflow > 0 { circle("+\(split.overflow)", fill: .gray) .contentTransition(.numericText()) .zIndex(Double(-split.visible)) .transition(.scale.combined(with: .opacity)) } } .accessibilityElement(children: .ignore) .accessibilityLabel(names.isEmpty ? "No people" : names.formatted(.list(type: .and))) } private func circle(_ text: String, fill: Color) -> some View { Text(text) .font(.system(.title3, design: .rounded, weight: .bold)) .minimumScaleFactor(0.6) .lineLimit(1) .foregroundStyle(.white) .frame(width: size, height: size) .background(fill.gradient, in: .circle) .overlay { Circle().strokeBorder(.background, lineWidth: 3) } } private func color(for name: String) -> Color { let total = name.unicodeScalars.reduce(0) { $0 + Int($1.value) } return Self.palette[total % Self.palette.count] } } // MARK: - Demo struct AvatarStackDemo: View { private let everyone = ["Ana Ruiz", "Luis Vega", "Marta Gil", "Omar Haddad", "Nora Lind", "Pablo Sanz", "Yuki Mori", "Sara Blanco", "Tomás Rey"] @State private var count = 3 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 28) { VStack(alignment: .leading, spacing: 16) { Text("Design review") .font(.system(.title, design: .rounded, weight: .bold)) Text("Today at 16:30") .font(.subheadline) .foregroundStyle(.secondary) AvatarStack(names: Array(everyone.prefix(count)), diameter: 64) .padding(.top, 8) } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) .background(.background, in: .rect(cornerRadius: 28)) .shadow(color: .black.opacity(0.12), radius: 18, y: 10) HStack(spacing: 14) { Button("Remove", systemImage: "person.badge.minus") { change(by: -1) } .disabled(count == 0) Button("Add", systemImage: "person.badge.plus") { change(by: 1) } .disabled(count == everyone.count) } .buttonStyle(.borderedProminent) .controlSize(.large) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(LinearGradient(colors: [.purple.opacity(0.4), .orange.opacity(0.3)], startPoint: .topLeading, endPoint: .bottomTrailing)) } private func change(by amount: Int) { withAnimation(reduceMotion ? nil : .bouncy(duration: 0.4)) { count += amount } } } #Preview { AvatarStackDemo() } ``` ## Expandable Info Card A card that expands to show more, with a chevron that turns. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/ExpandableInfoCard/ExpandableInfoCard.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A card that expands to show more, with a chevron that turns. /// /// With Reduce Motion on, the details fade in place. struct ExpandableInfoCard: View { let title: LocalizedStringResource var subtitle: LocalizedStringResource? var systemImage = "info.circle.fill" var tint: Color = .blue @ViewBuilder var details: () -> Details @State private var isExpanded = false @ScaledMetric(relativeTo: .body) private var iconSize = 40.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(alignment: .leading, spacing: 0) { Button { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.45, bounce: 0.2)) { isExpanded.toggle() } } label: { header } .buttonStyle(.plain) .accessibilityLabel(title) .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") .accessibilityHint(subtitle ?? "") if isExpanded { details() .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding([.horizontal, .bottom], 16) .transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top))) } } .background(.background, in: .rect(cornerRadius: 22)) .clipShape(.rect(cornerRadius: 22)) .shadow(color: .black.opacity(0.08), radius: 12, y: 6) } private var header: some View { HStack(spacing: 14) { Image(systemName: systemImage) .font(.title3) .foregroundStyle(ExpandableInfoCardContrastingForeground(background: tint)) .frame(width: iconSize, height: iconSize) .background(tint.gradient, in: .rect(cornerRadius: 12)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(title) .font(.headline) if let subtitle { Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) } } Spacer(minLength: 8) Image(systemName: "chevron.down") .font(.subheadline.weight(.bold)) .foregroundStyle(.tertiary) .rotationEffect(.degrees(isExpanded ? 180 : 0)) .accessibilityHidden(true) } .padding(16) .contentShape(.rect) } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct ExpandableInfoCardContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct ExpandableInfoCardDemo: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text("Your order") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .padding(.bottom, 8) ExpandableInfoCard(title: "Shipping", subtitle: "Arrives on Thursday", systemImage: "shippingbox.fill", tint: .orange) { Text("Your parcel left the warehouse this morning. You will get a message when the courier is one stop away.") } ExpandableInfoCard(title: "Warranty", subtitle: "Covered for two years", systemImage: "checkmark.shield.fill", tint: .green) { Text("Repairs and replacements are free for two years. Keep your order number handy if you need to contact us.") } ExpandableInfoCard(title: "Returns", subtitle: "Free within 30 days", systemImage: "arrow.uturn.left.circle.fill", tint: .pink) { Text("Print the label from your account, pack the item in its box and drop it at any collection point.") } } .padding(24) } .background(LinearGradient(colors: [.indigo.opacity(0.22), .teal.opacity(0.16)], startPoint: .top, endPoint: .bottom)) } } #Preview { ExpandableInfoCardDemo() } ``` ## Holographic Card A card with a holographic foil whose colors, glints and glare shift as you tilt it by dragging, drawn by a Metal shader. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/HolographicCard/HolographicCard.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum HolographicCardMath { /// How far the card leans for a drag, from -1 to 1 on each axis. /// Dragging half the card's size leans it all the way. static func tilt(for translation: CGSize, in size: CGSize) -> CGSize { guard size.width > 0, size.height > 0 else { return .zero } return CGSize( width: min(max(translation.width / (size.width / 2), -1), 1), height: min(max(translation.height / (size.height / 2), -1), 1) ) } /// The degrees the card turns around its vertical axis for a lean from -1 to 1. /// `rotation3DEffect` mirrors in a right-to-left layout and a drag does not, so the turn is reversed there. static func yaw(for lean: Double, maxAngle: Double, layoutDirection: LayoutDirection) -> Double { let turn = lean * maxAngle return layoutDirection == .rightToLeft ? -turn : turn } /// The function in `HolographicCard.metal`, with its arguments in the order it declares them. static func shader(size: CGSize, tilt: CGSize, intensity: Double) -> Shader { ShaderLibrary.HolographicCardFoil(.float2(size), .float2(tilt), .float(intensity)) } } /// A card with a holographic foil that shifts as you tilt it by dragging, drawn by a Metal shader. /// /// - Important: Copy two files into the same target: `HolographicCard.swift` and `HolographicCard.metal`. /// /// A shader draws what SwiftUI draws itself: a view backed by UIKit, such as a map, shows a placeholder. /// /// With Reduce Motion on, the card stays flat and the foil stays still. struct HolographicCard: View { /// The degrees the card leans at the end of a drag. var maxAngle: Double = 16 var cornerRadius: CGFloat = 28 /// How much foil the card has, from 0 for none to 1. var intensity: Double = 1 @ViewBuilder var content: () -> Content @GestureState private var tilt = CGSize.zero @State private var size = CGSize.zero @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { content() .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .modifier( HolographicCardSurface( tilt: reduceMotion ? .zero : tilt, size: size, maxAngle: maxAngle, cornerRadius: cornerRadius, intensity: intensity ) ) .gesture(drag) .animation(.spring(duration: 0.5, bounce: 0.3), value: tilt) } private var drag: some Gesture { DragGesture(coordinateSpace: .global) .updating($tilt) { value, tilt, _ in tilt = HolographicCardMath.tilt(for: value.translation, in: size) } } } /// `Animatable`, so the foil follows the same spring as the lean: a shader's arguments do not animate by themselves. private struct HolographicCardSurface: ViewModifier, Animatable { var tilt: CGSize let size: CGSize let maxAngle: Double let cornerRadius: CGFloat let intensity: Double @Environment(\.layoutDirection) private var layoutDirection nonisolated var animatableData: CGSize.AnimatableData { get { tilt.animatableData } set { tilt.animatableData = newValue } } func body(content: Content) -> some View { content .colorEffect(HolographicCardMath.shader(size: size, tilt: tilt, intensity: intensity)) .clipShape(.rect(cornerRadius: cornerRadius)) .shadow(color: .black.opacity(0.4), radius: 24, y: 16) .rotation3DEffect(.degrees(-tilt.height * maxAngle), axis: (x: 1, y: 0, z: 0), perspective: 0.6) .rotation3DEffect( .degrees(HolographicCardMath.yaw(for: tilt.width, maxAngle: maxAngle, layoutDirection: layoutDirection)), axis: (x: 0, y: 1, z: 0), perspective: 0.6 ) } } // MARK: - Demo struct HolographicCardDemo: View { var body: some View { ZStack { LinearGradient( colors: [Color(red: 0.04, green: 0.04, blue: 0.09), Color(red: 0.14, green: 0.09, blue: 0.27)], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() HolographicCard { card } .padding(.horizontal, 44) } } private var card: some View { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "sparkles") .font(.title) .accessibilityHidden(true) Spacer() Text("No. 001") .font(.footnote.monospaced()) .opacity(0.7) } Spacer() Text("Prism Pass") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Drag to tilt the card") .font(.subheadline) .opacity(0.8) } .foregroundStyle(.white) .padding(24) .frame(maxWidth: .infinity, alignment: .leading) .aspectRatio(0.68, contentMode: .fit) .background( LinearGradient( colors: [ Color(red: 0.1, green: 0.12, blue: 0.3), Color(red: 0.32, green: 0.12, blue: 0.42), Color(red: 0.06, green: 0.06, blue: 0.14), ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .accessibilityElement(children: .combine) } } #Preview { HolographicCardDemo() } ``` Shader: Components/Cards/HolographicCard/HolographicCard.metal ```metal // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors #include #include using namespace metal; // A repeatable pseudo-random number from 0 to 1 for each cell of a grid. static float HolographicCardRandom(float2 cell) { uint2 seed = uint2(int2(cell)); uint mixed = (seed.x * 0x9E3779B1u) ^ (seed.y * 0x85EBCA77u); mixed ^= mixed >> 15; mixed *= 0x2C1B3C6Du; mixed ^= mixed >> 12; return float(mixed & 0xFFFFu) / 65535.0; } // For SwiftUI's `colorEffect`. Adds a holographic foil to each pixel of a view // of `size` points. `tilt` runs from -1 to 1 on each axis, and moves the colors // and the glare as a real card does when it turns under a light. [[ stitchable ]] half4 HolographicCardFoil(float2 position, half4 color, float2 size, float2 tilt, float intensity) { if (color.a <= 0.0) { return color; } float2 fromCenter = position / max(size, float2(1.0)) - 0.5; // A diffraction foil changes hue with the viewing angle, so its bands of // color slide across the card as it tilts. float angle = dot(fromCenter, float2(0.9, 0.5)) * 1.4 + dot(tilt, float2(0.8, 0.6)); float3 spectrum = 0.5 + 0.5 * cos(6.2831853 * (angle + float3(0.0, 0.33, 0.67))); // Specks of glitter, one in each cell of a grid and somewhere off its // center. Each catches the light at its own angle, so they twinkle in turn. float2 grid = position / 5.0; float2 cell = floor(grid); float2 speck = 0.5 + 0.56 * (float2(HolographicCardRandom(cell + 17.0), HolographicCardRandom(cell + 43.0)) - 0.5); float shape = saturate(1.0 - length(grid - cell - speck) / 0.22); float facing = 0.5 + 0.5 * cos(6.2831853 * (HolographicCardRandom(cell) + angle * 2.0)); float glint = smoothstep(0.55, 1.0, facing) * shape * shape; // The glare of a light above the card slides against the tilt. float2 fromGlare = fromCenter + tilt * 0.35; float glare = exp(-6.0 * dot(fromGlare, fromGlare)); float3 foil = intensity * (spectrum * (0.14 + 0.4 * glare) + glint * (0.3 + 0.7 * glare)); // Screen the foil over the pixel, so it only ever adds light. Colors come // premultiplied by their alpha. float3 base = float3(color.rgb) / color.a; float3 lit = 1.0 - (1.0 - base) * (1.0 - saturate(foil)); return half4(half3(lit * color.a), color.a); } ``` ## Swipeable Card Stack A deck of cards you swipe away to reveal the next one. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/SwipeableCardStack/SwipeableCardStack.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum SwipeableCardStackDirection: Equatable, Sendable { case left, right var sign: CGFloat { self == .left ? -1 : 1 } } enum SwipeableCardStackLogic { /// Decides where a released card goes from where its momentum would carry it, /// so both a long drag and a short flick count, and a flick back cancels. static func direction(predictedEnd: CGFloat, width: CGFloat, threshold: CGFloat = 0.35) -> SwipeableCardStackDirection? { guard width > 0, abs(predictedEnd) >= width * threshold else { return nil } return predictedEnd > 0 ? .right : .left } /// One swiped card still flying away, the top card and three behind it. /// Empty when the deck shrank below the cards already swiped. static func positions(top: Int, count: Int, loops: Bool) -> [Int] { guard count > 0 else { return [] } let start = max(top - 1, 0) let end = loops ? top + 4 : min(top + 4, count) return start < end ? Array(start..: View { let items: [Item] var loops = false var threshold: CGFloat = 0.35 var onSwipe: (Item, SwipeableCardStackDirection) -> Void = { _, _ in } @ViewBuilder var card: (Item) -> Card @State private var top = 0 @State private var drag = CGSize.zero @State private var lastDirection = SwipeableCardStackDirection.right @State private var size = CGSize.zero @GestureState private var isDragging = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection private var width: CGFloat { size.width } /// Gestures and swipe directions are in screen terms, but layout mirrors in right-to-left /// layouts: 1 or -1 converts between them. private var forward: CGFloat { layoutDirection == .rightToLeft ? -1 : 1 } private var hasTopCard: Bool { !items.isEmpty && (loops || top < items.count) } private var positions: [Int] { SwipeableCardStackLogic.positions(top: top, count: items.count, loops: loops) } private var dragProgress: CGFloat { min(abs(drag.width) / max(width * threshold, 1), 1) } var body: some View { ZStack { ForEach(positions, id: \.self) { position in let depth = max(CGFloat(position - top) - dragProgress, 0) card(items[position % items.count]) .scaleEffect(1 - depth * 0.06) .offset(offset(of: position, depth: depth)) .rotationEffect(.degrees(rotation(of: position))) .opacity(position < top || position - top > 2 ? 0 : 1) .zIndex(Double(-position)) .accessibilityElement(children: .combine) .accessibilityHidden(position != top) .accessibilityAction(named: "Swipe left") { swipe(.left) } .accessibilityAction(named: "Swipe right") { swipe(.right) } } } .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .contentShape(.rect) .gesture(dragGesture) .onChange(of: isDragging) { _, dragging in if !dragging { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) { drag = .zero } } } } private func offset(of position: Int, depth: CGFloat) -> CGSize { if position < top { return CGSize(width: reduceMotion ? 0 : lastDirection.sign * forward * width * 1.5, height: 0) } if position == top { return CGSize(width: drag.width, height: drag.height * 0.25) } // Scaling shrinks a card around its center, so move it down by that amount plus the visible peek. return CGSize(width: 0, height: depth * (size.height * 0.03 + 14)) } private func rotation(of position: Int) -> Double { guard !reduceMotion, width > 0 else { return 0 } if position < top { return Double(lastDirection.sign * forward) * 18 } return position == top ? Double(drag.width / width) * 14 : 0 } private var dragGesture: some Gesture { DragGesture(minimumDistance: 8) .updating($isDragging) { _, state, _ in state = true } .onChanged { value in if hasTopCard { drag = CGSize(width: value.translation.width * forward, height: value.translation.height) } } .onEnded { value in let predictedEnd = value.predictedEndTranslation.width if let direction = SwipeableCardStackLogic.direction(predictedEnd: predictedEnd, width: width, threshold: threshold) { swipe(direction) } } } private func swipe(_ direction: SwipeableCardStackDirection) { guard hasTopCard else { return } let item = items[top % items.count] withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .snappy(duration: 0.4)) { lastDirection = direction top += 1 drag = .zero } onSwipe(item, direction) } } // MARK: - Demo private struct DemoPlace { let name: String let symbol: String let colors: [Color] } struct SwipeableCardStackDemo: View { @State private var liked = 0 @State private var passed = 0 private let places = [ DemoPlace(name: "Kyoto", symbol: "leaf.fill", colors: [.pink, .purple]), DemoPlace(name: "Reykjavik", symbol: "snowflake", colors: [.cyan, .blue]), DemoPlace(name: "Marrakesh", symbol: "sun.max.fill", colors: [.orange, .red]), DemoPlace(name: "Lisbon", symbol: "tram.fill", colors: [.yellow, .orange]), DemoPlace(name: "Patagonia", symbol: "mountain.2.fill", colors: [.teal, .indigo]), ] var body: some View { VStack(spacing: 28) { Text("Where next?") .font(.system(.largeTitle, design: .rounded, weight: .bold)) SwipeableCardStack(items: places, loops: true) { place, direction in if direction == .right { liked += 1 } else { passed += 1 } } card: { place in cardView(for: place) } .padding(.horizontal, 32) .frame(maxHeight: 500) .padding(.bottom, 32) HStack(spacing: 32) { Label("\(passed)", systemImage: "xmark") .accessibilityLabel("Passed \(passed)") Label("\(liked)", systemImage: "heart.fill") .accessibilityLabel("Liked \(liked)") } .font(.title3.weight(.semibold)) .monospacedDigit() .foregroundStyle(.secondary) } .padding(.vertical, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(LinearGradient(colors: [.indigo.opacity(0.18), .pink.opacity(0.12)], startPoint: .top, endPoint: .bottom)) } private func cardView(for place: DemoPlace) -> some View { VStack(spacing: 16) { Image(systemName: place.symbol) .font(.system(.largeTitle)) .imageScale(.large) .accessibilityHidden(true) Text(place.name) .font(.system(.title, design: .rounded, weight: .bold)) } .foregroundStyle(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 32)) .shadow(color: .black.opacity(0.18), radius: 14, y: 8) } } #Preview { SwipeableCardStackDemo() } ``` ## Tilt Card A card that tilts in 3D toward the finger with a moving highlight. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/TiltCard/TiltCard.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A card that leans in 3D toward the finger, with a highlight that follows it. /// /// `restingPoint` is where the card leans while nobody touches it. `nil` keeps it flat. /// With Reduce Motion on, the card stays flat and only the highlight moves. struct TiltCard: View { var maxAngle: Double = 14 var cornerRadius: CGFloat = 28 var restingPoint: UnitPoint? @ViewBuilder var content: () -> Content @GestureState private var touchPoint: UnitPoint? @State private var size = CGSize.zero @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.layoutDirection) private var layoutDirection private var focus: UnitPoint? { touchPoint ?? restingPoint } private var pitch: Double { guard let focus, !reduceMotion else { return 0 } return (0.5 - focus.y) * 2 * maxAngle } private var yaw: Double { guard let focus, !reduceMotion else { return 0 } return (focus.x - 0.5) * 2 * maxAngle } var body: some View { content() .overlay { highlight } .clipShape(.rect(cornerRadius: cornerRadius)) .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } .gesture(tilt) .shadow(color: .black.opacity(0.35), radius: 24, y: 16) .rotation3DEffect(.degrees(pitch), axis: (x: 1, y: 0, z: 0), perspective: 0.6) .rotation3DEffect(.degrees(yaw), axis: (x: 0, y: 1, z: 0), perspective: 0.6) .animation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.3), value: touchPoint) } private var highlight: some View { RadialGradient( colors: [.white.opacity(0.35), .white.opacity(0)], center: focus ?? .center, startRadius: 0, endRadius: max(size.width, size.height) * 0.8 ) .blendMode(.plusLighter) .opacity(focus == nil ? 0 : 1) .allowsHitTesting(false) .accessibilityHidden(true) } private var tilt: some Gesture { DragGesture(minimumDistance: 0) .updating($touchPoint) { value, state, _ in guard size.width > 0, size.height > 0 else { return } // Gestures report screen points, but a unit point counts from the leading edge. let x = min(max(value.location.x / size.width, 0), 1) state = UnitPoint( x: layoutDirection == .rightToLeft ? 1 - x : x, y: min(max(value.location.y / size.height, 0), 1) ) } } } // MARK: - Demo struct TiltCardDemo: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { LinearGradient(colors: [.black, .indigo], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: reduceMotion)) { context in let time = context.date.timeIntervalSinceReferenceDate * 1.6 TiltCard(restingPoint: reduceMotion ? nil : UnitPoint(x: 0.5 + 0.45 * cos(time), y: 0.5 + 0.45 * sin(time))) { card } } .padding(.horizontal, 44) } } private var card: some View { VStack(alignment: .leading, spacing: 8) { Image(systemName: "sparkles") .font(.largeTitle) .accessibilityHidden(true) Spacer() Text("Aurora Pass") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Touch and move your finger") .font(.subheadline) .opacity(0.8) } .foregroundStyle(.white) .padding(24) .frame(maxWidth: .infinity, alignment: .leading) .aspectRatio(0.68, contentMode: .fit) .background(LinearGradient(colors: [.purple, .pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)) .accessibilityElement(children: .combine) } } #Preview { TiltCardDemo() } ``` ## Wallet Card Stack A stack of cards where the one you tap rises to the top with its details and the rest wait in a pile below. Category: Cards. Minimum: iOS 18.0. Path: Components/Cards/WalletCardStack/WalletCardStack.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum WalletCardStackMath { /// The distance between the tops of two cards in the stack. /// /// It is `peek`, or less when the stack would not fit in `height`. static func step(count: Int, peek: Double, cardHeight: Double, height: Double) -> Double { guard count > 1, peek.isFinite, cardHeight.isFinite, height.isFinite else { return 0 } let room = max(height - cardHeight, 0) return min(max(peek, 0), room / Double(count - 1)) } /// Where the top of each card goes. /// /// With no card open they sit one below another, `step` apart. With one open, it goes to the /// top and the others gather in a pile that keeps `pileHeight` at the bottom of `height`. static func tops(count: Int, selected: Int?, step: Double, height: Double, pileHeight: Double, pileStep: Double = 14) -> [Double] { guard count > 0 else { return [] } guard let selected, (0.. 0 ? min(max(pileStep, 0), max(pileHeight, 0) / Double(others)) : 0 var place = 0 return (0..: View { let items: [Item] /// The card that is open, or `nil` when all of them are in the stack. @Binding var selection: Item.ID? var cardHeight: CGFloat = 200 /// How much of a card shows above the next one in the stack. It grows with Dynamic Type. var peek: CGFloat = 64 /// How much room the other cards keep at the bottom while one is open. var pileHeight: CGFloat = 76 @ViewBuilder var card: (Item) -> Card @ViewBuilder var detail: (Item) -> Detail private let spacing: CGFloat = 16 private let shadowRoom: CGFloat = 40 @State private var height: CGFloat = 0 @ScaledMetric(relativeTo: .headline) private var textScale = 1.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let selected = items.firstIndex { $0.id == selection } let step = WalletCardStackMath.step(count: items.count, peek: peek * textScale, cardHeight: cardHeight, height: height) let tops = WalletCardStackMath.tops(count: items.count, selected: selected, step: step, height: height, pileHeight: pileHeight) ZStack(alignment: .top) { if let selected { detail(items[selected]) .frame(maxWidth: .infinity, alignment: .top) .frame(height: max(height - cardHeight - pileHeight - spacing * 2, 0), alignment: .top) .clipped() .offset(y: cardHeight + spacing) .transition(.opacity) } ForEach(Array(items.enumerated()), id: \.element.id) { index, item in // In the stack only the top edge of a card is free, so that is where assistive touches land. let shown = selected == nil && index < items.count - 1 ? min(step, cardHeight) : cardHeight Button { selection = selection == nil ? item.id : nil } label: { card(item) .frame(height: cardHeight) } .buttonStyle(WalletCardStackPressStyle()) .offset(y: tops[index]) .zIndex(Double(index + 1)) .accessibilityActivationPoint(UnitPoint(x: 0.5, y: cardHeight > 0 ? shown / cardHeight / 2 : 0.5)) .accessibilityAddTraits(index == selected ? .isSelected : []) .accessibilityHint(index == selected ? "Returns to the stack" : "Shows its details") .accessibilityHidden(selected != nil && index != selected) .accessibilitySortPriority(index == selected ? 1 : 0) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height = $0 } // Cuts the pile at the bottom edge and leaves room for the shadows of the cards everywhere else. .mask { Rectangle().padding(.horizontal, -shadowRoom).padding(.top, -shadowRoom) } .animation(reduceMotion ? nil : .spring(duration: 0.5, bounce: 0.2), value: selection) .accessibilityAction(.escape) { selection = nil } } } private struct WalletCardStackPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.98 : 1) .animation(.easeOut(duration: 0.15), value: configuration.isPressed) } } // MARK: - Demo private struct WalletCardStackDemoCard: Identifiable { let id: Int let name: String let digits: String let symbol: String let colors: [Color] let payments: [(place: String, symbol: String, amount: Double)] } struct WalletCardStackDemo: View { private let cards = [ WalletCardStackDemoCard(id: 0, name: "Everyday", digits: "4821", symbol: "creditcard.fill", colors: [.indigo, .blue], payments: [ ("Corner Bakery", "cup.and.saucer.fill", 6.4), ("Bookshop", "book.fill", 18.9), ("Groceries", "cart.fill", 52.3), ]), WalletCardStackDemoCard(id: 1, name: "Savings", digits: "0375", symbol: "leaf.fill", colors: [.teal, .green], payments: [ ("Monthly transfer", "arrow.down.circle.fill", 250), ("Interest", "percent", 3.12), ("Round-ups", "plus.circle.fill", 14.75), ]), WalletCardStackDemoCard(id: 2, name: "Travel", digits: "9164", symbol: "airplane", colors: [.orange, .pink], payments: [ ("Night train", "tram.fill", 74), ("Harbor Hotel", "bed.double.fill", 139.5), ("Museum", "building.columns.fill", 16), ]), WalletCardStackDemoCard(id: 3, name: "Transit", digits: "2208", symbol: "bus.fill", colors: [.purple, .indigo], payments: [ ("Line 4", "bus.fill", 1.9), ("Top up", "plus.circle.fill", 20), ("Line 12", "bus.fill", 1.9), ]), ] @State private var selection: Int? var body: some View { VStack(alignment: .leading, spacing: 20) { Text("Wallet") .font(.largeTitle.bold()) WalletCardStack(items: cards, selection: $selection) { card in face(of: card) } detail: { card in payments(of: card) } .ignoresSafeArea(edges: .bottom) } .padding([.horizontal, .top], 20) .background(Color(.systemGroupedBackground)) } private func face(of card: WalletCardStackDemoCard) -> some View { VStack(alignment: .leading) { HStack { Text(card.name) .font(.headline) Spacer() Image(systemName: card.symbol) .font(.title3) } Spacer() Text("•••• \(card.digits)") .font(.title3.monospacedDigit()) } .padding(20) .foregroundStyle(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(LinearGradient(colors: card.colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 20)) .shadow(color: .black.opacity(0.3), radius: 10) .accessibilityElement(children: .ignore) .accessibilityLabel("\(card.name), ending in \(card.digits)") } private func payments(of card: WalletCardStackDemoCard) -> some View { VStack(alignment: .leading, spacing: 14) { Text("Latest") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) ForEach(card.payments, id: \.place) { payment in HStack(spacing: 12) { Image(systemName: payment.symbol) .frame(width: 32) .foregroundStyle(card.colors[0]) .accessibilityHidden(true) Text(payment.place) Spacer() Text(payment.amount, format: .currency(code: "USD")) .monospacedDigit() .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) } } .padding(16) .background(.background, in: .rect(cornerRadius: 16)) } } #Preview { WalletCardStackDemo() } ``` ## Animated Bar Chart Bars that grow from the baseline one after another with a spring. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/AnimatedBarChart/AnimatedBarChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct AnimatedBarChartEntry: Hashable { let label: String let value: Double } /// Bars that grow from the baseline one after another, and again whenever the data changes. /// /// With Reduce Motion on, the bars are drawn at their final height. struct AnimatedBarChart: View { let entries: [AnimatedBarChartEntry] var tint: Color = .indigo var stagger: Double = 0.09 @State private var isRevealed = false @ScaledMetric(relativeTo: .caption2) private var valueHeight = 22.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(spacing: 8) { GeometryReader { proxy in HStack(spacing: 10) { ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in column(entry, index: index, plotHeight: max(0, proxy.size.height - valueHeight)) } } } HStack(spacing: 10) { ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in Text(entry.label) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.6) .frame(maxWidth: .infinity) } } .accessibilityHidden(true) } .task(id: entries) { guard !reduceMotion else { isRevealed = true return } var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { isRevealed = false } try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } isRevealed = true } } private func column(_ entry: AnimatedBarChartEntry, index: Int, plotHeight: CGFloat) -> some View { let isShown = isRevealed || reduceMotion let ratio = AnimatedBarChartLogic.ratio(of: entry.value, in: entries.map(\.value)) let valueText = entry.value.formatted() return VStack(spacing: 4) { Text(valueText) .font(.caption2.weight(.semibold)) .lineLimit(1) .minimumScaleFactor(0.6) .opacity(isShown ? 1 : 0) UnevenRoundedRectangle(topLeadingRadius: 8, topTrailingRadius: 8) .fill(tint.gradient) .frame(height: isShown ? plotHeight * ratio : 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .animation( reduceMotion ? nil : .spring(duration: 0.6, bounce: 0.3).delay(Double(index) * max(0, stagger)), value: isRevealed ) .accessibilityElement(children: .ignore) .accessibilityLabel(entry.label) .accessibilityValue(valueText) } } enum AnimatedBarChartLogic { /// How tall a bar is compared with the tallest, from 0 to 1. static func ratio(of value: Double, in values: [Double]) -> Double { guard value.isFinite, value > 0, let highest = values.filter(\.isFinite).max(), highest > 0 else { return 0 } return min(value / highest, 1) } } // MARK: - Demo struct AnimatedBarChartDemo: View { @State private var index = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let weeks: [(title: String, values: [Double])] = [ ("This week", [12, 19, 8, 24, 16, 30, 22]), ("Last week", [20, 11, 27, 14, 29, 9, 17]), ] private var week: (title: String, values: [Double]) { weeks[index % weeks.count] } private var entries: [AnimatedBarChartEntry] { zip(["M", "T", "W", "T", "F", "S", "S"], week.values).map { AnimatedBarChartEntry(label: $0, value: $1) } } var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Workouts") .font(.largeTitle.bold()) Text(week.title) .font(.title3) .foregroundStyle(.secondary) AnimatedBarChart(entries: entries) .padding(.top, 24) } .padding(24) .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(2.6)) guard !Task.isCancelled else { return } index = (index + 1) % weeks.count } } } } #Preview { AnimatedBarChartDemo() } ``` ## Animated Donut Chart A donut chart whose segments grow in one by one, and the selected segment lifts out. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/AnimatedDonutChart/AnimatedDonutChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import Charts import SwiftUI struct AnimatedDonutChartSegment: Identifiable, Hashable { let id: String let value: Double let color: Color } /// A donut chart whose segments grow in one by one, and the selected segment lifts out. /// /// Tap a segment or its legend row to select it. With Reduce Motion on, the whole donut /// appears at once. struct AnimatedDonutChart: View { let segments: [AnimatedDonutChartSegment] @Binding var selection: String? @State private var revealedCount = 0 @State private var touchedAngle: Double? @Environment(\.accessibilityReduceMotion) private var reduceMotion private var visibleCount: Int { reduceMotion ? segments.count : revealedCount } private var total: Double { AnimatedDonutChartLogic.total(of: segments) } private var visibleTotal: Double { AnimatedDonutChartLogic.total(of: Array(segments.prefix(visibleCount))) } private var selectedSegment: AnimatedDonutChartSegment? { segments.first { $0.id == selection } } var body: some View { VStack(spacing: 24) { ZStack { chart centerLabel } .aspectRatio(1, contentMode: .fit) legend } .task(id: segments) { guard !reduceMotion else { revealedCount = segments.count return } var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { revealedCount = 0 } for count in segments.indices { try? await Task.sleep(for: .milliseconds(count == 0 ? 60 : 260)) guard !Task.isCancelled else { return } withAnimation(.smooth(duration: 0.35)) { revealedCount = count + 1 } } } .onChange(of: touchedAngle) { oldValue, newValue in guard let newValue, let id = AnimatedDonutChartLogic.segmentID(atAngleValue: newValue, in: segments) else { return } selection = oldValue == nil && selection == id ? nil : id } } private var chart: some View { Chart { ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in SectorMark( angle: .value("Value", index < visibleCount ? AnimatedDonutChartLogic.sanitized(segment.value) : 0), innerRadius: .ratio(0.62), outerRadius: .ratio(selection == segment.id ? 1 : 0.9), angularInset: 1.5 ) .cornerRadius(5) .foregroundStyle(segment.color) .opacity(selection == nil || selection == segment.id ? 1 : 0.4) } // The empty part of the ring, so that early segments do not fill the circle. SectorMark( angle: .value("Remaining", total > 0 ? max(total - visibleTotal, 0) : 1), innerRadius: .ratio(0.62), outerRadius: .ratio(0.9) ) .foregroundStyle(total > 0 ? Color.clear : Color.secondary.opacity(0.2)) } .chartLegend(.hidden) .chartAngleSelection(value: $touchedAngle) .animation(reduceMotion ? nil : .snappy, value: selection) .accessibilityHidden(true) } private var centerLabel: some View { VStack(spacing: 2) { Text(selectedSegment?.id ?? "Total") .font(.subheadline) .foregroundStyle(.secondary) Text((selectedSegment?.value ?? total).formatted()) .font(.title.bold()) .contentTransition(reduceMotion ? .identity : .numericText()) .animation(reduceMotion ? nil : .snappy, value: selection) } .lineLimit(1) .minimumScaleFactor(0.5) .frame(maxWidth: 130) .allowsHitTesting(false) .accessibilityHidden(true) } private var legend: some View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), alignment: .leading)], alignment: .leading, spacing: 10) { ForEach(segments) { segment in let isSelected = selection == segment.id let percent = AnimatedDonutChartLogic.share(of: segment, in: segments).formatted(.percent.precision(.fractionLength(0))) Button { selection = isSelected ? nil : segment.id } label: { HStack(spacing: 8) { Circle().fill(segment.color).frame(width: 10, height: 10) Text(segment.id) Spacer(minLength: 4) Text(percent).foregroundStyle(.secondary) } .font(isSelected ? .subheadline.weight(.semibold) : .subheadline) // A row is too short to hit. The padding widens what answers to a touch, // and the same padding taken back keeps the legend as compact as it was. .padding(.vertical, 13) .contentShape(.rect) .padding(.vertical, -13) } .buttonStyle(.plain) .accessibilityLabel(segment.id) .accessibilityValue("\(segment.value.formatted()), \(percent)") .accessibilityAddTraits(isSelected ? .isSelected : []) } } } } enum AnimatedDonutChartLogic { static func sanitized(_ value: Double) -> Double { value.isFinite ? max(value, 0) : 0 } static func total(of segments: [AnimatedDonutChartSegment]) -> Double { segments.reduce(0) { $0 + sanitized($1.value) } } static func share(of segment: AnimatedDonutChartSegment, in segments: [AnimatedDonutChartSegment]) -> Double { let total = total(of: segments) return total > 0 ? sanitized(segment.value) / total : 0 } /// Finds the segment under an angle, given in the same units as the values. static func segmentID(atAngleValue angle: Double, in segments: [AnimatedDonutChartSegment]) -> String? { guard angle.isFinite, angle >= 0 else { return nil } var cumulative = 0.0 for segment in segments where sanitized(segment.value) > 0 { cumulative += sanitized(segment.value) if angle <= cumulative { return segment.id } } return nil } } // MARK: - Demo struct AnimatedDonutChartDemo: View { @State private var selection: String? @State private var isAlternate = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private var segments: [AnimatedDonutChartSegment] { let values: [Double] = isAlternate ? [980, 240, 310, 160] : [1_120, 420, 180, 290] return zip(zip(["Housing", "Food", "Travel", "Fun"], values), [Color.indigo, .teal, .orange, .pink]) .map { AnimatedDonutChartSegment(id: $0.0, value: $0.1, color: $1) } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("Spending") .font(.largeTitle.bold()) AnimatedDonutChart(segments: segments, selection: $selection) } .padding(24) } .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1.5)) for id in ["Housing", "Travel"] { guard !Task.isCancelled else { return } selection = id try? await Task.sleep(for: .seconds(0.8)) } guard !Task.isCancelled else { return } selection = nil isAlternate.toggle() } } } } #Preview { AnimatedDonutChartDemo() } ``` ## Candlestick Chart A candlestick chart of open, high, low and close prices, with a rule that reads the day under the finger. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/CandlestickChart/CandlestickChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import Charts import SwiftUI nonisolated struct CandlestickChartCandle: Identifiable, Hashable, Sendable { var date: Date var open: Double var high: Double var low: Double var close: Double var id: Date { date } var isRising: Bool { close >= open } } nonisolated enum CandlestickChartMath { /// The prices the chart shows: from the lowest low to the highest high, with some room at /// both ends so no candle touches the edge. static func domain(of candles: [CandlestickChartCandle], padding: Double = 0.08) -> ClosedRange { guard let low = candles.map(\.low).min(), let high = candles.map(\.high).max(), high >= low else { return 0...1 } let room = max(high - low, abs(high) * 0.01, 0.01) * max(padding, 0) return (low - room)...(high + room) } /// The two ends of the body of a candle. A day that closed where it opened still gets a body /// `minimum` tall, so it shows as a line instead of vanishing. static func body(of candle: CandlestickChartCandle, minimum: Double) -> ClosedRange { let low = min(candle.open, candle.close) let high = max(candle.open, candle.close) let missing = max(minimum - (high - low), 0) return (low - missing / 2)...(high + missing / 2) } /// How much the price moved from open to close, as a share of the open. static func change(of candle: CandlestickChartCandle) -> Double { candle.open != 0 ? (candle.close - candle.open) / candle.open : 0 } /// The candle of the day closest to a date, which is how a touch between two days picks one. static func nearest(to date: Date, in candles: [CandlestickChartCandle]) -> CandlestickChartCandle? { candles.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } } } /// A candlestick chart: each day is a thin line from its low to its high and a body from its /// open to its close, in one color when the price rose and another when it fell. /// /// Drag over it to read a day: a rule follows the finger and `selection` gets its candle. /// VoiceOver treats the chart as one value to adjust, and reads the date and the four prices /// of a day at a time. With Differentiate Without Color on, the days /// that fell are drawn lighter, so the two kinds do not depend on the color alone. struct CandlestickChart: View { let candles: [CandlestickChartCandle] @Binding var selection: CandlestickChartCandle? var risingColor = Color.green var fallingColor = Color.red @State private var selectedDate: Date? @Environment(\.accessibilityDifferentiateWithoutColor) private var differentiateWithoutColor var body: some View { let domain = CandlestickChartMath.domain(of: candles) let minimumBody = (domain.upperBound - domain.lowerBound) * 0.004 Chart { ForEach(candles) { candle in let color = candle.isRising ? risingColor : fallingColor let prices = CandlestickChartMath.body(of: candle, minimum: minimumBody) RuleMark( x: .value("Day", candle.date, unit: .day), yStart: .value("Low", candle.low), yEnd: .value("High", candle.high) ) .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round)) .foregroundStyle(color) .accessibilityHidden(true) RectangleMark( x: .value("Day", candle.date, unit: .day), yStart: .value("Open", prices.lowerBound), yEnd: .value("Close", prices.upperBound), width: .ratio(0.62) ) .foregroundStyle(color.opacity(!candle.isRising && differentiateWithoutColor ? 0.45 : 1)) .clipShape(.rect(cornerRadius: 1.5)) .accessibilityLabel(candle.date.formatted(date: .abbreviated, time: .omitted)) .accessibilityValue(description(of: candle)) } if let selection { RuleMark(x: .value("Selected", selection.date, unit: .day)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .foregroundStyle(.secondary) .accessibilityHidden(true) } } .chartYScale(domain: domain) .chartXAxis { AxisMarks(preset: .aligned) } .chartXSelection(value: $selectedDate) .onChange(of: selectedDate) { _, date in selection = date.flatMap { CandlestickChartMath.nearest(to: $0, in: candles) } } .sensoryFeedback(.selection, trigger: selection) // Charts groups marks this close into ranges of dates, with no prices. One element that // steps through the days reads them instead. .accessibilityElement(children: .ignore) .accessibilityLabel("Candlestick chart") .accessibilityValue(accessibilityValue) .accessibilityAdjustableAction { direction in guard !candles.isEmpty else { return } let last = candles.count - 1 let current = selection.flatMap { chosen in candles.firstIndex { $0.date == chosen.date } } ?? last switch direction { case .increment: selection = candles[min(current + 1, last)] case .decrement: selection = candles[max(current - 1, 0)] @unknown default: break } } } private var accessibilityValue: String { guard let candle = selection ?? candles.last else { return "" } return "\(candle.date.formatted(date: .abbreviated, time: .omitted)), \(description(of: candle))" } private func description(of candle: CandlestickChartCandle) -> String { let prices = [("Open", candle.open), ("high", candle.high), ("low", candle.low), ("close", candle.close)] return prices.map { "\($0.0) \($0.1.formatted(.number.precision(.fractionLength(2))))" }.joined(separator: ", ") } } // MARK: - Demo struct CandlestickChartDemo: View { @State private var selection: CandlestickChartCandle? /// A month of made-up prices, the same on every run. private let candles: [CandlestickChartCandle] = { var seed: UInt64 = 42 func next() -> Double { seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 return Double(seed >> 33) / Double(1 << 31) } let start = Date(timeIntervalSinceReferenceDate: 815_000_000) var price = 182.0 return (0..<30).map { day in let open = price let close = open + (next() - 0.47) * 9 let high = max(open, close) + next() * 4 let low = min(open, close) - next() * 4 price = close return CandlestickChartCandle(date: start.addingTimeInterval(Double(day) * 86_400), open: open, high: high, low: low, close: close) } }() private var shown: CandlestickChartCandle? { selection ?? candles.last } var body: some View { VStack(alignment: .leading, spacing: 18) { if let shown { VStack(alignment: .leading, spacing: 4) { Text("ACME") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline) { Text(shown.close, format: .currency(code: "USD")) .font(.largeTitle.bold()) Text(CandlestickChartMath.change(of: shown), format: .percent.precision(.fractionLength(1)).sign(strategy: .always())) .font(.headline) .foregroundStyle(shown.isRising ? .green : .red) } .monospacedDigit() .contentTransition(.numericText()) Text(selection == nil ? "Last close · drag to read a day" : shown.date.formatted(date: .abbreviated, time: .omitted)) .font(.footnote) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) CandlestickChart(candles: candles, selection: $selection) .frame(height: 280) HStack { price("Open", shown.open) price("High", shown.high) price("Low", shown.low) price("Close", shown.close) } } } .padding(20) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } private func price(_ title: String, _ value: Double) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.caption) .foregroundStyle(.secondary) Text(value, format: .number.precision(.fractionLength(2))) .font(.subheadline.weight(.semibold)) .monospacedDigit() } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) } } #Preview { CandlestickChartDemo() } ``` ## Contribution Heatmap A calendar grid of colored cells whose intensity shows the activity of each day. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/ContributionHeatmap/ContributionHeatmap.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A calendar grid of colored cells whose intensity shows the activity of each day. /// /// It opens scrolled to the newest week. VoiceOver reads the whole grid as one summary, /// and with Reduce Motion on the cells appear without animating. struct ContributionHeatmap: View { let activity: [Date: Int] var endDate: Date = .now var weeks: Int = 26 var calendar: Calendar = .current var tint: Color = .green var cellSize: CGFloat = 18 var spacing: CGFloat = 4 @State private var isRevealed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private let levels = 4 var body: some View { let days = ContributionHeatmapLogic.days(endingAt: endDate, weeks: weeks, calendar: calendar) let counts = ContributionHeatmapLogic.counts(for: days, activity: activity, calendar: calendar) let highest = counts.max() ?? 0 let size = max(cellSize, 1) let gap = max(spacing, 0) VStack(alignment: .leading, spacing: 12) { ScrollView(.horizontal) { LazyHGrid(rows: Array(repeating: GridItem(.fixed(size), spacing: gap), count: ContributionHeatmapLogic.daysPerWeek), spacing: gap) { ForEach(Array(counts.enumerated()), id: \.offset) { index, count in cell( level: ContributionHeatmapLogic.level(count: count, highest: highest, levels: levels), delay: Double((counts.count - 1 - index) / ContributionHeatmapLogic.daysPerWeek) * 0.03 ) } } } .defaultScrollAnchor(.trailing) .scrollIndicators(.hidden) legend } .accessibilityElement(children: .ignore) .accessibilityLabel("Activity over the last \(max(weeks, 1)) weeks") .accessibilityValue(summary(counts)) .task(id: activity) { guard !reduceMotion else { isRevealed = true return } var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { isRevealed = false } try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } isRevealed = true } } private func cell(level: Int, delay: Double) -> some View { let isShown = isRevealed || reduceMotion return RoundedRectangle(cornerRadius: 4) .fill(color(for: level)) .scaleEffect(isShown ? 1 : 0.3) .opacity(isShown ? 1 : 0) .animation(reduceMotion ? nil : .spring(duration: 0.4, bounce: 0.3).delay(delay), value: isRevealed) } private var legend: some View { HStack(spacing: 4) { Text("Less") ForEach(0...levels, id: \.self) { level in RoundedRectangle(cornerRadius: 3) .fill(color(for: level)) .frame(width: 12, height: 12) } Text("More") } .font(.caption) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .trailing) } private func color(for level: Int) -> Color { guard level > 0 else { return Color.secondary.opacity(0.15) } return tint.opacity(0.25 + 0.75 * Double(level) / Double(levels)) } private func summary(_ counts: [Int]) -> String { let activeDays = counts.count { $0 > 0 } return "\(ContributionHeatmapLogic.sum(counts)) contributions on \(activeDays) days" } } /// The grid math, apart from the view so it can be tested. enum ContributionHeatmapLogic { static let daysPerWeek = 7 /// Every day shown, oldest first. The first day starts a week, so columns line up. static func days(endingAt end: Date, weeks: Int, calendar: Calendar) -> [Date] { let weeks = min(max(weeks, 1), 530) let last = calendar.startOfDay(for: end) let daysIntoWeek = (calendar.component(.weekday, from: last) - calendar.firstWeekday + daysPerWeek) % daysPerWeek let count = (weeks - 1) * daysPerWeek + daysIntoWeek + 1 return (0.. [Int] { var byDay: [Date: Int] = [:] for (date, count) in activity { let day = calendar.startOfDay(for: date) byDay[day] = max(byDay[day] ?? 0, count) } return days.map { byDay[calendar.startOfDay(for: $0)] ?? 0 } } /// The color step of a count: 0 for nothing, then 1 up to `levels`. static func level(count: Int, highest: Int, levels: Int) -> Int { guard count > 0, highest > 0, levels > 0 else { return 0 } let ratio = Double(count) / Double(highest) return min(max(Int((ratio * Double(levels)).rounded(.up)), 1), levels) } /// Adds positive counts and stops at `Int.max` instead of overflowing. static func sum(_ counts: [Int]) -> Int { counts.reduce(0) { total, count in let (result, overflow) = total.addingReportingOverflow(max(count, 0)) return overflow ? Int.max : result } } } // MARK: - Demo struct ContributionHeatmapDemo: View { @State private var seed = 1.0 private var activity: [Date: Int] { let today = Calendar.current.startOfDay(for: .now) var result: [Date: Int] = [:] for offset in 0..<210 { guard let day = Calendar.current.date(byAdding: .day, value: -offset, to: today) else { continue } let wave = sin(Double(offset) * 0.9 * seed) + cos(Double(offset) * 0.23 + seed) result[day] = max(0, Int(wave * 5)) } return result } var body: some View { VStack(alignment: .leading, spacing: 24) { Spacer() Text("Commits") .font(.largeTitle.bold()) Text("Your last 30 weeks of work") .font(.title3) .foregroundStyle(.secondary) ContributionHeatmap(activity: activity, weeks: 30) Button("Shuffle", systemImage: "shuffle") { seed = seed > 1.3 ? 1 : seed + 0.17 } .buttonStyle(.bordered) .controlSize(.large) Spacer() } .padding(24) } } #Preview { ContributionHeatmapDemo() } ``` ## Gauge Chart A half-moon gauge with colored zones and a needle that swings to the value. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/GaugeChart/GaugeChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated struct GaugeChartZone: Identifiable, Sendable { var name: String /// Where the zone ends. It starts where the one before it ends, or at the start of the range. var upTo: Double var color: Color var id: String { name } } nonisolated enum GaugeChartMath { /// How far along the dial a value is, from 0 at its start to 1 at its end. static func share(of value: Double, in range: ClosedRange) -> Double { let length = range.upperBound - range.lowerBound guard length > 0, value.isFinite else { return 0 } return min(max((value - range.lowerBound) / length, 0), 1) } /// The turn of the needle in degrees: -90 at the start of the dial, 0 straight up, 90 at its end. static func needleAngle(share: Double) -> Double { -90 + 180 * min(max(share, 0), 1) } /// The zone a value falls in: the first one that ends at or after it, or the last one for a /// value beyond them all. static func zone(for value: Double, in zones: [GaugeChartZone]) -> GaugeChartZone? { let sorted = zones.sorted { $0.upTo < $1.upTo } return sorted.first { value <= $0.upTo } ?? sorted.last } /// Where each zone starts and ends along the dial, as shares from 0 to 1. static func spans(of zones: [GaugeChartZone], in range: ClosedRange) -> [(zone: GaugeChartZone, start: Double, end: Double)] { var start = 0.0 return zones.sorted { $0.upTo < $1.upTo }.compactMap { zone in let end = share(of: zone.upTo, in: range) defer { start = max(start, end) } return end > start ? (zone, start, end) : nil } } } /// A half-moon gauge for a panel: a dial split into colored zones and a needle that swings to /// the value. For a small control inside a row, SwiftUI's own `Gauge` is the one to use. /// /// VoiceOver reads the value and the name of its zone, so the reading does not depend on the /// color. With Reduce Motion on, the needle goes to its place without swinging. struct GaugeChart: View { var value: Double var range: ClosedRange = 0...100 let zones: [GaugeChartZone] var label = "Gauge" var valueText: (Double) -> String = { $0.formatted(.number.precision(.fractionLength(0))) } private let lineWidth: CGFloat = 26 private let gap = 0.004 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let share = GaugeChartMath.share(of: value, in: range) let zone = GaugeChartMath.zone(for: value, in: zones) GeometryReader { proxy in let width = proxy.size.width ZStack(alignment: .bottom) { ZStack { ForEach(GaugeChartMath.spans(of: zones, in: range), id: \.zone.id) { span in // A circle starts at three o'clock. Half a turn makes it start at nine, where the dial does. Circle() .inset(by: lineWidth / 2) .trim(from: span.start / 2 + gap, to: span.end / 2 - gap) .stroke(span.zone.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .butt)) .rotationEffect(.degrees(180)) } } .frame(width: width, height: width) .frame(height: width / 2, alignment: .top) .clipped() Capsule() .fill(.primary) .frame(width: 5, height: width / 2 - lineWidth - 10) .rotationEffect(.degrees(GaugeChartMath.needleAngle(share: share)), anchor: .bottom) .animation(reduceMotion ? nil : .spring(duration: 0.9, bounce: 0.35), value: share) Circle() .fill(.primary) .frame(width: 18, height: 18) .offset(y: 9) } } .aspectRatio(2, contentMode: .fit) // A dial reads from left to right in every language, like a speedometer. .environment(\.layoutDirection, .leftToRight) .padding(.bottom, 9) .accessibilityElement() .accessibilityLabel(label) .accessibilityValue([valueText(value), zone?.name].compactMap { $0 }.joined(separator: ", ")) } } // MARK: - Demo struct GaugeChartDemo: View { private let zones = [ GaugeChartZone(name: "Good", upTo: 50, color: .green), GaugeChartZone(name: "Moderate", upTo: 100, color: .yellow), GaugeChartZone(name: "Unhealthy", upTo: 150, color: .orange), GaugeChartZone(name: "Hazardous", upTo: 200, color: .red), ] private let readings: [(place: String, value: Double)] = [("Harbor", 32), ("Old Town", 118), ("Ring Road", 171), ("Hillside", 74)] @State private var index = 0 var body: some View { let reading = readings[index] let zone = GaugeChartMath.zone(for: reading.value, in: zones) VStack(spacing: 18) { VStack(spacing: 4) { Text("Air Quality") .font(.title2.bold()) Text(reading.place) .font(.subheadline) .foregroundStyle(.secondary) .contentTransition(.opacity) } GaugeChart(value: reading.value, range: 0...200, zones: zones, label: "Air quality index") .padding(.horizontal, 12) VStack(spacing: 2) { Text(reading.value, format: .number) .font(.system(size: 52, weight: .bold, design: .rounded)) .contentTransition(.numericText()) Text(zone?.name ?? "") .font(.headline) .foregroundStyle(.secondary) } .accessibilityHidden(true) Button("Next Station", systemImage: "arrow.right") { withAnimation(.snappy) { index = (index + 1) % readings.count } } .buttonStyle(.bordered) } .padding(24) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } } #Preview { GaugeChartDemo() } ``` ## Interactive Line Chart A line chart that shows a rule and a value tooltip under the finger as it scrubs along the time axis. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/InteractiveLineChart/InteractiveLineChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import Charts import SwiftUI struct InteractiveLineChartPoint: Identifiable, Hashable { let date: Date let value: Double var id: Date { date } } /// A line chart that shows a rule and a value tooltip under the finger as it scrubs. /// /// The tooltip only exists during the touch, and nothing animates on its own. struct InteractiveLineChart: View { let points: [InteractiveLineChartPoint] var tint: Color = .indigo var format: (Double) -> String = { $0.formatted() } @State private var touchedDate: Date? @State private var tooltipWidth: CGFloat = 0 @Environment(\.layoutDirection) private var layoutDirection @ScaledMetric(relativeTo: .caption) private var tooltipHeadroom = 60.0 private var selected: InteractiveLineChartPoint? { touchedDate.flatMap { InteractiveLineChartLogic.nearest(to: $0, in: points) } } var body: some View { let domain = InteractiveLineChartLogic.domain(for: points) Chart { ForEach(points) { point in AreaMark( x: .value("Date", point.date), yStart: .value("Baseline", domain.lowerBound), yEnd: .value("Value", point.value) ) .foregroundStyle(.linearGradient(colors: [tint.opacity(0.3), tint.opacity(0)], startPoint: .top, endPoint: .bottom)) .interpolationMethod(.monotone) .accessibilityHidden(true) LineMark(x: .value("Date", point.date), y: .value("Value", point.value)) .foregroundStyle(tint) .lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) .interpolationMethod(.monotone) .accessibilityLabel(point.date.formatted(date: .abbreviated, time: .omitted)) .accessibilityValue(format(point.value)) } if let selected { RuleMark(x: .value("Selected", selected.date)) .foregroundStyle(.secondary) .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) .accessibilityHidden(true) PointMark(x: .value("Selected", selected.date), y: .value("Value", selected.value)) .symbolSize(140) .foregroundStyle(tint) .accessibilityHidden(true) } } .chartYScale(domain: domain, range: .plotDimension(endPadding: tooltipHeadroom)) // Keeps the last date inside the plot. Before iOS 26 it is drawn half cut at the trailing edge. .chartXAxis { AxisMarks(preset: .aligned) } .chartXSelection(value: $touchedDate) // Drawn over the chart and not as an annotation of the rule: on iOS 27 that // annotation is not drawn at all. .chartOverlay { proxy in GeometryReader { geometry in if let selected, let plotFrame = proxy.plotFrame, let x = proxy.position(forX: selected.date) { let plot = geometry[plotFrame] tooltip(for: selected) .environment(\.layoutDirection, layoutDirection) .fixedSize() .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { tooltipWidth = $0 } .position( x: InteractiveLineChartLogic.tooltipX(at: plot.minX + x, width: tooltipWidth, in: plot.minX...plot.maxX), y: plot.minY + tooltipHeadroom / 2 ) .allowsHitTesting(false) } } // The chart reports screen points, and a position is mirrored in a right-to-left // layout. Left to right here keeps the tooltip over the rule. .environment(\.layoutDirection, .leftToRight) } .sensoryFeedback(.selection, trigger: selected?.id) } private func tooltip(for point: InteractiveLineChartPoint) -> some View { VStack(spacing: 2) { Text(format(point.value)) .font(.headline) Text(point.date, format: .dateTime.day().month()) .font(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 12) .padding(.vertical, 6) // A material does not draw inside a chart annotation: it comes out black, or not at all. .background(Color(.secondarySystemBackground), in: .rect(cornerRadius: 12)) .overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(.separator) } } } enum InteractiveLineChartLogic { static func nearest(to date: Date, in points: [InteractiveLineChartPoint]) -> InteractiveLineChartPoint? { points.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } } /// The center of the tooltip: over the point, but never past either end of the plot. static func tooltipX(at x: CGFloat, width: CGFloat, in plot: ClosedRange) -> CGFloat { let half = max(width, 0) / 2 guard plot.upperBound - plot.lowerBound > half * 2 else { return (plot.lowerBound + plot.upperBound) / 2 } return min(max(x, plot.lowerBound + half), plot.upperBound - half) } /// The vertical range with some breathing room. A flat or empty series still gets a valid range. static func domain(for points: [InteractiveLineChartPoint]) -> ClosedRange { let values = points.map(\.value).filter(\.isFinite) guard let low = values.min(), let high = values.max() else { return 0...1 } guard high - low > 0, (high - low).isFinite else { return (low - 1)...(high + 1) } let padding = (high - low) * 0.15 return (low - padding)...(high + padding) } } // MARK: - Demo struct InteractiveLineChartDemo: View { @State private var points: [InteractiveLineChartPoint] = { let start = Calendar.current.startOfDay(for: .now) return (0..<36).compactMap { day in guard let date = Calendar.current.date(byAdding: .day, value: day - 35, to: start) else { return nil } let value = 1_200 + Double(day) * 14 + sin(Double(day) * 0.55) * 120 + cos(Double(day) * 1.7) * 40 return InteractiveLineChartPoint(date: date, value: value.rounded()) } }() private func euros(_ value: Double) -> String { value.formatted(.currency(code: "EUR").precision(.fractionLength(0))) } var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Savings") .font(.title3) .foregroundStyle(.secondary) Text(euros(points.last?.value ?? 0)) .font(.largeTitle.bold()) Text("Drag along the chart") .font(.subheadline) .foregroundStyle(.secondary) InteractiveLineChart(points: points, format: euros) .padding(.vertical, 12) } .padding(24) } } #Preview { InteractiveLineChartDemo() } ``` ## Line Chart Draw In A line chart whose line draws in from left to right while a dot follows its tip. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/LineChartDrawIn/LineChartDrawIn.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A line chart whose line draws in from left to right while a dot follows its tip. /// /// It draws again whenever the values change. With Reduce Motion on, the finished chart appears. struct LineChartDrawIn: View { let values: [Double] var tint: Color = .indigo var duration: Double = 1.6 @State private var progress = 0.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let inset = 12.0 private var shownProgress: Double { reduceMotion ? 1 : progress } var body: some View { ZStack { LineChartDrawInArea(values: values, inset: inset, progress: shownProgress) .fill(.linearGradient(colors: [tint.opacity(0.28), tint.opacity(0)], startPoint: .top, endPoint: .bottom)) LineChartDrawInLine(values: values, inset: inset) .trim(from: 0, to: shownProgress) .stroke(tint, style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round)) LineChartDrawInDot(values: values, inset: inset, radius: 12, progress: shownProgress) .fill(tint.opacity(0.25)) LineChartDrawInDot(values: values, inset: inset, radius: 6, progress: shownProgress) .fill(tint) } .accessibilityElement(children: .ignore) .accessibilityLabel("Line chart") .accessibilityValue(LineChartDrawInLogic.summary(of: values)) .task(id: values) { guard !reduceMotion else { progress = 1 return } var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { progress = 0 } try? await Task.sleep(for: .milliseconds(50)) guard !Task.isCancelled else { return } withAnimation(.easeInOut(duration: max(duration, 0))) { progress = 1 } } } } nonisolated private struct LineChartDrawInLine: Shape { let values: [Double] let inset: CGFloat func path(in rect: CGRect) -> Path { var path = Path() path.addLines(LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset))) return path } } /// The fill under the line, up to its tip, so it never runs ahead of the line being drawn. nonisolated private struct LineChartDrawInArea: Shape { let values: [Double] let inset: CGFloat var progress: Double var animatableData: Double { get { progress } set { progress = newValue } } func path(in rect: CGRect) -> Path { let points = LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset)) guard let first = points.first, let last = LineChartDrawInLogic.point(at: progress, along: points) else { return Path() } var path = Path() path.addLines(points.filter { $0.x < last.x } + [last]) path.addLine(to: CGPoint(x: last.x, y: rect.maxY)) path.addLine(to: CGPoint(x: first.x, y: rect.maxY)) path.closeSubpath() return path } } /// A circle that re-computes its position for every animation frame, so it stays on the tip. nonisolated private struct LineChartDrawInDot: Shape { let values: [Double] let inset: CGFloat let radius: CGFloat var progress: Double var animatableData: Double { get { progress } set { progress = newValue } } func path(in rect: CGRect) -> Path { let points = LineChartDrawInLogic.points(for: values, in: rect.insetBy(dx: inset, dy: inset)) guard let center = LineChartDrawInLogic.point(at: progress, along: points) else { return Path() } return Path(ellipseIn: CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)) } } /// The geometry of the chart, apart from the view so it can be tested. nonisolated enum LineChartDrawInLogic { static func points(for values: [Double], in rect: CGRect) -> [CGPoint] { let values = values.filter(\.isFinite) guard !rect.isNull, rect.width > 0, rect.height > 0, let low = values.min(), let high = values.max() else { return [] } let span = high - low let step = values.count > 1 ? rect.width / CGFloat(values.count - 1) : 0 return values.enumerated().map { index, value in let ratio = span > 0 && span.isFinite ? (value - low) / span : 0.5 return CGPoint(x: rect.minX + CGFloat(index) * step, y: rect.maxY - CGFloat(ratio) * rect.height) } } /// The point at a fraction of the line's length, which is how `trim` measures it too. static func point(at progress: Double, along points: [CGPoint]) -> CGPoint? { guard let first = points.first else { return nil } let lengths = zip(points, points.dropFirst()).map { hypot($1.x - $0.x, $1.y - $0.y) } let total = lengths.reduce(0, +) guard total > 0 else { return first } var remaining = total * CGFloat(min(max(progress, 0), 1)) for (index, length) in lengths.enumerated() where length > 0 { if remaining <= length { let fraction = remaining / length let start = points[index] let end = points[index + 1] return CGPoint(x: start.x + (end.x - start.x) * fraction, y: start.y + (end.y - start.y) * fraction) } remaining -= length } return points.last } static func summary(of values: [Double]) -> String { let values = values.filter(\.isFinite) guard let first = values.first, let last = values.last, let low = values.min(), let high = values.max() else { return "No data" } return "From \(first.formatted()) to \(last.formatted()). Lowest \(low.formatted()), highest \(high.formatted())." } } // MARK: - Demo struct LineChartDrawInDemo: View { @State private var isAlternate = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private var values: [Double] { isAlternate ? [62, 58, 70, 64, 80, 76, 66, 72, 88, 84, 96, 90] : [20, 34, 28, 46, 40, 58, 52, 74, 66, 82, 78, 96] } var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Active users") .font(.largeTitle.bold()) Text(isAlternate ? "Europe" : "Americas") .font(.title3) .foregroundStyle(.secondary) LineChartDrawIn(values: values) .padding(.vertical, 24) } .padding(24) .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(2.8)) guard !Task.isCancelled else { return } isAlternate.toggle() } } } } #Preview { LineChartDrawInDemo() } ``` ## Radar Chart A radar chart with an axis per quality around a center and a shape per series that grows out when it appears. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/RadarChart/RadarChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated struct RadarChartSeries: Identifiable, Sendable { var name: String /// One value per axis, in the order of the axes. var values: [Double] var color: Color var id: String { name } } nonisolated enum RadarChartMath { /// The direction of an axis in radians, with the first one pointing up and the rest going clockwise. static func angle(of axis: Int, count: Int) -> Double { guard count > 0 else { return -.pi / 2 } return Double(axis) / Double(count) * 2 * .pi - .pi / 2 } /// How far along its axis a value is, from 0 at the center to 1 at the edge. static func share(of value: Double, maximum: Double) -> Double { guard maximum > 0, value.isFinite else { return 0 } return min(max(value / maximum, 0), 1) } /// The point `share` of the way along an axis, in a circle of `radius` around `center`. static func point(axis: Int, count: Int, share: Double, center: CGPoint, radius: Double) -> CGPoint { let angle = angle(of: axis, count: count) return CGPoint(x: center.x + cos(angle) * radius * share, y: center.y + sin(angle) * radius * share) } /// The corners of the shape of a series: one per axis. A series with fewer values than axes /// is at the center on the rest. static func corners(values: [Double], axes: Int, maximum: Double, center: CGPoint, radius: Double) -> [CGPoint] { (0.. String { series .map { "\($0.name) \((axis < $0.values.count ? $0.values[axis] : 0).formatted())" } .joined(separator: ", ") } } /// The grid behind the shapes: a polygon per ring and a spoke per axis. nonisolated struct RadarChartGrid: Shape { var axes: Int var rings: Int func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.width, rect.height) / 2 var path = Path() guard axes > 2 else { return path } for ring in 1...max(rings, 1) { let share = Double(ring) / Double(max(rings, 1)) path.addLines((0.. Path { var path = Path() guard axes > 2 else { return path } let center = CGPoint(x: rect.midX, y: rect.midY) path.addLines(RadarChartMath.corners(values: values, axes: axes, maximum: maximum, center: center, radius: min(rect.width, rect.height) / 2 * growth)) path.closeSubpath() return path } } // MARK: - Demo struct RadarChartDemo: View { private let axes = ["Speed", "Power", "Range", "Comfort", "Price", "Design"] private let bikes = [ RadarChartSeries(name: "City", values: [55, 40, 62, 90, 85, 70], color: .teal), RadarChartSeries(name: "Trail", values: [80, 92, 78, 50, 45, 82], color: .orange), ] @State private var run = 0 var body: some View { VStack(spacing: 20) { VStack(spacing: 4) { Text("Which e-bike?") .font(.title2.bold()) Text("Two models, six things that matter") .font(.subheadline) .foregroundStyle(.secondary) } RadarChart(axes: axes, series: bikes) .id(run) HStack(spacing: 20) { ForEach(bikes) { bike in Label { Text(bike.name) } icon: { Circle() .fill(bike.color) .frame(width: 10, height: 10) } .font(.subheadline.weight(.medium)) } } .accessibilityHidden(true) Button("Replay", systemImage: "arrow.clockwise") { run += 1 } .buttonStyle(.bordered) } .padding(20) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) } } #Preview { RadarChartDemo() } ``` ## Scrollable Bar Chart A bar chart that scrolls sideways a week at a time and marks the average of the days in sight. Category: Charts. Minimum: iOS 18.0. Path: Components/Charts/ScrollableBarChart/ScrollableBarChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import Charts import SwiftUI nonisolated struct ScrollableBarChartEntry: Identifiable, Hashable, Sendable { var date: Date var value: Double var id: Date { date } } nonisolated enum ScrollableBarChartMath { static let secondsPerDay = 86_400.0 /// The entries of the days in sight: those from `start` up to `days` later. static func visible(_ entries: [ScrollableBarChartEntry], from start: Date, days: Int) -> [ScrollableBarChartEntry] { let end = start.addingTimeInterval(Double(max(days, 0)) * secondsPerDay) return entries.filter { $0.date >= start && $0.date < end } } /// The mean of the values, or `nil` when there are none. static func average(of entries: [ScrollableBarChartEntry]) -> Double? { entries.isEmpty ? nil : entries.map(\.value).reduce(0, +) / Double(entries.count) } /// Where the chart starts so that its last `days` are the ones in sight. static func lastStart(of entries: [ScrollableBarChartEntry], days: Int) -> Date? { guard let last = entries.map(\.date).max() else { return nil } return last.addingTimeInterval(-Double(max(days, 1) - 1) * secondsPerDay) } /// The top of the value axis: the highest value of all, with some room, so the bars keep /// their scale while the chart scrolls. static func ceiling(of entries: [ScrollableBarChartEntry]) -> Double { max((entries.map(\.value).max() ?? 0) * 1.12, 1) } } /// A bar chart with more days than fit, which scrolls sideways and settles on whole days, a /// week at a time. A line marks the average of the days in sight and `visible` reports them. /// /// It starts on the latest days. Swift Charts gives VoiceOver every bar and an audio graph. struct ScrollableBarChart: View { let entries: [ScrollableBarChartEntry] /// The entries of the days in sight, kept up to date as the chart scrolls. @Binding var visible: [ScrollableBarChartEntry] var visibleDays = 7 @State private var position = Date.now var body: some View { let average = ScrollableBarChartMath.average(of: visible) Chart { ForEach(entries) { entry in BarMark( x: .value("Day", entry.date, unit: .day), y: .value("Value", entry.value) ) .foregroundStyle(.tint) .clipShape(.rect(cornerRadius: 5)) } if let average { RuleMark(y: .value("Average", average)) .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [4, 4])) .foregroundStyle(.secondary) .accessibilityLabel("Average of the days in sight") } } .chartYScale(domain: 0...ScrollableBarChartMath.ceiling(of: entries)) .chartScrollableAxes(.horizontal) .chartXVisibleDomain(length: Double(visibleDays) * ScrollableBarChartMath.secondsPerDay) .chartScrollPosition(x: $position) .chartScrollTargetBehavior(.valueAligned(matching: DateComponents(hour: 0), majorAlignment: .matching(DateComponents(weekday: Calendar.current.firstWeekday)))) .chartXAxis { AxisMarks(values: .stride(by: .day)) { _ in AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true) } } .onAppear { if let start = ScrollableBarChartMath.lastStart(of: entries, days: visibleDays) { position = start } } .onChange(of: position, initial: true) { _, start in let shown = ScrollableBarChartMath.visible(entries, from: start, days: visibleDays) if shown != visible { visible = shown } } } } // MARK: - Demo struct ScrollableBarChartDemo: View { @State private var visible: [ScrollableBarChartEntry] = [] /// Eight weeks of made-up step counts, the same on every run. private let entries: [ScrollableBarChartEntry] = { var seed: UInt64 = 7 let start = Calendar.current.startOfDay(for: Date(timeIntervalSinceReferenceDate: 815_000_000)) return (0..<56).map { day in seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 let noise = Double(seed >> 33) / Double(1 << 31) let weekend = day % 7 >= 5 ? 0.7 : 1 return ScrollableBarChartEntry(date: start.addingTimeInterval(Double(day) * 86_400), value: (4_500 + noise * 7_500) * weekend) } }() var body: some View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("Steps") .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) HStack(alignment: .firstTextBaseline, spacing: 6) { Text(ScrollableBarChartMath.average(of: visible) ?? 0, format: .number.precision(.fractionLength(0))) .font(.largeTitle.bold()) .monospacedDigit() .contentTransition(.numericText()) Text("a day") .font(.headline) .foregroundStyle(.secondary) } Text(range) .font(.footnote) .foregroundStyle(.secondary) } .animation(.snappy, value: visible) .accessibilityElement(children: .combine) ScrollableBarChart(entries: entries, visible: $visible) .frame(height: 260) } .padding(20) .background(.background, in: .rect(cornerRadius: 24)) .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemGroupedBackground)) .tint(.orange) } private var range: String { guard let first = visible.first?.date, let last = visible.last?.date else { return " " } return (first.. some View { Label(percent(change), systemImage: change >= 0 ? "arrow.up.right" : "arrow.down.right") .font(.caption.weight(.semibold)) .foregroundStyle((change >= 0) == higherIsBetter ? .green : .red) } @ViewBuilder private var sparkline: some View { let values = trend.filter(\.isFinite) if values.count > 1 { let domain = SparklineStatTileLogic.domain(for: values) Chart(Array(values.enumerated()), id: \.offset) { index, value in AreaMark(x: .value("Index", index), yStart: .value("Low", domain.lowerBound), yEnd: .value("Value", value)) .foregroundStyle(.linearGradient(colors: [tint.opacity(0.3), tint.opacity(0)], startPoint: .top, endPoint: .bottom)) .interpolationMethod(.catmullRom) LineMark(x: .value("Index", index), y: .value("Value", value)) .foregroundStyle(tint) .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) .interpolationMethod(.catmullRom) } .chartXAxis(.hidden) .chartYAxis(.hidden) .chartLegend(.hidden) .chartXScale(domain: 0...(values.count - 1)) .chartYScale(domain: domain) .frame(height: minimumHeight * 0.55) .animation(reduceMotion ? nil : .smooth, value: values) .accessibilityHidden(true) } } private var accessibilityValue: String { guard let change else { return value } return "\(value), \(change >= 0 ? "up" : "down") \(abs(change).formatted(.percent.precision(.fractionLength(1))))" } private func percent(_ change: Double) -> String { change.formatted(.percent.precision(.fractionLength(1)).sign(strategy: .always())) } } enum SparklineStatTileLogic { /// The change from the first value to the last, or `nil` when it cannot be computed. static func change(in values: [Double]) -> Double? { let values = values.filter(\.isFinite) guard values.count > 1, let first = values.first, let last = values.last, first != 0 else { return nil } let change = (last - first) / abs(first) return change.isFinite ? change : nil } /// A valid vertical range, also for a flat series, with room so the line is not cut off. static func domain(for values: [Double]) -> ClosedRange { guard let low = values.min(), let high = values.max() else { return 0...1 } guard high - low > 0, (high - low).isFinite else { return (low - 1)...(high + 1) } return (low - (high - low) * 0.1)...(high + (high - low) * 0.5) } } // MARK: - Demo struct SparklineStatTileDemo: View { @State private var tick = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize private func series(base: Double, swing: Double, slope: Double, phase: Double) -> [Double] { (0..<14).map { index in let position = Double(index + tick) return (base + position * slope + sin(position * 0.8 + phase) * swing).rounded() } } private var columns: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 14), count: dynamicTypeSize.isAccessibilitySize ? 1 : 2) } private func tile(_ title: LocalizedStringResource, _ systemImage: String, _ tint: Color, _ trend: [Double], suffix: String = "", higherIsBetter: Bool = true) -> some View { SparklineStatTile( title: title, value: (trend.last ?? 0).formatted() + suffix, trend: trend, systemImage: systemImage, tint: tint, higherIsBetter: higherIsBetter ) } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Overview") .font(.largeTitle.bold()) LazyVGrid(columns: columns, spacing: 14) { tile("Revenue", "eurosign.circle", .indigo, series(base: 4_200, swing: 320, slope: 60, phase: 0)) tile("Signups", "person.badge.plus", .teal, series(base: 310, swing: 40, slope: 8, phase: 1.4)) tile("Churn", "arrow.uturn.left.circle", .pink, series(base: 60, swing: 7, slope: -1.5, phase: 2.2), suffix: " users", higherIsBetter: false) tile("Sessions", "waveform.path.ecg", .orange, series(base: 1_900, swing: 240, slope: 18, phase: 3.1)) tile("Orders", "shippingbox", .green, series(base: 840, swing: 90, slope: 12, phase: 4.3)) tile("Tickets", "lifepreserver", .purple, series(base: 120, swing: 22, slope: -2, phase: 5), higherIsBetter: false) } } .padding(20) } .background(LinearGradient(colors: [.indigo.opacity(0.18), .teal.opacity(0.12)], startPoint: .top, endPoint: .bottom)) .task(id: reduceMotion) { guard !reduceMotion else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) guard !Task.isCancelled else { return } tick = (tick + 1) % 1_000 } } } } #Preview { SparklineStatTileDemo() } ``` ## Surface 3D Chart A 3D surface plot that rotates when dragged, with buttons for preset viewing angles. Category: Charts. Minimum: iOS 26.0. Path: Components/Charts/Surface3DChart/Surface3DChart.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import Charts import Spatial import SwiftUI @available(iOS 26, *) enum Surface3DChartPreset: String, CaseIterable, Identifiable { case angled = "Angled" case front = "Front" case top = "Top" case side = "Side" var id: Self { self } var pose: Chart3DPose { switch self { case .angled: Chart3DPose(azimuth: .degrees(35), inclination: .degrees(25)) case .front: .front case .top: .top case .side: .right } } } /// A 3D surface plot that rotates when dragged, with buttons for preset viewing angles. /// /// With Reduce Motion on, the presets jump to their angle. @available(iOS 26, *) struct Surface3DChart: View { var domain: ClosedRange = -3...3 var gradient = Gradient(colors: [.indigo, .purple, .pink, .orange]) let function: @Sendable (Double, Double) -> Double @State private var pose = Surface3DChartPreset.angled.pose @Environment(\.accessibilityReduceMotion) private var reduceMotion private var safeDomain: ClosedRange { domain.lowerBound < domain.upperBound ? domain : -1...1 } private var currentPreset: Surface3DChartPreset? { Surface3DChartPreset.allCases.first { $0.pose == pose } } var body: some View { let function = self.function VStack(spacing: 20) { Chart3D { SurfacePlot(x: "X", y: "Height", z: "Z") { x, z in let height = function(x, z) return height.isFinite ? height : 0 } .foregroundStyle(.heightBased(gradient)) .roughness(0.35) } .chartXScale(domain: safeDomain) .chartZScale(domain: safeDomain) .chart3DPose($pose) // The chart sizes its plot for the longer side of its frame. In a tall frame that // is wider than the view, which cuts the corners and the axis labels. .aspectRatio(1, contentMode: .fit) .accessibilityLabel("3D surface plot") .accessibilityValue(currentPreset.map { "\($0.rawValue) view" } ?? "Custom view") .accessibilityHint("Use the view buttons to change the angle") ViewThatFits(in: .horizontal) { presetRow ScrollView(.horizontal) { presetRow }.scrollIndicators(.hidden) } } } private var presetRow: some View { HStack(spacing: 8) { ForEach(Surface3DChartPreset.allCases) { preset in Button { withAnimation(reduceMotion ? nil : .smooth(duration: 0.6)) { pose = preset.pose } } label: { Text(preset.rawValue) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .frame(minHeight: 44) .foregroundStyle(preset == currentPreset ? Color.white : Color.primary) .background(preset == currentPreset ? AnyShapeStyle(.indigo) : AnyShapeStyle(.thinMaterial), in: .capsule) .contentShape(.capsule) } .buttonStyle(.plain) .accessibilityAddTraits(preset == currentPreset ? .isSelected : []) } } } } // MARK: - Demo @available(iOS 26, *) struct Surface3DChartDemo: View { var body: some View { VStack(alignment: .leading, spacing: 8) { Text("Ripple") .font(.largeTitle.bold()) Text("Drag to rotate, or pick a view") .font(.title3) .foregroundStyle(.secondary) Surface3DChart { x, z in let radius = (x * x + z * z).squareRoot() return sin(radius * 2.2) * exp(-radius * 0.28) } .padding(.top, 16) } .padding(24) } } @available(iOS 26, *) #Preview { Surface3DChartDemo() } ``` ## Chat Auto Scroll List A message list that stays on the newest message as messages arrive, and stops following when the user scrolls up to read. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/ChatAutoScrollList/ChatAutoScrollList.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A message list that stays on the newest message, and stops following when you scroll up. /// /// While you read older messages, a button brings you back to the latest. With Reduce /// Motion on, the list jumps instead of scrolling. struct ChatAutoScrollList: View where Item.ID: Sendable { let items: [Item] var spacing: CGFloat @ViewBuilder let row: (Item) -> Row @State private var position = ScrollPosition(idType: Item.ID.self) @State private var follow = ChatAutoScrollListFollowState() @State private var isUserScrolling = false @Environment(\.accessibilityReduceMotion) private var reduceMotion init(items: [Item], spacing: CGFloat = 8, @ViewBuilder row: @escaping (Item) -> Row) { self.items = items self.spacing = spacing self.row = row } var body: some View { ScrollView { LazyVStack(spacing: max(spacing, 0)) { ForEach(items) { row($0) } } .scrollTargetLayout() .padding() } .scrollPosition($position) .defaultScrollAnchor(.bottom, for: .initialOffset) .onScrollPhaseChange { _, phase in isUserScrolling = phase == .tracking || phase == .interacting || phase == .decelerating } .onScrollGeometryChange(for: Bool.self) { geometry in ChatAutoScrollListLogic.isAtBottom( visibleMaxY: geometry.visibleRect.maxY, contentHeight: geometry.contentSize.height ) } action: { _, isAtBottom in follow.scrolled(isAtBottom: isAtBottom, isUserDriven: isUserScrolling) } .onChange(of: items.last?.id) { _, newID in guard let newID else { return } follow.messageArrived() if follow.isFollowing { scroll(to: newID) } } .overlay(alignment: .bottom) { ZStack { if !follow.isFollowing { jumpButton.transition(reduceMotion ? .opacity : .scale.combined(with: .opacity)) } } .animation(reduceMotion ? nil : .snappy, value: follow) } } private var jumpButton: some View { Button { follow.jumpedToLatest() if let id = items.last?.id { scroll(to: id) } } label: { Label(follow.hasNewMessages ? "New messages" : "Latest", systemImage: "arrow.down") .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .padding(.vertical, 10) .background(.regularMaterial, in: .capsule) .shadow(color: .black.opacity(0.15), radius: 8, y: 3) } .buttonStyle(.plain) .padding(.bottom, 12) } private func scroll(to id: Item.ID) { withAnimation(reduceMotion ? nil : .snappy) { position.scrollTo(id: id, anchor: .bottom) } } } /// Whether the list follows new messages. /// /// Only the user can stop following. Otherwise the moment a new message makes the list /// taller, or an animated scroll is on its way down, would count as having left the bottom. struct ChatAutoScrollListFollowState: Equatable { private(set) var isFollowing = true private(set) var hasNewMessages = false mutating func messageArrived() { if !isFollowing { hasNewMessages = true } } mutating func scrolled(isAtBottom: Bool, isUserDriven: Bool) { if isAtBottom { jumpedToLatest() } else if isUserDriven { isFollowing = false } } mutating func jumpedToLatest() { isFollowing = true hasNewMessages = false } } enum ChatAutoScrollListLogic { static func isAtBottom(visibleMaxY: CGFloat, contentHeight: CGFloat, tolerance: CGFloat = 32) -> Bool { visibleMaxY >= contentHeight - max(tolerance, 0) } } // MARK: - Demo private struct ChatAutoScrollListDemoMessage: Identifiable { let id: Int let text: String var isMine: Bool { id.isMultiple(of: 3) } } struct ChatAutoScrollListDemo: View { private static let initialCount = 12 private static let lines = [ "Did the build finish?", "Almost, the last tests are running now.", "Great. Scroll up and the list stops following.", "And when you come back to the bottom?", "It starts following the new messages again, without you doing anything.", "Nice.", ] @State private var messages = (0.. 60 { messages.removeFirst(30) } } } #Preview { ChatAutoScrollListDemo() } ``` ## Chat Bubble Shape A message bubble with a tail, whose corners adapt to grouped consecutive messages. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/ChatBubbleShape/ChatBubbleShape.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum ChatBubbleShapeSide: Sendable { case leading, trailing } /// Where a message sits inside a run of messages from the same sender. nonisolated enum ChatBubbleShapePosition: Sendable { case single, first, middle, last } /// A message bubble with a tail, whose corners tighten where messages are grouped. /// /// The tail is drawn just outside the shape's rect, so leave about 8 points beside it. /// Corners and tail animate when a bubble's position changes. nonisolated struct ChatBubbleShape: Shape { var side: ChatBubbleShapeSide = .trailing private var radii: RectangleCornerRadii private var tail: CGFloat private let tailWidth: CGFloat = 7 init( side: ChatBubbleShapeSide = .trailing, position: ChatBubbleShapePosition = .single, radius: CGFloat = 18, tightRadius: CGFloat = 5 ) { self.side = side radii = ChatBubbleShapeLogic.radii(for: position, radius: radius, tightRadius: tightRadius) tail = ChatBubbleShapeLogic.hasTail(position) ? 1 : 0 } var animatableData: AnimatablePair { get { AnimatablePair(radii.animatableData, tail) } set { radii.animatableData = newValue.first tail = newValue.second } } func path(in rect: CGRect) -> Path { guard rect.width > 0, rect.height > 0 else { return Path() } let path = outline(width: rect.width, height: rect.height) let placed = side == .trailing ? path : path.applying(CGAffineTransform(scaleX: -1, y: 1).translatedBy(x: -rect.width, y: 0)) return placed.offsetBy(dx: rect.minX, dy: rect.minY) } /// One closed outline, with the tail on the right. The left side is its mirror image. private func outline(width: CGFloat, height: CGFloat) -> Path { let limit = min(width, height) / 2 let topLeft = min(max(radii.topLeading, 0), limit) let topRight = min(max(radii.topTrailing, 0), limit) let bottomRight = min(max(radii.bottomTrailing, 0), limit) let bottomLeft = min(max(radii.bottomLeading, 0), limit) // The bottom right corner is two curves that meet at `tip`. With no tail they // form an ordinary rounded corner, and the tail pulls them out to a point. let cornerStart = CGPoint(x: width, y: height - bottomRight) let cornerEnd = CGPoint(x: width - bottomRight, y: height) let corner = CGPoint(x: width, y: height) let firstControl = midpoint(cornerStart, corner) let secondControl = midpoint(corner, cornerEnd) let roundedTip = midpoint(firstControl, secondControl) var path = Path() path.move(to: CGPoint(x: topLeft, y: 0)) path.addLine(to: CGPoint(x: width - topRight, y: 0)) path.addArc(tangent1End: CGPoint(x: width, y: 0), tangent2End: CGPoint(x: width, y: topRight), radius: topRight) path.addLine(to: cornerStart) path.addQuadCurve( to: blend(roundedTip, CGPoint(x: width + tailWidth, y: height), tail), control: blend(firstControl, CGPoint(x: width, y: height - 2), tail) ) path.addQuadCurve( to: cornerEnd, control: blend(secondControl, CGPoint(x: width - 2, y: height - 1), tail) ) path.addLine(to: CGPoint(x: bottomLeft, y: height)) path.addArc(tangent1End: CGPoint(x: 0, y: height), tangent2End: CGPoint(x: 0, y: height - bottomLeft), radius: bottomLeft) path.addLine(to: CGPoint(x: 0, y: topLeft)) path.addArc(tangent1End: .zero, tangent2End: CGPoint(x: topLeft, y: 0), radius: topLeft) path.closeSubpath() return path } private func midpoint(_ a: CGPoint, _ b: CGPoint) -> CGPoint { blend(a, b, 0.5) } private func blend(_ a: CGPoint, _ b: CGPoint, _ amount: CGFloat) -> CGPoint { CGPoint(x: a.x + (b.x - a.x) * amount, y: a.y + (b.y - a.y) * amount) } } nonisolated enum ChatBubbleShapeLogic { static func hasTail(_ position: ChatBubbleShapePosition) -> Bool { position == .single || position == .last } /// The corners, as if the tail were on the right. static func radii(for position: ChatBubbleShapePosition, radius: CGFloat, tightRadius: CGFloat) -> RectangleCornerRadii { let tightTop = position == .middle || position == .last let tightBottom = position == .first || position == .middle return RectangleCornerRadii( topLeading: radius, bottomLeading: radius, bottomTrailing: tightBottom ? tightRadius : radius, topTrailing: tightTop ? tightRadius : radius ) } /// Works out each message's position from who sent it. static func positions(for senders: [Sender]) -> [ChatBubbleShapePosition] { senders.indices.map { index in let joinsPrevious = index > senders.startIndex && senders[index - 1] == senders[index] let joinsNext = index + 1 < senders.endIndex && senders[index + 1] == senders[index] switch (joinsPrevious, joinsNext) { case (false, false): return .single case (false, true): return .first case (true, true): return .middle case (true, false): return .last } } } } // MARK: - Demo struct ChatBubbleShapeDemo: View { @State private var visibleCount = ChatBubbleShapeDemo.messages.count @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let messages: [(isMine: Bool, text: String)] = [ (false, "Are you coming tonight?"), (true, "Yes!"), (true, "Running a little late"), (true, "Save me a seat"), (false, "Of course"), (false, "Front row, near the stage"), (true, "You are the best"), ] private var visible: ArraySlice<(isMine: Bool, text: String)> { Self.messages.prefix(visibleCount) } var body: some View { let positions = ChatBubbleShapeLogic.positions(for: visible.map(\.isMine)) ScrollView { VStack(spacing: 0) { ForEach(Array(visible.enumerated()), id: \.offset) { index, message in bubble(message.text, isMine: message.isMine, position: positions[index]) .transition(reduceMotion ? .opacity : .scale(scale: 0.8, anchor: .bottom).combined(with: .opacity)) } } .padding(20) } .defaultScrollAnchor(.bottom) .task(id: reduceMotion) { guard !reduceMotion else { visibleCount = Self.messages.count return } while !Task.isCancelled { for count in 1...Self.messages.count { guard !Task.isCancelled else { return } withAnimation(.snappy(duration: 0.3)) { visibleCount = count } try? await Task.sleep(for: .seconds(0.45)) } try? await Task.sleep(for: .seconds(1)) } } } private func bubble(_ text: String, isMine: Bool, position: ChatBubbleShapePosition) -> some View { Text(text) .padding(.horizontal, 14) .padding(.vertical, 9) .foregroundStyle(isMine ? Color.white : Color.primary) .background( isMine ? AnyShapeStyle(.blue) : AnyShapeStyle(.quaternary), in: ChatBubbleShape(side: isMine ? .trailing : .leading, position: position) ) .padding(.horizontal, 8) .frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading) .padding(.top, position == .single || position == .first ? 14 : 2) } } #Preview { ChatBubbleShapeDemo() } ``` ## Keyboard Tracking Input Bar A composer bar pinned below its content that rises with the keyboard and grows as the user types more lines. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/KeyboardTrackingInputBar/KeyboardTrackingInputBar.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A composer pinned below its content that rises with the keyboard and grows as you type. /// /// `safeAreaInset` makes the content leave room for the bar and moves the bar with the /// keyboard, so nothing here needs to measure it. struct KeyboardTrackingInputBar: View { @Binding var text: String var placeholder: LocalizedStringResource var tint: Color var onSend: (String) -> Void @ViewBuilder let content: Content @FocusState private var isFocused: Bool @ScaledMetric(relativeTo: .body) private var sendSize = 34.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion init( text: Binding, placeholder: LocalizedStringResource = "Message", tint: Color = .blue, onSend: @escaping (String) -> Void, @ViewBuilder content: () -> Content ) { _text = text self.placeholder = placeholder self.tint = tint self.onSend = onSend self.content = content() } private var message: String { text.trimmingCharacters(in: .whitespacesAndNewlines) } var body: some View { content.safeAreaInset(edge: .bottom, spacing: 0) { HStack(alignment: .bottom, spacing: 8) { TextField(placeholder, text: $text, axis: .vertical) .lineLimit(1...5) .focused($isFocused) .padding(.horizontal, 14) .padding(.vertical, 8) .background(.background, in: .rect(cornerRadius: 20)) .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.quaternary) } .accessibilityLabel(placeholder) Button(action: send) { Image(systemName: "arrow.up") .font(.body.weight(.bold)) .foregroundStyle(KeyboardTrackingInputBarContrastingForeground(background: tint)) .frame(width: sendSize, height: sendSize) .background(message.isEmpty ? AnyShapeStyle(.gray.opacity(0.4)) : AnyShapeStyle(tint), in: .circle) .frame(minWidth: 44, minHeight: 44) .contentShape(.rect) } .disabled(message.isEmpty) .accessibilityLabel("Send") .animation(reduceMotion ? nil : .snappy, value: message.isEmpty) } .padding(.horizontal, 12) .padding(.vertical, 8) .background(.bar) } } private func send() { let sent = message guard !sent.isEmpty else { return } text = "" onSend(sent) } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct KeyboardTrackingInputBarContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo private struct KeyboardTrackingInputBarDemoMessage: Identifiable { let id = UUID() let text: String } struct KeyboardTrackingInputBarDemo: View { @State private var draft = "" @State private var messages = [ KeyboardTrackingInputBarDemoMessage(text: "Tap the field and the bar rides up on the keyboard."), KeyboardTrackingInputBarDemoMessage(text: "Long messages make it grow, up to five lines."), ] var body: some View { KeyboardTrackingInputBar(text: $draft, onSend: { sent in messages.append(.init(text: sent)) }, content: { ScrollView { LazyVStack(alignment: .trailing, spacing: 8) { ForEach(messages) { message in Text(message.text) .padding(.horizontal, 14) .padding(.vertical, 9) .foregroundStyle(.white) .background(.blue, in: .rect(cornerRadius: 18)) } } .padding() .frame(maxWidth: .infinity, alignment: .trailing) } .defaultScrollAnchor(.bottom) .scrollDismissesKeyboard(.interactively) }) } } #Preview { KeyboardTrackingInputBarDemo() } ``` ## Nested Comment Thread Threaded comments with indentation, connector lines and collapsible replies. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/NestedCommentThread/NestedCommentThread.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct NestedCommentThreadComment: Identifiable, Hashable { let id: String let author: String let text: String var age: String = "" var replies: [NestedCommentThreadComment] = [] } /// Threaded comments with indentation, connector lines and collapsible replies. /// /// Put it in a `ScrollView`. Indentation stops growing past a few levels, sooner with /// accessibility text sizes, so deep threads keep room for their text. struct NestedCommentThread: View { let comments: [NestedCommentThreadComment] var tint: Color = .indigo @State private var collapsed: Set = [] @ScaledMetric(relativeTo: .subheadline) private var avatarSize = 34.0 @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.accessibilityReduceMotion) private var reduceMotion private let verticalPadding = 8.0 private var maximumLevels: Int { dynamicTypeSize.isAccessibilitySize ? 2 : 5 } var body: some View { LazyVStack(alignment: .leading, spacing: 0) { ForEach(NestedCommentThreadLogic.rows(for: comments, collapsed: collapsed)) { row in rowView(row).transition(.opacity) } } } private func rowView(_ row: NestedCommentThreadLogic.Row) -> some View { let guides = Array(row.guides.suffix(maximumLevels)) let leading = CGFloat(guides.count) * avatarSize let showsStem = !row.comment.replies.isEmpty && !collapsed.contains(row.comment.id) return HStack(alignment: .top, spacing: 10) { avatar(for: row.comment) content(for: row.comment) } .padding(.vertical, verticalPadding) .padding(.leading, leading) .frame(maxWidth: .infinity, alignment: .leading) .background(alignment: .leading) { HStack(spacing: 0) { ForEach(Array(guides.enumerated()), id: \.offset) { index, continues in NestedCommentThreadGuide( continues: continues, elbowY: index == guides.count - 1 ? verticalPadding + avatarSize / 2 : nil ) .stroke(.quaternary, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) .frame(width: avatarSize) } } } .background(alignment: .topLeading) { if showsStem { Rectangle() .fill(.quaternary) .frame(width: 1.5) .padding(.top, verticalPadding + avatarSize) .frame(maxHeight: .infinity) .offset(x: leading + avatarSize / 2 - 0.75) } } } private func avatar(for comment: NestedCommentThreadComment) -> some View { Circle() .fill(tint.gradient) .frame(width: avatarSize, height: avatarSize) .overlay { Text(comment.author.prefix(1)) .font(.subheadline.weight(.bold)) .foregroundStyle(NestedCommentThreadContrastingForeground(background: tint)) } .accessibilityHidden(true) } private func content(for comment: NestedCommentThreadComment) -> some View { VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(comment.author).font(.subheadline.weight(.semibold)) Text(comment.age).font(.caption).foregroundStyle(.secondary) } Text(comment.text).font(.subheadline) } .accessibilityElement(children: .combine) if !comment.replies.isEmpty { toggle(for: comment) } } } private func toggle(for comment: NestedCommentThreadComment) -> some View { let isCollapsed = collapsed.contains(comment.id) let count = NestedCommentThreadLogic.replyCount(of: comment) let title = "\(isCollapsed ? "Show" : "Hide") \(count) \(count == 1 ? "reply" : "replies")" return Button { withAnimation(reduceMotion ? nil : .snappy) { if isCollapsed { collapsed.remove(comment.id) } else { collapsed.insert(comment.id) } } } label: { Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(tint) .frame(minHeight: 28) // The padding widens what answers to a touch, and the same padding taken // back keeps the thread as compact as it was. .padding(.vertical, 8) .contentShape(.rect) .padding(.vertical, -8) } .buttonStyle(.plain) .accessibilityLabel("\(title) to \(comment.author)") } } /// One indentation column: a straight line, and for a comment's own level an elbow into it. nonisolated private struct NestedCommentThreadGuide: Shape { let continues: Bool let elbowY: CGFloat? func path(in rect: CGRect) -> Path { var path = Path() if let elbowY { let radius = min(10, rect.width / 2) path.move(to: CGPoint(x: rect.midX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.midX, y: max(rect.minY, elbowY - radius))) path.addQuadCurve(to: CGPoint(x: rect.midX + radius, y: elbowY), control: CGPoint(x: rect.midX, y: elbowY)) path.addLine(to: CGPoint(x: rect.maxX, y: elbowY)) } if continues { path.move(to: CGPoint(x: rect.midX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) } return path } } /// Turns the tree into the flat list of visible rows, apart from the view so it can be tested. enum NestedCommentThreadLogic { struct Row: Identifiable, Equatable { let comment: NestedCommentThreadComment /// One entry per indentation level: whether its line continues below this row. let guides: [Bool] var id: String { comment.id } } static func rows(for comments: [NestedCommentThreadComment], collapsed: Set) -> [Row] { var result: [Row] = [] func visit(_ list: [NestedCommentThreadComment], guides: [Bool], isTopLevel: Bool) { for (index, comment) in list.enumerated() { let rowGuides = isTopLevel ? [] : guides + [index < list.count - 1] result.append(Row(comment: comment, guides: rowGuides)) if !collapsed.contains(comment.id) { visit(comment.replies, guides: rowGuides, isTopLevel: false) } } } visit(comments, guides: [], isTopLevel: true) return result } /// Every reply below a comment, however deep. static func replyCount(of comment: NestedCommentThreadComment) -> Int { comment.replies.reduce(comment.replies.count) { $0 + replyCount(of: $1) } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct NestedCommentThreadContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct NestedCommentThreadDemo: View { private let comments = [ NestedCommentThreadComment(id: "1", author: "Marta", text: "Should we ship this behind a flag first?", age: "2h", replies: [ NestedCommentThreadComment(id: "2", author: "Luis", text: "Yes, at least for the first week.", age: "1h", replies: [ NestedCommentThreadComment(id: "3", author: "Ana", text: "Agreed. I can watch the metrics.", age: "40m"), ]), NestedCommentThreadComment(id: "4", author: "Noa", text: "I will set up the flag today.", age: "32m"), ]), NestedCommentThreadComment(id: "5", author: "Diego", text: "The new empty state looks great.", age: "25m", replies: [ NestedCommentThreadComment(id: "6", author: "Iris", text: "Thanks! It took a few tries.", age: "12m"), ]), NestedCommentThreadComment(id: "7", author: "Sara", text: "Can someone review the copy before Friday?", age: "5m"), ] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { Text("Discussion") .font(.largeTitle.bold()) NestedCommentThread(comments: comments) } .padding(20) } } } #Preview { NestedCommentThreadDemo() } ``` ## Tapback Reaction Bubble A message you press and hold to react to, with a bar of reactions above it and the chosen one kept as a badge on its corner. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/TapbackReactionBubble/TapbackReactionBubble.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum TapbackReactionBubbleLogic { /// The reaction a message has after one is chosen: choosing the one it had takes it away. static func toggled(_ current: String?, choosing reaction: String) -> String? { current == reaction ? nil : reaction } /// How long a reaction of the bar waits before it pops in, so they arrive in a quick wave /// that a long bar does not stretch. static func delay(for index: Int, step: Double = 0.03, limit: Double = 0.18) -> Double { min(Double(max(index, 0)) * step, limit) } } /// A message you press and hold to react to: it lifts, a bar of reactions opens above it, and the /// one you choose stays on its corner as a badge. Choose it again to take it away. /// /// The caller owns `isPicking`, so it can close the bar from outside and dim the other messages. /// VoiceOver offers each reaction as an action of the message. With Reduce Motion on, nothing /// scales: the bar and the badge fade. struct TapbackReactionBubble: View { @Binding var reaction: String? @Binding var isPicking: Bool var reactions = ["❤️", "👍", "👎", "😂", "‼️", "❓"] /// The side of the screen the message sits on. The bar grows from that side and the badge /// goes to the other corner, toward the middle of the screen. var side = HorizontalAlignment.leading @ViewBuilder var content: () -> Content private let gap: CGFloat = 8 private let badgeOverhang = 0.65 @State private var isBarShown = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // Plain values for the alignment guides, which may not capture a generic view: Xcode 26 rejects it. let gap = gap let overhang = badgeOverhang let isLeading = side == .leading content() .overlay(alignment: Alignment(horizontal: side == .leading ? .trailing : .leading, vertical: .top)) { // The guides go on a container that is always there: on a view that comes and goes // with a transition they are not read. ZStack { if let reaction { badge(reaction) } } // Most of the badge hangs outside the corner, so it does not cover the message. .alignmentGuide(.top) { $0.height * overhang } .alignmentGuide(isLeading ? .trailing : .leading) { $0.width * (isLeading ? 1 - overhang : overhang) } } .scaleEffect(isPicking && !reduceMotion ? 1.04 : 1) .overlay(alignment: Alignment(horizontal: side, vertical: .top)) { ZStack { if isPicking { bar .transition(reduceMotion ? .opacity : .scale(scale: 0.5, anchor: .bottom).combined(with: .opacity)) } } // The bottom of the bar sits a gap above the top of the message. .alignmentGuide(.top) { $0[.bottom] + gap } } .zIndex(isPicking ? 1 : 0) .onLongPressGesture(minimumDuration: 0.4) { isPicking = true } .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.35, bounce: 0.3), value: isPicking) .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.35, bounce: 0.5), value: reaction) .sensoryFeedback(trigger: isPicking) { _, isPicking in isPicking ? .impact(weight: .medium) : nil } .sensoryFeedback(.selection, trigger: reaction) .accessibilityElement(children: .combine) .accessibilityValue(reaction.map { "Reacted with \($0)" } ?? "") .accessibilityActions { ForEach(reactions, id: \.self) { option in Button(option == reaction ? "Remove \(option)" : "React with \(option)") { choose(option) } } } } private func badge(_ reaction: String) -> some View { Text(reaction) .font(.footnote) .padding(6) .background(.regularMaterial, in: .circle) .overlay { Circle().strokeBorder(.background, lineWidth: 2) } .transition(reduceMotion ? .opacity : .scale(scale: 0.2).combined(with: .opacity)) .id(reaction) .accessibilityHidden(true) } private var bar: some View { HStack(spacing: 4) { ForEach(Array(reactions.enumerated()), id: \.element) { index, option in Button { choose(option) } label: { Text(option) .font(.title2) .frame(minWidth: 40, minHeight: 40) .background(option == reaction ? AnyShapeStyle(.tint.opacity(0.25)) : AnyShapeStyle(.clear), in: .circle) } .buttonStyle(.plain) .scaleEffect(isBarShown || reduceMotion ? 1 : 0.4) .animation(.spring(duration: 0.25, bounce: 0.45).delay(TapbackReactionBubbleLogic.delay(for: index)), value: isBarShown) .accessibilityLabel(option == reaction ? "Remove \(option)" : "React with \(option)") } } .padding(6) .background(.regularMaterial, in: .capsule) .shadow(color: .black.opacity(0.18), radius: 12, y: 4) .fixedSize() .onAppear { isBarShown = true } .onDisappear { isBarShown = false } } private func choose(_ option: String) { reaction = TapbackReactionBubbleLogic.toggled(reaction, choosing: option) isPicking = false } } // MARK: - Demo private struct TapbackReactionBubbleDemoMessage: Identifiable { let id: Int let text: String let isMine: Bool } struct TapbackReactionBubbleDemo: View { private let messages = [ TapbackReactionBubbleDemoMessage(id: 0, text: "Are we still on for the hike on Saturday?", isMine: false), TapbackReactionBubbleDemoMessage(id: 1, text: "Yes! I’ll bring the snacks.", isMine: true), TapbackReactionBubbleDemoMessage(id: 2, text: "Perfect. Meet at the trailhead at eight?", isMine: false), TapbackReactionBubbleDemoMessage(id: 3, text: "Eight it is.", isMine: true), ] @State private var reactions: [Int: String] = [1: "❤️"] @State private var picking: Int? var body: some View { VStack(spacing: 18) { Text("Press and hold a message") .font(.footnote) .foregroundStyle(.secondary) .padding(.bottom, 4) ForEach(messages) { message in let isOther = picking != nil && picking != message.id TapbackReactionBubble( reaction: Binding { reactions[message.id] } set: { reactions[message.id] = $0 }, isPicking: Binding { picking == message.id } set: { picking = $0 ? message.id : nil }, side: message.isMine ? .trailing : .leading ) { Text(message.text) .padding(.horizontal, 14) .padding(.vertical, 10) .foregroundStyle(message.isMine ? .white : .primary) .background(message.isMine ? AnyShapeStyle(.blue) : AnyShapeStyle(.fill.tertiary), in: .rect(cornerRadius: 20)) } .frame(maxWidth: 280, alignment: message.isMine ? .trailing : .leading) .frame(maxWidth: .infinity, alignment: message.isMine ? .trailing : .leading) .opacity(isOther ? 0.3 : 1) .allowsHitTesting(!isOther) .zIndex(picking == message.id ? 1 : 0) } Spacer() } .padding(.horizontal, 16) .padding(.top, 80) .frame(maxWidth: .infinity, maxHeight: .infinity) .background { // Catches the taps outside the open message, which close its bar. Color(.systemBackground) .ignoresSafeArea() .onTapGesture { picking = nil } } .animation(.easeInOut(duration: 0.25), value: picking) } } #Preview { TapbackReactionBubbleDemo() } ``` ## Typing Indicator Bubble Three dots that bounce in turn inside a message bubble while someone is typing, and shrink away when they stop. Category: Chat. Minimum: iOS 18.0. Path: Components/Chat/TypingIndicatorBubble/TypingIndicatorBubble.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// When each dot rises. Every dot waits, bounces and rests for the same total, so the three /// keep their order however long the indicator stays on screen. nonisolated enum TypingIndicatorBubbleTiming { static let bounce = 0.5 /// What a dot waits before it rises: each one a step after the one before it. static func delay(for index: Int, stagger: Double) -> Double { Double(max(index, 0)) * max(stagger, 0) } /// What a dot rests after its bounce, so its wait, bounce and rest add up to `period`. static func rest(for index: Int, stagger: Double, period: Double) -> Double { max(period - bounce - delay(for: index, stagger: stagger), 0) } /// The shortest period in which the last dot still has time to bounce. static func period(_ period: Double, dots: Int, stagger: Double) -> Double { max(period, bounce + delay(for: dots - 1, stagger: stagger)) } } /// Three dots that bounce in turn inside a message bubble while someone is typing. /// /// Set `isTyping` to false and the bubble shrinks away into its corner. With Reduce Motion /// on, the dots only brighten in turn and the bubble fades. VoiceOver reads `label`. struct TypingIndicatorBubble: View { var isTyping = true var label: LocalizedStringResource = "Typing" var bubbleColor = Color(.secondarySystemFill) var dotColor = Color.secondary var dotCount = 3 var period = 1.3 @ScaledMetric(relativeTo: .body) private var dotSize = 9.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let stagger = 0.16 private let cornerRadius: CGFloat = 20 var body: some View { ZStack(alignment: .bottomLeading) { if isTyping { bubble .transition(reduceMotion ? .opacity : .scale(scale: 0.5, anchor: .bottomLeading).combined(with: .opacity)) } } .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.35), value: isTyping) } private var bubble: some View { HStack(spacing: dotSize * 0.6) { ForEach(0.. some View { let count = max(dotCount, 1) let total = TypingIndicatorBubbleTiming.period(period, dots: count, stagger: stagger) let delay = TypingIndicatorBubbleTiming.delay(for: index, stagger: stagger) let rest = TypingIndicatorBubbleTiming.rest(for: index, stagger: stagger, period: total) let half = TypingIndicatorBubbleTiming.bounce / 2 let lift = reduceMotion ? 0 : dotSize * 0.7 return Circle() .fill(dotColor) .frame(width: dotSize, height: dotSize) .keyframeAnimator(initialValue: 0.0, repeating: true) { content, rise in content .offset(y: -lift * rise) .opacity(0.45 + 0.55 * rise) } keyframes: { _ in KeyframeTrack { LinearKeyframe(0, duration: delay) CubicKeyframe(1, duration: half) CubicKeyframe(0, duration: half) LinearKeyframe(0, duration: rest) } } } } // MARK: - Demo struct TypingIndicatorBubbleDemo: View { private static let replies = ["On my way!", "Save me a seat", "Five minutes, I promise"] @State private var messages = ["Are you coming tonight?"] @State private var isTyping = false /// Only for this demo: it plays by itself until someone touches it. @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Maya") .font(.largeTitle.bold()) .padding(.bottom, 8) ForEach(Array(messages.enumerated()), id: \.offset) { index, message in Text(message) .padding(.horizontal, 14) .padding(.vertical, 10) .foregroundStyle(index == 0 ? .white : .primary) .background(index == 0 ? AnyShapeStyle(.tint) : AnyShapeStyle(Color(.secondarySystemFill)), in: .rect(cornerRadius: 20)) .frame(maxWidth: .infinity, alignment: index == 0 ? .trailing : .leading) .transition(.opacity.combined(with: .move(edge: .bottom))) } TypingIndicatorBubble(isTyping: isTyping, label: "Maya is typing") Spacer(minLength: 0) Button(isTyping ? "Stop typing" : "Start typing") { isTyping.toggle() } .buttonStyle(.borderedProminent) .controlSize(.large) .frame(maxWidth: .infinity) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .animation(reduceMotion ? nil : .snappy, value: messages) .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { _ in isAutoPlaying = false }) .task { while isAutoPlaying { try? await Task.sleep(for: .seconds(0.7)) guard !Task.isCancelled, isAutoPlaying else { return } isTyping = true try? await Task.sleep(for: .seconds(1.9)) guard !Task.isCancelled, isAutoPlaying else { return } isTyping = false messages = messages.count > Self.replies.count ? [messages[0]] : messages + [Self.replies[messages.count - 1]] } } } } #Preview { TypingIndicatorBubbleDemo() } ``` ## Add To Cart Fly Button An add-to-cart button that sends a copy of the product image arcing into the cart badge. Category: Commerce. Minimum: iOS 18.0. Path: Components/Commerce/AddToCartFlyButton/AddToCartFlyButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A product image and an add button that sends a copy of the image arcing into the cart. /// /// Measure your cart icon in the `.global` space and pass its frame as `cartFrame`. The /// count goes up when the copy lands. With Reduce Motion on, or with no `cartFrame`, the /// count goes up at once and nothing flies. struct AddToCartFlyButton: View { let image: Image @Binding var count: Int var cartFrame: CGRect? var title: LocalizedStringResource = "Add to Cart" var imageLabel: LocalizedStringResource = "Product" var tint: Color = .indigo var onAdd: () -> Void = {} @State private var flights: [AddToCartFlyButtonLaunch] = [] @State private var sourceFrame = CGRect.zero @Environment(\.accessibilityReduceMotion) private var reduceMotion private let maximumFlights = 6 var body: some View { VStack(spacing: 20) { image .resizable() .scaledToFit() .foregroundStyle(tint) .padding(36) .frame(maxWidth: .infinity) .frame(height: 260) .background(tint.opacity(0.12), in: .rect(cornerRadius: 28)) .accessibilityLabel(imageLabel) .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { sourceFrame = $0 } .overlay { ForEach(flights) { flight in AddToCartFlyButtonFlight(image: image, destination: flight.destination, tint: tint) { land(flight.id) } } } Button(action: add) { Label(title, systemImage: "cart.badge.plus") .font(.headline) .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.borderedProminent) .tint(tint) } } private func add() { guard !reduceMotion, flights.count < maximumFlights, let cartFrame, sourceFrame != .zero else { increment() return } let destination = CGSize(width: cartFrame.midX - sourceFrame.midX, height: cartFrame.midY - sourceFrame.midY) flights.append(AddToCartFlyButtonLaunch(destination: destination)) } private func land(_ id: UUID) { guard let index = flights.firstIndex(where: { $0.id == id }) else { return } flights.remove(at: index) increment() } private func increment() { count = count == .max ? count : count + 1 onAdd() } } private struct AddToCartFlyButtonLaunch: Identifiable { let id = UUID() let destination: CGSize } /// The flying copy. It lands even if its view goes away mid-flight, so no item is lost. private struct AddToCartFlyButtonFlight: View { let image: Image let destination: CGSize let tint: Color let onLand: () -> Void @State private var progress = 0.0 private let duration = 0.65 var body: some View { image .resizable() .scaledToFit() .foregroundStyle(tint) .padding(14) .frame(width: 84, height: 84) .background(.background, in: .circle) .overlay { Circle().strokeBorder(tint.opacity(0.3)) } .modifier(AddToCartFlyButtonArc(progress: progress, destination: destination)) .allowsHitTesting(false) .accessibilityHidden(true) .task { withAnimation(.easeIn(duration: duration)) { progress = 1 } try? await Task.sleep(for: .seconds(duration)) onLand() } } } /// Moves a view along the arc and shrinks it as it travels. private struct AddToCartFlyButtonArc: GeometryEffect { var progress: Double let destination: CGSize var animatableData: Double { get { progress } set { progress = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { let offset = AddToCartFlyButtonLogic.arcOffset(progress: progress, destination: destination, lift: 120) let scale = 1 - 0.75 * CGFloat(min(max(progress, 0), 1)) let transform = CGAffineTransform(translationX: offset.width + size.width / 2, y: offset.height + size.height / 2) .scaledBy(x: scale, y: scale) .translatedBy(x: -size.width / 2, y: -size.height / 2) return ProjectionTransform(transform) } } enum AddToCartFlyButtonLogic { /// A point on a curve from zero to `destination` that first rises by about `lift`. static func arcOffset(progress: Double, destination: CGSize, lift: CGFloat) -> CGSize { let t = CGFloat(min(max(progress, 0), 1)) let controlX = destination.width * 0.35 let controlY = min(destination.height, 0) - lift return CGSize( width: 2 * (1 - t) * t * controlX + t * t * destination.width, height: 2 * (1 - t) * t * controlY + t * t * destination.height ) } } /// A cart icon with a count badge that bumps when the count changes. struct AddToCartFlyButtonCart: View { let count: Int var tint: Color = .indigo @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Image(systemName: "cart.fill") .font(.title2) .foregroundStyle(tint) .padding(8) .overlay(alignment: .topTrailing) { if count > 0 { Text(count, format: .number) .font(.caption2.weight(.bold)) .foregroundStyle(.white) .contentTransition(reduceMotion ? .identity : .numericText(value: Double(count))) .animation(reduceMotion ? nil : .snappy, value: count) .padding(.horizontal, 5) .frame(minWidth: 18, minHeight: 18) .background(.red, in: .capsule) } } .keyframeAnimator(initialValue: 1.0, trigger: reduceMotion ? 0 : count) { [reduceMotion] view, scale in view.scaleEffect(reduceMotion ? 1 : scale) } keyframes: { _ in SpringKeyframe(1.3, duration: 0.14) SpringKeyframe(1, duration: 0.3) } .accessibilityElement(children: .ignore) .accessibilityLabel("Cart") .accessibilityValue(count == 1 ? "1 item" : "\(count) items") } } // MARK: - Demo struct AddToCartFlyButtonDemo: View { @State private var count = 0 @State private var cartFrame: CGRect? var body: some View { VStack(spacing: 0) { HStack { Text("Shop") .font(.largeTitle.bold()) Spacer() AddToCartFlyButtonCart(count: count) .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { cartFrame = $0 } } .padding(.horizontal, 24) .padding(.top, 8) ScrollView { VStack(alignment: .leading, spacing: 8) { AddToCartFlyButton( image: Image(systemName: "backpack.fill"), count: $count, cartFrame: cartFrame, imageLabel: "Trail backpack" ) .padding(.top, 48) Text("Trail backpack") .font(.title2.bold()) .padding(.top, 12) Text("Twenty liters, water resistant and light enough for every day.") .foregroundStyle(.secondary) } .padding(24) } .scrollClipDisabled() } } } #Preview { AddToCartFlyButtonDemo() } ``` ## Add To Cart Stepper An add button that morphs into a quantity stepper and collapses back to the button when the quantity returns to zero. Category: Commerce. Minimum: iOS 18.0. Path: Components/Commerce/AddToCartStepper/AddToCartStepper.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum AddToCartStepperMath { /// The highest quantity allowed. Zero is valid, and means nothing can be added. static func limit(maximum: Int) -> Int { min(max(maximum, 0), 1_000_000) } } /// An add button that morphs into a quantity stepper, and back again at zero. /// /// The capsule is one background around content that changes, so it resizes by itself /// and nothing has to match geometry. VoiceOver can also adjust the count directly. struct AddToCartStepper: View { @Binding var quantity: Int var maximum: Int = 99 var title: LocalizedStringResource = "Add" var tint: Color = .indigo @ScaledMetric(relativeTo: .headline) private var buttonSize = 44.0 @ScaledMetric(relativeTo: .headline) private var countWidth = 34.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var limit: Int { AddToCartStepperMath.limit(maximum: maximum) } private var current: Int { min(max(quantity, 0), limit) } private var animation: Animation? { reduceMotion ? nil : .bouncy(duration: 0.35) } var body: some View { HStack(spacing: 0) { if quantity <= 0 { addButton.transition(.opacity) } else { stepButton(systemImage: quantity == 1 ? "trash" : "minus", label: "Decrease quantity", change: -1) .transition(.opacity) countLabel.transition(.opacity) stepButton(systemImage: "plus", label: "Increase quantity", change: 1) .disabled(quantity >= limit) .transition(.opacity) } } .foregroundStyle(AddToCartStepperContrastingForeground(background: tint)) .background(tint.gradient, in: .capsule) .sensoryFeedback(.selection, trigger: quantity) } private var addButton: some View { Button { set(1) } label: { Label(title, systemImage: "plus") .font(.headline) .padding(.horizontal, 22) .frame(minHeight: buttonSize) .contentShape(.capsule) } .buttonStyle(.plain) .disabled(limit == 0) .accessibilityLabel("\(title) to cart") } private func stepButton(systemImage: String, label: String, change: Int) -> some View { Button { set(current + change) } label: { Image(systemName: systemImage) .font(.headline) .frame(width: buttonSize, height: buttonSize) .contentTransition(reduceMotion ? .identity : .symbolEffect(.replace)) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(label) } private var countLabel: some View { Text(quantity, format: .number) .font(.headline.monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.6) .contentTransition(reduceMotion ? .identity : .numericText(value: Double(quantity))) .frame(minWidth: countWidth) .accessibilityLabel("Quantity") .accessibilityValue(quantity.formatted()) .accessibilityAdjustableAction { direction in switch direction { case .increment: set(current + 1) case .decrement: set(current - 1) @unknown default: break } } } private func set(_ newValue: Int) { withAnimation(animation) { quantity = min(max(newValue, 0), limit) } } } /// White or black, whichever reads better on `background`. Black only on a /// light color, so the system's own colors keep white. nonisolated struct AddToCartStepperContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct AddToCartStepperDemo: View { @State private var quantity = 0 private var subtotal: String { (Double(quantity) * 3.5).formatted(.currency(code: "EUR")) } var body: some View { ViewThatFits(in: .vertical) { content ScrollView { content } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(LinearGradient(colors: [.orange.opacity(0.16), .indigo.opacity(0.12)], startPoint: .top, endPoint: .bottom)) } private var content: some View { VStack(spacing: 24) { Image(systemName: "cup.and.saucer.fill") .resizable() .scaledToFit() .frame(height: 110) .foregroundStyle(.linearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)) .accessibilityHidden(true) VStack(spacing: 4) { Text("Flat white") .font(.largeTitle.bold()) Text("Double shot, velvety milk") .foregroundStyle(.secondary) } AddToCartStepper(quantity: $quantity) .frame(minHeight: 44) Text("Subtotal \(subtotal)") .font(.title3.weight(.semibold)) .opacity(quantity > 0 ? 1 : 0) } .padding(24) } } #Preview { AddToCartStepperDemo() } ``` ## Billing Period Toggle A monthly and yearly switch where the price rolls to its new value and a savings badge appears. Category: Commerce. Minimum: iOS 18.0. Path: Components/Commerce/BillingPeriodToggle/BillingPeriodToggle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum BillingPeriodToggleOption: String, CaseIterable, Hashable { case monthly = "Monthly" case yearly = "Yearly" } enum BillingPeriodTogglePricing { /// The whole percent saved by paying yearly, or nil when there is no saving to show. static func savingsPercent(monthly: Double, yearly: Double) -> Int? { let fullYear = monthly * 12 guard fullYear.isFinite, yearly.isFinite, fullYear > 0, yearly >= 0, yearly < fullYear else { return nil } let percent = Int(((1 - yearly / fullYear) * 100).rounded()) return percent > 0 ? percent : nil } } /// A monthly and yearly switch: the price rolls to its new value and a savings badge appears. /// /// With Reduce Motion on, everything changes at once. struct BillingPeriodToggle: View { @Binding var period: BillingPeriodToggleOption var monthlyPrice: Double = 12 var yearlyPrice: Double = 99 var currencyCode: String = "USD" var tint: Color = .indigo @Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var dynamicTypeSize private var pricePerMonth: Double { period == .monthly ? monthlyPrice : yearlyPrice / 12 } private var savings: Int? { BillingPeriodTogglePricing.savingsPercent(monthly: monthlyPrice, yearly: yearlyPrice) } private var rowLayout: AnyLayout { dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(spacing: 6)) : AnyLayout(HStackLayout(spacing: 6)) } var body: some View { VStack(spacing: 18) { segments price footnote } .animation(reduceMotion ? nil : .spring(duration: 0.45, bounce: 0.2), value: period) .sensoryFeedback(.selection, trigger: period) } private var segments: some View { rowLayout { ForEach(BillingPeriodToggleOption.allCases, id: \.self) { option in let isSelected = option == period Button { period = option } label: { Text(option.rawValue) .font(.headline) .foregroundStyle(isSelected ? Color.white : Color.primary) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background { if isSelected { Capsule() .fill(tint.gradient) .matchedGeometryEffect(id: "selection", in: namespace) } } .contentShape(.capsule) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } } .padding(4) .background(.quaternary, in: .capsule) } private var price: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(pricePerMonth, format: .currency(code: currencyCode)) .font(.system(.largeTitle, design: .rounded, weight: .bold)) .monospacedDigit() .contentTransition(.numericText(value: pricePerMonth)) Text("per month") .font(.subheadline) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) } private var footnote: some View { rowLayout { Text(period == .monthly ? "Billed every month" : "Billed once a year") .font(.subheadline) .foregroundStyle(.secondary) if period == .yearly, let savings { Text("Save \(savings)%") .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Color.green.gradient, in: .capsule) .transition(.scale(scale: 0.5).combined(with: .opacity)) } } .multilineTextAlignment(.center) } } // MARK: - Demo struct BillingPeriodToggleDemo: View { @State private var period = BillingPeriodToggleOption.monthly private let features = ["Unlimited projects", "Sync across devices", "Priority support"] var body: some View { ScrollView { VStack(spacing: 28) { VStack(spacing: 8) { Image(systemName: "sparkles") .font(.largeTitle) .foregroundStyle(.indigo) .accessibilityHidden(true) Text("Go Pro") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Everything you need, billed your way.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } BillingPeriodToggle(period: $period) .padding(20) .background(.regularMaterial, in: .rect(cornerRadius: 28)) VStack(alignment: .leading, spacing: 12) { ForEach(features, id: \.self) { feature in Label(feature, systemImage: "checkmark.circle.fill") .symbolRenderingMode(.hierarchical) .foregroundStyle(.indigo) } } .font(.body) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 8) } .padding(24) } .defaultScrollAnchor(.center, for: .alignment) .background( LinearGradient(colors: [.indigo.opacity(0.18), .pink.opacity(0.12)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() ) } } #Preview { BillingPeriodToggleDemo() } ``` ## Pricing Tier Cards Snapping horizontal pricing cards with the recommended plan highlighted and enlarged. Category: Commerce. Minimum: iOS 18.0. Path: Components/Commerce/PricingTierCards/PricingTierCards.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI struct PricingTierCardsTier: Identifiable, Hashable { let name: String var price: String var period: String var features: [String] var isRecommended = false var id: String { name } } /// Horizontal pricing cards that snap into place, with the recommended plan highlighted and larger. /// /// It opens centered on the recommended plan. Place it in a vertical `ScrollView` /// so tall cards stay reachable at the largest text sizes. struct PricingTierCards: View { let tiers: [PricingTierCardsTier] @Binding var selection: PricingTierCardsTier.ID? var tint: Color = .indigo @State private var centeredID: PricingTierCardsTier.ID? @State private var containerWidth: CGFloat = 0 private var cardWidth: CGFloat { min(max(containerWidth - 72, 220), 300) } var body: some View { ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(tiers) { tier in card(for: tier) .frame(width: cardWidth) .scaleEffect(tier.isRecommended ? 1 : 0.92) .zIndex(tier.isRecommended ? 1 : 0) } } .fixedSize(horizontal: false, vertical: true) .scrollTargetLayout() .padding(.vertical, 24) } .scrollIndicators(.hidden) // The shadow of the recommended plan reaches past the row. .scrollClipDisabled() .contentMargins(.horizontal, max((containerWidth - cardWidth) / 2, 16), for: .scrollContent) .scrollTargetBehavior(.viewAligned) .scrollPosition(id: $centeredID) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { containerWidth = $0 } .task(id: containerWidth) { guard containerWidth > 0, centeredID == nil else { return } centeredID = (tiers.first(where: \.isRecommended) ?? tiers.first)?.id } .sensoryFeedback(.selection, trigger: selection) } private func card(for tier: PricingTierCardsTier) -> some View { let isSelected = selection == tier.id let secondary = tier.isRecommended ? Color.white.opacity(0.85) : Color.secondary return VStack(alignment: .leading, spacing: 16) { HStack { Text(tier.name) .font(.title3.bold()) Spacer(minLength: 8) if tier.isRecommended { Text("Popular") .font(.caption.bold()) .padding(.horizontal, 10) .padding(.vertical, 4) .background(.white.opacity(0.22), in: .capsule) } } VStack(alignment: .leading, spacing: 2) { Text(tier.price) .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text(tier.period) .font(.subheadline) .foregroundStyle(secondary) } VStack(alignment: .leading, spacing: 10) { ForEach(Array(tier.features.enumerated()), id: \.offset) { _, feature in Label(feature, systemImage: "checkmark.circle.fill") .font(.subheadline) } } Spacer(minLength: 0) Button { selection = tier.id } label: { Text(isSelected ? "\(tier.name) selected" : "Choose \(tier.name)") .font(.headline) .foregroundStyle(tier.isRecommended ? tint : Color.white) .frame(maxWidth: .infinity) .padding(.vertical, 12) .background(tier.isRecommended ? Color.white : tint, in: .capsule) .contentShape(.capsule) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } .foregroundStyle(tier.isRecommended ? Color.white : Color.primary) .padding(20) .frame(maxHeight: .infinity, alignment: .top) .background( tier.isRecommended ? AnyShapeStyle(tint.gradient) : AnyShapeStyle(.regularMaterial), in: .rect(cornerRadius: 28) ) .shadow(color: tier.isRecommended ? tint.opacity(0.4) : .clear, radius: 18, y: 10) .accessibilityElement(children: .contain) } } // MARK: - Demo struct PricingTierCardsDemo: View { @State private var selection: PricingTierCardsTier.ID? private let tiers = [ PricingTierCardsTier(name: "Free", price: "$0", period: "forever", features: ["3 projects", "Community help"]), PricingTierCardsTier( name: "Pro", price: "$12", period: "per month", features: ["Unlimited projects", "Sync across devices", "Priority support"], isRecommended: true ), PricingTierCardsTier(name: "Team", price: "$29", period: "per seat", features: ["Everything in Pro", "Shared spaces", "Admin tools"]), ] var body: some View { ScrollView { VStack(spacing: 8) { Text("Pick your plan") .font(.system(.largeTitle, design: .rounded, weight: .bold)) Text("Swipe to compare") .font(.body) .foregroundStyle(.secondary) PricingTierCards(tiers: tiers, selection: $selection) } .padding(.vertical, 24) } .defaultScrollAnchor(.center, for: .alignment) .background( LinearGradient(colors: [.indigo.opacity(0.2), .cyan.opacity(0.14)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() ) } } #Preview { PricingTierCardsDemo() } ``` ## Trial Timeline Paywall A paywall that shows what happens on each day of the free trial, with the plans and the button to start it. Category: Commerce. Minimum: iOS 18.0. Path: Components/Commerce/TrialTimelinePaywall/TrialTimelinePaywall.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum TrialTimelinePaywallLogic { /// The days of a trial worth showing, counted from today: today, the day of the reminder and /// the day the trial ends. A trial too short for a reminder has none, and no trial is just today. static func days(trialDays: Int, reminderLead: Int = 2) -> [Int] { guard trialDays > 0 else { return [0] } let reminder = trialDays - reminderLead return reminderLead > 0 && reminder > 0 ? [0, reminder, trialDays] : [0, trialDays] } static func date(afterDays days: Int, from start: Date, calendar: Calendar = .current) -> Date { calendar.date(byAdding: .day, value: days, to: start) ?? start } } struct TrialTimelinePaywallPlan: Identifiable, Equatable { var id: String var title: LocalizedStringResource /// The price as it should be read, such as StoreKit's `displayPrice` and its period. var price: String var badge: LocalizedStringResource? /// How long its free trial lasts. Zero when it has none. var trialDays = 0 } /// A paywall that shows what happens on each day of the free trial before asking to start it. /// /// It draws the plans and the timeline and reports the taps: buying and restoring are up to you, /// with StoreKit or whatever you use. The reminder step is a promise, so schedule that /// notification, or set `reminderLead` to 0 to leave the step out. struct TrialTimelinePaywall: View { var title: LocalizedStringResource = "How your free trial works" let plans: [TrialTimelinePaywallPlan] @Binding var selection: TrialTimelinePaywallPlan.ID /// How many days before the trial ends the reminder is sent. var reminderLead = 2 var startDate = Date.now var isPurchasing = false var onContinue: (TrialTimelinePaywallPlan) -> Void var onRestore: () -> Void private let margin: CGFloat = 24 private let cornerRadius: CGFloat = 16 private let lineWidth: CGFloat = 6 @State private var hasAppeared = false @ScaledMetric(relativeTo: .headline) private var markSize = 34.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var plan: TrialTimelinePaywallPlan? { plans.first { $0.id == selection } ?? plans.first } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 28) { Text(title) .font(.largeTitle.bold()) .accessibilityAddTraits(.isHeader) if let plan { timeline(for: plan) } VStack(spacing: 10) { ForEach(plans) { plan in card(for: plan) } } } .padding(.horizontal, margin) .padding(.top, 32) .padding(.bottom, margin) .frame(maxWidth: 520, alignment: .leading) .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) .safeAreaInset(edge: .bottom) { if let plan { footer(for: plan) } } .animation(reduceMotion ? nil : .snappy, value: selection) .onAppear { hasAppeared = true } } // MARK: Timeline private func timeline(for plan: TrialTimelinePaywallPlan) -> some View { let days = TrialTimelinePaywallLogic.days(trialDays: plan.trialDays, reminderLead: reminderLead) return VStack(alignment: .leading, spacing: 0) { ForEach(Array(days.enumerated()), id: \.element) { index, day in step(day: day, plan: plan, isLast: index == days.count - 1) .opacity(hasAppeared ? 1 : 0) .animation(reduceMotion ? nil : .easeOut(duration: 0.4).delay(Double(index) * 0.15), value: hasAppeared) .transition(.opacity) } } } private func step(day: Int, plan: TrialTimelinePaywallPlan, isLast: Bool) -> some View { let isCharge = day == plan.trialDays return HStack(alignment: .top, spacing: 16) { Image(systemName: day == 0 && !isCharge ? "lock.open.fill" : isCharge ? "star.fill" : "bell.fill") .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .frame(width: markSize, height: markSize) .background(.tint, in: .circle) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(day == 0 ? "Today" : "Day \(day)") .font(.headline) if day > 0 { Text(TrialTimelinePaywallLogic.date(afterDays: day, from: startDate), format: .dateTime.month(.abbreviated).day()) .font(.subheadline) .foregroundStyle(.secondary) } } Text(text(day: day, plan: plan)) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(.top, 6) .padding(.bottom, isLast ? 0 : 22) } .background(alignment: .topLeading) { // The line that joins this step to the next one. After the last step it fades away. Capsule() .fill(isLast ? AnyShapeStyle(LinearGradient(colors: [.accentColor.opacity(0.3), .clear], startPoint: .top, endPoint: .bottom)) : AnyShapeStyle(.tint.opacity(0.3))) .frame(width: lineWidth) .frame(maxHeight: isLast ? markSize * 2 : .infinity, alignment: .top) .padding(.leading, (markSize - lineWidth) / 2) .padding(.top, markSize / 2) .accessibilityHidden(true) } .padding(.bottom, isLast ? markSize : 0) .accessibilityElement(children: .combine) } private func text(day: Int, plan: TrialTimelinePaywallPlan) -> String { if day == plan.trialDays { return plan.trialDays > 0 ? "Your subscription starts and you are charged \(plan.price). Cancel any time before." : "Your subscription starts and you are charged \(plan.price)." } return day == 0 ? "You get full access to everything, free." : "We remind you that your trial is about to end." } // MARK: Plans private func card(for plan: TrialTimelinePaywallPlan) -> some View { let isSelected = plan.id == self.plan?.id return Button { selection = plan.id } label: { HStack(spacing: 12) { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.title3) .foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { HStack { Text(plan.title) .font(.headline) if let badge = plan.badge { Text(badge) .font(.caption.weight(.semibold)) .foregroundStyle(.tint) .padding(.horizontal, 8) .padding(.vertical, 3) .background(.tint.opacity(0.15), in: .capsule) } } Text(plan.trialDays > 0 ? "\(plan.trialDays) days free, then \(plan.price)" : plan.price) .font(.subheadline) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(.fill.quaternary, in: .rect(cornerRadius: cornerRadius)) .overlay { RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder(.tint, lineWidth: isSelected ? 2 : 0) } .contentShape(.rect(cornerRadius: cornerRadius)) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } private func footer(for plan: TrialTimelinePaywallPlan) -> some View { VStack(spacing: 10) { Button { onContinue(plan) } label: { Text(plan.trialDays > 0 ? "Start \(plan.trialDays)-Day Free Trial" : "Subscribe") .font(.headline) .opacity(isPurchasing ? 0 : 1) .frame(maxWidth: .infinity) .overlay { if isPurchasing { ProgressView() .controlSize(.regular) .tint(.white) } } } .buttonStyle(.borderedProminent) .controlSize(.large) // Not `disabled`, which would take the color away from under the spinner. .allowsHitTesting(!isPurchasing) .accessibilityValue(isPurchasing ? "In progress" : "") Button(action: onRestore) { // A line of text is too short to hit. The padding widens what answers to a // touch, and the same padding taken back keeps the layout as it was. Text("Restore Purchases") .padding(.vertical, 14) .contentShape(.rect) .padding(.vertical, -14) } .font(.footnote) .disabled(isPurchasing) } .padding(.horizontal, margin) .padding(.vertical, 12) .frame(maxWidth: 520) .frame(maxWidth: .infinity) .background(.bar) } } // MARK: - Demo struct TrialTimelinePaywallDemo: View { private let plans = [ TrialTimelinePaywallPlan(id: "yearly", title: "Yearly", price: "$39.99 a year", badge: "Save 44%", trialDays: 7), TrialTimelinePaywallPlan(id: "monthly", title: "Monthly", price: "$5.99 a month"), ] @State private var selection = "yearly" @State private var isPurchasing = false var body: some View { TrialTimelinePaywall(plans: plans, selection: $selection, isPurchasing: isPurchasing) { _ in Task { isPurchasing = true try? await Task.sleep(for: .seconds(1.5)) isPurchasing = false } } onRestore: { } .tint(.indigo) } } #Preview { TrialTimelinePaywallDemo() } ``` ## Animated Radio Button A radio button whose inner dot springs in and whose ring pulses once when selected. Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/AnimatedRadioButton/AnimatedRadioButton.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A radio button whose dot springs in and whose ring pulses once when selected. /// /// With Reduce Motion on, the dot appears at once and there is no pulse. struct AnimatedRadioButton: View { let title: LocalizedStringResource let isSelected: Bool var tint: Color = .indigo let action: () -> Void @ScaledMetric(relativeTo: .body) private var size = 26.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Button(action: action) { HStack(spacing: 14) { indicator Text(title) .font(.body) .foregroundStyle(.primary) .multilineTextAlignment(.leading) Spacer(minLength: 0) } // The whole row is the touch target, at the height recommended for one. .frame(minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityAddTraits(isSelected ? .isSelected : []) } private var indicator: some View { ZStack { Circle() .strokeBorder(isSelected ? tint : Color.secondary, lineWidth: 2) Circle() .fill(tint) .padding(size * 0.22) .scaleEffect(isSelected ? 1 : 0.01) .opacity(isSelected ? 1 : 0) } .frame(width: size, height: size) .background { Circle() .stroke(tint, lineWidth: 2) .keyframeAnimator(initialValue: 0.0, trigger: isSelected) { [reduceMotion] ring, progress in let isPulsing = isSelected && !reduceMotion && progress > 0 && progress < 1 ring .scaleEffect(1 + progress * 0.9) .opacity(isPulsing ? 1 - progress : 0) } keyframes: { _ in // Each run starts from the last value, so the ring has to jump back. MoveKeyframe(0) CubicKeyframe(1, duration: 0.5) } } .animation(reduceMotion ? nil : .spring(duration: 0.35, bounce: 0.55), value: isSelected) .accessibilityHidden(true) } } // MARK: - Demo struct AnimatedRadioButtonDemo: View { private let options: [LocalizedStringResource] = ["Standard delivery", "Express delivery", "Pick up in store"] @State private var selection = 0 @State private var isAutoPlaying = true @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("How should we send it?") .font(.system(.title, design: .rounded, weight: .bold)) VStack(spacing: 4) { ForEach(options.indices, id: \.self) { index in AnimatedRadioButton(title: options[index], isSelected: selection == index) { isAutoPlaying = false selection = index } } } .padding(24) .background(.regularMaterial, in: .rect(cornerRadius: 28)) } .padding(24) } .defaultScrollAnchor(.center, for: .alignment) .background( LinearGradient(colors: [.indigo.opacity(0.2), .mint.opacity(0.14)], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() ) .task(id: reduceMotion) { guard !reduceMotion else { return } while isAutoPlaying { try? await Task.sleep(for: .seconds(1.1)) guard !Task.isCancelled, isAutoPlaying else { return } selection = (selection + 1) % options.count } } } } #Preview { AnimatedRadioButtonDemo() } ``` ## Checkmark Box Toggle Style A checkbox toggle style for iOS whose box fills and draws its checkmark, with a dash for a mixed Toggle(sources:). Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/CheckmarkBoxToggleStyle/CheckmarkBoxToggleStyle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A checkbox for any `Toggle`: a rounded box that fills and draws its checkmark /// when turned on, and shows a dash when a `Toggle(sources:isOn:)` is mixed. /// The whole row is tappable, and VoiceOver reads it as a standard switch. /// /// It is not called `CheckboxToggleStyle` because SwiftUI already has one, for /// macOS only, and the two names would collide in a multiplatform app. /// With Reduce Motion on, the checkmark appears without being drawn. struct CheckmarkBoxToggleStyle: ToggleStyle { var color: Color = .accentColor func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn = configuration.isMixed ? true : !configuration.isOn } label: { HStack(alignment: .top, spacing: 12) { CheckmarkBoxToggleStyleBox(isOn: configuration.isOn, isMixed: configuration.isMixed, color: color) configuration.label .frame(maxWidth: .infinity, alignment: .leading) } .frame(minHeight: 44) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityRepresentation { if configuration.isMixed { Toggle(isOn: configuration.$isOn) { configuration.label } .accessibilityValue(Text("Mixed")) } else { Toggle(isOn: configuration.$isOn) { configuration.label } } } } } extension ToggleStyle where Self == CheckmarkBoxToggleStyle { static var checkmarkBox: CheckmarkBoxToggleStyle { CheckmarkBoxToggleStyle() } } private struct CheckmarkBoxToggleStyleBox: View { let isOn: Bool let isMixed: Bool let color: Color @ScaledMetric(relativeTo: .body) private var size = 22.0 @Environment(\.isEnabled) private var isEnabled @Environment(\.accessibilityReduceMotion) private var reduceMotion private var isFilled: Bool { isOn || isMixed } var body: some View { let shape = RoundedRectangle(cornerRadius: size * 0.28, style: .continuous) let mark = CheckmarkBoxToggleStyleContrastingForeground(background: color) let lineWidth = size * 0.12 ZStack { shape.fill(isFilled ? color : .clear) shape.strokeBorder(isFilled ? color : Color.secondary.opacity(0.6), lineWidth: lineWidth) if isMixed { Capsule() .fill(mark) .frame(width: size * 0.5, height: lineWidth) .transition(.opacity) } else { CheckmarkBoxToggleStyleCheck() .trim(from: 0, to: isOn ? 1 : 0) .stroke(mark, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) } } .frame(width: size, height: size) // A checkmark is not mirrored right-to-left, like the system symbol. .environment(\.layoutDirection, .leftToRight) .scaleEffect(isFilled && !reduceMotion ? 1 : 0.94) .opacity(isEnabled ? 1 : 0.4) .animation(reduceMotion ? nil : .spring(duration: 0.3, bounce: 0.45), value: isFilled) .animation(reduceMotion ? nil : .easeOut(duration: 0.22), value: isOn) .animation(.easeInOut(duration: 0.15), value: isMixed) } } nonisolated struct CheckmarkBoxToggleStyleCheck: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.width * 0.26, y: rect.height * 0.52)) path.addLine(to: CGPoint(x: rect.width * 0.43, y: rect.height * 0.69)) path.addLine(to: CGPoint(x: rect.width * 0.75, y: rect.height * 0.33)) return path } } nonisolated struct CheckmarkBoxToggleStyleContrastingForeground: ShapeStyle { let background: Color func resolve(in environment: EnvironmentValues) -> Color { let color = background.resolve(in: environment) let luminance = 0.2126 * color.linearRed + 0.7152 * color.linearGreen + 0.0722 * color.linearBlue return luminance > 0.5 ? .black : .white } } // MARK: - Demo struct CheckmarkBoxToggleStyleDemo: View { @State private var remember = true @State private var newsletter = false @State private var terms = false @State private var toppings = [false, true, false] private let toppingNames: [LocalizedStringResource] = ["Cheese", "Mushrooms", "Olives"] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { Text("Create Account") .font(.largeTitle.bold()) VStack(alignment: .leading, spacing: 4) { Toggle("Remember me", isOn: $remember) Toggle("Email me about new features", isOn: $newsletter) Toggle("I agree to the Terms of Service and the Privacy Policy", isOn: $terms) Toggle("Two-factor authentication (coming soon)", isOn: .constant(false)) .disabled(true) } VStack(alignment: .leading, spacing: 4) { Toggle("All toppings", sources: $toppings, isOn: \.self) .font(.headline) ForEach(toppings.indices, id: \.self) { index in Toggle(isOn: $toppings[index]) { Text(toppingNames[index]) } .padding(.leading, 34) } } .toggleStyle(CheckmarkBoxToggleStyle(color: .orange)) } .toggleStyle(.checkmarkBox) .padding(24) } } } #Preview { CheckmarkBoxToggleStyleDemo() } ``` ## Circular Range Slider A dial with two handles that sets a range around the clock, like a night of sleep. Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/CircularRangeSlider/CircularRangeSlider.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI nonisolated enum CircularRangeSliderPart: Equatable, Sendable { case start, end, both } nonisolated enum CircularRangeSliderMath { static let touchTarget = 44.0 /// How far around the dial a point is: 0 at twelve o'clock, growing clockwise up to 1. static func turn(at point: CGPoint, in size: CGSize) -> Double { let x = point.x - size.width / 2 let y = point.y - size.height / 2 guard x != 0 || y != 0 else { return 0 } let turn = atan2(x, -y) / (2 * .pi) return turn < 0 ? turn + 1 : turn } /// The center of a handle that is `turn` around a dial drawn with a line `lineWidth` thick. static func point(atTurn turn: Double, in size: CGSize, lineWidth: Double) -> CGPoint { let radius = (min(size.width, size.height) - lineWidth) / 2 let angle = turn * 2 * .pi return CGPoint(x: size.width / 2 + sin(angle) * radius, y: size.height / 2 - cos(angle) * radius) } /// `value` snapped to `step` and brought into `0.. Double { guard total > 0, value.isFinite else { return 0 } let snapped = step > 0 ? (value / step).rounded() * step : value let rest = snapped.truncatingRemainder(dividingBy: total) return rest < 0 ? rest + total : rest } /// How long the range is, going clockwise from `start` to `end`. static func span(from start: Double, to end: Double, total: Double) -> Double { wrapped(end - start, total: total) } /// Whether a handle may move so that the range becomes `new` long. /// /// The range keeps `minimumSpan` and leaves as much free, and a handle cannot jump over the /// other one, which would turn a short range into a long one at once. static func allows(span new: Double, from old: Double, total: Double, minimumSpan: Double) -> Bool { let least = min(max(minimumSpan, 0), total / 2) return new >= least && new <= total - least && abs(new - old) <= total / 2 } /// What a touch at `point` takes hold of: a handle, the range between them, or nothing. static func part(at point: CGPoint, in size: CGSize, startTurn: Double, endTurn: Double, lineWidth: Double) -> CircularRangeSliderPart? { let reach = max(lineWidth, touchTarget) / 2 let start = self.point(atTurn: startTurn, in: size, lineWidth: lineWidth) let end = self.point(atTurn: endTurn, in: size, lineWidth: lineWidth) let toStart = hypot(point.x - start.x, point.y - start.y) let toEnd = hypot(point.x - end.x, point.y - end.y) if min(toStart, toEnd) <= reach { return toEnd <= toStart ? .end : .start } let radius = (min(size.width, size.height) - lineWidth) / 2 let fromCenter = hypot(point.x - size.width / 2, point.y - size.height / 2) guard abs(fromCenter - radius) <= reach else { return nil } let here = span(from: startTurn, to: turn(at: point, in: size), total: 1) return here <= span(from: startTurn, to: endTurn, total: 1) ? .both : nil } } /// A dial with a range on it: drag either handle to move one end, or the band between them to /// move both. The range can cross twelve o'clock, like a night from 23:00 to 7:00. /// /// `start` and `end` go from 0 up to `total`, which is one full turn. Set the color with `tint`. /// VoiceOver reads each handle as an adjustable value, described by `valueText`. struct CircularRangeSlider: View { @Binding var start: Double @Binding var end: Double /// The value of a full turn. var total = 24.0 /// The handles stop at multiples of it. Zero lets them stop anywhere. var step = 0.25 /// The shortest range, which is also the shortest gap left outside it. var minimumSpan = 0.0 var lineWidth: CGFloat = 40 var startSymbol: String? var endSymbol: String? var startLabel = "Start" var endLabel = "End" var valueText: (Double) -> String = { $0.formatted() } @ViewBuilder var center: () -> Center private let handleInset: CGFloat = 4 private let centerPadding: CGFloat = 12 @State private var grab: CircularRangeSliderGrab? @GestureState private var isDragging = false private var startValue: Double { CircularRangeSliderMath.wrapped(start, total: total) } private var endValue: Double { CircularRangeSliderMath.wrapped(end, total: total) } private var span: Double { CircularRangeSliderMath.span(from: startValue, to: endValue, total: total) } var body: some View { ZStack { GeometryReader { proxy in dial(in: proxy.size) } // A dial turns clockwise in every language, like a clock, and a gesture is not mirrored either. .environment(\.layoutDirection, .leftToRight) center() .padding(lineWidth + centerPadding) } .aspectRatio(1, contentMode: .fit) .sensoryFeedback(trigger: [start, end]) { _, _ in step > 0 ? .selection : nil } .onChange(of: isDragging) { _, dragging in if !dragging { grab = nil } } } private func dial(in size: CGSize) -> some View { let startTurn = turn(of: startValue) let endTurn = turn(of: endValue) return ZStack { Circle() .strokeBorder(.fill.tertiary, lineWidth: lineWidth) Circle() .inset(by: lineWidth / 2) .trim(from: 0, to: turn(of: span)) .stroke(.tint, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(startTurn * 360 - 90)) handle(.start, symbol: startSymbol, label: startLabel, value: startValue, in: size) handle(.end, symbol: endSymbol, label: endLabel, value: endValue, in: size) } .frame(width: size.width, height: size.height) .contentShape(Circle().inset(by: lineWidth / 2).stroke(lineWidth: max(lineWidth, CircularRangeSliderMath.touchTarget))) .gesture( DragGesture(minimumDistance: 0) .updating($isDragging) { _, state, _ in state = true } .onChanged { value in if grab == nil { let part = CircularRangeSliderMath.part(at: value.startLocation, in: size, startTurn: startTurn, endTurn: endTurn, lineWidth: lineWidth) grab = CircularRangeSliderGrab(part: part, turn: CircularRangeSliderMath.turn(at: value.startLocation, in: size), start: startValue, span: span) } if let grab, let part = grab.part { move(part, to: CircularRangeSliderMath.turn(at: value.location, in: size), grab: grab) } } .onEnded { _ in grab = nil } ) } private func handle(_ part: CircularRangeSliderPart, symbol: String?, label: String, value: Double, in size: CGSize) -> some View { Circle() .fill(.white) .shadow(color: .black.opacity(0.2), radius: 3, y: 1) .overlay { if let symbol { Image(systemName: symbol) .font(.system(size: lineWidth * 0.4, weight: .semibold)) .foregroundStyle(.tint) } } .frame(width: lineWidth - handleInset * 2, height: lineWidth - handleInset * 2) .position(CircularRangeSliderMath.point(atTurn: turn(of: value), in: size, lineWidth: lineWidth)) .accessibilityElement() .accessibilityLabel(label) .accessibilityValue(valueText(value)) .accessibilityAdjustableAction { direction in let stride = step > 0 ? step : total / 100 switch direction { case .increment: set(part, to: value + stride) case .decrement: set(part, to: value - stride) @unknown default: break } } } private func turn(of value: Double) -> Double { total > 0 ? value / total : 0 } private func move(_ part: CircularRangeSliderPart, to turn: Double, grab: CircularRangeSliderGrab) { guard part == .both else { return set(part, to: turn * total) } let moved = CircularRangeSliderMath.wrapped(grab.start + (turn - grab.turn) * total, total: total, step: step) guard moved != startValue else { return } start = moved end = CircularRangeSliderMath.wrapped(moved + grab.span, total: total) } private func set(_ part: CircularRangeSliderPart, to value: Double) { let value = CircularRangeSliderMath.wrapped(value, total: total, step: step) let new = part == .start ? CircularRangeSliderMath.span(from: value, to: endValue, total: total) : CircularRangeSliderMath.span(from: startValue, to: value, total: total) guard CircularRangeSliderMath.allows(span: new, from: span, total: total, minimumSpan: minimumSpan) else { return } if part == .start, value != startValue { start = value } if part == .end, value != endValue { end = value } } } private struct CircularRangeSliderGrab: Equatable { /// `nil` when the touch began outside the handles and the range, and so moves nothing. var part: CircularRangeSliderPart? var turn: Double var start: Double var span: Double } // MARK: - Demo struct CircularRangeSliderDemo: View { @State private var bedtime = 23.0 @State private var wake = 7.0 var body: some View { VStack(alignment: .leading, spacing: 28) { Text("Sleep") .font(.largeTitle.bold()) CircularRangeSlider(start: $bedtime, end: $wake, minimumSpan: 1, startSymbol: "bed.double.fill", endSymbol: "alarm.fill", startLabel: "Bedtime", endLabel: "Wake up", valueText: clock) { ZStack { hours VStack(spacing: 4) { Text(Duration.seconds(asleep * 3600), format: .units(allowed: [.hours, .minutes], width: .narrow)) .font(.title.bold()) .monospacedDigit() Text("of sleep") .font(.subheadline) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) } } .tint(.indigo) .padding(.horizontal, 20) HStack { time("Bedtime", bedtime, symbol: "bed.double.fill") Spacer() time("Wake up", wake, symbol: "alarm.fill") } } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.systemGroupedBackground)) } /// The face of a 24-hour clock, with a mark every hour and a number every six. private var hours: some View { ZStack { ForEach(0..<24, id: \.self) { hour in let isMajor = hour % 6 == 0 VStack(spacing: 4) { Capsule() .fill(isMajor ? HierarchicalShapeStyle.secondary : .tertiary) .frame(width: 2, height: isMajor ? 10 : 5) if isMajor { Text(hour.formatted()) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(Double(-hour) * 15)) } } .frame(maxHeight: .infinity, alignment: .top) .rotationEffect(.degrees(Double(hour) * 15)) } } // The numbers of a clock stay where they are in a right-to-left layout. .environment(\.layoutDirection, .leftToRight) .accessibilityHidden(true) } private var asleep: Double { CircularRangeSliderMath.span(from: bedtime, to: wake, total: 24) } private func time(_ title: String, _ value: Double, symbol: String) -> some View { VStack(alignment: .leading, spacing: 4) { Label(title, systemImage: symbol) .font(.subheadline) .foregroundStyle(.secondary) Text(clock(value)) .font(.title2.bold()) .monospacedDigit() } .accessibilityElement(children: .combine) } private func clock(_ value: Double) -> String { let minutes = Int((value * 60).rounded()) let twoDigits = IntegerFormatStyle.number.precision(.integerLength(2)) return "\((minutes / 60 % 24).formatted(twoDigits)):\((minutes % 60).formatted(twoDigits))" } } #Preview { CircularRangeSliderDemo() } ``` ## Counter Stepper Minus and plus buttons around a number that rolls as it changes and stops at its limits. Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/CounterStepper/CounterStepper.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI enum CounterStepperValue { /// The result of adding `step` to `value`, kept inside `range`. static func stepped(_ value: Int, by step: Int, in range: ClosedRange) -> Int { let (sum, overflowed) = value.addingReportingOverflow(step) if overflowed { return step > 0 ? range.upperBound : range.lowerBound } return min(max(sum, range.lowerBound), range.upperBound) } static func canStep(_ value: Int, by step: Int, in range: ClosedRange) -> Bool { stepped(value, by: step, in: range) != value } } /// Minus and plus buttons around a number that rolls as it changes. /// /// With Reduce Motion on, the number changes without rolling. struct CounterStepper: View { @Binding var value: Int var range: ClosedRange = 0...99 var step: Int = 1 var title: LocalizedStringResource = "Value" var tint: Color = .indigo @ScaledMetric(relativeTo: .title) private var buttonSize = 56.0 @ScaledMetric(relativeTo: .largeTitle) private var numberWidth = 76.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { HStack(spacing: 6) { stepButton(systemImage: "minus", label: "Decrease", delta: -step) Text(value, format: .number) .font(.system(.largeTitle, design: .rounded, weight: .bold)) .monospacedDigit() .contentTransition(reduceMotion ? .identity : .numericText(value: Double(value))) .frame(minWidth: numberWidth) .accessibilityLabel(title) .accessibilityValue(value.formatted()) stepButton(systemImage: "plus", label: "Increase", delta: step) } .padding(6) .background(.quaternary, in: .capsule) .sensoryFeedback(.selection, trigger: value) } private func stepButton(systemImage: String, label: String, delta: Int) -> some View { let isEnabled = CounterStepperValue.canStep(value, by: delta, in: range) return Button { withAnimation(reduceMotion ? nil : .snappy) { value = CounterStepperValue.stepped(value, by: delta, in: range) } } label: { Image(systemName: systemImage) .font(.title2.bold()) .foregroundStyle(isEnabled ? Color.white : Color.secondary) .frame(width: buttonSize, height: buttonSize) .background(isEnabled ? tint : Color.gray.opacity(0.2), in: .circle) .contentShape(.circle) } .buttonStyle(.plain) .disabled(!isEnabled) .accessibilityLabel(label) } } // MARK: - Demo struct CounterStepperDemo: View { @State private var tickets = 3 @Environment(\.accessibilityReduceMotion) private var reduceMotion private var total: Int { tickets * 24 } var body: some View { VStack(spacing: 32) { VStack(spacing: 6) { Text("Tickets") .font(.largeTitle.bold()) Text(total, format: .currency(code: "EUR").precision(.fractionLength(0))) .font(.title2.weight(.semibold)) .foregroundStyle(.secondary) .contentTransition(reduceMotion ? .identity : .numericText(value: Double(total))) } .accessibilityElement(children: .combine) CounterStepper(value: $tickets, range: 0...5, title: "Tickets") } .frame(maxWidth: .infinity, maxHeight: .infinity) } } #Preview { CounterStepperDemo() } ``` ## Day Night Toggle A switch whose icon morphs between a sun and a moon while its background shifts between day and night colors. Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/DayNightToggle/DayNightToggle.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// A switch whose icon morphs between a sun and a moon while the sky behind it changes. /// VoiceOver reads it as a standard switch. /// /// With Reduce Motion on, it changes without sliding. struct DayNightToggle: View { @Binding var isNight: Bool var title: LocalizedStringResource = "Night mode" var body: some View { Toggle(isOn: $isNight) { Text(title) } .toggleStyle(DayNightToggleStyle()) } } private struct DayNightToggleStyle: ToggleStyle { @ScaledMetric(relativeTo: .title) private var height = 56.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let day = LinearGradient(colors: [.cyan, .blue], startPoint: .top, endPoint: .bottom) private static let night = LinearGradient(colors: [.indigo, .black], startPoint: .top, endPoint: .bottom) func makeBody(configuration: Configuration) -> some View { let isNight = configuration.isOn let knob = height - 8 Button { configuration.isOn.toggle() } label: { ZStack { Capsule().fill(Self.day) Capsule().fill(Self.night).opacity(isNight ? 1 : 0) scenery(isNight: isNight, knob: knob) .padding(.horizontal, height * 0.3) Circle() .fill(isNight ? Color(white: 0.88) : Color.white) .frame(width: knob, height: knob) .overlay { Image(systemName: isNight ? "moon.stars.fill" : "sun.max.fill") .font(.system(size: knob * 0.56)) .foregroundStyle(isNight ? Color.indigo : Color.orange) .contentTransition(.symbolEffect(.replace)) } .shadow(color: .black.opacity(0.25), radius: 4, y: 2) .frame(maxWidth: .infinity, alignment: isNight ? .trailing : .leading) .padding(4) } .frame(width: height * 2.2, height: height) .contentShape(.capsule) } .buttonStyle(.plain) .animation(reduceMotion ? nil : .spring(duration: 0.55, bounce: 0.25), value: isNight) .accessibilityRepresentation { Toggle(isOn: configuration.$isOn) { configuration.label } } } /// Stars sit where the knob is not at night, and a cloud does the same by day. private func scenery(isNight: Bool, knob: CGFloat) -> some View { ZStack { HStack(spacing: knob * 0.14) { Image(systemName: "sparkle").font(.system(size: knob * 0.3)) Image(systemName: "sparkle").font(.system(size: knob * 0.18)).offset(y: -knob * 0.2) } .frame(maxWidth: .infinity, alignment: .leading) .opacity(isNight ? 1 : 0) Image(systemName: "cloud.fill") .font(.system(size: knob * 0.44)) .frame(maxWidth: .infinity, alignment: .trailing) .opacity(isNight ? 0 : 1) } .foregroundStyle(.white) .accessibilityHidden(true) } } // MARK: - Demo struct DayNightToggleDemo: View { @State private var isNight = false @State private var lastAutoValue = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { ZStack { LinearGradient(colors: [.cyan.opacity(0.55), .white], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() LinearGradient(colors: [.black, .indigo], startPoint: .top, endPoint: .bottom) .ignoresSafeArea() .opacity(isNight ? 1 : 0) ScrollView { VStack(spacing: 24) { Text(isNight ? "Good night" : "Good morning") .font(.system(.largeTitle, design: .rounded, weight: .bold)) .foregroundStyle(isNight ? Color.white : Color.primary) .contentTransition(.opacity) DayNightToggle(isNight: $isNight) } .padding(24) .frame(maxWidth: .infinity) } .defaultScrollAnchor(.center, for: .alignment) } .animation(reduceMotion ? nil : .easeInOut(duration: 0.55), value: isNight) .task(id: reduceMotion) { guard !reduceMotion else { return } while isNight == lastAutoValue { try? await Task.sleep(for: .seconds(1.5)) guard !Task.isCancelled, isNight == lastAutoValue else { return } lastAutoValue.toggle() isNight = lastAutoValue } } } } #Preview { DayNightToggleDemo() } ``` ## Filter Chip Group Selectable filter chips that show a checkmark when chosen and wrap onto as many rows as they need. Category: Controls. Minimum: iOS 18.0. Path: Components/Controls/FilterChipGroup/FilterChipGroup.swift ```swift // SPDX-License-Identifier: MIT // Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors import SwiftUI /// Flows chips into rows, starting a new row when the next chip does not fit. nonisolated struct FilterChipGroupFlowLayout: Layout { var spacing: CGFloat = 8 /// The indices of the items in each row. An item wider than the row gets a row of its own. static func rows(for widths: [CGFloat], maxWidth: CGFloat, spacing: CGFloat) -> [[Int]] { var rows: [[Int]] = [] var current: [Int] = [] var used: CGFloat = 0 for (index, width) in widths.enumerated() { let needed = current.isEmpty ? width : used + spacing + width if needed > maxWidth, !current.isEmpty { rows.append(current) current = [index] used = width } else { current.append(index) used = needed } } if !current.isEmpty { rows.append(current) } return rows } /// Measures each chip at no more than the row, so a long one truncates instead of overflowing. private func sizes(of subviews: Subviews, maxWidth: CGFloat) -> [CGSize] { subviews.map { subview in let natural = subview.sizeThatFits(.unspecified) return natural.width > maxWidth ? subview.sizeThatFits(ProposedViewSize(width: maxWidth, height: nil)) : natural } } func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let maxWidth = proposal.width ?? .infinity let sizes = sizes(of: subviews, maxWidth: maxWidth) var width: CGFloat = 0 var height: CGFloat = 0 for (number, row) in Self.rows(for: sizes.map(\.width), maxWidth: maxWidth, spacing: spacing).enumerated() { let rowWidths = row.map { sizes[$0].width } let rowWidth = rowWidths.reduce(0, +) + spacing * CGFloat(row.count - 1) let rowHeight = row.map { sizes[$0].height }.max() ?? 0 width = max(width, rowWidth) height += number == 0 ? rowHeight : spacing + rowHeight } return CGSize(width: min(width, maxWidth), height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let sizes = sizes(of: subviews, maxWidth: bounds.width) var y = bounds.minY for row in Self.rows(for: sizes.map(\.width), maxWidth: bounds.width, spacing: spacing) { var x = bounds.minX let rowHeight = row.map { sizes[$0].height }.max() ?? 0 for index in row { subviews[index].place(at: CGPoint(x: x, y: y + rowHeight / 2), anchor: .leading, proposal: ProposedViewSize(sizes[index])) x += sizes[index].width + spacing } y += rowHeight + spacing } } } /// Chips that filter a list: each one toggles on its own, shows a checkmark /// when selected, and the group wraps onto as many rows as it needs. /// /// With Reduce Motion on, the checkmark fades in instead of growing. struct FilterChipGroup: View { let options: [Option] @Binding var selection: Set