# SwiftUI Motion Library: full reference > Every effect with its API, usage, an implementation description and the complete source. Generated from the catalog; 145 effects. ## Install ```swift // Package.swift .package(url: "https://github.com/BigTsung/SwiftUI-Motion-Library", from: "0.6.0") // target dependencies .product(name: "MotionLibrary", package: "SwiftUI-Motion-Library") ``` Then `import MotionLibrary`. Requires iOS 17, macOS 14 or visionOS 1; effects marked iOS 18+ are `@available(iOS 18, macOS 15, visionOS 2, *)`. In an Xcode app project, add the package with **File → Add Package Dependencies…** rather than editing `project.pbxproj` by hand. To use one effect without the package, copy its source file plus any files listed under "Files" for it (shared helpers such as `Support/Haptics.swift`); every effect compiles from just those files. --- ## Typewriter (`typewriter`) Characters appear one by one with a blinking cursor. - API: `TypewriterText("…")` - Component: Text - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/TypewriterText.swift - Files: Sources/MotionLibrary/Text/TypewriterText.swift ### Usage ```swift TypewriterText("Hello, SwiftUI!", interval: 0.06) .font(.title.bold()) ``` ### How it works Create a SwiftUI "typewriter" text view (iOS 17+). It reveals a string one character at a time at a fixed interval (default 60 ms), followed by a cursor character "|" in the accent color that blinks every 0.5 s. Implementation: keep a @State visibleCount, render Text(String(text.prefix(visibleCount))) concatenated with the cursor Text, and drive it from .task(id: text) with Task.sleep so it restarts when the text changes and cancels when the view disappears. Use the full text as the accessibility label. ### Source ```swift import SwiftUI /// Reveals a string one character at a time with an optional blinking cursor. /// /// ```swift /// TypewriterText("Hello, SwiftUI!") /// .font(.title.bold()) /// ``` /// /// The animation restarts whenever `text` changes. public struct TypewriterText: View { private let text: String private let interval: Double private let showsCursor: Bool private let cursor: String @State private var visibleCount = 0 @State private var cursorVisible = true /// - Parameters: /// - text: The text to type out. /// - interval: Seconds between characters. /// - showsCursor: Shows a blinking cursor after the text. /// - cursor: The cursor character. public init( _ text: String, interval: Double = 0.06, showsCursor: Bool = true, cursor: String = "|" ) { self.text = text self.interval = interval self.showsCursor = showsCursor self.cursor = cursor } @available(*, deprecated, message: "Pass interval in seconds as a Double, e.g. interval: 0.06") public init( _ text: String, interval: Duration, showsCursor: Bool = true, cursor: String = "|" ) { let parts = interval.components self.init(text, interval: Double(parts.seconds) + Double(parts.attoseconds) / 1e18, showsCursor: showsCursor, cursor: cursor) } public var body: some View { ( Text(String(text.prefix(visibleCount))) + Text(showsCursor ? cursor : "") .foregroundStyle(cursorVisible ? Color.accentColor : Color.clear) ) .accessibilityLabel(text) .task(id: text) { visibleCount = 0 guard !text.isEmpty else { return } for index in 1...text.count { try? await Task.sleep(for: .seconds(interval)) if Task.isCancelled { return } visibleCount = index } } .task { guard showsCursor else { return } while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(500)) cursorVisible.toggle() } } } } #Preview { TypewriterText("Turning complex data into meaningful visuals.") .font(.title2.weight(.semibold)) .padding() } ``` --- ## Scramble / Decode (`scramble`) Random glyphs resolve into the real text from left to right. - API: `ScrambleText("…")` - Component: Text - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/ScrambleText.swift - Files: Sources/MotionLibrary/Text/ScrambleText.swift ### Usage ```swift ScrambleText("ACCESS GRANTED", duration: 1.0) .font(.title.bold()) ``` ### How it works Create a SwiftUI "decode" text effect (iOS 17+). On appear, every character of the string is shown as a random glyph from a charset (A–Z, 0–9, #%&*), re-randomised every 30 ms. Over the given duration, characters lock into their real values from left to right until the full string is shown. Spaces never scramble. Use a monospaced font design so the width doesn't jitter, drive frames from .task(id: text) with Task.sleep, and set the real string as the accessibility label. ### Source ```swift import SwiftUI /// "Decoding" text effect: random glyphs settle into the real string from /// left to right. /// /// ```swift /// ScrambleText("ACCESS GRANTED") /// .font(.title.bold()) /// ``` public struct ScrambleText: View { private let text: String private let duration: Double private let charset: [Character] @State private var display: String @Environment(\.accessibilityReduceMotion) private var reduceMotion public init( _ text: String, duration: Double = 1.0, charset: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*" ) { self.text = text self.duration = duration self.charset = Array(charset) _display = State(initialValue: text) } public var body: some View { Text(display) .fontDesign(.monospaced) .accessibilityLabel(text) .task(id: text) { await run() } } private func run() async { // Reduce Motion: show the text without the flicker of random glyphs. guard !reduceMotion else { display = text return } let target = Array(text) let frameInterval = 0.03 let steps = max(1, Int(duration / frameInterval)) for step in 0...steps { let revealed = Int(Double(target.count) * Double(step) / Double(steps)) var output: [Character] = [] output.reserveCapacity(target.count) for (index, character) in target.enumerated() { if index < revealed || character == " " || charset.isEmpty { output.append(character) } else { output.append(charset.randomElement() ?? character) } } display = String(output) try? await Task.sleep(for: .seconds(frameInterval)) if Task.isCancelled { return } } display = text } } #Preview { ScrambleText("ACCESS GRANTED", duration: 1.2) .font(.title.bold()) .padding() } ``` --- ## Shimmer (`shimmer`) A band of light sweeps across text or skeleton placeholders. - API: `.shimmer()` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Shimmer.swift - Files: Sources/MotionLibrary/Text/Shimmer.swift ### Usage ```swift Text("Loading…") .font(.title) .shimmer() RoundedRectangle(cornerRadius: 8) .fill(.gray.opacity(0.3)) .frame(height: 44) .shimmer(duration: 1.2) ``` ### How it works Create a reusable SwiftUI .shimmer() view modifier (iOS 17+). A soft white highlight band (a horizontal LinearGradient clear → white 80% → clear, about 35% of the view width) moves from just off the left edge to just off the right edge, repeating forever with linear timing (~1.6 s). The band is placed in an overlay with a GeometryReader and masked by the original content so it only lights up the view's own pixels (text glyphs or skeleton shapes). It must not intercept touches and should accept isActive, duration, bandWidth and highlight color parameters. ### Source ```swift import SwiftUI /// A band of light that sweeps across the view, clipped to the view's own /// shape. Works on text, images, and skeleton placeholders. struct ShimmerModifier: ViewModifier { var isActive: Bool var duration: Double var bandWidth: CGFloat var highlight: Color @State private var animating = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { if isActive { GeometryReader { geometry in let width = geometry.size.width let band = max(1, width * bandWidth) LinearGradient( colors: [.clear, highlight, .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: band, height: geometry.size.height) .offset(x: animating ? width : -band) } .mask { content } .allowsHitTesting(false) .onAppear { animating = false // Reduce Motion: no sweep; the band stays parked off-screen. guard !reduceMotion else { return } withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) { animating = true } } } } } } public extension View { /// Sweeps a highlight across the view. /// /// ```swift /// Text("Loading…").font(.title).shimmer() /// RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.3)).shimmer() /// ``` func shimmer( isActive: Bool = true, duration: Double = 1.6, bandWidth: CGFloat = 0.35, highlight: Color = .white.opacity(0.8) ) -> some View { modifier(ShimmerModifier( isActive: isActive, duration: duration, bandWidth: bandWidth, highlight: highlight )) } } #Preview { VStack(spacing: 16) { Text("Shimmering title") .font(.largeTitle.bold()) .foregroundStyle(.gray) .shimmer() RoundedRectangle(cornerRadius: 12) .fill(.gray.opacity(0.25)) .frame(height: 60) .shimmer() } .padding() } ``` --- ## Gradient Sweep (`gradient-sweep`) A multi-colour gradient flows through the letters forever. - API: `.gradientSweep()` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/GradientSweep.swift - Files: Sources/MotionLibrary/Text/GradientSweep.swift ### Usage ```swift Text("Motion Library") .font(.largeTitle.weight(.heavy)) .gradientSweep() Image(systemName: "sparkles") .font(.system(size: 60)) .gradientSweep(colors: [.yellow, .orange, .pink], duration: 2) ``` ### How it works Create a SwiftUI .gradientSweep(colors:duration:) view modifier (iOS 17+) that paints a view's own pixels with a flowing multi-colour gradient. In an overlay, use a GeometryReader to draw a horizontal LinearGradient twice as wide as the view whose colours are the palette repeated twice plus the first colour again (so the right half matches the left half). Offset it from 0 to −width with a linear animation that repeats forever without autoreversing, so the loop is seamless. Mask the overlay with the original content so only glyphs or shapes are coloured, and disable hit testing. Default palette pink, orange, yellow, mint, cyan, purple over 3 s. With Reduce Motion on, show the gradient without moving it. ### Source ```swift import SwiftUI /// Fills the view's own pixels with a multi-colour gradient that keeps /// flowing sideways. Made for headlines, but works on symbols and shapes too. struct GradientSweepModifier: ViewModifier { var colors: [Color] var duration: Double @State private var flowing = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { GeometryReader { geometry in let width = max(1, geometry.size.width) // Two copies of the palette side by side, twice as wide as // the view. Sliding left by one view width shows the second // copy, which looks exactly like the first, so it loops. let palette = colors.isEmpty ? [Color.primary] : colors LinearGradient( colors: palette + palette + [palette[0]], startPoint: .leading, endPoint: .trailing ) .frame(width: width * 2, height: geometry.size.height) .offset(x: flowing ? -width : 0) } .mask { content } .allowsHitTesting(false) } .onAppear { guard !reduceMotion else { return } flowing = false withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) { flowing = true } } } } public extension View { /// Paints the view with a gradient that flows sideways forever. /// /// ```swift /// Text("Motion Library") /// .font(.largeTitle.bold()) /// .gradientSweep() /// ``` func gradientSweep( colors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple], duration: Double = 3 ) -> some View { modifier(GradientSweepModifier(colors: colors, duration: duration)) } } #Preview { Text("Motion Library") .font(.largeTitle.weight(.heavy)) .gradientSweep() .padding() } ``` --- ## Blur-in Words (`blur-in`) Words come into focus one after another: fade, rise and un-blur. - API: `BlurInText("…")` - Component: Text - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/BlurInText.swift - Files: Sources/MotionLibrary/Text/BlurInText.swift ### Usage ```swift BlurInText("Design is how it works.") .font(.title.weight(.semibold)) BlurInText("Letter by letter", by: .character, stagger: 0.03) ``` ### How it works Create a SwiftUI BlurInText view (iOS 17+) that reveals a string piece by piece, split by word (default) or by character. Lay the pieces out with a custom Layout that flows left to right, wraps to new lines and centres each line; the gap between words is about 0.28 × the line height. Each piece starts at opacity 0, blur radius 8 and 8 pt lower, and animates to opacity 1, no blur and no offset with easeOut(0.6 s), delayed by index × stagger (0.08 s default). Replay when the text changes by resetting a revealed flag and setting it again on the next run loop. Expose the whole string as one accessibility label. With Reduce Motion on, only fade. ### Source ```swift import SwiftUI /// Text that comes into focus one word (or one letter) at a time: each piece /// fades in, rises a few points and un-blurs, staggered from first to last. /// Replays whenever the string changes. public struct BlurInText: View { /// How the string is split into animated pieces. public enum Unit: Sendable { case word case character } private let text: String private let unit: Unit private let stagger: Double private let duration: Double @State private var revealed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The string to show. /// - unit: Animate word by word or character by character. /// - stagger: Delay between consecutive pieces, in seconds. /// - duration: How long each piece takes to come into focus. public init(_ text: String, by unit: Unit = .word, stagger: Double = 0.08, duration: Double = 0.6) { self.text = text self.unit = unit self.stagger = stagger self.duration = duration } public var body: some View { FlowLayout(spacing: unit == .word ? 0.28 : 0) { ForEach(Array(pieces.enumerated()), id: \.offset) { index, piece in Text(piece) .opacity(revealed ? 1 : 0) .blur(radius: revealed || reduceMotion ? 0 : 8) .offset(y: revealed || reduceMotion ? 0 : 8) .animation( .easeOut(duration: duration).delay(Double(index) * stagger), value: revealed ) } } .id(text) .onAppear(perform: reveal) .onChange(of: text) { reveal() } .accessibilityElement(children: .ignore) .accessibilityLabel(text) } private var pieces: [String] { switch unit { case .word: text.split(separator: " ", omittingEmptySubsequences: true).map(String.init) case .character: text.map(String.init) } } private func reveal() { revealed = false DispatchQueue.main.async { revealed = true } } } /// Lays children out left to right, wrapping onto new lines. `spacing` is a /// fraction of the line height, so word gaps scale with the font. struct FlowLayout: Layout { var spacing: CGFloat func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let rows = arrange(subviews: subviews, maxWidth: proposal.width ?? .infinity) let width = rows.map(\.width).max() ?? 0 let height = rows.reduce(0) { $0 + $1.height } return CGSize(width: width, height: height) } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let rows = arrange(subviews: subviews, maxWidth: bounds.width) var y = bounds.minY for row in rows { // Centre each row, like multi-line centred text. var x = bounds.minX + (bounds.width - row.width) / 2 for item in row.items { subviews[item.index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(item.size)) x += item.size.width + item.gap } y += row.height } } private struct Row { var items: [(index: Int, size: CGSize, gap: CGFloat)] = [] var width: CGFloat = 0 var height: CGFloat = 0 } private func arrange(subviews: Subviews, maxWidth: CGFloat) -> [Row] { var rows: [Row] = [] var row = Row() for index in subviews.indices { let size = subviews[index].sizeThatFits(.unspecified) let gapBefore = row.items.last?.gap ?? 0 let width = row.items.isEmpty ? size.width : row.width + gapBefore + size.width if width > maxWidth, !row.items.isEmpty { rows.append(row) row = Row() row.width = size.width } else { row.width = width } row.items.append((index, size, size.height * spacing)) row.height = max(row.height, size.height) } rows.append(row) return rows } } #Preview { BlurInText("Design is not just what it looks like. Design is how it works.") .font(.title2.weight(.semibold)) .padding() } ``` --- ## Marquee (`marquee`) Text too long for its line scrolls sideways forever, with faded edges. - API: `MarqueeText("…")` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/MarqueeText.swift - Files: Sources/MotionLibrary/Text/MarqueeText.swift ### Usage ```swift MarqueeText("Now playing: Midnight Drive — Neon Coast") .font(.headline) .frame(width: 200) MarqueeText(longTitle, speed: 1.5, gap: 24) ``` ### How it works Create a SwiftUI MarqueeText view (iOS 17+) for a single line of text. Measure the text's natural width (fixedSize text with a GeometryReader background) and the container width. If the text fits, show it still. If not, show two copies in an HStack separated by a gap (40 pt) and offset them left by (time × 40 pt/s × speed, a multiplier defaulting to 1) modulo (textWidth + gap) using TimelineView(.animation), so the second copy takes the first copy's place and the loop is seamless. Speed defaults to 40 pt/s. Clip to the container and mask the edges with 16 pt linear fades. Reserve one line of height with a hidden copy of the text. Expose the full text to accessibility, and with Reduce Motion on, don't scroll. ### Source ```swift import SwiftUI /// A single line of text that scrolls sideways forever when it's too long to /// fit, with faded edges. Short text just sits still. public struct MarqueeText: View { private let text: String private let speed: Double private let gap: CGFloat private let fadeWidth: CGFloat @State private var textWidth: CGFloat = 0 @State private var containerWidth: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The string to show. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - gap: Space between the end of the text and its next repetition. /// - fadeWidth: Width of the faded edges. public init(_ text: String, speed: Double = 1, gap: CGFloat = 40, fadeWidth: CGFloat = 16) { self.text = text self.speed = speed * 40 self.gap = gap self.fadeWidth = fadeWidth } public var body: some View { let scrolls = textWidth > containerWidth && !reduceMotion // Reserve one line of height; the measured text lives in the overlay. Text(text) .lineLimit(1) .hidden() .frame(maxWidth: .infinity, alignment: .leading) .overlay(alignment: .leading) { TimelineView(.animation(paused: !scrolls || speed == 0)) { timeline in let cycle = textWidth + gap let offset = scrolls ? -CGFloat((timeline.date.timeIntervalSinceReferenceDate * speed) .truncatingRemainder(dividingBy: Double(max(cycle, 1)))) : 0 HStack(spacing: gap) { label if scrolls { label } } .offset(x: offset) } } .background { GeometryReader { proxy in Color.clear .onAppear { containerWidth = proxy.size.width } .onChange(of: proxy.size.width) { _, width in containerWidth = width } } } .clipped() .mask { if scrolls { HStack(spacing: 0) { LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) .frame(width: fadeWidth) Color.black LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) .frame(width: fadeWidth) } } else { Color.black } } .accessibilityElement(children: .ignore) .accessibilityLabel(text) } private var label: some View { Text(text) .lineLimit(1) .fixedSize() .background { GeometryReader { proxy in Color.clear .onAppear { textWidth = proxy.size.width } .onChange(of: proxy.size.width) { _, width in textWidth = width } } } } } #Preview { VStack(alignment: .leading, spacing: 20) { MarqueeText("Now playing: Midnight City — M83 · Hurry Up, We're Dreaming") .font(.headline) MarqueeText("Short text stays put") } .frame(width: 240) .padding() } ``` --- ## Wave Text (`wave-text`) Letters bob in a travelling wave, drawn with a TextRenderer. - API: `WaveText("…")` - Component: Text - Trigger: Loop - Requires: iOS 18+ · macOS 15+ · visionOS 2+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/WaveText.swift - Files: Sources/MotionLibrary/Text/WaveText.swift ### Usage ```swift if #available(iOS 18, *) { WaveText("Making waves") .font(.largeTitle.bold()) .foregroundStyle(.blue) } ``` ### How it works Create a SwiftUI WaveText view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) that makes each letter bob up and down in a travelling wave while keeping normal text layout. Implement a TextRenderer whose draw(layout:in:) walks every line, run and glyph, keeps a running glyph index, copies the GraphicsContext, translates it vertically by sin((index / wavelength − time × speed) × 2π) × amplitude, and draws the glyph. Set displayPadding to the amplitude above and below so nothing clips. Drive time from TimelineView(.animation) and apply the renderer with .textRenderer. Defaults: amplitude 5 pt, wavelength 8 letters, 1 wave per second. With Reduce Motion on, pause the timeline and use zero amplitude. ### Source ```swift import SwiftUI /// Letters bob up and down in a travelling wave, drawn with a `TextRenderer` /// so the text keeps its normal layout, wrapping and accessibility. @available(iOS 18, macOS 15, visionOS 2, *) public struct WaveText: View { private let text: Text private let amplitude: CGFloat private let wavelength: Double private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The string to show. /// - amplitude: How far letters move up and down, in points. /// - wavelength: Number of letters in one full wave. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(_ text: String, amplitude: CGFloat = 5, wavelength: Double = 8, speed: Double = 1) { self.init(Text(text), amplitude: amplitude, wavelength: wavelength, speed: speed) } /// Wraps an existing `Text`, keeping its styling. public init(_ text: Text, amplitude: CGFloat = 5, wavelength: Double = 8, speed: Double = 1) { self.text = text self.amplitude = amplitude self.wavelength = wavelength self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in text.textRenderer(WaveRenderer( time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate, amplitude: reduceMotion ? 0 : amplitude, wavelength: wavelength, speed: speed )) } } } @available(iOS 18, macOS 15, visionOS 2, *) struct WaveRenderer: TextRenderer { var time: Double var amplitude: CGFloat var wavelength: Double var speed: Double var displayPadding: EdgeInsets { EdgeInsets(top: amplitude, leading: 0, bottom: amplitude, trailing: 0) } func draw(layout: Text.Layout, in context: inout GraphicsContext) { var index = 0 for line in layout { for run in line { for glyph in run { let angle = (Double(index) / max(wavelength, 1) - time * speed) * 2 * .pi var copy = context copy.translateBy(x: 0, y: CGFloat(sin(angle)) * amplitude) copy.draw(glyph) index += 1 } } } } } #Preview { if #available(iOS 18, macOS 15, visionOS 2, *) { WaveText("Making waves in SwiftUI") .font(.title.bold()) .foregroundStyle(.blue) .padding() } } ``` --- ## Count Up (`count-up`) A number counts up from zero, and from old to new value on change. - API: `CountUpText(value: …)` - Component: Text - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/CountUpText.swift - Files: Sources/MotionLibrary/Text/CountUpText.swift ### Usage ```swift CountUpText(value: revenue, prefix: "$") .font(.largeTitle.bold()) CountUpText(value: 98.6, fractionDigits: 1, suffix: "%", duration: 2) ``` ### How it works Create a SwiftUI CountUpText view (iOS 17+) with value, duration (1.4 s), fractionDigits (0), prefix and suffix. Keep a @State shown value starting at 0; on appear and whenever value changes, set shown = value inside withAnimation(.easeOut(duration:)). Render through a private view that conforms to both View and Animatable (animatableData = the number) so SwiftUI calls body on every frame with the interpolated value, and format it with value.formatted(.number.precision(.fractionLength(fractionDigits))) between prefix and suffix. Use .monospacedDigit() so the width doesn't jitter, and give it the final formatted value as its accessibility label. With Reduce Motion on, jump straight to the value. ### Source ```swift import SwiftUI /// A number that counts up from zero on appear, and counts from the old value /// to the new one whenever `value` changes. public struct CountUpText: View { private let value: Double private let duration: Double private let fractionDigits: Int private let prefix: String private let suffix: String @State private var shown: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - value: The number to count to. /// - duration: Length of the count, in seconds. /// - fractionDigits: Digits after the decimal point. /// - prefix: Text before the number, e.g. "$". /// - suffix: Text after the number, e.g. "%". public init( value: Double, duration: Double = 1.4, fractionDigits: Int = 0, prefix: String = "", suffix: String = "" ) { self.value = value self.duration = duration self.fractionDigits = fractionDigits self.prefix = prefix self.suffix = suffix } public var body: some View { CountingLabel(value: shown, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix) .monospacedDigit() .onAppear { count(to: value) } .onChange(of: value) { _, newValue in count(to: newValue) } .accessibilityElement(children: .ignore) .accessibilityLabel(CountingLabel.format(value, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix)) } private func count(to target: Double) { // Reduce Motion: jump straight to the value. withAnimation(reduceMotion ? nil : .easeOut(duration: duration)) { shown = target } } } /// Re-renders the formatted number for every animation frame. private struct CountingLabel: View, Animatable { var value: Double let fractionDigits: Int let prefix: String let suffix: String nonisolated var animatableData: Double { get { value } set { value = newValue } } var body: some View { Text(Self.format(value, fractionDigits: fractionDigits, prefix: prefix, suffix: suffix)) } static func format(_ value: Double, fractionDigits: Int, prefix: String, suffix: String) -> String { prefix + value.formatted(.number.precision(.fractionLength(max(0, fractionDigits)))) + suffix } } #Preview { VStack(spacing: 12) { CountUpText(value: 12_480) .font(.largeTitle.bold()) CountUpText(value: 98.6, fractionDigits: 1, suffix: "%") .font(.title) } .padding() } ``` --- ## Split Text (`split-text`) Letters rise into place one after another with a small overshoot. - API: `SplitText("…")` - Component: Text - Trigger: Appear / Value change - Requires: iOS 18+ · macOS 15+ · visionOS 2+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/SplitText.swift - Files: Sources/MotionLibrary/Text/SplitText.swift ### Usage ```swift if #available(iOS 18, *) { SplitText("Letters take the stage") .font(.largeTitle.bold()) } ``` ### How it works Create a SwiftUI SplitText view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) with duration per letter (0.5 s), stagger (0.035 s) and distance (18 pt). Apply a TextRenderer that is also Animatable, with a progress value 0…1 as animatableData. In draw(layout:in:), count all glyphs, then for glyph i compute start = spread × i / (count − 1), where spread is the share of the total time taken by the delays, and local = clamp((progress − start) / (1 − spread), 0, 1). Copy the GraphicsContext, set opacity to min(1, local × 2), translate it down by (1 − backOut(local)) × distance (backOut: ease-out with ~1.7 overshoot) and draw the glyph. Set displayPadding at the bottom to distance. In .task(id: string), reset progress to 0 without animation, then animate it to 1 with linear timing over duration + stagger × (count − 1). With Reduce Motion on, use zero distance (a fade). ### Source ```swift import SwiftUI /// Letters rise into place one after another with a small overshoot, drawn /// with a `TextRenderer` so the text keeps its normal layout and wrapping. /// Plays on appear and again when the string changes. @available(iOS 18, macOS 15, visionOS 2, *) public struct SplitText: View { private let string: String private let duration: Double private let stagger: Double private let distance: CGFloat @State private var progress: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - string: The text to show. /// - duration: How long each letter takes to land, in seconds. /// - stagger: Delay between consecutive letters, in seconds. /// - distance: How far below its place each letter starts, in points. public init(_ string: String, duration: Double = 0.5, stagger: Double = 0.035, distance: CGFloat = 18) { self.string = string self.duration = duration self.stagger = stagger self.distance = distance } public var body: some View { let glyphs = max(1, string.count) let total = duration + stagger * Double(glyphs - 1) Text(string) .textRenderer(SplitRenderer( progress: progress, spread: total > 0 ? stagger * Double(glyphs - 1) / total : 0, distance: reduceMotion ? 0 : distance )) .task(id: string) { var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { progress = 0 } try? await Task.sleep(for: .milliseconds(16)) withAnimation(.linear(duration: reduceMotion ? 0.3 : total)) { progress = 1 } } } } @available(iOS 18, macOS 15, visionOS 2, *) struct SplitRenderer: TextRenderer, Animatable { /// 0 = nothing shown, 1 = every letter in place. var progress: Double /// Fraction of the timeline taken up by the delays between letters. var spread: Double var distance: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } var displayPadding: EdgeInsets { EdgeInsets(top: 0, leading: 0, bottom: distance, trailing: 0) } func draw(layout: Text.Layout, in context: inout GraphicsContext) { let count = layout.flatMap { $0 }.reduce(0) { $0 + $1.count } var index = 0 for line in layout { for run in line { for glyph in run { let start = count > 1 ? spread * Double(index) / Double(count - 1) : 0 let local = min(1, max(0, (progress - start) / max(0.0001, 1 - spread))) var copy = context copy.opacity = min(1, local * 2) copy.translateBy(x: 0, y: (1 - Self.backOut(local)) * distance) copy.draw(glyph) index += 1 } } } } /// Ease-out with a small overshoot. static func backOut(_ t: Double) -> Double { let c1 = 1.70158, c3 = c1 + 1 return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2) } } #Preview { if #available(iOS 18, macOS 15, visionOS 2, *) { SplitText("Letters take the stage") .font(.largeTitle.bold()) .padding() } } ``` --- ## Rotating Text (`rotating-text`) Words take turns: the current one slides up and out as the next rises in. - API: `RotatingText([…])` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/RotatingText.swift - Files: Sources/MotionLibrary/Text/RotatingText.swift ### Usage ```swift HStack { Text("We") RotatingText(["design", "build", "ship"]) .foregroundStyle(.blue) } .font(.largeTitle.bold()) ``` ### How it works Create a SwiftUI RotatingText view (iOS 17+) that cycles through an array of words every interval (2 s). Keep a @State index and show Text(words[index]).id(index) inside a ZStack with .fixedSize() and .clipped(). Give the text an asymmetric transition: insertion .move(edge: .bottom) combined with .opacity, removal .move(edge: .top) combined with .opacity. Drive it from .task with a loop of Task.sleep followed by withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { index += 1 }, so the width also springs to fit each word. Expose all words joined by commas as the accessibility label. With Reduce Motion on, use a plain opacity transition. ### Source ```swift import SwiftUI /// Cycles through a list of words: the current word slides up and out while /// the next one rises in, and the width springs to fit. public struct RotatingText: View { private let words: [String] private let interval: Double @State private var index = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - words: The words to cycle through. /// - interval: Seconds each word stays on screen. public init(_ words: [String], interval: Double = 2) { self.words = words self.interval = interval } public var body: some View { ZStack { if !words.isEmpty { Text(words[index % words.count]) .id(index) .transition(reduceMotion ? .opacity : .asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) )) } } .fixedSize() .clipped() .task { guard words.count > 1 else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(interval)) guard !Task.isCancelled else { return } withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { index = (index + 1) % words.count } } } .accessibilityElement(children: .ignore) .accessibilityLabel(words.joined(separator: ", ")) } } #Preview { HStack(spacing: 6) { Text("We") RotatingText(["design", "build", "ship"]) .foregroundStyle(.blue) Text("apps.") } .font(.title.bold()) .padding() } ``` --- ## Glitch (`glitch`) Every few seconds the view tears: an RGB split, sliced rows and jitter. - API: `.glitch()` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Glitch.swift - Files: Sources/MotionLibrary/Text/Glitch.swift ### Usage ```swift Text("SYSTEM FAILURE") .font(.title.monospaced().bold()) .glitch() Image("logo") .glitch(intensity: 2, interval: 4) ``` ### How it works Create a SwiftUI .glitch(isActive:intensity:interval:) view modifier (iOS 17+) that works on any view. Use TimelineView(.animation(minimumInterval: 1/24)) and derive everything from the clock: glitch during the first 0.35 s of every interval (2 s); within that window pick new pseudo-random values 24 times a second from a cheap integer hash of (frame, salt). While glitching: (1) behind the content, draw two silhouettes of it, content.brightness(1).colorMultiply(.cyan) offset left and content.brightness(1).colorMultiply(.red) offset right by 2–6 pt × intensity, at 85 % opacity; (2) in an overlay, draw a copy of the content offset sideways by up to ±12 pt, masked to a horizontal strip at a random height (10–35 % of the view tall); (3) jitter the whole view by up to ±1.5 pt. Outside the window, show the content untouched. With Reduce Motion on, don't glitch at all. ### Source ```swift import SwiftUI /// Digital glitch: every couple of seconds the view splits into red and cyan /// copies, and horizontal slices jump sideways for a fraction of a second. /// Works on any view (text, images, logos). struct GlitchModifier: ViewModifier { var isActive: Bool var intensity: CGFloat var interval: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: no glitching at all. let running = isActive && !reduceMotion TimelineView(.animation(minimumInterval: 1.0 / 24, paused: !running)) { timeline in let state = running ? GlitchState(time: timeline.date.timeIntervalSinceReferenceDate, interval: interval) : .calm content // Colour fringes: solid cyan and red silhouettes (brightness 1 // turns any colour white first) to the left and right. .background { ZStack { content.brightness(1).colorMultiply(.cyan).offset(x: -state.split * intensity) content.brightness(1).colorMultiply(.red).offset(x: state.split * intensity) } .opacity(state.split == 0 ? 0 : 0.85) .accessibilityHidden(true) } // A slice of the view displaced sideways. .overlay { GeometryReader { geometry in let height = geometry.size.height content .offset(x: state.sliceOffset * intensity) .mask { Rectangle() .frame(height: height * state.sliceHeight) .offset(y: height * (state.sliceY - 0.5)) } .frame(width: geometry.size.width, height: height) } .opacity(state.sliceOffset == 0 ? 0 : 1) .accessibilityHidden(true) } .offset(x: state.jitter * intensity) } } } /// The glitch values for one moment, derived from the clock so the effect /// needs no state. private struct GlitchState { var split: CGFloat = 0 var sliceOffset: CGFloat = 0 var sliceY: CGFloat = 0.5 var sliceHeight: CGFloat = 0.2 var jitter: CGFloat = 0 static let calm = GlitchState() init() {} init(time: Double, interval: Double) { let cycle = max(0.4, interval) let phase = time.truncatingRemainder(dividingBy: cycle) // Glitch for the first 0.35 s of every cycle. guard phase < 0.35 else { return } // Change the random values 24 times a second. let step = Int(time * 24) split = 2 + 4 * Self.noise(step, 1) if Self.noise(step, 2) > 0.35 { sliceOffset = (Self.noise(step, 3) - 0.5) * 24 sliceY = 0.15 + 0.7 * Self.noise(step, 4) sliceHeight = 0.1 + 0.25 * Self.noise(step, 5) } jitter = (Self.noise(step, 6) - 0.5) * 3 } /// Cheap hash to 0..<1. static func noise(_ step: Int, _ salt: Int) -> CGFloat { var x = UInt64(bitPattern: Int64(step &* 7919 &+ salt &* 104_729)) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x = (x ^ (x >> 33)) &* 0xc4ce_b9fe_1a85_ec53 x ^= x >> 33 return CGFloat(x % 10_000) / 10_000 } } public extension View { /// Makes the view glitch every `interval` seconds: an RGB split, displaced /// slices and a little jitter. /// /// ```swift /// Text("SYSTEM FAILURE") /// .font(.title.monospaced().bold()) /// .glitch() /// ``` /// /// - Parameters: /// - isActive: Turns the effect on and off. /// - intensity: Multiplies every offset (1 = default strength). /// - interval: Seconds between glitch bursts. func glitch(isActive: Bool = true, intensity: CGFloat = 1, interval: Double = 2) -> some View { modifier(GlitchModifier(isActive: isActive, intensity: intensity, interval: interval)) } } #Preview { Text("SYSTEM FAILURE") .font(.largeTitle.monospaced().bold()) .glitch() .padding() } ``` --- ## Circular Text (`circular-text`) Text wrapped around a circle that slowly turns, like a rotating badge. - API: `CircularText("…")` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/CircularText.swift - Files: Sources/MotionLibrary/Text/CircularText.swift ### Usage ```swift ZStack { CircularText("SWIFTUI • MOTION • LIBRARY • ", radius: 60) .font(.caption.monospaced().bold()) Image(systemName: "sparkles").font(.largeTitle) } ``` ### How it works Create a SwiftUI CircularText view (iOS 17+) with text, radius (60 pt) and speed (a multiplier, default 1, on 20° per second; negative turns the other way). Split the string into characters and place each one in a ZStack as Text(character).offset(y: −radius).rotationEffect(.degrees(index / count × 360)), so letters stand evenly spaced around the circle. Wrap the ZStack in TimelineView(.animation) and rotate it by time × 20 × speed degrees (modulo 360). Frame it to 2 × radius plus 12 pt padding. Use the full text as a single accessibility label. With Reduce Motion on (or speed 0), pause the timeline. ### Source ```swift import SwiftUI /// Text laid out around a circle that slowly turns, like a rotating badge or /// stamp. Put an icon in the middle with a `ZStack`. public struct CircularText: View { private let characters: [String] private let text: String private let radius: CGFloat private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The text to wrap around the circle. Add a separator such as /// " • " at the end so the start and end don't touch. /// - radius: Radius of the circle the letters stand on, in points. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// Negative turns counter-clockwise, 0 stops it. public init(_ text: String, radius: CGFloat = 60, speed: Double = 1) { self.text = text self.characters = text.map(String.init) self.radius = radius self.speed = speed * 20 } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let angle = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed ZStack { ForEach(characters.indices, id: \.self) { index in Text(characters[index]) .offset(y: -radius) .rotationEffect(.degrees(Double(index) / Double(max(1, characters.count)) * 360)) } } .rotationEffect(.degrees(angle.truncatingRemainder(dividingBy: 360))) } .frame(width: radius * 2, height: radius * 2) .padding(12) .accessibilityElement(children: .ignore) .accessibilityLabel(text) } } #Preview { ZStack { CircularText("SWIFTUI • MOTION • LIBRARY • ") .font(.system(size: 13, weight: .bold, design: .monospaced)) Image(systemName: "sparkles") .font(.largeTitle) } .padding() } ``` --- ## Focus Text (`focus-text`) Focus hops from word to word: sharp and bracketed, the rest blurred. - API: `FocusText("…")` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/FocusText.swift - Files: Sources/MotionLibrary/Text/FocusText.swift ### Usage ```swift FocusText("Motion made simple") .font(.title.bold()) FocusText("Plan Build Ship", interval: 0.8, bracketColor: .pink) ``` ### How it works Create a SwiftUI FocusText view (iOS 17+) that splits a sentence into words in an HStack (10 pt spacing). A @State focus index advances every interval (1.2 s) in a .task loop with withAnimation(.spring(response: 0.45, dampingFraction: 0.8)). Unfocused words get blur radius 3 and opacity 0.55; the focused word is sharp. Behind the focused word only, draw four L-shaped corner brackets (Canvas, 2.5 pt round strokes, arms 8 pt, 2 pt outside the word's padded frame) and give them .matchedGeometryEffect(id: "focus", in: namespace) so the brackets glide and resize from word to word. Use the sentence as the accessibility label. With Reduce Motion on, use a short ease instead of the spring. ### Source ```swift import SwiftUI /// Focus moves from word to word: the current word is sharp and framed by /// corner brackets that glide to it, while the others are blurred. public struct FocusText: View { private let words: [String] private let text: String private let interval: Double private let blur: CGFloat private let bracketColor: Color @State private var focus = 0 @Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The sentence; it is split into words at spaces. /// - interval: Seconds the focus stays on each word. /// - blur: Blur radius of the unfocused words. /// - bracketColor: Colour of the corner brackets. public init(_ text: String, interval: Double = 1.2, blur: CGFloat = 3, bracketColor: Color = .accentColor) { self.text = text self.words = text.split(separator: " ").map(String.init) self.interval = interval self.blur = blur self.bracketColor = bracketColor } public var body: some View { HStack(spacing: 10) { ForEach(words.indices, id: \.self) { index in let focused = index == focus Text(words[index]) .padding(.horizontal, 6) .padding(.vertical, 2) .blur(radius: focused ? 0 : blur) .opacity(focused ? 1 : 0.55) .background { if focused { FocusBrackets(color: bracketColor) .matchedGeometryEffect(id: "focus", in: namespace) } } } } .task { guard words.count > 1 else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(interval)) guard !Task.isCancelled else { return } withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.45, dampingFraction: 0.8)) { focus = (focus + 1) % words.count } } } .accessibilityElement(children: .ignore) .accessibilityLabel(text) } } /// Four L-shaped corners. private struct FocusBrackets: View { let color: Color var body: some View { Canvas { context, size in let arm: CGFloat = min(8, size.width / 3, size.height / 3) var path = Path() for (x, y, dx, dy) in [(0.0, 0.0, 1.0, 1.0), (size.width, 0, -1, 1), (0, size.height, 1, -1), (size.width, size.height, -1, -1)] { path.move(to: CGPoint(x: x + dx * arm, y: y)) path.addLine(to: CGPoint(x: x, y: y)) path.addLine(to: CGPoint(x: x, y: y + dy * arm)) } context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) } .padding(-2) .allowsHitTesting(false) } } #Preview { FocusText("Motion made simple") .font(.title.bold()) .padding() } ``` --- ## Highlighter (`highlighter`) A marker pen swipes across the text, or underlines it, left to right. - API: `.highlighter()` - Component: Text - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/Highlighter.swift - Files: Sources/MotionLibrary/Text/Highlighter.swift ### Usage ```swift Text("the important part") .highlighter() Text("Pricing") .highlighter(isActive: isSelected, color: .pink, style: .underline) ``` ### How it works Create a SwiftUI .highlighter(isActive:color:style:duration:) view modifier (iOS 17+) with two styles. In a background GeometryReader, draw a rounded rectangle 6 pt wider than the text: for .marker, 55 % of the text height, yellow at 45 % opacity, rotated −1.2° and sitting over the lower half; for .underline, a full-opacity capsule about 10 % of the height (at least 3 pt) at the baseline. Scale it horizontally from 0 to 1 with anchor .leading, animated with easeOut(0.6 s) on appear and whenever isActive changes, so it swipes on from the left and back off. Disable hit testing and hide it from accessibility. With Reduce Motion on, switch it without animation. ### Source ```swift import SwiftUI /// The look of a `.highlighter()` stroke. public enum HighlighterStyle: Sendable { /// A translucent marker band behind the lower part of the text. case marker /// A thick line under the text. case underline } /// A highlighter pen swipes across the text from left to right, and back off /// again when deactivated. struct HighlighterModifier: ViewModifier { var isActive: Bool var color: Color var style: HighlighterStyle var duration: Double @State private var drawn = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .background(alignment: .bottomLeading) { GeometryReader { geometry in let size = geometry.size let band: CGFloat = style == .marker ? size.height * 0.55 : max(3, size.height * 0.1) RoundedRectangle(cornerRadius: style == .marker ? 3 : band / 2) .fill(color.opacity(style == .marker ? 0.45 : 1)) .frame(width: size.width + 6, height: band) // Slightly tilted, like a hand-drawn stroke. .rotationEffect(.degrees(style == .marker ? -1.2 : 0)) .scaleEffect(x: drawn ? 1 : 0, anchor: .leading) .offset(x: -3, y: size.height - band * (style == .marker ? 0.9 : 0.6)) } .allowsHitTesting(false) .accessibilityHidden(true) } .onAppear { update(isActive) } .onChange(of: isActive) { _, newValue in update(newValue) } } private func update(_ active: Bool) { // Reduce Motion: fade in place of the swipe. withAnimation(reduceMotion ? nil : .easeOut(duration: duration)) { drawn = active } } } public extension View { /// Draws a highlighter stroke across the view while `isActive` is true. /// /// ```swift /// Text("the important part") /// .highlighter() /// /// Text("Pricing") /// .highlighter(isActive: isSelected, color: .pink, style: .underline) /// ``` func highlighter( isActive: Bool = true, color: Color = .yellow, style: HighlighterStyle = .marker, duration: Double = 0.6 ) -> some View { modifier(HighlighterModifier(isActive: isActive, color: color, style: style, duration: duration)) } } #Preview { VStack(spacing: 20) { Text("Read the important part") .font(.title2.bold()) .highlighter() Text("Underlined") .font(.title2.bold()) .highlighter(color: .pink, style: .underline) } .padding() } ``` --- ## Morphing Text (`morphing-text`) Words melt into each other with a gooey, liquid morph. - API: `MorphingText([…])` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/MorphingText.swift - Files: Sources/MotionLibrary/Text/MorphingText.swift ### Usage ```swift MorphingText(["Design", "Build", "Ship"]) .font(.system(size: 44, weight: .black, design: .rounded)) ``` ### How it works Create a SwiftUI MorphingText(_ words:interval:morphDuration:color:) view (iOS 17+) that cycles through words with a gooey morph. Size it with hidden copies of every word in a ZStack and draw in an overlay Canvas padded out by 12 pt. In TimelineView(.animation), word index = Int(time / interval) % count; the morph runs over the last morphDuration (0.9 s) of each interval (2.2 s) with smoothstep progress p. In the Canvas, add .alphaThreshold(min: 0.3, color: color) then .blur(radius: 1.5), and inside drawLayer draw the outgoing word (resolved symbol, tagged by index) with its own .blur(radius: 6 × p) and opacity min(1, 2(1 − p)), and the incoming word with blur 6 × (1 − p) and opacity min(1, 2p), both centred; keeping both opaque through the middle stops the blob thinning out below the threshold. The threshold turns the overlapping blurry glyphs into one liquid shape. With Reduce Motion on, cross-fade plain Text views on a TimelineView(.periodic). Set the accessibility label to the words. ### Source ```swift import SwiftUI /// Cycles through words with a gooey morph: the outgoing word melts into the /// incoming one. Both are blurred inside a `Canvas` and cut back to sharp /// edges with an alpha threshold, so overlapping strokes fuse like liquid. public struct MorphingText: View { private let words: [String] private let interval: Double private let morphDuration: Double private let color: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - words: The words to cycle through, in order. /// - interval: Seconds each word stays, including the morph. /// - morphDuration: Seconds the morph takes. /// - color: Fill colour. The alpha threshold needs a solid colour. public init(_ words: [String], interval: Double = 2.2, morphDuration: Double = 0.9, color: Color = .primary) { self.words = words.isEmpty ? [""] : words self.interval = max(interval, morphDuration + 0.1) self.morphDuration = morphDuration self.color = color } public var body: some View { // Hidden copies of every word size the view to the widest one. ZStack { ForEach(words.indices, id: \.self) { index in Text(words[index]).hidden() } } .overlay { if reduceMotion { // Reduce Motion: a plain cross-fade. TimelineView(.periodic(from: .now, by: interval)) { timeline in let index = wordIndex(at: timeline.date) Text(words[index]) .foregroundStyle(color) .id(index) .transition(.opacity) .animation(.easeInOut(duration: 0.4), value: index) } } else { TimelineView(.animation) { timeline in morph(at: timeline.date) } } } .accessibilityElement() .accessibilityLabel(words.joined(separator: ", ")) } private func wordIndex(at date: Date) -> Int { Int(date.timeIntervalSinceReferenceDate / interval) % words.count } @ViewBuilder private func morph(at date: Date) -> some View { let time = date.timeIntervalSinceReferenceDate let current = wordIndex(at: date) let next = (current + 1) % words.count let local = time.truncatingRemainder(dividingBy: interval) // 0 while the word rests, then 0 → 1 over the last `morphDuration`. let raw = (local - (interval - morphDuration)) / morphDuration let progress = min(1, max(0, raw)) let eased = progress * progress * (3 - 2 * progress) if progress <= 0 { // At rest: crisp, plain text. Text(words[current]).foregroundStyle(color) } else { morphCanvas(current: current, next: next, eased: eased) } } private func morphCanvas(current: Int, next: Int, eased: Double) -> some View { Canvas { context, size in context.addFilter(.alphaThreshold(min: 0.3, color: color)) context.addFilter(.blur(radius: 1.5)) context.drawLayer { layer in let centre = CGPoint(x: size.width / 2, y: size.height / 2) // Each word is blurred more the further it is from fully shown, // so mid-morph they overlap as soft blobs that the threshold fuses. // Both stay fully opaque through the middle so the blob never // thins out below the threshold and vanishes. if eased < 1, let outgoing = layer.resolveSymbol(id: current) { var copy = layer copy.addFilter(.blur(radius: 6 * eased)) copy.opacity = min(1, (1 - eased) * 2) copy.draw(outgoing, at: centre) } if eased > 0, let incoming = layer.resolveSymbol(id: next) { var copy = layer copy.addFilter(.blur(radius: 6 * (1 - eased))) copy.opacity = min(1, eased * 2) copy.draw(incoming, at: centre) } } } symbols: { ForEach(words.indices, id: \.self) { index in Text(words[index]).tag(index) } } .padding(-12) .accessibilityHidden(true) } } #Preview { MorphingText(["Design", "Build", "Ship"]) .font(.system(size: 44, weight: .black, design: .rounded)) .padding() } ``` --- ## Neon Flicker (`neon`) A glowing neon tube that stutters on and glitches now and then. - API: `.neon(color:flicker:)` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/NeonFlicker.swift - Files: Sources/MotionLibrary/Text/NeonFlicker.swift ### Usage ```swift Text("OPEN") .font(.system(size: 48, weight: .bold, design: .rounded)) .neon(color: .pink) ``` ### How it works Create a SwiftUI .neon(color:flicker:) view modifier (iOS 17+) for text or symbols on a dark background. Draw the content white when lit (or the colour at 55 % opacity when it drops out) and stack three shadows in the colour: radius 2 (opacity = level), 8 (0.9 × level) and 18 (0.7 × level), with overall opacity 0.35 + 0.65 × level. With flicker on, drive level from TimelineView(.animation) over a 6 s cycle: a stuttering switch-on with dips to 0.1 (0–0.08 s), 0.2 (0.14–0.18 s), 0.15 (0.26–0.30 s) and 0.5 (0.38–0.40 s), a glitch at 4.20–4.45 s (0.3, 0.15, 0.6), otherwise a faint hum of 0.94 + 0.06 × sin(time × 40). With Reduce Motion on, keep a steady glow with no flicker. ### Source ```swift import SwiftUI /// A neon tube: a white-hot core with layered coloured glow. With `flicker` /// on, it stutters on like a tube warming up, then glitches now and again. struct NeonModifier: ViewModifier { var color: Color var flicker: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: a steady glow (flashing can also be uncomfortable). if flicker && !reduceMotion { TimelineView(.animation) { timeline in let level = Self.brightness(at: timeline.date.timeIntervalSinceReferenceDate) glow(content, level: level) } } else { glow(content, level: 1) } } private func glow(_ content: Content, level: Double) -> some View { content // A white-hot core when lit; the bare coloured glass when it drops out. .foregroundStyle(level > 0.55 ? Color.white : color.opacity(0.55)) .shadow(color: color.opacity(level), radius: 2) .shadow(color: color.opacity(0.9 * level), radius: 8) .shadow(color: color.opacity(0.7 * level), radius: 18) .opacity(0.35 + 0.65 * level) } /// 1 = fully lit, lower = dimmed. A 6 s cycle: a stuttering switch-on, /// a steady stretch, then a short double glitch. static func brightness(at time: Double) -> Double { let t = time.truncatingRemainder(dividingBy: 6) let dips: [(start: Double, end: Double, level: Double)] = [ (0.00, 0.08, 0.1), (0.14, 0.18, 0.2), (0.26, 0.30, 0.15), (0.38, 0.40, 0.5), (4.20, 4.24, 0.3), (4.30, 4.36, 0.15), (4.42, 4.45, 0.6), ] for dip in dips where t >= dip.start && t < dip.end { return dip.level } // A barely visible hum keeps the steady part alive. return 0.94 + 0.06 * sin(time * 40) } } public extension View { /// Turns text or symbols into a glowing neon sign. /// /// ```swift /// Text("OPEN") /// .font(.system(size: 48, weight: .bold, design: .rounded)) /// .neon(color: .pink) /// ``` /// /// - Parameters: /// - color: The colour of the glow. /// - flicker: Stutter on and glitch now and again, like a real tube. func neon(color: Color = .pink, flicker: Bool = true) -> some View { modifier(NeonModifier(color: color, flicker: flicker)) } } #Preview { VStack(spacing: 24) { Text("OPEN").font(.system(size: 52, weight: .bold, design: .rounded)).neon() Image(systemName: "bolt.fill").font(.system(size: 40)).neon(color: .cyan, flicker: false) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(white: 0.08)) } ``` --- ## Karaoke Text (`karaoke-text`) A line fills with colour left to right as progress goes from 0 to 1. - API: `KaraokeText(_:progress:)` - Component: Text - Trigger: Value change / Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/KaraokeText.swift - Files: Sources/MotionLibrary/Text/KaraokeText.swift ### Usage ```swift KaraokeText("Never gonna stop the music", progress: playback, highlight: .pink) .font(.title2.bold()) ``` ### How it works Create a SwiftUI KaraokeText(_:progress:highlight:base:feather:) view (iOS 17+): a single-line Text in the base colour (secondary at 50 %) with an overlay of the same Text in the highlight colour, masked (alignment .leading) by an HStack in a GeometryReader: a Rectangle of width max(0, (width + feather) × progress − feather) followed by a LinearGradient black → clear of width feather (14 pt), so progress 0 shows nothing, 1 shows everything and the leading edge is soft. progress is clamped to 0…1 and changes to it animate because the frame width animates. Hide the overlay from accessibility. ### Source ```swift import SwiftUI /// A line of text that fills with colour from left to right as `progress` /// goes from 0 to 1, with a soft leading edge, like karaoke lyrics or a /// reading progress highlight. Drive `progress` from playback time, a scroll /// position or an animation. public struct KaraokeText: View { private let string: String private let progress: Double private let highlight: Color private let base: Color private let feather: CGFloat /// - Parameters: /// - string: The text. It's laid out on one line. /// - progress: 0 = nothing filled, 1 = all filled. Animatable. /// - highlight: The fill colour. /// - base: The colour of the unfilled text. /// - feather: Width of the soft edge, in points. public init(_ string: String, progress: Double, highlight: Color = .accentColor, base: Color = .secondary.opacity(0.5), feather: CGFloat = 14) { self.string = string self.progress = progress self.highlight = highlight self.base = base self.feather = feather } public var body: some View { Text(string) .lineLimit(1) .foregroundStyle(base) .overlay { Text(string) .lineLimit(1) .foregroundStyle(highlight) .mask(alignment: .leading) { GeometryReader { geometry in let p = min(1, max(0, progress)) // The solid part grows from -feather so 0 shows nothing // and 1 shows everything, edge included. HStack(spacing: 0) { Rectangle() .frame(width: max(0, (geometry.size.width + feather) * p - feather)) LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) .frame(width: feather) } } } .accessibilityHidden(true) } } } #Preview { struct Demo: View { @State private var progress = 0.0 var body: some View { KaraokeText("Never gonna stop the music", progress: progress, highlight: .pink) .font(.title2.bold()) .onAppear { withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) { progress = 1 } } .padding() } } return Demo() } ``` --- ## Weight Wave (`weight-wave`) Letters swell from thin to black and back in a travelling wave. - API: `WeightWaveText(_:)` - Component: Text - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/WeightWaveText.swift - Files: Sources/MotionLibrary/Text/WeightWaveText.swift ### Usage ```swift WeightWaveText("Variable weight") .font(.system(size: 36, design: .rounded)) ``` ### How it works Create a SwiftUI WeightWaveText(_:speed:wavelength:) view (iOS 17+) that lays the characters out in an HStack(spacing: 0) inside TimelineView(.animation). Each character gets .fontWeight from the nine weights ultraLight…black, indexed by level = (sin(2π × (index / wavelength − time × speed)) + 1) / 2 (wavelength 8 letters, 0.6 waves/s × speed, a multiplier defaulting to 1), so a swell of bold travels through the word. The font comes from the environment. With Reduce Motion on, pause the timeline and use .regular. Combine into one accessibility element labelled with the string. ### Source ```swift import SwiftUI /// Letters swell from thin to black and back in a travelling wave, like a /// variable font being played. Uses the font from the environment. public struct WeightWaveText: View { private let string: String private let speed: Double private let wavelength: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion private static let weights: [Font.Weight] = [ .ultraLight, .thin, .light, .regular, .medium, .semibold, .bold, .heavy, .black, ] /// - Parameters: /// - string: The text to show. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - wavelength: Letters per wave. public init(_ string: String, speed: Double = 1, wavelength: Double = 8) { self.string = string self.speed = speed * 0.6 self.wavelength = max(1, wavelength) } public var body: some View { let characters = Array(string) TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate HStack(spacing: 0) { ForEach(characters.indices, id: \.self) { index in Text(String(characters[index])) .fontWeight(reduceMotion ? .regular : weight(index: index, time: time)) } } } .accessibilityElement() .accessibilityLabel(string) } private func weight(index: Int, time: Double) -> Font.Weight { let phase = (Double(index) / wavelength - time * speed) * 2 * .pi let level = (sin(phase) + 1) / 2 let weights = Self.weights return weights[min(weights.count - 1, Int(level * Double(weights.count)))] } } #Preview { WeightWaveText("Variable weight") .font(.system(size: 36, design: .rounded)) .padding() } ``` --- ## Exploding Text (`exploding-text`) Letters blast apart spinning and spring back into place. - API: `ExplodingText(_:isExploded:)` - Component: Text - Trigger: Value change / Tap - Requires: iOS 18+ · macOS 15+ · visionOS 2+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/ExplodingText.swift - Files: Sources/MotionLibrary/Text/ExplodingText.swift ### Usage ```swift ExplodingText("Kaboom!", isExploded: exploded) .font(.system(size: 44, weight: .heavy, design: .rounded)) .onTapGesture { exploded.toggle() } ``` ### How it works Create a SwiftUI ExplodingText(_:isExploded:distance:) view (iOS 18+) using a TextRenderer that is Animatable over progress (0 = assembled, 1 = scattered). Its displayPadding is distance on every side (60 pt). In draw(layout:in:), for each glyph (counted across lines and runs) take three stable pseudo-random values r0, r1, r2 from a hash of its index; translate by (cos, sin)(r0 × 2π) × distance × (0.5 + 0.5 × r1) × progress, rotate around the glyph's typographic-bounds centre by (r2 − 0.5) × 540° × progress, and set opacity 1 − 0.7 × progress. Animate with .easeOut(0.45 s) when exploding and .spring(duration: 0.8, bounce: 0.35) when reassembling. With Reduce Motion on, use distance 0 (just a fade) and a 0.3 s ease. Keep the string as the accessibility label. ### Source ```swift import SwiftUI /// Letters blast apart in random directions, spinning, and spring back into /// place, drawn with a `TextRenderer` so the text keeps its normal layout. @available(iOS 18, macOS 15, visionOS 2, *) public struct ExplodingText: View { private let string: String private let isExploded: Bool private let distance: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - string: The text to show. /// - isExploded: `true` scatters the letters, `false` reassembles them. /// - distance: How far letters fly, in points. public init(_ string: String, isExploded: Bool, distance: CGFloat = 60) { self.string = string self.isExploded = isExploded self.distance = distance } public var body: some View { Text(string) .textRenderer(ExplodeRenderer( progress: isExploded ? 1 : 0, distance: reduceMotion ? 0 : distance )) // Out fast, back on a bouncy spring. Reduce Motion: a fade only. .animation( reduceMotion ? .easeInOut(duration: 0.3) : isExploded ? .easeOut(duration: 0.45) : .spring(duration: 0.8, bounce: 0.35), value: isExploded ) .accessibilityLabel(string) } } @available(iOS 18, macOS 15, visionOS 2, *) struct ExplodeRenderer: TextRenderer, Animatable { /// 0 = assembled, 1 = fully scattered. var progress: Double var distance: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } var displayPadding: EdgeInsets { EdgeInsets(top: distance, leading: distance, bottom: distance, trailing: distance) } func draw(layout: Text.Layout, in context: inout GraphicsContext) { var index = 0 for line in layout { for run in line { for glyph in run { let random = Self.random(index) let angle = random.0 * 2 * .pi let reach = distance * (0.5 + 0.5 * random.1) let bounds = glyph.typographicBounds.rect var copy = context copy.opacity = 1 - 0.7 * min(1, max(0, progress)) copy.translateBy(x: cos(angle) * reach * progress, y: sin(angle) * reach * progress) // Spin around the glyph's own centre. copy.translateBy(x: bounds.midX, y: bounds.midY) copy.rotate(by: .degrees((random.2 - 0.5) * 540 * progress)) copy.translateBy(x: -bounds.midX, y: -bounds.midY) copy.draw(glyph) index += 1 } } } } /// Three stable pseudo-random values in 0..<1 for a glyph index. static func random(_ index: Int) -> (Double, Double, Double) { func hash(_ n: Int) -> Double { var x = UInt64(truncatingIfNeeded: n &* 2_654_435_761) x = (x ^ (x >> 31)) &* 0x9E37_79B9_7F4A_7C15 x ^= x >> 29 return Double(x % 10_000) / 10_000 } return (hash(index * 3 + 1), hash(index * 3 + 2), hash(index * 3 + 3)) } } @available(iOS 18, macOS 15, visionOS 2, *) private struct ExplodingTextPreview: View { @State private var exploded = false var body: some View { ExplodingText("Kaboom!", isExploded: exploded) .font(.system(size: 44, weight: .heavy, design: .rounded)) .onTapGesture { exploded.toggle() } .padding(60) } } #Preview { if #available(iOS 18, macOS 15, visionOS 2, *) { ExplodingTextPreview() } } ``` --- ## Liquid Fill Text (`liquid-fill-text`) A wavy water line rises inside the letters as progress grows. - API: `LiquidFillText(_:progress:)` - Component: Text - Trigger: Value change / Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Text/LiquidFillText.swift - Files: Sources/MotionLibrary/Text/LiquidFillText.swift ### Usage ```swift LiquidFillText("\(Int(level * 100))%", progress: level, color: .cyan) .font(.system(size: 64, weight: .black, design: .rounded)) ``` ### How it works Create a SwiftUI LiquidFillText(_:progress:color:waveHeight:) view (iOS 17+). Draw the Text in the colour at 15 % opacity as an empty glass, then overlay a TimelineView(.animation) with a ZStack of two wave shapes, masked by the same Text: a back wave in the colour at 45 % (phase = time × 2.2 + 1.3, amplitude 0.8 × waveHeight) and a front wave in the full colour (phase = time × 3, amplitude waveHeight, default 6 pt). The wave Shape has level, phase and amplitude, animates level, and fills everything below y = baseline + amplitude × sin(x / width × 3π + phase). A line of text has descender room below the glyphs and space above the caps, so fill between bottom = maxY − 0.2 × height + 2 × amplitude and top = minY + 0.12 × height − 2 × amplitude: baseline = bottom − (bottom − top) × level, so 0 is empty and 1 full. Clamp progress to 0…1 and animate level changes with .spring(duration: 0.9, bounce: 0.25). With Reduce Motion on, pause the timeline (the level still animates). Hide the overlay from accessibility; the label is the text. ### Source ```swift import SwiftUI /// Text that fills with liquid: a wavy water line rises inside the glyphs as /// `progress` goes from 0 to 1, with a second, lighter wave behind it. /// /// ```swift /// LiquidFillText("72%", progress: 0.72, color: .cyan) /// .font(.system(size: 64, weight: .black, design: .rounded)) /// ``` public struct LiquidFillText: View { private let text: String private let progress: Double private let color: Color private let waveHeight: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - text: The string to fill. /// - progress: Fill level, 0 (empty) to 1 (full). Changes animate. /// - color: Colour of the liquid. /// - waveHeight: Height of the waves, in points. public init(_ text: String, progress: Double, color: Color = .blue, waveHeight: CGFloat = 6) { self.text = text self.progress = min(1, max(0, progress)) self.color = color self.waveHeight = waveHeight } public var body: some View { Text(text) .foregroundStyle(color.opacity(0.15)) .overlay { TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate ZStack { LiquidWave(level: progress, phase: time * 2.2 + 1.3, amplitude: waveHeight * 0.8) .fill(color.opacity(0.45)) LiquidWave(level: progress, phase: time * 3, amplitude: waveHeight) .fill(color) } .animation(.spring(duration: 0.9, bounce: 0.25), value: progress) } .mask { Text(text) } .accessibilityHidden(true) } .accessibilityLabel(text) } } /// A sine-wave water line at `level` (0 = bottom, 1 = top), filled below. struct LiquidWave: Shape { var level: Double var phase: Double var amplitude: CGFloat nonisolated var animatableData: Double { get { level } set { level = newValue } } nonisolated func path(in rect: CGRect) -> Path { // A line of text has room for descenders below the glyphs and accents // above them; fill between roughly the baseline and the cap height, // going a wave's height past both so 0 is empty and 1 full. let bottom = rect.maxY - rect.height * 0.2 + amplitude * 2 let top = rect.minY + rect.height * 0.12 - amplitude * 2 let baseline = bottom - (bottom - top) * level var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) let steps = max(8, Int(rect.width / 4)) for step in 0...steps { let x = rect.minX + rect.width * CGFloat(step) / CGFloat(steps) let angle = Double(x / max(rect.width, 1)) * 2 * .pi * 1.5 + phase path.addLine(to: CGPoint(x: x, y: baseline + amplitude * sin(angle))) } path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.closeSubpath() return path } } #Preview { struct Demo: View { @State private var progress = 0.3 var body: some View { LiquidFillText("72%", progress: progress, color: .cyan) .font(.system(size: 80, weight: .black, design: .rounded)) .onTapGesture { progress = progress > 0.8 ? 0.1 : progress + 0.3 } } } return Demo() } ``` --- ## Pressable (`pressable`) Button squashes and darkens on press, then springs back with a light haptic. - API: `.buttonStyle(.pressable)` - Component: Button - Trigger: Press - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/PressableButtonStyle.swift - Files: Sources/MotionLibrary/Button/PressableButtonStyle.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift Button("Continue") { } .buttonStyle(.pressable) Button("Big squish") { } .buttonStyle(.pressable(scale: 0.85)) ``` ### How it works Create a SwiftUI ButtonStyle called PressableButtonStyle (iOS 17+). While pressed, the label scales to 0.94 and its brightness drops slightly (-0.06); on release it springs back using .spring(response: 0.3, dampingFraction: 0.6). Fire a light impact haptic with .sensoryFeedback only when the press begins. Expose it as .buttonStyle(.pressable) and .buttonStyle(.pressable(scale:haptics:)) via a static extension on ButtonStyle. ### Source ```swift import SwiftUI /// A button style that squashes slightly and darkens while pressed, then /// springs back. Adds a light haptic on press (iOS). /// /// ```swift /// Button("Continue") { } /// .buttonStyle(.pressable) /// /// Button("Big squish") { } /// .buttonStyle(.pressable(scale: 0.85)) /// ``` public struct PressableButtonStyle: ButtonStyle { public var scale: CGFloat public var haptics: Bool public init(scale: CGFloat = 0.94, haptics: Bool = true) { self.scale = scale self.haptics = haptics } public func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? scale : 1) .brightness(configuration.isPressed ? -0.06 : 0) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: configuration.isPressed) .haptic(.lightImpact, trigger: configuration.isPressed) { _, isPressed in haptics && isPressed } } } public extension ButtonStyle where Self == PressableButtonStyle { static var pressable: PressableButtonStyle { PressableButtonStyle() } static func pressable(scale: CGFloat = 0.94, haptics: Bool = true) -> PressableButtonStyle { PressableButtonStyle(scale: scale, haptics: haptics) } } #Preview { Button { } label: { Text("Press me") .font(.headline) .foregroundStyle(.white) .padding(.horizontal, 28) .padding(.vertical, 14) .background(Capsule().fill(Color.accentColor)) } .buttonStyle(.pressable) .padding() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Tap Ripple (`ripple`) A circular ripple expands from the exact tap location. - API: `.ripple()` - Component: Button - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Ripple.swift - Files: Sources/MotionLibrary/Button/Ripple.swift ### Usage ```swift Text("Tap") .frame(width: 200, height: 60) .background(.indigo, in: RoundedRectangle(cornerRadius: 12)) .ripple(color: .white, cornerRadius: 12) ``` ### How it works Create a SwiftUI .ripple() view modifier (iOS 17+) that shows a Material-style ripple. Use a simultaneous SpatialTapGesture so it doesn't block the view's own button action. Each tap appends a ripple at the tap location; each ripple is a translucent circle (white, 35% opacity) sized to 2× the view's diagonal that scales from ~0 to 1 while fading to 0 with easeOut over 0.7 s, then is removed. Ripples are drawn in an overlay clipped to a rounded rectangle with a configurable corner radius and never intercept touches. Multiple quick taps should produce overlapping ripples. ### Source ```swift import SwiftUI /// Material-style ripple that expands from the exact point the user tapped. struct RippleModifier: ViewModifier { var color: Color var cornerRadius: CGFloat var duration: Double @State private var ripples: [Ripple] = [] private struct Ripple: Identifiable { let id = UUID() let location: CGPoint } func body(content: Content) -> some View { content .overlay { GeometryReader { geometry in let size = geometry.size // Large enough to cover the view from any tap point. let diameter = 2 * hypot(size.width, size.height) ZStack { ForEach(ripples) { ripple in RippleCircle(color: color, diameter: diameter, duration: duration) .position(ripple.location) } } .frame(width: size.width, height: size.height) } .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .allowsHitTesting(false) } .simultaneousGesture( SpatialTapGesture().onEnded { value in let ripple = Ripple(location: value.location) ripples.append(ripple) DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) { ripples.removeAll { $0.id == ripple.id } } } ) } } private struct RippleCircle: View { let color: Color let diameter: CGFloat let duration: Double @State private var expanded = false var body: some View { Circle() .fill(color) .frame(width: diameter, height: diameter) .scaleEffect(expanded ? 1 : 0.01) .opacity(expanded ? 0 : 0.35) .onAppear { withAnimation(.easeOut(duration: duration)) { expanded = true } } } } public extension View { /// Adds a ripple that starts at the tap location. /// /// ```swift /// Button("Tap") { } /// .padding() /// .background(.blue, in: RoundedRectangle(cornerRadius: 12)) /// .ripple(cornerRadius: 12) /// ``` func ripple( color: Color = .white, cornerRadius: CGFloat = 12, duration: Double = 0.7 ) -> some View { modifier(RippleModifier(color: color, cornerRadius: cornerRadius, duration: duration)) } } #Preview { Text("Tap anywhere") .font(.headline) .foregroundStyle(.white) .frame(width: 240, height: 80) .background(Color.indigo, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .ripple(cornerRadius: 16) .padding() } ``` --- ## Error Shake (`shake`) Horizontal "no" shake for wrong input, triggered by a value change. - API: `.shake(trigger:)` - Component: Button - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Shake.swift - Files: Sources/MotionLibrary/Button/Shake.swift ### Usage ```swift @State private var attempts = 0 SecureField("Password", text: $password) .shake(trigger: attempts) .sensoryFeedback(.error, trigger: attempts) // On a failed login: attempts += 1 ``` ### How it works Create a SwiftUI .shake(trigger:) view modifier (iOS 17+) for error feedback. Every time the Equatable trigger changes, the view shakes horizontally using keyframeAnimator on an x offset: -10, +10, -7, +7, -3 points (≈0.07 s each, cubic keyframes) and then settles back to 0 with a bouncy SpringKeyframe. Amplitude should be configurable. Pair it with .sensoryFeedback(.error, trigger:) for haptics. ### Source ```swift import SwiftUI /// Horizontal "no" shake, e.g. for a wrong password. Plays every time /// `trigger` changes. struct ShakeModifier: ViewModifier { var trigger: Trigger var amplitude: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: no shake (pair it with a colour or message change). let scale: CGFloat = reduceMotion ? 0 : 1 return content.keyframeAnimator(initialValue: CGFloat(0), trigger: trigger) { view, offset in view.offset(x: offset * scale) } keyframes: { _ in KeyframeTrack { CubicKeyframe(-amplitude, duration: 0.07) CubicKeyframe(amplitude, duration: 0.07) CubicKeyframe(-amplitude * 0.7, duration: 0.07) CubicKeyframe(amplitude * 0.7, duration: 0.07) CubicKeyframe(-amplitude * 0.3, duration: 0.06) SpringKeyframe(0, duration: 0.2, spring: .bouncy) } } } } public extension View { /// Shakes the view horizontally whenever `trigger` changes. /// /// ```swift /// @State private var attempts = 0 /// /// SecureField("Password", text: $password) /// .shake(trigger: attempts) /// // on failure: attempts += 1 /// ``` func shake(trigger: Trigger, amplitude: CGFloat = 10) -> some View { modifier(ShakeModifier(trigger: trigger, amplitude: amplitude)) } } #Preview { struct Demo: View { @State private var attempts = 0 var body: some View { Button("Wrong password") { attempts += 1 } .buttonStyle(.borderedProminent) .tint(.red) .shake(trigger: attempts) .haptic(.error, trigger: attempts) } } return Demo() } ``` --- ## Magnetic Button (`magnetic`) The button drifts toward a nearby pointer or dragging finger, then springs home. - API: `.magnetic()` - Component: Button - Trigger: Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Magnetic.swift - Files: Sources/MotionLibrary/Button/Magnetic.swift ### Usage ```swift Button("Get started") { } .buttonStyle(.borderedProminent) .magnetic() Image(systemName: "plus.circle.fill") .font(.system(size: 44)) .magnetic(strength: 0.5, radius: 40) ``` ### How it works Create a SwiftUI .magnetic(strength:radius:) view modifier (iOS 17+) that pulls a view toward the pointer. Pad the view by `radius` (60 pt) to enlarge its tracking area, give it a rectangular contentShape, measure the padded size with a GeometryReader background, then remove the padding again with .padding(-radius). In onContinuousHover (pointer on iPad, Mac and visionOS) and in a simultaneous DragGesture(minimumDistance: 0) (touch), offset the original content by (location − centre) × strength (0.3) using an interactiveSpring(response 0.25, damping 0.7). When hover ends or the drag ends, spring back to zero with spring(response 0.45, damping 0.45) for a little overshoot. With Reduce Motion on, don't move. ### Source ```swift import SwiftUI /// Pulls the view toward the pointer while it hovers nearby (iPad pointer, /// Mac, visionOS), or toward the finger while it drags on touch screens, then /// springs back to rest. struct MagneticModifier: ViewModifier { var strength: CGFloat var radius: CGFloat @State private var offset: CGSize = .zero /// Size of the padded tracking area. @State private var areaSize: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .offset(reduceMotion ? .zero : offset) // Grow the tracking area by `radius` so the pull starts before the // pointer reaches the view. .padding(radius) .contentShape(Rectangle()) .background { GeometryReader { proxy in Color.clear .onAppear { areaSize = proxy.size } .onChange(of: proxy.size) { _, newSize in areaSize = newSize } } } .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(location): attract(to: location) case .ended: release() } } .simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { attract(to: $0.location) } .onEnded { _ in release() } ) .padding(-radius) } private func attract(to location: CGPoint) { // `location` is in the padded tracking area, whose centre is the view's centre. let centre = CGPoint(x: areaSize.width / 2, y: areaSize.height / 2) withAnimation(.interactiveSpring(response: 0.25, dampingFraction: 0.7)) { offset = CGSize( width: (location.x - centre.x) * strength, height: (location.y - centre.y) * strength ) } } private func release() { withAnimation(.spring(response: 0.45, dampingFraction: 0.45)) { offset = .zero } } } public extension View { /// Makes the view drift toward a nearby pointer (or a dragging finger) and /// spring back when it leaves. /// /// ```swift /// Button("Get started") { } /// .buttonStyle(.borderedProminent) /// .magnetic() /// ``` /// /// - Parameters: /// - strength: Fraction of the pointer's distance from the centre that /// the view moves (0…1). /// - radius: How far outside the view, in points, the pull starts. func magnetic(strength: CGFloat = 0.3, radius: CGFloat = 60) -> some View { modifier(MagneticModifier(strength: strength, radius: radius)) } } #Preview { Button("Get started") { } .buttonStyle(.borderedProminent) .controlSize(.large) .magnetic() .padding(80) } ``` --- ## Hold to Confirm (`hold-to-confirm`) Liquid fills the button while you hold; the action runs only when it's full. - API: `HoldToConfirmButton("…") { }` - Component: Button - Trigger: Press - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/HoldToConfirmButton.swift - Files: Sources/MotionLibrary/Button/HoldToConfirmButton.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift HoldToConfirmButton("Hold to delete") { deleteAccount() } HoldToConfirmButton(duration: 2, color: .orange) { launch() } label: { Label("Hold to launch", systemImage: "flame") } ``` ### How it works Create a SwiftUI HoldToConfirmButton (iOS 17+) for destructive actions. A capsule with a 15% tint of color (red) shows the label in color. While pressed (onLongPressGesture(minimumDuration:perform:onPressingChanged:)), animate a progress value linearly from its current value to 1 over the remaining duration (1.2 s); releasing early animates it back to 0 with easeOut(0.35). Draw the liquid as a Shape filled from the bottom up to progress, with a sine-wave top edge (4 pt amplitude, phase driven by TimelineView) that flattens when empty or full. Mask a stack of the liquid plus a white copy of the label with the same shape, so the submerged part of the text turns white. On completion, run the action, swap the label for a checkmark with a spring, play a success haptic and reset after 1.2 s. Scale to 0.97 while pressing. ### Source ```swift import SwiftUI /// A capsule button that fills with liquid while it is held and runs its /// action only when the fill reaches the top. Letting go early drains it. /// Good for destructive or irreversible actions. public struct HoldToConfirmButton: View { private let duration: Double private let color: Color private let action: () -> Void private let label: Label @State private var progress: CGFloat = 0 @State private var isPressing = false @State private var isConfirmed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - duration: Seconds the user must hold. /// - color: Colour of the liquid. The resting background is a tint /// of it and the label is drawn in it, turning white where submerged. /// - action: Runs once the hold completes. /// - label: The button's content. public init( duration: Double = 1.2, color: Color = .red, action: @escaping () -> Void, @ViewBuilder label: () -> Label ) { self.duration = duration self.color = color self.action = action self.label = label() } @available(*, deprecated, renamed: "init(duration:color:action:label:)") public init( duration: Double = 1.2, fillColor: Color, action: @escaping () -> Void, @ViewBuilder label: () -> Label ) { self.init(duration: duration, color: fillColor, action: action, label: label) } public var body: some View { TimelineView(.animation(paused: reduceMotion || (!isPressing && progress == 0))) { timeline in let liquid = LiquidFill( progress: progress, phase: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * 6, waveHeight: progress > 0 && progress < 1 ? 4 : 0 ) // The label sizes the button; the capsule and liquid only decorate it. content .foregroundStyle(color) .background(Capsule().fill(color.opacity(0.15))) .overlay { // The liquid, with the label redrawn in white where it's submerged. ZStack { liquid.fill(color) content.foregroundStyle(.white) } .mask(liquid) } .clipShape(Capsule()) } .scaleEffect(isPressing ? 0.97 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressing) .contentShape(Capsule()) .onLongPressGesture(minimumDuration: duration) { confirm() } onPressingChanged: { pressing in guard !isConfirmed else { return } isPressing = pressing if pressing { withAnimation(.linear(duration: duration * (1 - progress))) { progress = 1 } } else if progress < 1 { withAnimation(.easeOut(duration: 0.35)) { progress = 0 } } } .haptic(.success, trigger: isConfirmed) { _, confirmed in confirmed } .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) .accessibilityHint("Press and hold to confirm") .accessibilityAction { action() } } /// The label (swapped for a checkmark once confirmed), padded to button size. private var content: some View { label .opacity(isConfirmed ? 0 : 1) .overlay { Image(systemName: "checkmark") .fontWeight(.bold) .scaleEffect(isConfirmed ? 1 : 0.3) .opacity(isConfirmed ? 1 : 0) } .padding(.horizontal, 24) .padding(.vertical, 14) } private func confirm() { isPressing = false withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { isConfirmed = true } action() DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { withAnimation(.easeInOut(duration: 0.4)) { isConfirmed = false progress = 0 } } } } public extension HoldToConfirmButton where Label == Text { /// A hold-to-confirm button with a text label. /// /// ```swift /// HoldToConfirmButton("Hold to delete") { deleteAccount() } /// ``` init( _ title: LocalizedStringKey, duration: Double = 1.2, color: Color = .red, action: @escaping () -> Void ) { self.init(duration: duration, color: color, action: action) { Text(title).font(.headline) } } /// A hold-to-confirm button whose label is a string you already have, shown as is. @_disfavoredOverload init( _ title: S, duration: Double = 1.2, color: Color = .red, action: @escaping () -> Void ) { self.init(duration: duration, color: color, action: action) { Text(title).font(.headline) } } @available(*, deprecated, renamed: "init(_:duration:color:action:)") init( _ title: LocalizedStringKey, duration: Double = 1.2, fillColor: Color, action: @escaping () -> Void ) { self.init(title, duration: duration, color: fillColor, action: action) } } /// A rectangle filled from the bottom up to `progress`, with a sine wave on top. private struct LiquidFill: Shape { var progress: CGFloat var phase: Double var waveHeight: CGFloat nonisolated var animatableData: AnimatablePair { get { AnimatablePair(progress, waveHeight) } set { progress = newValue.first; waveHeight = newValue.second } } nonisolated func path(in rect: CGRect) -> Path { // Overfill slightly so the wave crests are hidden when full. let level = rect.maxY - (rect.height + waveHeight * 2) * progress var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) let steps = 24 for step in 0...steps { let x = rect.minX + rect.width * CGFloat(step) / CGFloat(steps) let angle = Double(step) / Double(steps) * .pi * 3 + phase path.addLine(to: CGPoint(x: x, y: level + CGFloat(sin(angle)) * waveHeight)) } path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.closeSubpath() return path } } #Preview { HoldToConfirmButton("Hold to delete") { } .padding() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Loading → Success (`status-button`) The button morphs into a spinner, then a green checkmark or a red shake. - API: `StatusButton("…", status:) { }` - Component: Button - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/StatusButton.swift - Files: Sources/MotionLibrary/Button/StatusButton.swift, Sources/MotionLibrary/Support/CheckmarkShape.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift @State private var status = StatusButton.Status.idle StatusButton("Pay now", status: status) { status = .loading Task { status = await pay() ? .success : .failure try? await Task.sleep(for: .seconds(1.5)) status = .idle } } ``` ### How it works Create a SwiftUI StatusButton (iOS 17+) driven by a status enum: idle, loading, success, failure. It is a 50 pt tall capsule Button. Idle shows the title at its natural width. For any other status the frame width animates to 50 pt, turning the capsule into a circle (spring response 0.45, damping 0.75), and the title shrinks and fades. Loading shows a white 70% arc spinning forever. Success turns the fill green and draws a white checkmark path with trim from 0 to 1 (easeOut 0.35 s, 0.15 s delay). Failure turns the fill red, scales in an xmark and shakes horizontally with a GeometryEffect (8 pt, three swings). Play success and error haptics with sensoryFeedback, disable the button when not idle, and expose the status as the accessibility value. ### Source ```swift import SwiftUI /// A capsule button that morphs to show the result of its action: it shrinks /// into a spinning circle while loading, then turns green with a drawn /// checkmark on success, or red with a shake on failure. /// /// You own the status, so it works with any async work: /// /// ```swift /// @State private var status = StatusButton.Status.idle /// /// StatusButton("Pay now", status: status) { /// status = .loading /// Task { /// status = await pay() ? .success : .failure /// try? await Task.sleep(for: .seconds(1.5)) /// status = .idle /// } /// } /// ``` public struct StatusButton: View { /// The phases a `StatusButton` moves through. public enum Status: Equatable, Sendable { case idle case loading case success case failure } private let title: Text private let status: Status private let color: Color private let action: () -> Void @State private var spin = false @State private var shakes = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let height: CGFloat = 50 /// - Parameters: /// - title: The button's label, looked up in your app's strings. /// - status: The current phase; change it to morph the button. /// - color: Background while idle or loading. /// - action: Runs on tap (only while idle). public init( _ title: LocalizedStringKey, status: Status, color: Color = .accentColor, action: @escaping () -> Void ) { self.title = Text(title) self.status = status self.color = color self.action = action } /// A button whose label is a string you already have, shown as is. @_disfavoredOverload public init( _ title: S, status: Status, color: Color = .accentColor, action: @escaping () -> Void ) { self.title = Text(title) self.status = status self.color = color self.action = action } @available(*, deprecated, renamed: "init(_:status:color:action:)") public init(_ title: LocalizedStringKey, status: Status, tint: Color, action: @escaping () -> Void) { self.init(title, status: status, color: tint, action: action) } public var body: some View { Button(action: action) { ZStack { title .font(.headline) .lineLimit(1) .fixedSize() .padding(.horizontal, 28) .opacity(status == .idle ? 1 : 0) .scaleEffect(status == .idle ? 1 : 0.6) if status == .loading { Circle() .trim(from: 0, to: 0.7) .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .frame(width: 22, height: 22) .rotationEffect(.degrees(spin ? 360 : 0)) .onAppear { spin = false withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { spin = true } } .transition(.scale(scale: 0.4).combined(with: .opacity)) } CheckmarkShape() .trim(from: 0, to: status == .success ? 1 : 0) .stroke(.white, style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round)) .frame(width: 20, height: 16) .animation(status == .success ? .easeOut(duration: 0.35).delay(0.15) : nil, value: status) Image(systemName: "xmark") .font(.system(size: 18, weight: .bold)) .scaleEffect(status == .failure ? 1 : 0.3) .opacity(status == .failure ? 1 : 0) } .foregroundStyle(.white) .frame(width: status == .idle ? nil : height, height: height) .background(Capsule().fill(background)) .contentShape(Capsule()) } .buttonStyle(StatusPressStyle()) .disabled(status != .idle) .modifier(StatusShake(shakes: CGFloat(shakes))) .animation(.spring(response: 0.45, dampingFraction: 0.75), value: status) .onChange(of: status) { _, newValue in if newValue == .failure, !reduceMotion { withAnimation(.linear(duration: 0.4)) { shakes += 1 } } } .haptic(trigger: status) { _, newValue in switch newValue { case .success: .success case .failure: .error default: nil } } .accessibilityLabel(title) .accessibilityValue(accessibilityStatus) } private var background: Color { switch status { case .idle, .loading: color case .success: .green case .failure: .red } } private var accessibilityStatus: Text { switch status { case .idle: Text("") case .loading: Text("Loading") case .success: Text("Done") case .failure: Text("Failed") } } } private struct StatusPressStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.95 : 1) .animation(.spring(response: 0.25, dampingFraction: 0.6), value: configuration.isPressed) } } /// The old name of `StatusButton.Status`. @available(*, deprecated, renamed: "StatusButton.Status") public typealias MotionButtonStatus = StatusButton.Status /// Horizontal shake, one full shake per unit of `shakes`. private struct StatusShake: GeometryEffect { var shakes: CGFloat nonisolated var animatableData: CGFloat { get { shakes } set { shakes = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { ProjectionTransform(CGAffineTransform(translationX: 8 * sin(shakes * .pi * 6), y: 0)) } } #Preview { struct Demo: View { @State private var status = StatusButton.Status.idle var body: some View { StatusButton("Pay now", status: status) { status = .loading DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { status = .success } DispatchQueue.main.asyncAfter(deadline: .now() + 2.8) { status = .idle } } } } return Demo() } // MARK: - CheckmarkShape.swift // Needed by this effect: save it as its own file next to the one above. // Shared by StatusButton and AnimatedCheckmark. import SwiftUI /// A checkmark drawn left to right, sized to its frame. struct CheckmarkShape: Shape { nonisolated func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.midY)) path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.38, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) return path } } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Glow Border (`glow-border`) A rainbow border runs around the view with a soft glow behind it. - API: `.glowBorder()` - Component: Button - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/GlowBorder.swift - Files: Sources/MotionLibrary/Button/GlowBorder.swift ### Usage ```swift Text("Upgrade to Pro") .padding() .background(.black, in: RoundedRectangle(cornerRadius: 14)) .glowBorder(cornerRadius: 14) CardView() .glowBorder(colors: [.orange, .yellow], cornerRadius: 24, lineWidth: 3) ``` ### How it works Create a SwiftUI .glowBorder(colors:cornerRadius:lineWidth:glowRadius:duration:) view modifier (iOS 17+). In a non-interactive overlay, use TimelineView(.animation) to compute a rotation angle of (time / duration) turns (3 s per turn). Build an AngularGradient from the palette plus its first colour again (pink, purple, blue, cyan, mint by default) starting at that angle. Stroke a continuous RoundedRectangle with it twice: once at 2× line width, blurred by glowRadius (8) at 80% opacity for the glow, and once crisp at lineWidth (2). With Reduce Motion on, pause the rotation. ### Source ```swift import SwiftUI /// A gradient border that keeps rotating around the view, with a soft glow /// behind it. Draws attention to a primary action or a highlighted card. struct GlowBorderModifier: ViewModifier { var colors: [Color] var cornerRadius: CGFloat var lineWidth: CGFloat var glowRadius: CGFloat var duration: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { TimelineView(.animation(paused: reduceMotion)) { timeline in let turns = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate / duration let gradient = AngularGradient( colors: colors + [colors.first ?? .clear], center: .center, angle: .degrees(turns.truncatingRemainder(dividingBy: 1) * 360) ) let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ZStack { shape.stroke(gradient, lineWidth: lineWidth * 2) .blur(radius: glowRadius) .opacity(0.8) shape.stroke(gradient, lineWidth: lineWidth) } } .allowsHitTesting(false) } } } public extension View { /// Adds a rotating gradient border with a glow. /// /// ```swift /// Text("Upgrade to Pro") /// .padding() /// .background(.black, in: RoundedRectangle(cornerRadius: 14)) /// .glowBorder(cornerRadius: 14) /// ``` func glowBorder( colors: [Color] = [.pink, .purple, .blue, .cyan, .mint], cornerRadius: CGFloat = 14, lineWidth: CGFloat = 2, glowRadius: CGFloat = 8, duration: Double = 3 ) -> some View { modifier(GlowBorderModifier( colors: colors, cornerRadius: cornerRadius, lineWidth: lineWidth, glowRadius: glowRadius, duration: duration )) } } #Preview { Text("Upgrade to Pro") .font(.headline) .foregroundStyle(.white) .padding(.horizontal, 28) .padding(.vertical, 16) .background(.black, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) .glowBorder() .padding(40) } ``` --- ## Shine (`shine`) A slanted glint of light sweeps across the button, on cue or on a beat. - API: `.shine(every:) / .shine(trigger:)` - Component: Button - Trigger: Value change / Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Shine.swift - Files: Sources/MotionLibrary/Button/Shine.swift ### Usage ```swift PremiumBadge() .shine(every: 3) Button("Upgrade") { purchases += 1 } .buttonStyle(.borderedProminent) .shine(trigger: purchases) ``` ### How it works Create two SwiftUI modifiers (iOS 17+): .shine(trigger:color:duration:) and .shine(every:color:duration:). Both overlay a "band": a horizontal LinearGradient clear → white 70 % → clear, 30 % of the view's width (at least 24 pt), twice the view's height, rotated 20°, whose x offset goes from −1.5 × band (fully off the left) at progress 0 to width + 1.5 × band (off the right) at progress 1. Mask the band with the content so only the view's own pixels shine, and disable hit testing. The trigger version runs progress 0 → 1 with keyframeAnimator(initialValue: 0, trigger:) and one CubicKeyframe over duration (0.75 s), so the band is hidden at rest. The looping version uses TimelineView(.animation) with progress = (time mod interval) / duration, clamped to 1. With Reduce Motion on, skip the band. ### Source ```swift import SwiftUI /// A slanted glint of light that sweeps across the view once, either every /// time `trigger` changes or on a fixed interval. struct ShineModifier: ViewModifier { var trigger: Trigger var color: Color var duration: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: no sweep. let enabled = !reduceMotion let color = color return content .keyframeAnimator(initialValue: 0.0, trigger: trigger) { view, progress in view.overlay { if enabled { ShineBand(progress: progress, color: color).mask { view } } } } keyframes: { _ in // 0 and 1 are both off the view, so the band is hidden at rest. CubicKeyframe(1, duration: duration) } } } /// Repeats the glint every `interval` seconds. struct LoopingShineModifier: ViewModifier { var interval: Double var color: Color var duration: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content.overlay { if !reduceMotion { TimelineView(.animation) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate let phase = time.truncatingRemainder(dividingBy: max(interval, duration)) ShineBand(progress: min(1, phase / duration), color: color) } .mask { content } } } } } /// The band itself: a skewed white gradient that travels left to right as /// `progress` goes from 0 to 1. private struct ShineBand: View { var progress: Double var color: Color var body: some View { GeometryReader { geometry in let width = geometry.size.width let band = max(24, width * 0.3) LinearGradient(colors: [.clear, color, .clear], startPoint: .leading, endPoint: .trailing) .frame(width: band, height: geometry.size.height * 2) .rotationEffect(.degrees(20)) .offset(x: -band * 1.5 + (width + band * 3) * progress, y: -geometry.size.height / 2) } .allowsHitTesting(false) .accessibilityHidden(true) } } public extension View { /// Sweeps a glint of light across the view every time `trigger` changes. /// /// ```swift /// Button("Upgrade") { purchases += 1 } /// .buttonStyle(.borderedProminent) /// .shine(trigger: purchases) /// ``` func shine( trigger: Trigger, color: Color = .white.opacity(0.7), duration: Double = 0.75 ) -> some View { modifier(ShineModifier(trigger: trigger, color: color, duration: duration)) } /// Sweeps a glint of light across the view every `interval` seconds. /// /// ```swift /// PremiumBadge() /// .shine(every: 3) /// ``` func shine( every interval: Double, color: Color = .white.opacity(0.7), duration: Double = 0.75 ) -> some View { modifier(LoopingShineModifier(interval: interval, color: color, duration: duration)) } } #Preview { Text("Go Premium") .font(.headline) .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Capsule().fill(Color.orange.gradient)) .shine(every: 2) .padding() } ``` --- ## Jelly (`jelly`) A rubbery squash-and-stretch wobble on every tap. - API: `.jelly(trigger:)` - Component: Button - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/Jelly.swift - Files: Sources/MotionLibrary/Button/Jelly.swift ### Usage ```swift Button("Boing") { taps += 1 } .buttonStyle(.borderedProminent) .jelly(trigger: taps) ``` ### How it works Create a SwiftUI .jelly(trigger:amount:) view modifier (iOS 17+) using keyframeAnimator(initialValue:trigger:) on a struct with x and y scale (both 1), applied with scaleEffect(x:y:). With a = amount (0.25): the x track goes 1 + a (0.1 s), 1 − 0.8a (0.12 s), 1 + 0.5a (0.12 s) with cubic keyframes, then a SpringKeyframe(.bouncy) back to 1 over 0.4 s; the y track does the opposite (1 − a, 1 + 0.8a, 1 − 0.5a, spring to 1), so the view squashes wide and flat, then stretches tall and thin, then settles. With Reduce Motion on, use a = 0. ### Source ```swift import SwiftUI private struct JellyScale { var x: CGFloat = 1 var y: CGFloat = 1 } /// A rubbery squash-and-stretch wobble that plays every time `trigger` /// changes: wide and flat, then tall and thin, settling back with a spring. struct JellyModifier: ViewModifier { var trigger: Trigger var amount: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: stay still. let a = reduceMotion ? 0 : amount return content.keyframeAnimator(initialValue: JellyScale(), trigger: trigger) { view, scale in view.scaleEffect(x: scale.x, y: scale.y) } keyframes: { _ in KeyframeTrack(\.x) { CubicKeyframe(1 + a, duration: 0.1) CubicKeyframe(1 - a * 0.8, duration: 0.12) CubicKeyframe(1 + a * 0.5, duration: 0.12) SpringKeyframe(1, duration: 0.4, spring: .bouncy) } KeyframeTrack(\.y) { CubicKeyframe(1 - a, duration: 0.1) CubicKeyframe(1 + a * 0.8, duration: 0.12) CubicKeyframe(1 - a * 0.5, duration: 0.12) SpringKeyframe(1, duration: 0.4, spring: .bouncy) } } } } public extension View { /// Wobbles the view like jelly every time `trigger` changes. /// /// ```swift /// Button("Boing") { taps += 1 } /// .buttonStyle(.borderedProminent) /// .jelly(trigger: taps) /// ``` /// /// - Parameter amount: How far it squashes (0.25 = 25 %). func jelly(trigger: Trigger, amount: CGFloat = 0.25) -> some View { modifier(JellyModifier(trigger: trigger, amount: amount)) } } #Preview { struct Demo: View { @State private var taps = 0 var body: some View { Button("Boing") { taps += 1 } .buttonStyle(.borderedProminent) .controlSize(.large) .jelly(trigger: taps) } } return Demo().padding(40) } ``` --- ## Click Spark (`click-spark`) Short spark lines shoot out from the exact point you tapped. - API: `.clickSpark()` - Component: Button - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/ClickSpark.swift - Files: Sources/MotionLibrary/Button/ClickSpark.swift ### Usage ```swift Button("Launch") { } .buttonStyle(.borderedProminent) .clickSpark(color: .orange) ``` ### How it works Create a SwiftUI .clickSpark(color:count:radius:) view modifier (iOS 17+). Detect taps with .simultaneousGesture(SpatialTapGesture()) and append a spark (UUID + location) to a @State array, removing it 0.6 s later. In a hit-testing-disabled overlay, place each spark at its location: count (8) capsules 2.5 pt wide and 0.45 × radius tall, each rotated by index / count × 360°. On appear, animate with easeOut(0.45 s) from offset −0.3 × radius to −radius (outward), scaleEffect(y:) from 1 to 0.1 anchored at the top (so each line shrinks toward its outer tip) and opacity from 1 to 0. With Reduce Motion on, don't add sparks. ### Source ```swift import SwiftUI /// Short spark lines shoot out from the exact point the user tapped. struct ClickSparkModifier: ViewModifier { var color: Color var count: Int var radius: CGFloat @State private var sparks: [Spark] = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion private struct Spark: Identifiable { let id = UUID() let location: CGPoint } func body(content: Content) -> some View { content .overlay { ZStack { ForEach(sparks) { spark in SparkLines(color: color, count: count, radius: radius) .position(spark.location) } } .allowsHitTesting(false) .accessibilityHidden(true) } .simultaneousGesture( SpatialTapGesture().onEnded { value in // Reduce Motion: no sparks. guard !reduceMotion else { return } let spark = Spark(location: value.location) sparks.append(spark) DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { sparks.removeAll { $0.id == spark.id } } } ) } } private struct SparkLines: View { let color: Color let count: Int let radius: CGFloat @State private var fired = false var body: some View { ZStack { ForEach(0.. some View { modifier(ClickSparkModifier(color: color, count: count, radius: radius)) } } #Preview { RoundedRectangle(cornerRadius: 16) .fill(.black) .frame(width: 260, height: 160) .overlay(Text("Click anywhere").foregroundStyle(.white)) .clickSpark() } ``` --- ## Like Button (`like-button`) The heart pops in as a ring bursts open and coloured dots fly out. - API: `LikeButton(isLiked:)` - Component: Button - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/LikeButton.swift - Files: Sources/MotionLibrary/Button/LikeButton.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift LikeButton(isLiked: $post.isLiked) LikeButton(isLiked: $liked, size: 40, color: .red) ``` ### How it works Create a SwiftUI LikeButton (iOS 17+) bound to isLiked, with size (28 pt), color (pink) and dotColors. It's a plain Button showing Image(systemName: isLiked ? "heart.fill" : "heart"); tapping toggles and, on like, increments a counter used as a keyframeAnimator trigger over a struct {heart, ring, dots}. Tracks: heart drops to 0.2 instantly, holds 0.15 s, then springs to 1 with a bouncy overshoot; ring goes 0 → 1 over 0.3 s then back to 0; dots wait 0.15 s then go 0 → 1 over 0.55 s. Behind the heart draw (a) a circle strokeBorder in color whose diameter is 1.5 × size × ring and whose line width is 0.5 × size × (1 − ring), so it bursts open and thins out; (b) 7 pairs of small dots (0.16 × size) at angles i/7 × 360° ± 10°, at distance size × (0.6 + 0.55 × dots), scaling down to 10 % as they fly. Unliking only springs the heart back. Add a light haptic on like, and hide the burst for Reduce Motion. ### Source ```swift import SwiftUI private struct LikePop { var heart: CGFloat = 1 var ring: CGFloat = 0 var dots: CGFloat = 0 } /// A Twitter-style like button: on like, a ring bursts open, the heart pops /// in with an overshoot and a circle of coloured dots flies out. Unliking /// just shrinks the heart back. public struct LikeButton: View { @Binding private var isLiked: Bool private let size: CGFloat private let color: Color private let dotColors: [Color] @State private var likes = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isLiked: Whether the item is liked; tapping toggles it. /// - size: Point size of the heart. /// - color: Colour of the filled heart and the ring. /// - dotColors: Colours cycled through by the burst dots. public init( isLiked: Binding, size: CGFloat = 28, color: Color = .pink, dotColors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple] ) { _isLiked = isLiked self.size = size self.color = color self.dotColors = dotColors } public var body: some View { let showBurst = !reduceMotion let size = size, color = color, dotColors = dotColors return Button { isLiked.toggle() if isLiked { likes += 1 } } label: { Image(systemName: isLiked ? "heart.fill" : "heart") .font(.system(size: size)) .foregroundStyle(isLiked ? color : Color.secondary) .frame(width: size * 1.6, height: size * 1.6) .contentShape(Rectangle()) .keyframeAnimator(initialValue: LikePop(), trigger: likes) { heart, pop in heart .scaleEffect(pop.heart) .background { if showBurst { LikeBurst(pop: pop, size: size, color: color, dotColors: dotColors) } } } keyframes: { _ in KeyframeTrack(\.heart) { CubicKeyframe(0.2, duration: 0.05) CubicKeyframe(0.2, duration: 0.15) SpringKeyframe(1, duration: 0.5, spring: .bouncy(duration: 0.4, extraBounce: 0.25)) } KeyframeTrack(\.ring) { LinearKeyframe(0.01, duration: 0.01) CubicKeyframe(1, duration: 0.3) LinearKeyframe(0, duration: 0.01) } KeyframeTrack(\.dots) { LinearKeyframe(0, duration: 0.15) CubicKeyframe(1, duration: 0.55) LinearKeyframe(0, duration: 0.01) } } } .buttonStyle(.plain) .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isLiked) .haptic(.lightImpact, trigger: likes) .accessibilityLabel(isLiked ? "Unlike" : "Like") } } /// The ring and the dots behind the heart. private struct LikeBurst: View { let pop: LikePop let size: CGFloat let color: Color let dotColors: [Color] var body: some View { ZStack { // The ring grows and its stroke thins out to nothing. if pop.ring > 0 { Circle() .strokeBorder(color, lineWidth: max(0.1, size * 0.5 * (1 - pop.ring))) .frame(width: size * 1.5 * pop.ring, height: size * 1.5 * pop.ring) } // Seven pairs of dots fly outward and shrink. if pop.dots > 0 { ForEach(0..<14, id: \.self) { index in dot(index) } } } .allowsHitTesting(false) .accessibilityHidden(true) } private func dot(_ index: Int) -> some View { let pair = index % 2 let base = Double(index / 2) / 7 * 2 * .pi - .pi / 2 let angle = base + (pair == 0 ? -0.18 : 0.18) let reach: CGFloat = 0.6 + 0.55 * pop.dots let distance: CGFloat = size * reach * (pair == 0 ? 1 : 0.85) let fill: Color = dotColors.isEmpty ? color : dotColors[index % dotColors.count] return Circle() .fill(fill) .frame(width: size * 0.16, height: size * 0.16) .scaleEffect(1 - pop.dots * 0.9) .offset(x: CGFloat(cos(angle)) * distance, y: CGFloat(sin(angle)) * distance) } } #Preview { struct Demo: View { @State private var liked = false var body: some View { LikeButton(isLiked: $liked, size: 40) } } return Demo().padding(60) } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Animated Symbol (`animated-symbol`) SF Symbols that keep moving: bounce on a beat, pulse, variable colour. - API: `AnimatedSymbol("…", style:)` - Component: Button - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/AnimatedSymbol.swift - Files: Sources/MotionLibrary/Button/AnimatedSymbol.swift ### Usage ```swift AnimatedSymbol("bell.fill") // bounces every 1.5 s AnimatedSymbol("heart.fill", style: .pulse) AnimatedSymbol("wifi", style: .variableColor) AnimatedSymbol("mic.fill", style: .scaleUp, isActive: isRecording) ``` ### How it works Create a SwiftUI AnimatedSymbol view (iOS 17+) wrapping Image(systemName:) with a style enum: .bounce, .pulse, .variableColor and .scaleUp, plus isActive and interval (1.5 s). For .bounce, apply .symbolEffect(.bounce, value: beats) and increment beats in a .task(id: isActive) loop every interval. For the others, use the indefinite effects .symbolEffect(.pulse, isActive:), .symbolEffect(.variableColor.iterative.reversing, isActive:) and .symbolEffect(.scale.up, isActive:). The view inherits font and foregroundStyle like any Image. With Reduce Motion on, treat it as inactive. ### Source ```swift import SwiftUI /// An SF Symbol that keeps playing a symbol effect: a bounce on a beat, or a /// continuous pulse, variable-colour cycle or scale-up. public struct AnimatedSymbol: View { /// Which symbol effect to play. public enum Style: CaseIterable, Sendable { /// Bounces once every `interval` seconds. case bounce /// Fades the symbol's layers in and out. case pulse /// Lights up the symbol's layers one after another (wifi, speaker…). case variableColor /// Grows the symbol slightly and holds it. case scaleUp } private let systemName: String private let style: Style private let isActive: Bool private let interval: Double @State private var beats = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - systemName: The SF Symbol name. /// - style: The effect to play. /// - isActive: Pauses the effect when false. /// - interval: Seconds between bounces (`.bounce` only). public init(_ systemName: String, style: Style = .bounce, isActive: Bool = true, interval: Double = 1.5) { self.systemName = systemName self.style = style self.isActive = isActive self.interval = interval } public var body: some View { // Reduce Motion: the symbol stays still. let active = isActive && !reduceMotion let image = Image(systemName: systemName) Group { switch style { case .bounce: image.symbolEffect(.bounce, value: beats) case .pulse: image.symbolEffect(.pulse, isActive: active) case .variableColor: image.symbolEffect(.variableColor.iterative.reversing, isActive: active) case .scaleUp: image.symbolEffect(.scale.up, isActive: active) } } .task(id: active) { guard style == .bounce, active else { return } while !Task.isCancelled { beats += 1 try? await Task.sleep(for: .seconds(max(0.3, interval))) } } } } #Preview { HStack(spacing: 24) { AnimatedSymbol("bell.fill") AnimatedSymbol("heart.fill", style: .pulse) AnimatedSymbol("wifi", style: .variableColor) AnimatedSymbol("mic.fill", style: .scaleUp) } .font(.largeTitle) .padding() } ``` --- ## Liquid Toggle (`liquid-toggle`) A switch whose knob stretches like a drop of liquid as it slides. - API: `.toggleStyle(.liquid)` - Component: Button - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/LiquidToggleStyle.swift - Files: Sources/MotionLibrary/Button/LiquidToggleStyle.swift ### Usage ```swift Toggle("Wi-Fi", isOn: $wifi) .toggleStyle(.liquid) Toggle("Dark mode", isOn: $dark) .toggleStyle(.liquid(color: .purple)) ``` ### How it works Create a SwiftUI LiquidToggleStyle (iOS 17+) with a color (green) and static accessors .liquid and .liquid(color:). makeBody puts the label, a Spacer and a 52 × 32 pt switch in an HStack. The switch is a plain Button that toggles configuration.isOn inside withAnimation(.spring(response: 0.35, dampingFraction: 0.7)); it shows a ZStack aligned .trailing when on and .leading when off, with a capsule track (tint when on, gray 35 % when off) and a white capsule knob (26 pt tall, 3 pt padding, small shadow). The knob's width runs a keyframeAnimator triggered by isOn: CubicKeyframe to 1.5 × 26 pt over 0.12 s, then SpringKeyframe(.bouncy) back to 26 over 0.35 s, so it stretches as it slides and snaps back round. Give it an "On"/"Off" accessibility value. With Reduce Motion on, don't stretch and use a short ease. ### Source ```swift import SwiftUI /// A switch whose knob stretches like a drop of liquid as it slides across, /// then springs back to a circle. /// /// ```swift /// Toggle("Wi-Fi", isOn: $isOn) /// .toggleStyle(.liquid) /// /// Toggle("Dark mode", isOn: $dark) /// .toggleStyle(.liquid(color: .purple)) /// ``` public struct LiquidToggleStyle: ToggleStyle { /// Colour of the track while on. public var color: Color public init(color: Color = .green) { self.color = color } @available(*, deprecated, renamed: "init(color:)") public init(tint: Color) { self.color = tint } @available(*, deprecated, renamed: "color") public var tint: Color { get { color } set { color = newValue } } public func makeBody(configuration: Configuration) -> some View { HStack { configuration.label Spacer(minLength: 12) LiquidSwitch(isOn: configuration.$isOn, tint: color) } } } private struct LiquidSwitch: View { @Binding var isOn: Bool let tint: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion private let width: CGFloat = 52 private let height: CGFloat = 32 private let knob: CGFloat = 26 var body: some View { let knob = knob // Reduce Motion: no stretch. let stretch = reduceMotion ? knob : knob * 1.5 return Button { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.35, dampingFraction: 0.7)) { isOn.toggle() } } label: { ZStack(alignment: isOn ? .trailing : .leading) { Capsule() .fill(isOn ? tint : Color.gray.opacity(0.35)) Capsule() .fill(.white) .shadow(color: .black.opacity(0.2), radius: 2, y: 1) .keyframeAnimator(initialValue: knob, trigger: isOn) { view, knobWidth in view.frame(width: knobWidth, height: knob) } keyframes: { _ in CubicKeyframe(stretch, duration: 0.12) SpringKeyframe(knob, duration: 0.35, spring: .bouncy) } .padding(3) } .frame(width: width, height: height) .contentShape(Capsule()) } .buttonStyle(.plain) .accessibilityValue(isOn ? "On" : "Off") } } public extension ToggleStyle where Self == LiquidToggleStyle { /// A switch with a stretchy, liquid knob. static var liquid: LiquidToggleStyle { LiquidToggleStyle() } /// A switch with a stretchy, liquid knob in the given colour. static func liquid(color: Color) -> LiquidToggleStyle { LiquidToggleStyle(color: color) } @available(*, deprecated, renamed: "liquid(color:)") static func liquid(tint: Color) -> LiquidToggleStyle { LiquidToggleStyle(color: tint) } } #Preview { struct Demo: View { @State private var on = true var body: some View { Toggle("Notifications", isOn: $on) .toggleStyle(.liquid) .frame(width: 240) } } return Demo().padding() } ``` --- ## Gooey Menu (`gooey-menu`) A round button splits into a fan of buttons like a drop of liquid. - API: `GooeyMenu(symbols:action:)` - Component: Button - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/GooeyMenu.swift - Files: Sources/MotionLibrary/Button/GooeyMenu.swift ### Usage ```swift GooeyMenu(symbols: ["camera.fill", "photo.fill", "mic.fill"], color: .indigo) { index in attach(index) } ``` ### How it works Create a SwiftUI GooeyMenu(symbols:color:distance:action:) view (iOS 17+). Frame: width 2 × distance + 60, height distance + 66 (distance 76). Behind the buttons, a Canvas adds .alphaThreshold(min: 0.5, color: color) and .blur(radius: 7) and, in drawLayer, draws resolved symbols at the main button's centre (bottom centre, 8 pt up): a 56 pt Circle (tag −1) and one 44 pt Circle per item (tags 0…n−1) offset to its fan position, so the circles fuse like liquid while they separate. Item i of n sits at angle π + π × (i + 1) / (n + 1) on a half-circle of radius distance above the button, or at the centre when closed. Each item animates with .spring(duration: 0.55, bounce: 0.35) delayed 0.04 s × its order (reversed when closing). On top of the canvas, place real Buttons with white SF Symbols at the same offsets (opacity 0 and no hit testing when closed) and the main button with a "plus" that rotates 45° when open. Tapping an item calls action(index) and closes the menu. With Reduce Motion on, hide the goo, draw plain circles and fade items in place. ### Source ```swift import SwiftUI /// A round button that splits into a fan of smaller buttons like a drop of /// liquid, and swallows them back up. The blobs are blurred inside a `Canvas` /// and cut back with an alpha threshold, so they stretch and merge as they /// separate (metaballs). public struct GooeyMenu: View { private let symbols: [String] private let color: Color private let distance: CGFloat private let action: (Int) -> Void @State private var isOpen = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private let mainSize: CGFloat = 56 private let itemSize: CGFloat = 44 /// - Parameters: /// - symbols: SF Symbol names, one per menu item. /// - color: Colour of the button and the goo. /// - distance: How far items travel from the main button, in points. /// - action: Called with the index of the tapped item. The menu closes. public init(symbols: [String], color: Color = .accentColor, distance: CGFloat = 76, action: @escaping (Int) -> Void) { self.symbols = symbols self.color = color self.distance = distance self.action = action } public var body: some View { let width = distance * 2 + itemSize + 16 let height = distance + itemSize / 2 + mainSize / 2 + 16 ZStack(alignment: .bottom) { Canvas { context, _ in context.addFilter(.alphaThreshold(min: 0.5, color: color)) context.addFilter(.blur(radius: 7)) context.drawLayer { layer in for id in [-1] + Array(symbols.indices) { if let blob = layer.resolveSymbol(id: id) { layer.draw(blob, at: CGPoint(x: width / 2, y: height - 8 - mainSize / 2)) } } } } symbols: { Circle().frame(width: mainSize, height: mainSize).tag(-1) ForEach(symbols.indices, id: \.self) { index in Circle() .frame(width: itemSize, height: itemSize) .offset(offset(index)) .animation(itemAnimation(index), value: isOpen) .tag(index) } } .frame(width: width, height: height) .opacity(reduceMotion ? 0 : 1) .accessibilityHidden(true) // The real, crisp buttons sit on top of the goo. ForEach(symbols.indices, id: \.self) { index in Button { action(index) isOpen = false } label: { Image(systemName: symbols[index]) .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) .frame(width: itemSize, height: itemSize) .background { // Reduce Motion: plain circles that fade in place. if reduceMotion { Circle().fill(color) } } .contentShape(Circle()) } .buttonStyle(.plain) .offset(offset(index)) .opacity(isOpen ? 1 : 0) .animation(itemAnimation(index), value: isOpen) .allowsHitTesting(isOpen) .padding(.bottom, 8 + (mainSize - itemSize) / 2) } Button { isOpen.toggle() } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .bold)) .foregroundStyle(.white) .rotationEffect(.degrees(isOpen ? 45 : 0)) .frame(width: mainSize, height: mainSize) .background { if reduceMotion { Circle().fill(color) } } .contentShape(Circle()) } .buttonStyle(.plain) .padding(.bottom, 8) .animation(.spring(duration: 0.4, bounce: 0.4), value: isOpen) .accessibilityLabel(isOpen ? "Close menu" : "Open menu") } .frame(width: width, height: height) } /// Items fan out along the upper half-circle. private func offset(_ index: Int) -> CGSize { guard isOpen || reduceMotion else { return .zero } let angle = Double.pi + Double.pi * Double(index + 1) / Double(symbols.count + 1) return CGSize(width: cos(angle) * distance, height: sin(angle) * distance) } private func itemAnimation(_ index: Int) -> Animation { if reduceMotion { return .easeInOut(duration: 0.25) } let order = isOpen ? index : symbols.count - 1 - index return .spring(duration: 0.55, bounce: 0.35).delay(Double(order) * 0.04) } } #Preview { GooeyMenu(symbols: ["camera.fill", "photo.fill", "mic.fill", "doc.fill"], color: .indigo) { index in print("tapped", index) } .padding() } ``` --- ## Slide to Confirm (`slide-to-confirm`) Drag the knob all the way across to run the action; let go early and it springs back. - API: `SlideToConfirm(_:action:)` - Component: Button - Trigger: Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/SlideToConfirm.swift - Files: Sources/MotionLibrary/Button/SlideToConfirm.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift SlideToConfirm("Slide to pay", color: .green) { pay() } .frame(width: 280) ``` ### How it works Create a SwiftUI SlideToConfirm(_ title:color:resetAfter:action:) control (iOS 17+), 56 pt tall. In a GeometryReader ZStack(alignment: .leading): a Capsule track (secondary 15 %), the title (headline, secondary) whose opacity is 1 − progress × 1.6, a Capsule fill of width offset + knob + 8 in the colour at 35 %, and a 48 pt knob Circle (inset 4 pt) with a white "chevron.right.2" (or "checkmark" once confirmed, using contentTransition(.symbolEffect(.replace))) and a coloured shadow. A DragGesture on the knob sets offset = clamp(translation.width, 0, maxOffset). On release past 85 % of maxOffset: snap to the end with a 0.3 s spring, mark confirmed, call action and play a success haptic; then after resetAfter (1.5 s, or never if nil) spring back. Otherwise spring back with .spring(duration: 0.45, bounce: 0.35). Expose it to accessibility as a button whose action confirms. ### Source ```swift import SwiftUI /// A "slide to confirm" control: drag the knob all the way across to run the /// action. Let go early and it springs back. The label fades as you slide and /// the track fills behind the knob. public struct SlideToConfirm: View { private let title: Text private let color: Color private let resetAfter: Double? private let action: () -> Void @State private var offset: CGFloat = 0 @State private var isConfirmed = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private let height: CGFloat = 56 private let inset: CGFloat = 4 /// - Parameters: /// - title: The label inside the track. /// - color: Knob and fill colour. /// - resetAfter: Seconds after confirming before the control resets; /// `nil` keeps it confirmed. /// - action: Runs once the knob reaches the end. public init(_ title: LocalizedStringKey, color: Color = .accentColor, resetAfter: Double? = 1.5, action: @escaping () -> Void) { self.title = Text(title) self.color = color self.resetAfter = resetAfter self.action = action } /// A slider whose label is a string you already have, shown as is. @_disfavoredOverload public init(_ title: S, color: Color = .accentColor, resetAfter: Double? = 1.5, action: @escaping () -> Void) { self.title = Text(title) self.color = color self.resetAfter = resetAfter self.action = action } public var body: some View { GeometryReader { geometry in let knob = height - inset * 2 let maxOffset = max(0, geometry.size.width - knob - inset * 2) let progress = maxOffset > 0 ? offset / maxOffset : 0 ZStack(alignment: .leading) { Capsule().fill(Color.secondary.opacity(0.15)) title .font(.headline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) .padding(.leading, knob * 0.6) .opacity(1 - progress * 1.6) // The fill grows with the knob. Capsule() .fill(color.opacity(0.35)) .frame(width: offset + knob + inset * 2) Circle() .fill(color) .overlay { Image(systemName: isConfirmed ? "checkmark" : "chevron.right.2") .font(.system(size: 18, weight: .bold)) .foregroundStyle(.white) .contentTransition(.symbolEffect(.replace)) } .frame(width: knob, height: knob) .shadow(color: color.opacity(0.4), radius: 6, y: 2) .padding(inset) .offset(x: offset) .gesture( DragGesture() .onChanged { value in guard !isConfirmed else { return } offset = min(maxOffset, max(0, value.translation.width)) } .onEnded { _ in guard !isConfirmed else { return } if offset > maxOffset * 0.85 { confirm(maxOffset) } else { withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.45, bounce: 0.35)) { offset = 0 } } } ) } } .frame(height: height) .haptic(.success, trigger: isConfirmed) { _, new in new } .accessibilityElement(children: .ignore) .accessibilityLabel(title) .accessibilityAddTraits(.isButton) .accessibilityAction { confirm(nil) } } private func confirm(_ maxOffset: CGFloat?) { withAnimation(.spring(duration: 0.3)) { if let maxOffset { offset = maxOffset } isConfirmed = true } action() guard let resetAfter else { return } Task { @MainActor in try? await Task.sleep(for: .seconds(resetAfter)) withAnimation(.spring(duration: 0.5, bounce: 0.2)) { offset = 0 isConfirmed = false } } } } #Preview { SlideToConfirm("Slide to pay", color: .green) {} .frame(width: 280) .padding() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Download Button (`download-button`) A capsule shrinks into a progress ring, then pops into a checkmark. - API: `DownloadButton(progress:) { }` - Component: Button - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/DownloadButton.swift - Files: Sources/MotionLibrary/Button/DownloadButton.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift DownloadButton(progress: download.progress) { download.start() } ``` ### How it works Create a SwiftUI DownloadButton(_ title:progress:color:action:) (iOS 17+) with three phases from progress: nil = idle, 0..<1 = loading, ≥ 1 = done. It's a ZStack whose frame is 150 × 44 when idle and 44 × 44 otherwise, over a Capsule filled with the colour (idle), secondary 12 % (loading) or green (done). Idle shows a white Label(title, systemImage: "arrow.down") with a scale 0.8 + opacity transition; loading shows a Circle trimmed to progress, stroked 4 pt with round caps, rotated −90°, padded 3 pt, and a small "stop.fill"; done shows a white "checkmark" with a scale-from-0.2 + opacity transition. Animate phase changes with .spring(duration: 0.5, bounce: 0.35) so the capsule visibly shrinks into the ring, and play a success haptic when it finishes. The action only runs from the idle phase. With Reduce Motion on, use a 0.25 s ease instead of the spring. ### Source ```swift import SwiftUI /// A button that morphs through a download: a labelled capsule shrinks into /// a circle whose ring fills with progress, then pops into a checkmark. public struct DownloadButton: View { private let title: Text private let progress: Double? private let color: Color private let action: () -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion private enum Phase: Equatable { case idle, loading, done } /// - Parameters: /// - title: The label shown before the download starts. /// - progress: `nil` = not started, 0..<1 = downloading, 1 = finished. /// - color: Tint of the button and the ring. /// - action: Called when the idle button is tapped. public init(_ title: LocalizedStringKey = "Download", progress: Double?, color: Color = .accentColor, action: @escaping () -> Void) { self.title = Text(title) self.progress = progress self.color = color self.action = action } /// A download button whose label is a string you already have, shown as is. @_disfavoredOverload public init(_ title: S, progress: Double?, color: Color = .accentColor, action: @escaping () -> Void) { self.title = Text(title) self.progress = progress self.color = color self.action = action } private var phase: Phase { guard let progress else { return .idle } return progress >= 1 ? .done : .loading } public var body: some View { let size: CGFloat = 44 Button { if phase == .idle { action() } } label: { ZStack { Capsule() .fill(phase == .loading ? Color.secondary.opacity(0.12) : (phase == .done ? Color.green : color)) if phase == .loading { Circle() .trim(from: 0, to: progress ?? 0) .stroke(color, style: StrokeStyle(lineWidth: 4, lineCap: .round)) .rotationEffect(.degrees(-90)) .padding(3) .animation(.easeOut(duration: 0.2), value: progress) Image(systemName: "stop.fill") .font(.system(size: 12)) .foregroundStyle(color) } if phase == .idle { Label { title } icon: { Image(systemName: "arrow.down") } .font(.headline) .foregroundStyle(.white) .fixedSize() .transition(.opacity.combined(with: .scale(scale: 0.8))) } if phase == .done { Image(systemName: "checkmark") .font(.system(size: 18, weight: .bold)) .foregroundStyle(.white) .transition(.scale(scale: 0.2).combined(with: .opacity)) } } .frame(width: phase == .idle ? 150 : size, height: size) .contentShape(Capsule()) } .buttonStyle(.plain) .animation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(duration: 0.5, bounce: 0.35), value: phase) .haptic(.success, trigger: phase) { _, new in new == .done } .accessibilityLabel(phase == .idle ? title : Text(phase == .done ? "Downloaded" : "Downloading")) .accessibilityValue(phase == .loading ? "\(Int((progress ?? 0) * 100)) percent" : "") } } #Preview { struct Demo: View { @State private var progress: Double? var body: some View { DownloadButton(progress: progress) { Task { progress = 0 while let p = progress, p < 1 { try? await Task.sleep(for: .milliseconds(60)) progress = min(1, p + 0.03) } try? await Task.sleep(for: .seconds(1.2)) progress = nil } } .padding() } } return Demo() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Star Rating (`star-rating`) Stars fill one after another with a springy pop and a burst of sparks. - API: `StarRating(rating:)` - Component: Button - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/StarRating.swift - Files: Sources/MotionLibrary/Button/StarRating.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift StarRating(rating: $rating) ``` ### How it works Create a SwiftUI StarRating(rating: Binding, maximum:color:size:) control (iOS 17+): an HStack (spacing size × 0.25) of SF Symbols "star.fill" (filled, yellow) or "star" (secondary 50 %), size 28 pt. Tapping star i sets rating = i. Each filled star runs keyframeAnimator(initialValue: 1, trigger: rating) on its scale: hold 1 for (i − 1) × 0.06 s, SpringKeyframe to 1.4 (0.15 s, .snappy), then to 1 (0.4 s, .bouncy), so they pop in sequence. Behind the tapped star, eight 4 pt dots at 45° steps fly out from size × 0.3 to size × 0.95 over 0.45 s while fading (after the same delay). Play a light haptic on change. Make the whole control one adjustable accessibility element ("Rating", "3 of 5"). With Reduce Motion on, skip the pops and sparks. ### Source ```swift import SwiftUI /// A star rating control. Tapping a star fills the stars up to it one after /// another, each with a springy pop, and the tapped star bursts a ring of /// sparks. public struct StarRating: View { @Binding private var rating: Int private let maximum: Int private let color: Color private let size: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - rating: The number of filled stars, 0…maximum. /// - maximum: How many stars to show. /// - color: Colour of filled stars and sparks. /// - size: Point size of each star. public init(rating: Binding, maximum: Int = 5, color: Color = .yellow, size: CGFloat = 28) { _rating = rating self.maximum = max(1, maximum) self.color = color self.size = size } public var body: some View { HStack(spacing: size * 0.25) { ForEach(1...maximum, id: \.self) { index in star(index) } } .haptic(.lightImpact, trigger: rating) .accessibilityElement() .accessibilityLabel("Rating") .accessibilityValue("\(rating) of \(maximum)") .accessibilityAdjustableAction { direction in switch direction { case .increment: rating = min(maximum, rating + 1) case .decrement: rating = max(0, rating - 1) @unknown default: break } } } private func star(_ index: Int) -> some View { let filled = index <= rating // Keyframes must have a positive duration, so the first star waits a hair. let delay = max(0.001, Double(index - 1) * 0.06) let animate = filled && !reduceMotion return Image(systemName: filled ? "star.fill" : "star") .font(.system(size: size)) .foregroundStyle(filled ? color : Color.secondary.opacity(0.5)) .keyframeAnimator(initialValue: 1.0, trigger: rating) { view, scale in view.scaleEffect(animate ? scale : 1) } keyframes: { _ in LinearKeyframe(1, duration: delay) SpringKeyframe(1.4, duration: 0.15, spring: .snappy) SpringKeyframe(1, duration: 0.4, spring: .bouncy) } .background { if index == rating && !reduceMotion { StarSparks(color: color, size: size, trigger: rating, delay: delay) } } .contentShape(Rectangle()) .onTapGesture { rating = index } } } /// Eight short dots that shoot out from the star and fade. private struct StarSparks: View { let color: Color let size: CGFloat let trigger: Int let delay: Double var body: some View { ZStack { ForEach(0..<8, id: \.self) { spark in Circle() .fill(color) .frame(width: 4, height: 4) .keyframeAnimator(initialValue: SparkValues(), trigger: trigger) { view, value in view .offset(y: -value.distance) .rotationEffect(.degrees(Double(spark) * 45)) .opacity(value.opacity) } keyframes: { _ in KeyframeTrack(\.distance) { LinearKeyframe(size * 0.3, duration: delay) CubicKeyframe(size * 0.95, duration: 0.45) } KeyframeTrack(\.opacity) { LinearKeyframe(0, duration: delay) LinearKeyframe(1, duration: 0.05) LinearKeyframe(0, duration: 0.4) } } } } .allowsHitTesting(false) .accessibilityHidden(true) } } private struct SparkValues { var distance: CGFloat = 0 var opacity: Double = 0 } #Preview { struct Demo: View { @State private var rating = 3 var body: some View { StarRating(rating: $rating).padding() } } return Demo() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Bouncy Tab Bar (`bouncy-tab-bar`) A pill glides to the selected tab and its icon squashes and hops. - API: `BouncyTabBar(selection:items:)` - Component: Button - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/BouncyTabBar.swift - Files: Sources/MotionLibrary/Button/BouncyTabBar.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift BouncyTabBar(selection: $tab, items: [ .init("house.fill", "Home"), .init("magnifyingglass", "Search"), .init("person.fill", "Profile"), ]) ``` ### How it works Create a SwiftUI BouncyTabBar(selection: Binding, items:color:) (iOS 17+) where each item is an SF Symbol and a title. Lay the tabs out in an HStack (spacing 4, padding 6) on a .regularMaterial Capsule with a soft shadow. Each tab is a plain Button with a VStack of the 18 pt semibold symbol and a caption2 title, tinted with the colour when selected (secondary otherwise), and a Capsule at 15 % of the colour behind the selected tab, shared with matchedGeometryEffect so it glides; selection changes use .spring(duration: 0.4, bounce: 0.3). The selected icon runs keyframeAnimator(trigger: selection), anchored at the bottom: scaleX 1.25 → 0.9 → spring 1, scaleY 0.75 → 1.12 → spring 1, y 0 → −7 → spring 0 (0.08 s, 0.14 s, 0.3 s .bouncy). Play a light haptic. With Reduce Motion on, skip the hop. ### Source ```swift import SwiftUI /// A floating tab bar: a pill glides to the selected tab and its icon does a /// little squash-and-hop. public struct BouncyTabBar: View { /// One tab: an SF Symbol and a short title. public struct Item: Hashable, Sendable { public var symbol: String public var title: String public init(_ symbol: String, _ title: String) { self.symbol = symbol self.title = title } } @Binding private var selection: Int private let items: [Item] private let color: Color @Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - selection: Index of the selected tab. /// - items: The tabs, in order. /// - color: Tint of the selected tab. public init(selection: Binding, items: [Item], color: Color = .accentColor) { _selection = selection self.items = items self.color = color } public var body: some View { HStack(spacing: 4) { ForEach(items.indices, id: \.self) { index in tab(index) } } .padding(6) .background(.regularMaterial, in: Capsule()) .shadow(color: .black.opacity(0.12), radius: 12, y: 4) .haptic(.lightImpact, trigger: selection) } private func tab(_ index: Int) -> some View { let selected = index == selection let bounce = selected && !reduceMotion return Button { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) { selection = index } } label: { VStack(spacing: 2) { Image(systemName: items[index].symbol) .font(.system(size: 18, weight: .semibold)) .keyframeAnimator(initialValue: HopValues(), trigger: selection) { view, value in view .scaleEffect(x: bounce ? value.scaleX : 1, y: bounce ? value.scaleY : 1, anchor: .bottom) .offset(y: bounce ? value.y : 0) } keyframes: { _ in KeyframeTrack(\.scaleX) { CubicKeyframe(1.25, duration: 0.08) CubicKeyframe(0.9, duration: 0.14) SpringKeyframe(1, duration: 0.3, spring: .bouncy) } KeyframeTrack(\.scaleY) { CubicKeyframe(0.75, duration: 0.08) CubicKeyframe(1.12, duration: 0.14) SpringKeyframe(1, duration: 0.3, spring: .bouncy) } KeyframeTrack(\.y) { LinearKeyframe(0, duration: 0.08) CubicKeyframe(-7, duration: 0.14) SpringKeyframe(0, duration: 0.3, spring: .bouncy) } } Text(items[index].title) .font(.caption2.weight(.semibold)) } .foregroundStyle(selected ? color : Color.secondary) .frame(maxWidth: .infinity) .padding(.vertical, 6) .background { if selected { Capsule() .fill(color.opacity(0.15)) .matchedGeometryEffect(id: "pill", in: namespace) } } .contentShape(Capsule()) } .buttonStyle(.plain) .accessibilityLabel(items[index].title) .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) } } private struct HopValues { var scaleX: CGFloat = 1 var scaleY: CGFloat = 1 var y: CGFloat = 0 } #Preview { struct Demo: View { @State private var tab = 0 var body: some View { BouncyTabBar(selection: $tab, items: [ .init("house.fill", "Home"), .init("magnifyingglass", "Search"), .init("heart.fill", "Saved"), .init("person.fill", "Profile"), ]) .padding() } } return Demo() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Pull Cord (`pull-cord`) A lamp cord toggle: pull the knob down, it clicks and springs back swinging. - API: `PullCord(isOn:)` - Component: Button - Trigger: Drag / Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Button/PullCord.swift - Files: Sources/MotionLibrary/Button/PullCord.swift, Sources/MotionLibrary/Support/Haptics.swift ### Usage ```swift HStack(alignment: .top) { Image(systemName: lightsOn ? "lightbulb.max.fill" : "lightbulb") PullCord(isOn: $lightsOn) } ``` ### How it works Create a SwiftUI PullCord(isOn:length:color:) toggle (iOS 17+). A VStack(spacing: 0) of a 2 pt wide Rectangle (height length + stretch, default length 70) and a 16 × 26 Capsule knob, yellow with a yellow shadow (radius 8) when on. Rotate the whole cord with .rotationEffect(swing, anchor: .top) inside a 60 pt wide frame with room below to stretch. A DragGesture(minimumDistance: 0) sets stretch = 1.6c × (1 − e^(−pull / 1.6c)) with c = 0.45 × length (rubber-band resistance) and swing = −translation.width / 6 clamped to ±18°. On release, toggle when stretch passed c or the drag was a tap (moved < 4 pt), play a light haptic, then spring stretch back to 0 with .spring(duration: 0.55, bounce: 0.6) and swing back with .spring(duration: 0.9, bounce: 0.7). With Reduce Motion on, no swing and an ease-out return. Make it one accessibility element with the button trait, value On/Off and an action that toggles. ### Source ```swift import SwiftUI /// A toggle you pull like a lamp cord: drag the knob down, it stretches with /// rubber-band resistance, and past the catch point it clicks, flips the /// value and springs back up, swinging. A tap also toggles it. /// /// ```swift /// PullCord(isOn: $lightsOn) /// ``` public struct PullCord: View { @Binding private var isOn: Bool private let length: CGFloat private let color: Color @State private var stretch: CGFloat = 0 @State private var swing: Double = 0 @State private var clicks = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isOn: The value the cord toggles. /// - length: Length of the cord at rest, in points. /// - color: Colour of the cord and knob. public init(isOn: Binding, length: CGFloat = 70, color: Color = .primary) { _isOn = isOn self.length = length self.color = color } private var catchPoint: CGFloat { length * 0.45 } public var body: some View { VStack(spacing: 0) { Rectangle() .fill(color.opacity(0.7)) .frame(width: 2, height: length + stretch) Capsule() .fill(isOn ? Color.yellow : color) .overlay(Capsule().strokeBorder(color.opacity(0.4), lineWidth: 1)) .frame(width: 16, height: 26) .shadow(color: isOn ? .yellow.opacity(0.7) : .clear, radius: 8) } .rotationEffect(.degrees(swing), anchor: .top) .frame(width: 60, height: length + 26 + catchPoint * 1.6, alignment: .top) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in // Rubber band: easy at first, then stiffer. let pull = max(0, value.translation.height) stretch = catchPoint * 1.6 * (1 - exp(-pull / (catchPoint * 1.6))) swing = reduceMotion ? 0 : Double(-value.translation.width / 6).clamped(to: -18...18) } .onEnded { value in let pulled = stretch > catchPoint || (abs(value.translation.height) < 4 && abs(value.translation.width) < 4) if pulled { toggle() } withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.55, bounce: 0.6)) { stretch = 0 } if reduceMotion { swing = 0 } else { // Swing out a little the other way, then settle. withAnimation(.spring(duration: 0.9, bounce: 0.7)) { swing = 0 } } } ) .haptic(.lightImpact, trigger: clicks) .accessibilityElement() .accessibilityLabel("Pull cord") .accessibilityValue(isOn ? "On" : "Off") .accessibilityAddTraits(.isButton) .accessibilityAction { toggle() } } private func toggle() { isOn.toggle() clicks += 1 } } private extension Double { func clamped(to range: ClosedRange) -> Double { Swift.min(range.upperBound, Swift.max(range.lowerBound, self)) } } #Preview { struct Demo: View { @State private var on = false var body: some View { ZStack(alignment: .top) { (on ? Color.yellow.opacity(0.15) : Color.black.opacity(0.05)) PullCord(isOn: $on) } .frame(width: 200, height: 220) } } return Demo() } // MARK: - Haptics.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// The haptics the package plays. `SensoryFeedback` and `sensoryFeedback` /// need visionOS 26, but the package supports visionOS 1, so call sites use /// this enum and `.haptic(…)`, which does nothing on visionOS (it has no /// haptics anyway). enum Haptic: Equatable { case success case error case lightImpact #if !os(visionOS) var feedback: SensoryFeedback { switch self { case .success: .success case .error: .error case .lightImpact: .impact(weight: .light) } } #endif } extension View { /// Plays `haptic` every time `trigger` changes. func haptic(_ haptic: Haptic, trigger: T) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger) #endif } /// Plays `haptic` when `trigger` changes and `condition` returns true. func haptic(_ haptic: Haptic, trigger: T, condition: @escaping (T, T) -> Bool) -> some View { #if os(visionOS) self #else sensoryFeedback(haptic.feedback, trigger: trigger, condition: condition) #endif } /// Plays the haptic returned by `haptic` (if any) when `trigger` changes. func haptic(trigger: T, _ haptic: @escaping (T, T) -> Haptic?) -> some View { #if os(visionOS) self #else sensoryFeedback(trigger: trigger) { old, new in haptic(old, new)?.feedback } #endif } } ``` --- ## Staggered Entrance (`staggered-appear`) List items fade and rise into place one after another. - API: `.staggeredAppear(index:)` - Component: Card & Group - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/StaggeredAppear.swift - Files: Sources/MotionLibrary/Card/StaggeredAppear.swift ### Usage ```swift ForEach(Array(items.enumerated()), id: \.element.id) { index, item in CardView(item) .staggeredAppear(index: index) } ``` ### How it works Create a SwiftUI .staggeredAppear(index:) view modifier (iOS 17+) for list and grid entrances. Each item starts at opacity 0, offset 24 pt down and scale 0.96, then on appear animates to its resting state with .spring(response: 0.5, dampingFraction: 0.8), delayed by index × 0.08 s so the items cascade in. Delay per item and travel distance are configurable. ### Source ```swift import SwiftUI /// Items fade, rise and settle into place one after another when they first /// appear. struct StaggeredAppearModifier: ViewModifier { var index: Int var delayPerItem: Double var distance: CGFloat @State private var visible = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .opacity(visible ? 1 : 0) // Reduce Motion: fade only. .offset(y: visible || reduceMotion ? 0 : distance) .scaleEffect(visible || reduceMotion ? 1 : 0.96) .onAppear { withAnimation(.spring(response: 0.5, dampingFraction: 0.8).delay(Double(index) * delayPerItem)) { visible = true } } } } public extension View { /// Staggered entrance. Give each item in a list its position. /// /// ```swift /// ForEach(Array(items.enumerated()), id: \.element.id) { index, item in /// CardView(item) /// .staggeredAppear(index: index) /// } /// ``` func staggeredAppear(index: Int, delayPerItem: Double = 0.08, distance: CGFloat = 24) -> some View { modifier(StaggeredAppearModifier(index: index, delayPerItem: delayPerItem, distance: distance)) } } #Preview { VStack(spacing: 12) { ForEach(0..<5, id: \.self) { index in RoundedRectangle(cornerRadius: 12) .fill(Color.accentColor.opacity(0.2 + Double(index) * 0.15)) .frame(height: 56) .staggeredAppear(index: index) } } .padding() } ``` --- ## 3D Tilt (`tilt`) Card tilts in 3D toward the finger with a moving glare, then springs flat. - API: `.tiltOnDrag()` - Component: Card & Group - Trigger: Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Tilt.swift - Files: Sources/MotionLibrary/Card/Tilt.swift ### Usage ```swift CardView() .tiltOnDrag(maxAngle: 15, cornerRadius: 20) ``` ### How it works Create a SwiftUI .tiltOnDrag() view modifier (iOS 17+). While the user drags on the view, convert the drag translation into a normalised -1…1 value per axis (120 pt = full tilt) and apply two rotation3DEffects (x axis from vertical drag, y axis from horizontal drag) up to a max angle of ~12°, plus a 1.03 scale. Overlay a white glare gradient whose start/end points follow the tilt and which is only visible while dragging, clipped to the card's corner radius. Use .interactiveSpring while dragging and a bouncy spring back to flat on release. ### Source ```swift import SwiftUI /// 3D card tilt that follows the finger, with a moving glare, then springs /// back flat on release. struct TiltModifier: ViewModifier { var maxAngle: Double var glare: Bool var cornerRadius: CGFloat /// Normalised drag, each axis in -1...1. @State private var tilt: CGSize = .zero @State private var isDragging = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Translation (in points) that maps to the maximum angle. private let range: CGFloat = 120 func body(content: Content) -> some View { content .overlay { if glare { LinearGradient( colors: [.white.opacity(isDragging ? 0.35 : 0), .clear], startPoint: UnitPoint(x: 0.5 - tilt.width * 0.5, y: 0.5 - tilt.height * 0.5), endPoint: UnitPoint(x: 0.5 + tilt.width * 0.5, y: 0.5 + tilt.height * 0.5) ) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .allowsHitTesting(false) } } // Reduce Motion: keep the glare and the lift, drop the rotation. .rotation3DEffect(.degrees(Double(-tilt.height) * angle), axis: (x: 1, y: 0, z: 0)) .rotation3DEffect(.degrees(Double(tilt.width) * angle), axis: (x: 0, y: 1, z: 0)) .scaleEffect(isDragging ? 1.03 : 1) .gesture( DragGesture() .onChanged { value in withAnimation(.interactiveSpring()) { isDragging = true tilt = CGSize( width: clamp(value.translation.width / range), height: clamp(value.translation.height / range) ) } } .onEnded { _ in withAnimation(.spring(response: 0.5, dampingFraction: 0.55)) { isDragging = false tilt = .zero } } ) } private var angle: Double { reduceMotion ? 0 : maxAngle } private func clamp(_ value: CGFloat) -> CGFloat { min(1, max(-1, value)) } } public extension View { /// Tilts the view in 3D while it is dragged. /// /// ```swift /// CardView() /// .tiltOnDrag(maxAngle: 15, cornerRadius: 20) /// ``` func tiltOnDrag(maxAngle: Double = 12, glare: Bool = true, cornerRadius: CGFloat = 16) -> some View { modifier(TiltModifier(maxAngle: maxAngle, glare: glare, cornerRadius: cornerRadius)) } } #Preview { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(LinearGradient(colors: [.purple, .blue], startPoint: .topLeading, endPoint: .bottomTrailing)) .frame(width: 220, height: 300) .tiltOnDrag(cornerRadius: 20) .padding(40) } ``` --- ## Scroll Reveal (`scroll-reveal`) Rows scale up and come into focus as they enter the visible area. - API: `.scrollReveal()` - Component: Card & Group - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollReveal.swift - Files: Sources/MotionLibrary/Card/ScrollReveal.swift ### Usage ```swift ScrollView { LazyVStack { ForEach(items) { item in Row(item).scrollReveal() } } } ``` ### How it works Create a SwiftUI .scrollReveal() view modifier (iOS 17+) using scrollTransition(.interactive). When an item is fully inside the scroll view (phase.isIdentity) it is at full opacity, scale 1 and no blur; as it moves toward the top or bottom edge it interpolates to opacity 0.3, scale 0.85 and a 2 pt blur. Axis, minimum scale, minimum opacity and blur amount should be parameters. ### Source ```swift import SwiftUI public extension View { /// Items scale up and come into focus as they scroll into the visible /// area, and fade back out as they leave. Use inside a `ScrollView`. /// /// ```swift /// ScrollView { /// LazyVStack { /// ForEach(items) { item in /// Row(item).scrollReveal() /// } /// } /// } /// ``` func scrollReveal( axis: Axis = .vertical, minScale: CGFloat = 0.85, minOpacity: Double = 0.3, blur: CGFloat = 2 ) -> some View { modifier(ScrollRevealModifier(axis: axis, minScale: minScale, minOpacity: minOpacity, blur: blur)) } } struct ScrollRevealModifier: ViewModifier { var axis: Axis var minScale: CGFloat var minOpacity: Double var blur: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: keep the fade, drop the scaling and blur. let minScale = reduceMotion ? 1 : minScale let blur = reduceMotion ? 0 : blur let minOpacity = minOpacity return content.scrollTransition(.interactive, axis: axis) { view, phase in view .opacity(phase.isIdentity ? 1 : minOpacity) .scaleEffect(phase.isIdentity ? 1 : minScale) .blur(radius: phase.isIdentity ? 0 : blur) } } } #Preview { ScrollView { LazyVStack(spacing: 12) { ForEach(0..<30, id: \.self) { index in RoundedRectangle(cornerRadius: 14) .fill(Color.accentColor.gradient) .frame(height: 80) .overlay(Text("Row \(index)").foregroundStyle(.white)) .scrollReveal() } } .padding() } } ``` --- ## Flip Card (`flip-card`) A two-sided card turns over in 3D with a spring. - API: `FlipCard(isFlipped:) { } back: { }` - Component: Card & Group - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/FlipCard.swift - Files: Sources/MotionLibrary/Card/FlipCard.swift ### Usage ```swift @State private var flipped = false FlipCard(isFlipped: flipped) { QuestionView() } back: { AnswerView() } .frame(width: 220, height: 300) .onTapGesture { flipped.toggle() } ``` ### How it works Create a SwiftUI FlipCard view (iOS 17+) with isFlipped and axis (horizontal or vertical) parameters and front/back view builders. Stack both faces in a ZStack and give each an Animatable view modifier holding the animated angle (0 or 180°). The back face uses angle − 180 so it reads correctly once turned. Each face applies rotation3DEffect around the y axis (or x for vertical) with perspective 0.5 and sets opacity to 1 only while |faceAngle| < 90°, so the faces swap exactly at the halfway point and the back never shows mirrored. Animate isFlipped changes with spring(response 0.6, damping 0.75). Hide the hidden face from accessibility. With Reduce Motion on, cross-fade instead of rotating. ### Source ```swift import SwiftUI /// A two-sided card that turns over in 3D, showing `back` when `isFlipped` /// is true. The faces swap exactly at 90°, so the back never shows mirrored. public struct FlipCard: View { private let isFlipped: Bool private let axis: Axis private let front: Front private let back: Back @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isFlipped: Shows the back when true. /// - axis: `.horizontal` turns like a page (around the vertical axis), /// `.vertical` tumbles top over bottom. public init( isFlipped: Bool, axis: Axis = .horizontal, @ViewBuilder front: () -> Front, @ViewBuilder back: () -> Back ) { self.isFlipped = isFlipped self.axis = axis self.front = front() self.back = back() } public var body: some View { if reduceMotion { // Cross-fade instead of turning. ZStack { front.opacity(isFlipped ? 0 : 1) back.opacity(isFlipped ? 1 : 0) } .animation(.easeInOut(duration: 0.3), value: isFlipped) } else { ZStack { front.modifier(FlipFace(angle: isFlipped ? 180 : 0, axis: axis, isBack: false)) back.modifier(FlipFace(angle: isFlipped ? 180 : 0, axis: axis, isBack: true)) } .animation(.spring(response: 0.6, dampingFraction: 0.75), value: isFlipped) } } } /// Rotates one face by `angle` and hides it while it faces away. private struct FlipFace: ViewModifier, Animatable { var angle: Double var axis: Axis var isBack: Bool nonisolated var animatableData: Double { get { angle } set { angle = newValue } } func body(content: Content) -> some View { // The back starts already turned 180° so it reads correctly when shown. let faceAngle = isBack ? angle - 180 : angle let visible = abs(faceAngle) < 90 content .opacity(visible ? 1 : 0) .accessibilityHidden(!visible) .rotation3DEffect( .degrees(faceAngle), axis: axis == .horizontal ? (x: 0, y: 1, z: 0) : (x: 1, y: 0, z: 0), perspective: 0.5 ) } } #Preview { struct Demo: View { @State private var flipped = false var body: some View { FlipCard(isFlipped: flipped) { RoundedRectangle(cornerRadius: 20).fill(.blue.gradient) .overlay(Text("Front").font(.title.bold()).foregroundStyle(.white)) } back: { RoundedRectangle(cornerRadius: 20).fill(.orange.gradient) .overlay(Text("Back").font(.title.bold()).foregroundStyle(.white)) } .frame(width: 200, height: 260) .onTapGesture { flipped.toggle() } } } return Demo() } ``` --- ## Swipe Card Stack (`swipe-stack`) Tinder-style deck: drag the top card away and the pile moves up. - API: `SwipeCardStack(items) { item in }` - Component: Card & Group - Trigger: Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/SwipeCardStack.swift - Files: Sources/MotionLibrary/Card/SwipeCardStack.swift ### Usage ```swift SwipeCardStack(profiles) { profile, direction in direction == .right ? like(profile) : pass(profile) } card: { profile in ProfileCard(profile) .frame(width: 300, height: 420) } ``` ### How it works Create a generic SwiftUI SwipeCardStack (iOS 17+) over a collection of Identifiable items with a card view builder and an onSwipe(item, direction) callback. Show the top 3 cards from the current position (looping by default). Cards behind are scaled down 5% per level and moved down 12 pt per level. The top card follows a DragGesture: offset by the translation, rotate by translation.width / 20 degrees around the bottom anchor, and overlay a green (right) or red (left) wash, masked by the card, whose opacity grows to 25% at the threshold. On release, if the predicted end translation passes 110 pt, fly the card 600 pt off in that direction with easeIn(0.25), then reset the drag without animation, advance the position with a spring so the pile moves up, and call onSwipe. Otherwise spring back. Add Swipe left and Swipe right accessibility actions. ### Source ```swift import SwiftUI /// Which way a card left a `SwipeCardStack`. public enum SwipeCardDirection: Sendable { case left case right } /// The old name of `SwipeCardDirection`. @available(*, deprecated, renamed: "SwipeCardDirection") public typealias SwipeDirection = SwipeCardDirection /// A Tinder-style deck. The top card follows the finger and tilts; past a /// threshold it flies off and the cards behind move up. Drag less and it /// springs back. public struct SwipeCardStack: View where Data.Element: Identifiable { private let items: Data private let visibleCount: Int private let loops: Bool private let onSwipe: (Data.Element, SwipeCardDirection) -> Void private let card: (Data.Element) -> Card @State private var position = 0 @State private var drag: CGSize = .zero @State private var flyingOff: SwipeCardDirection? @Environment(\.accessibilityReduceMotion) private var reduceMotion private let threshold: CGFloat = 110 /// - Parameters: /// - items: The cards, top card first. /// - visibleCount: How many cards are drawn in the pile. /// - loops: Starts over from the first card when the deck runs out. /// - onSwipe: Called after a card leaves, with the direction it went. /// - card: Builds the view for one item. public init( _ items: Data, visibleCount: Int = 3, loops: Bool = true, onSwipe: @escaping (Data.Element, SwipeCardDirection) -> Void = { _, _ in }, @ViewBuilder card: @escaping (Data.Element) -> Card ) { self.items = items self.visibleCount = visibleCount self.loops = loops self.onSwipe = onSwipe self.card = card } public var body: some View { let elements = Array(items) ZStack { ForEach(Array(visibleCards(elements).enumerated()).reversed(), id: \.element.id) { depth, item in card(item) .modifier(StackedCard(depth: depth, drag: depth == 0 ? drag : .zero, threshold: threshold)) .allowsHitTesting(depth == 0) .gesture(depth == 0 ? dragGesture(for: item) : nil) .accessibilityHidden(depth != 0) .accessibilityActions { Button("Swipe left") { swipe(item, .left) } Button("Swipe right") { swipe(item, .right) } } } } } private func visibleCards(_ elements: [Data.Element]) -> [Data.Element] { guard !elements.isEmpty else { return [] } let remaining = loops ? elements.count : max(0, elements.count - position) return (0.. some Gesture { DragGesture() .onChanged { value in guard flyingOff == nil else { return } drag = value.translation } .onEnded { value in guard flyingOff == nil else { return } // Use the predicted end so a quick flick counts too. let x = value.predictedEndTranslation.width if abs(x) > threshold { swipe(item, x > 0 ? .right : .left) } else { withAnimation(.spring(response: 0.4, dampingFraction: 0.6)) { drag = .zero } } } } private func swipe(_ item: Data.Element, _ direction: SwipeCardDirection) { flyingOff = direction let distance: CGFloat = direction == .right ? 600 : -600 withAnimation(reduceMotion ? .easeOut(duration: 0.2) : .easeIn(duration: 0.25)) { drag = CGSize(width: distance, height: drag.height + 40) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { drag = .zero } withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { position += 1 } flyingOff = nil onSwipe(item, direction) } } } /// Places a card in the pile: deeper cards are smaller and lower; the top /// card follows the drag and tilts away from it. private struct StackedCard: ViewModifier { var depth: Int var drag: CGSize var threshold: CGFloat func body(content: Content) -> some View { content .overlay { // Green or red wash that grows as the card nears the threshold. let progress = min(1, abs(drag.width) / threshold) (drag.width > 0 ? Color.green : Color.red) .opacity(depth == 0 ? Double(progress) * 0.25 : 0) .mask { content } .allowsHitTesting(false) } .scaleEffect(1 - CGFloat(depth) * 0.05) .offset(y: CGFloat(depth) * 12) .offset(drag) .rotationEffect(.degrees(Double(drag.width / 20)), anchor: .bottom) } } #Preview { struct Item: Identifiable { let id: Int let color: Color } let items = [Color.pink, .orange, .teal, .indigo].enumerated().map { Item(id: $0, color: $1) } return SwipeCardStack(items) { item in RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(item.color.gradient) .frame(width: 220, height: 300) .overlay(Text("Card \(item.id + 1)").font(.title.bold()).foregroundStyle(.white)) } .padding(40) } ``` --- ## Parallax Carousel (`parallax-carousel`) Paging cards whose images slide slower than the cards, for depth. - API: `ParallaxCarousel(items) { item in }` - Component: Card & Group - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ParallaxCarousel.swift - Files: Sources/MotionLibrary/Card/ParallaxCarousel.swift ### Usage ```swift ParallaxCarousel(places) { place in Image(place.photo) .resizable() .scaledToFill() } .frame(height: 240) ``` ### How it works Create a generic SwiftUI ParallaxCarousel (iOS 17+) over Identifiable items with an optional selection binding. Use a horizontal ScrollView with a LazyHStack (14 pt spacing), .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), hidden indicators, .scrollPosition(id:anchor: .center), and horizontal contentMargins so each card (78% of the width) snaps to the centre with neighbours peeking. Draw each card's content wider than the card by the parallax factor (0.35), then use .visualEffect to read the card's frame in the .scrollView coordinate space and offset the content by −(distance of the card centre from the viewport centre) × parallax. Finally frame it to the card size and clip it with a continuous rounded rectangle (22 pt), so the image appears to move slower than its frame. With Reduce Motion on, set parallax to 0. ### Source ```swift import SwiftUI /// A paging carousel whose card contents slide slower than the cards /// themselves, so images seem to sit deeper than the frame around them. /// Neighbouring cards peek in from both sides. public struct ParallaxCarousel: View where Data.Element: Identifiable { private let items: Data private let selection: Binding? private let cardWidth: CGFloat private let spacing: CGFloat private let cornerRadius: CGFloat private let parallax: CGFloat private let content: (Data.Element) -> Content @State private var internalSelection: Data.Element.ID? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: One card per item. /// - selection: Optional binding to the centred card; set it to scroll. /// - cardWidth: Card width as a fraction of the carousel's width. /// - spacing: Gap between cards. /// - cornerRadius: Corner radius of each card. /// - parallax: How much slower the content moves than its card (0…1). /// - content: The card's content, usually an image. It is drawn wider /// than the card so the parallax never reveals an edge. public init( _ items: Data, selection: Binding? = nil, cardWidth: CGFloat = 0.78, spacing: CGFloat = 14, cornerRadius: CGFloat = 22, parallax: CGFloat = 0.35, @ViewBuilder content: @escaping (Data.Element) -> Content ) { self.items = items self.selection = selection self.cardWidth = cardWidth self.spacing = spacing self.cornerRadius = cornerRadius self.parallax = parallax self.content = content } public var body: some View { GeometryReader { geometry in let width = geometry.size.width * cardWidth let strength = reduceMotion ? 0 : parallax ScrollView(.horizontal) { LazyHStack(spacing: spacing) { ForEach(items) { item in content(item) // Extra width on both sides for the content to slide into. .frame(width: width * (1 + strength), height: geometry.size.height) .visualEffect { view, proxy in // How far this card's centre is from the carousel's centre. let frame = proxy.frame(in: .scrollView(axis: .horizontal)) let viewport = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? frame.width let distance = frame.midX - viewport / 2 return view.offset(x: -distance * strength) } .frame(width: width, height: geometry.size.height) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .id(item.id) } } .scrollTargetLayout() } .contentMargins(.horizontal, (geometry.size.width - width) / 2, for: .scrollContent) .scrollTargetBehavior(.viewAligned) .scrollIndicators(.hidden) .scrollPosition(id: selection ?? $internalSelection, anchor: .center) } } } #Preview { struct Place: Identifiable { let id: Int let colors: [Color] } let places = [ Place(id: 0, colors: [.orange, .pink]), Place(id: 1, colors: [.teal, .blue]), Place(id: 2, colors: [.purple, .indigo]), Place(id: 3, colors: [.mint, .green]), ] return ParallaxCarousel(places) { place in LinearGradient(colors: place.colors, startPoint: .topLeading, endPoint: .bottomTrailing) .overlay(Image(systemName: "mountain.2.fill").font(.system(size: 60)).foregroundStyle(.white.opacity(0.8))) } .frame(height: 220) } ``` --- ## Expandable Card (`expandable-card`) Tap the header and the card springs open, un-blurring its detail. - API: `ExpandableCard(isExpanded:) { } detail: { }` - Component: Card & Group - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ExpandableCard.swift - Files: Sources/MotionLibrary/Card/ExpandableCard.swift ### Usage ```swift @State private var isOpen = false ExpandableCard(isExpanded: $isOpen) { Text("Shipping").font(.headline) } detail: { Text("Arrives Thursday. Free returns within 30 days.") } ``` ### How it works Create a SwiftUI ExpandableCard (iOS 17+) bound to isExpanded. A VStack holds a plain-style Button containing the header, a spacer and a chevron.down that rotates −180° when open, then, if expanded, the detail view. Toggle inside withAnimation(.spring(response 0.45, damping 0.8)) so the card's height springs. Insert the detail with a combined transition of opacity, an 8 pt upward offset and a custom blur transition (radius 6 → 0). The background is a continuous rounded rectangle (16 pt) filled with the background style whose shadow deepens when open. Expose Expanded/Collapsed as the accessibility value. With Reduce Motion on, use a short ease and an opacity transition only. ### Source ```swift import SwiftUI /// A card with a tappable header that expands to reveal its detail. The /// height springs open, the chevron turns, and the detail un-blurs into place. /// Stack several for an accordion. public struct ExpandableCard: View { @Binding private var isExpanded: Bool private let cornerRadius: CGFloat private let header: Header private let detail: Detail @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isExpanded: Whether the detail is showing. /// - cornerRadius: Corner radius of the card. /// - header: Always visible; tapping it toggles the card. /// - detail: Shown below the header when expanded. public init( isExpanded: Binding, cornerRadius: CGFloat = 16, @ViewBuilder header: () -> Header, @ViewBuilder detail: () -> Detail ) { _isExpanded = isExpanded self.cornerRadius = cornerRadius self.header = header() self.detail = detail() } public var body: some View { VStack(alignment: .leading, spacing: 0) { Button { withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.45, dampingFraction: 0.8)) { isExpanded.toggle() } } label: { HStack { header Spacer(minLength: 8) Image(systemName: "chevron.down") .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) .rotationEffect(.degrees(isExpanded ? -180 : 0)) } .padding() .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityValue(isExpanded ? Text("Expanded") : Text("Collapsed")) if isExpanded { detail .padding([.horizontal, .bottom]) .frame(maxWidth: .infinity, alignment: .leading) .transition( reduceMotion ? .opacity : .opacity.combined(with: .offset(y: -8)).combined(with: BlurTransition.blur) ) } } .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(.background) .shadow(color: .black.opacity(isExpanded ? 0.12 : 0.06), radius: isExpanded ? 12 : 4, y: isExpanded ? 6 : 2) ) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } } /// Blurs a view while it is inserted or removed. private struct BlurTransition: ViewModifier { var radius: CGFloat func body(content: Content) -> some View { content.blur(radius: radius) } static var blur: AnyTransition { .modifier(active: BlurTransition(radius: 6), identity: BlurTransition(radius: 0)) } } #Preview { struct Demo: View { @State private var open = [true, false, false] var body: some View { VStack(spacing: 12) { ForEach(0..<3) { index in ExpandableCard(isExpanded: $open[index]) { Text("Question \(index + 1)").font(.headline) } detail: { Text("Answers expand smoothly with a spring, and the detail fades and un-blurs into place.") .foregroundStyle(.secondary) } } } .padding() .background(Color.gray.opacity(0.1)) } } return Demo() } ``` --- ## Hero Expand (`hero-expand`) A thumbnail grows into a full detail card, morphing with matchedGeometryEffect. - API: `HeroGrid(items) { thumbnail } detail: { }` - Component: Card & Group - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/HeroGrid.swift - Files: Sources/MotionLibrary/Card/HeroGrid.swift ### Usage ```swift HeroGrid(photos, columns: 3) { photo in Image(photo.thumbnail).resizable().scaledToFill().frame(height: 90) } detail: { photo in PhotoDetail(photo) } ``` ### How it works Create a generic SwiftUI HeroGrid (iOS 17+) over Identifiable items with columns (3), spacing (10), cornerRadius (14) and two view builders: thumbnail and detail. Keep @State selected: Item.ID? and a @Namespace. In a ZStack, show a LazyVGrid of flexible columns; for each item, if it isn't selected show a plain Button with the thumbnail clipped to a continuous rounded rectangle and .matchedGeometryEffect(id: item.id, in: namespace), otherwise show the thumbnail .hidden() to keep the slot. Dim the grid to 30 % while something is selected. Above it, if selected, show the detail in a Button sized maxWidth/maxHeight .infinity, clipped the same way, with the same matchedGeometryEffect id and zIndex 1. Open with withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) and close by tapping the detail. Both views get an .opacity transition so their content cross-fades while the frame morphs. With Reduce Motion on, pass properties: [] to matchedGeometryEffect so it only cross-fades. ### Source ```swift import SwiftUI /// A grid of thumbnails where tapping one grows it into a detail card that /// fills the grid, morphing frame and corners with `matchedGeometryEffect`. /// Tapping the detail shrinks it back into its slot. public struct HeroGrid: View { private let items: [Item] private let columns: Int private let spacing: CGFloat private let cornerRadius: CGFloat private let thumbnail: (Item) -> Thumbnail private let detail: (Item) -> Detail @State private var selected: Item.ID? @Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: The items to show. /// - columns: Number of grid columns. /// - spacing: Gap between cells, in points. /// - cornerRadius: Corner radius of thumbnails and the detail card. /// - thumbnail: The small cell for an item. Give it a background so the /// morph has something to stretch. /// - detail: The expanded card for an item. public init( _ items: [Item], columns: Int = 3, spacing: CGFloat = 10, cornerRadius: CGFloat = 14, @ViewBuilder thumbnail: @escaping (Item) -> Thumbnail, @ViewBuilder detail: @escaping (Item) -> Detail ) { self.items = items self.columns = columns self.spacing = spacing self.cornerRadius = cornerRadius self.thumbnail = thumbnail self.detail = detail } public var body: some View { // Reduce Motion: cross-fade in place of the morph. let morph: MatchedGeometryProperties = reduceMotion ? [] : .frame ZStack { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: spacing), count: max(1, columns)), spacing: spacing) { ForEach(items) { item in ZStack { if selected != item.id { Button { open(item.id) } label: { thumbnail(item) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } .buttonStyle(.plain) .matchedGeometryEffect(id: item.id, in: namespace, properties: morph) .transition(.opacity.animation(.easeInOut(duration: 0.15))) } else { // Keep the slot so the grid doesn't reflow. thumbnail(item).hidden() } } } } .opacity(selected == nil ? 1 : 0.3) if let id = selected, let item = items.first(where: { $0.id == id }) { Button { close() } label: { detail(item) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) } .buttonStyle(.plain) .matchedGeometryEffect(id: id, in: namespace, properties: morph) .transition(.opacity.animation(.easeInOut(duration: 0.15))) .zIndex(1) } } } private func open(_ id: Item.ID) { withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(response: 0.45, dampingFraction: 0.82)) { selected = id } } private func close() { withAnimation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(response: 0.4, dampingFraction: 0.85)) { selected = nil } } } #Preview { struct Tile: Identifiable { let id: Int let color: Color } let tiles = [Color.pink, .orange, .teal, .indigo, .mint, .purple].enumerated().map { Tile(id: $0.offset, color: $0.element) } return HeroGrid(tiles) { tile in tile.color.frame(height: 70) } detail: { tile in tile.color.overlay(Text("Tile \(tile.id + 1)").font(.title.bold()).foregroundStyle(.white)) } .frame(width: 300, height: 160) .padding() } ``` --- ## Spotlight Card (`spotlight-card`) A soft glow follows the pointer across the card, like a torch. - API: `.spotlight()` - Component: Card & Group - Trigger: Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Spotlight.swift - Files: Sources/MotionLibrary/Card/Spotlight.swift ### Usage ```swift FeatureCard() .background(.black, in: RoundedRectangle(cornerRadius: 20)) .spotlight() PricingCard() .spotlight(color: .purple.opacity(0.4), radius: 160) ``` ### How it works Create a SwiftUI .spotlight(color:radius:) view modifier (iOS 17+). Track the pointer with .onContinuousHover(coordinateSpace: .local) and a finger with a simultaneous DragGesture(minimumDistance: 0); store the location in @State (nil when the pointer leaves or the drag ends) and remember the last location so the glow can fade out where it was. In an overlay GeometryReader, draw a RadialGradient from color (white 35 %) to clear, centred at the location converted to a UnitPoint, endRadius = radius (120 pt), and mask it with the content so it only lights the card. Move with .interactiveSpring(response: 0.2, dampingFraction: 0.9) and fade out with easeOut(0.35 s). Disable hit testing on the glow. With Reduce Motion on, move the glow without animation. ### Source ```swift import SwiftUI /// A soft glow that follows the pointer (or a dragging finger) across the /// view, like a torch shone on the card. Fades out when the pointer leaves. struct SpotlightModifier: ViewModifier { var color: Color var radius: CGFloat @State private var location: CGPoint? @State private var lastLocation: CGPoint = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { GeometryReader { geometry in let size = geometry.size let point = location ?? lastLocation RadialGradient( colors: [color, color.opacity(0)], center: UnitPoint(x: size.width > 0 ? point.x / size.width : 0.5, y: size.height > 0 ? point.y / size.height : 0.5), startRadius: 0, endRadius: radius ) .opacity(location == nil ? 0 : 1) } .mask { content } .allowsHitTesting(false) .accessibilityHidden(true) } .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(point): move(to: point) case .ended: hide() } } .simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { move(to: $0.location) } .onEnded { _ in hide() } ) } private func move(to point: CGPoint) { // Reduce Motion: no glide, the glow jumps with the pointer. withAnimation(reduceMotion ? nil : .interactiveSpring(response: 0.2, dampingFraction: 0.9)) { location = point lastLocation = point } } private func hide() { withAnimation(.easeOut(duration: 0.35)) { location = nil } } } public extension View { /// Shines a soft glow on the view where the pointer or finger is. /// /// ```swift /// FeatureCard() /// .background(.black, in: RoundedRectangle(cornerRadius: 20)) /// .spotlight() /// ``` /// /// - Parameters: /// - color: Colour at the centre of the glow. /// - radius: Radius of the glow, in points. func spotlight(color: Color = .white.opacity(0.35), radius: CGFloat = 120) -> some View { modifier(SpotlightModifier(color: color, radius: radius)) } } #Preview { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(white: 0.1)) .frame(width: 260, height: 160) .overlay(Text("Hover me").font(.headline).foregroundStyle(.white)) .spotlight() .padding() } ``` --- ## Magnifying Dock (`dock`) Icons swell as the pointer passes over them, like the macOS Dock. - API: `MagnifyingDock(items) { item in }` - Component: Card & Group - Trigger: Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/MagnifyingDock.swift - Files: Sources/MotionLibrary/Card/MagnifyingDock.swift ### Usage ```swift MagnifyingDock(apps) { app in Image(app.icon).resizable() } .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) ``` ### How it works Create a generic SwiftUI MagnifyingDock (iOS 17+) over Identifiable items with size (40 pt), magnification (1.8), range (110 pt), spacing (8) and a content builder. Lay the items out in an HStack(alignment: .bottom) and frame each icon to size × scale(i). Put the HStack in a frame exactly as wide as the resting row (count × size + gaps) and size × magnification tall, aligned to the bottom, so the magnified row overflows it evenly; track the pointer x on that frame with .onContinuousHover and a simultaneous DragGesture(minimumDistance: 0). scale(i) = 1 + (magnification − 1) × cos(d / range × π/2) where d is the distance from the pointer to the icon's resting centre (i × (size + spacing) + size/2), or 1 beyond range. Animate pointer changes with .interactiveSpring(response: 0.2) and the release with .spring(response: 0.35, dampingFraction: 0.75). Add horizontal padding for the overflow. With Reduce Motion on, don't magnify. ### Source ```swift import SwiftUI /// A row of icons that swell as the pointer (or a dragging finger) passes /// over them, like the macOS Dock. Neighbours grow a little too. public struct MagnifyingDock: View { private let items: [Item] private let size: CGFloat private let magnification: CGFloat private let range: CGFloat private let spacing: CGFloat private let content: (Item) -> Content @State private var pointerX: CGFloat? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: The items to show. /// - size: Resting icon size, in points. /// - magnification: Scale of the icon right under the pointer. /// - range: Distance in points over which neighbours still grow. /// - spacing: Gap between icons. /// - content: The icon for an item; it is sized to fill a square. public init( _ items: [Item], size: CGFloat = 40, magnification: CGFloat = 1.8, range: CGFloat = 110, spacing: CGFloat = 8, @ViewBuilder content: @escaping (Item) -> Content ) { self.items = items self.size = size self.magnification = magnification self.range = range self.spacing = spacing self.content = content } public var body: some View { HStack(alignment: .bottom, spacing: spacing) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in let side = size * scale(for: index) content(item) .frame(width: side, height: side) } } // A frame the size of the resting row: the magnified row overflows it // evenly on both sides, and the pointer is measured in resting // coordinates, so the icons don't run away from it. .frame(width: restingWidth, height: size * magnification, alignment: .bottom) .contentShape(Rectangle()) .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(point): track(point.x) case .ended: track(nil) } } .simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { track($0.location.x) } .onEnded { _ in track(nil) } ) .padding(.horizontal, size * (magnification - 1) + spacing) .padding(.vertical, 6) } private var restingWidth: CGFloat { CGFloat(items.count) * size + CGFloat(max(0, items.count - 1)) * spacing } /// Scale of the icon at `index`: a cosine falloff of the distance between /// its resting centre and the pointer. private func scale(for index: Int) -> CGFloat { guard let pointerX, !reduceMotion else { return 1 } let restingCentre = CGFloat(index) * (size + spacing) + size / 2 let distance = abs(pointerX - restingCentre) guard distance < range else { return 1 } let falloff = cos(distance / range * .pi / 2) return 1 + (magnification - 1) * falloff } private func track(_ x: CGFloat?) { withAnimation(x == nil ? .spring(response: 0.35, dampingFraction: 0.75) : .interactiveSpring(response: 0.2, dampingFraction: 0.85)) { pointerX = x } } } #Preview { struct App: Identifiable { let id: String let color: Color } let apps = [("message.fill", Color.green), ("safari.fill", .blue), ("music.note", .pink), ("photo.fill", .orange), ("gearshape.fill", .gray)] .map { App(id: $0.0, color: $0.1) } return MagnifyingDock(apps) { app in RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(app.color.gradient) .overlay(Image(systemName: app.id).foregroundStyle(.white)) } .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16)) .padding() } ``` --- ## Jiggle (`jiggle`) The home-screen edit-mode wiggle, each item slightly out of step. - API: `.jiggle(isActive:)` - Component: Card & Group - Trigger: Loop / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/Jiggle.swift - Files: Sources/MotionLibrary/Card/Jiggle.swift ### Usage ```swift ForEach(apps) { app in AppIcon(app) .jiggle(isActive: isEditing) } ``` ### How it works Create a SwiftUI .jiggle(isActive:angle:) view modifier (iOS 17+) that wiggles a view like an app icon in edit mode. Give each instance a random @State phase (0–1) and rate (0.9–1.1) so neighbours never move in lockstep. In TimelineView(.animation(paused: !isActive)), let t = (time × rate + phase) × 2π × 3.6, rotate by sin(t) × angle (2.5°) and offset y by cos(t/2) × 0.8 pt. When inactive, return to 0 with a 0.15 s ease. With Reduce Motion on, don't move. ### Source ```swift import SwiftUI /// The home-screen "edit mode" wiggle: a quick, slightly irregular rotation /// and bob. Each view gets its own phase so a grid never moves in lockstep. struct JiggleModifier: ViewModifier { var isActive: Bool var angle: Double @State private var phase = Double.random(in: 0..<1) @State private var rate = Double.random(in: 0.9..<1.1) @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: stay still. let running = isActive && !reduceMotion TimelineView(.animation(paused: !running)) { timeline in let t = (timeline.date.timeIntervalSinceReferenceDate * rate + phase) * 2 * .pi * 3.6 content .rotationEffect(.degrees(running ? sin(t) * angle : 0)) .offset(y: running ? CGFloat(cos(t * 0.5)) * 0.8 : 0) .animation(.easeOut(duration: 0.15), value: running) } } } public extension View { /// Wiggles the view like an app icon in edit mode while `isActive` is true. /// /// ```swift /// ForEach(apps) { app in /// AppIcon(app) /// .jiggle(isActive: isEditing) /// } /// ``` /// /// - Parameter angle: Peak rotation, in degrees. func jiggle(isActive: Bool = true, angle: Double = 2.5) -> some View { modifier(JiggleModifier(isActive: isActive, angle: angle)) } } #Preview { HStack(spacing: 16) { ForEach([Color.blue, .green, .orange, .pink], id: \.self) { color in RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(color.gradient) .frame(width: 54, height: 54) .jiggle() } } .padding() } ``` --- ## Fan Stack (`fan-stack`) A pile of cards fans out like a hand of playing cards, and gathers back. - API: `FanStack(items, isSpread:) { item in }` - Component: Card & Group - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/FanStack.swift - Files: Sources/MotionLibrary/Card/FanStack.swift ### Usage ```swift FanStack(cards, isSpread: isSpread) { card in CardView(card).frame(width: 80, height: 120) } .onTapGesture { isSpread.toggle() } ``` ### How it works Create a generic SwiftUI FanStack (iOS 17+) over Identifiable items with isSpread, angle (10°) and spacing (34 pt). Draw the cards in a ZStack, bottom first. For card i, let c = i − (count − 1)/2. Fanned: rotate by c × angle around the bottom anchor, offset x by c × spacing and y by c² × 4 pt so the outer cards dip along an arc. Stacked: rotate by c × 1.5° around the centre and offset y by −2 pt × i for a neat pile. Animate each card with .spring(response: 0.45, dampingFraction: 0.7).delay(i × 0.03) on isSpread so they follow each other. With Reduce Motion on, use a 0.25 s ease without the stagger. ### Source ```swift import SwiftUI /// A stack of cards that fans out like a hand of playing cards, and gathers /// back into a neat pile. Cards follow each other with a short stagger. public struct FanStack: View { private let items: [Item] private let isSpread: Bool private let angle: Double private let spacing: CGFloat private let content: (Item) -> Content @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: The cards, bottom first. /// - isSpread: Fanned out (true) or stacked (false). /// - angle: Rotation between neighbouring cards when fanned, in degrees. /// - spacing: Horizontal distance between neighbouring cards when fanned. /// - content: The card for an item. public init( _ items: [Item], isSpread: Bool, angle: Double = 10, spacing: CGFloat = 34, @ViewBuilder content: @escaping (Item) -> Content ) { self.items = items self.isSpread = isSpread self.angle = angle self.spacing = spacing self.content = content } public var body: some View { ZStack { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in let centred = Double(index) - Double(items.count - 1) / 2 content(item) .rotationEffect( .degrees(isSpread ? centred * angle : centred * 1.5), anchor: isSpread ? .bottom : .center ) .offset( x: isSpread ? CGFloat(centred) * spacing : 0, // The outer cards dip, following the arc of the fan. y: isSpread ? CGFloat(abs(centred) * abs(centred)) * 4 : CGFloat(-index) * 2 ) .animation( reduceMotion ? .easeInOut(duration: 0.25) : .spring(response: 0.45, dampingFraction: 0.7).delay(Double(index) * 0.03), value: isSpread ) } } } } #Preview { struct Card: Identifiable { let id: Int let color: Color } struct Demo: View { @State private var spread = false let cards = [Color.pink, .orange, .yellow, .mint, .blue].enumerated().map { Card(id: $0.offset, color: $0.element) } var body: some View { FanStack(cards, isSpread: spread) { card in RoundedRectangle(cornerRadius: 10).fill(card.color.gradient).frame(width: 60, height: 86) } .onTapGesture { spread.toggle() } } } return Demo().padding(60) } ``` --- ## Scratch Card (`scratch-card`) Rub away a scratch-off cover to reveal what's underneath. - API: `.scratchToReveal(_:onReveal:)` - Component: Card & Group - Trigger: Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScratchCard.swift - Files: Sources/MotionLibrary/Card/ScratchCard.swift ### Usage ```swift PrizeView() .scratchToReveal(Color.gray) { didReveal = true } ``` ### How it works Create a SwiftUI .scratchToReveal(_ cover:lineWidth:threshold:onReveal:) view modifier (iOS 17+). Overlay a Rectangle filled with the cover style (with a faint "hand.draw.fill" hint until the first stroke) and mask it with a compositingGroup of a full Rectangle plus a stroked Path of every finger stroke (lineWidth 34, round caps and joins) using .blendMode(.destinationOut), so strokes punch holes in the cover. A DragGesture(minimumDistance: 0) starts a new stroke when translation is zero and appends locations otherwise. Track coverage on a 12 × 12 grid: mark every cell within lineWidth / 2 of each point; once 55 % of cells are marked, fade the cover out over 0.4 s, disable its hit testing and call onReveal once. Give the view a new .id to reset it. Accessibility: a button whose action reveals. ### Source ```swift import SwiftUI /// Covers the view with a scratch-off layer. Dragging a finger erases the /// cover along its path; once enough of it is scratched away, the rest /// fades out and `onReveal` runs. struct ScratchModifier: ViewModifier { var cover: Cover var lineWidth: CGFloat var threshold: Double var onReveal: () -> Void @State private var strokes: [[CGPoint]] = [] @State private var scratched: Set = [] @State private var isRevealed = false /// The cover is split into a grid to estimate how much has been scratched. private let grid = 12 func body(content: Content) -> some View { content.overlay { GeometryReader { geometry in Rectangle() .fill(cover) .overlay { Image(systemName: "hand.draw.fill") .font(.title) .foregroundStyle(.white.opacity(0.7)) .opacity(strokes.isEmpty ? 1 : 0) .animation(.easeOut(duration: 0.2), value: strokes.isEmpty) } .mask { ZStack { Rectangle() ScratchPath(strokes: strokes) .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) .blendMode(.destinationOut) } .compositingGroup() } .opacity(isRevealed ? 0 : 1) .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard !isRevealed else { return } if value.translation == .zero || strokes.isEmpty { strokes.append([value.location]) } else { strokes[strokes.count - 1].append(value.location) } mark(value.location, in: geometry.size) } ) .allowsHitTesting(!isRevealed) } .accessibilityElement() .accessibilityLabel("Scratch card") .accessibilityAddTraits(.isButton) .accessibilityAction { reveal() } .accessibilityHidden(isRevealed) } } /// Marks every grid cell the brush touches at `point`. private func mark(_ point: CGPoint, in size: CGSize) { guard size.width > 0, size.height > 0 else { return } let cellW = size.width / CGFloat(grid), cellH = size.height / CGFloat(grid) let r = lineWidth / 2 let minC = max(0, Int((point.x - r) / cellW)), maxC = min(grid - 1, Int((point.x + r) / cellW)) let minR = max(0, Int((point.y - r) / cellH)), maxR = min(grid - 1, Int((point.y + r) / cellH)) guard minC <= maxC, minR <= maxR else { return } for row in minR...maxR { for column in minC...maxC { scratched.insert(row * grid + column) } } if Double(scratched.count) / Double(grid * grid) >= threshold { reveal() } } private func reveal() { guard !isRevealed else { return } withAnimation(.easeOut(duration: 0.4)) { isRevealed = true } onReveal() } } struct ScratchPath: Shape { var strokes: [[CGPoint]] nonisolated func path(in rect: CGRect) -> Path { var path = Path() for stroke in strokes { guard let first = stroke.first else { continue } path.move(to: first) // A single tap still leaves a round dot. path.addLine(to: stroke.count > 1 ? stroke[1] : CGPoint(x: first.x + 0.1, y: first.y)) if stroke.count > 2 { path.addLines(Array(stroke.dropFirst())) } } return path } } public extension View { /// Hides the view under a scratch-off cover that the user rubs away. /// /// ```swift /// PrizeView() /// .scratchToReveal(LinearGradient(colors: [.gray, .secondary], startPoint: .top, endPoint: .bottom)) { /// didReveal = true /// } /// ``` /// /// Give the view a new `.id` to reset the cover. /// /// - Parameters: /// - cover: The scratch-off layer's fill. /// - lineWidth: Width of the "coin", in points. /// - threshold: Fraction of the cover to scratch before it clears itself. /// - onReveal: Called once when the cover clears. func scratchToReveal( _ cover: Cover = Color.gray, lineWidth: CGFloat = 34, threshold: Double = 0.55, onReveal: @escaping () -> Void = {} ) -> some View { modifier(ScratchModifier(cover: cover, lineWidth: lineWidth, threshold: threshold, onReveal: onReveal)) } } #Preview { VStack(spacing: 6) { Text("🎉").font(.system(size: 44)) Text("You won!").font(.headline) } .frame(width: 220, height: 140) .background(Color.yellow.opacity(0.3)) .scratchToReveal(LinearGradient(colors: [Color(white: 0.62), Color(white: 0.78)], startPoint: .topLeading, endPoint: .bottomTrailing)) .clipShape(RoundedRectangle(cornerRadius: 16)) .padding() } ``` --- ## Stacking Cards (`stacking-cards`) Cards stick at the top while scrolling and shrink as the next one covers them. - API: `.stackingCard()` - Component: Card & Group - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/StackingCards.swift - Files: Sources/MotionLibrary/Card/StackingCards.swift ### Usage ```swift ScrollView { VStack(spacing: 16) { ForEach(cards) { card in CardView(card).stackingCard() } } } ``` ### How it works Create a SwiftUI .stackingCard(top:scale:) view modifier (iOS 17+) for cards in a VStack inside a vertical ScrollView, built with visualEffect so it needs no layout passes. From proxy.frame(in: .scrollView): overshoot = max(0, top − minY) (top 16 pt), covered = min(1, overshoot / height), gone = clamp(overshoot / height − 1, 0, 1). Apply offset(y: overshoot) so the card sticks at the top, scaleEffect(1 − (1 − scale) × covered, anchor: .top) with scale 0.88, brightness(−0.25 × covered) and opacity(1 − gone). Later cards naturally draw on top in the VStack, so they slide over the pinned ones like a wallet. With Reduce Motion on, skip the scaling. ### Source ```swift import SwiftUI /// While scrolling, each card sticks at the top of the scroll view, then /// shrinks and dims as the next card slides over it, so the cards pile up /// like a deck (Apple Wallet style). Uses `visualEffect`, so it costs no /// layout passes. struct StackingCardModifier: ViewModifier { var top: CGFloat var scale: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let top = top, scale = scale, reduceMotion = reduceMotion return content.visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let height = max(1, frame.height) // How far the card has scrolled past its pin line. let overshoot = max(0, top - frame.minY) let covered = min(1, overshoot / height) // Once fully covered, fade it so the pile stays tidy. let gone = min(1, max(0, overshoot / height - 1)) return effect .offset(y: overshoot) .scaleEffect(reduceMotion ? 1 : 1 - (1 - scale) * covered, anchor: .top) .brightness(-0.25 * covered) .opacity(1 - gone) } } } public extension View { /// Pins the card at the top of its scroll view and shrinks it as the /// next card slides over it. Apply to every card in a `VStack` inside a /// vertical `ScrollView`. /// /// ```swift /// ScrollView { /// VStack(spacing: 16) { /// ForEach(cards) { card in /// CardView(card).stackingCard() /// } /// } /// } /// ``` /// /// - Parameters: /// - top: Distance from the top of the scroll view where cards stick. /// - scale: How small a fully covered card gets. func stackingCard(top: CGFloat = 16, scale: CGFloat = 0.88) -> some View { modifier(StackingCardModifier(top: top, scale: scale)) } } #Preview { ScrollView { VStack(spacing: 16) { ForEach([Color.pink, .orange, .teal, .indigo, .mint, .purple], id: \.self) { color in RoundedRectangle(cornerRadius: 20) .fill(color.gradient) .frame(height: 180) .stackingCard() } } .padding() .padding(.bottom, 300) } } ``` --- ## Deck Shuffle (`deck-shuffle`) A deck splits in two, mixes, and springs back into a messy stack. - API: `DeckShuffle(items, trigger:) { item in }` - Component: Card & Group - Trigger: Value change / Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/DeckShuffle.swift - Files: Sources/MotionLibrary/Card/DeckShuffle.swift ### Usage ```swift DeckShuffle(cards, trigger: shuffles) { card in CardFace(card) } .onTapGesture { shuffles += 1 } ``` ### How it works Create a generic SwiftUI DeckShuffle(_ items:trigger:spread:content:) view (iOS 17+) for Identifiable items. Keep the order as @State [ID]. Draw the cards in a ZStack with zIndex = stack position, offset y = −2 pt × position and a small hand-made tilt per position (0, −2.5, 2, −1, 3, −2, 1.5, −3°). When trigger changes: animate isSplit = true with .easeOut(0.28 s), which moves even positions to x −spread (70 pt) rotated −8° and odd ones to +spread rotated +8°; after 0.3 s, in one .spring(duration: 0.55, bounce: 0.3) animation, shuffle the order and set isSplit = false, so cards change sides and visibly cross over as they come together. With Reduce Motion on, just re-order with a 0.3 s ease. ### Source ```swift import SwiftUI /// A small deck of cards that riffle-shuffles every time `trigger` changes: /// the deck splits into two halves that slide apart, the order is mixed, and /// the cards spring back together into a slightly messy stack. public struct DeckShuffle: View where Data.Element: Identifiable { private let items: Data private let trigger: Trigger private let spread: CGFloat private let content: (Data.Element) -> Content @State private var order: [Data.Element.ID] = [] @State private var isSplit = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: The cards, bottom to top. /// - trigger: Shuffles whenever it changes. /// - spread: How far each half slides out, in points. /// - content: Draws one card. public init(_ items: Data, trigger: Trigger, spread: CGFloat = 70, @ViewBuilder content: @escaping (Data.Element) -> Content) { self.items = items self.trigger = trigger self.spread = spread self.content = content } public var body: some View { let ids = order.isEmpty ? items.map(\.id) : order ZStack { ForEach(Array(items)) { item in let position = ids.firstIndex(of: item.id) ?? 0 let tilt = Self.tilt(for: position) content(item) .rotationEffect(.degrees(isSplit ? (position.isMultiple(of: 2) ? -8 : 8) : tilt)) .offset( x: isSplit ? (position.isMultiple(of: 2) ? -spread : spread) : 0, y: isSplit ? 0 : -CGFloat(position) * 2 ) .zIndex(Double(position)) } } .onAppear { if order.isEmpty { order = items.map(\.id) } } .onChange(of: trigger) { shuffle() } } /// A small, stable tilt per stack position so the deck looks hand-made. private static func tilt(for position: Int) -> Double { let values: [Double] = [0, -2.5, 2, -1, 3, -2, 1.5, -3] return values[position % values.count] } private func shuffle() { let shuffled = (order.isEmpty ? items.map(\.id) : order).shuffled() if reduceMotion { withAnimation(.easeInOut(duration: 0.3)) { order = shuffled } return } withAnimation(.easeOut(duration: 0.28)) { isSplit = true } Task { @MainActor in try? await Task.sleep(for: .seconds(0.3)) // Re-ordering while apart changes which half each card is in, so // cards visibly cross over as they come back together. withAnimation(.spring(duration: 0.55, bounce: 0.3)) { order = shuffled isSplit = false } } } } #Preview { struct Card: Identifiable { let id: Int; let color: Color } struct Demo: View { @State private var shuffles = 0 let cards = [Card(id: 0, color: .pink), Card(id: 1, color: .orange), Card(id: 2, color: .teal), Card(id: 3, color: .indigo), Card(id: 4, color: .mint)] var body: some View { DeckShuffle(cards, trigger: shuffles) { card in RoundedRectangle(cornerRadius: 12).fill(card.color.gradient).frame(width: 80, height: 112) } .onTapGesture { shuffles += 1 } .padding(80) } } return Demo() } ``` --- ## Looping Carousel (`looping-carousel`) A paging carousel that wraps around forever, with peeking neighbours. - API: `LoopingCarousel(items) { item in }` - Component: Card & Group - Trigger: Drag / Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/LoopingCarousel.swift - Files: Sources/MotionLibrary/Card/LoopingCarousel.swift ### Usage ```swift LoopingCarousel(banners, autoplay: 3) { banner in BannerCard(banner) } .frame(height: 200) ``` ### How it works Create a SwiftUI LoopingCarousel(_ items:spacing:peek:autoplay:content:) view (iOS 17+). Inside a horizontal ScrollView, a LazyHStack(spacing: 12) lays the items out three times (index 0..<3n, item = items[index % n]), each .containerRelativeFrame(.horizontal) with .scrollTransition(.interactive) scaling to 1 − |phase| × 0.12 and fading to 1 − |phase| × 0.4, and .id(index). Use .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), .contentMargins(.horizontal, peek + spacing) (peek 36 pt) and .scrollPosition(id:), starting at n (the middle copy). In .task(id: position), wait 0.45 s for scrolling to settle; if the position is outside the middle copy, jump to n + position % n inside a transaction with animations disabled, which looks identical. For autoplay, loop: sleep autoplay seconds, then position += 1 with .spring(duration: 0.6, bounce: 0.15). With Reduce Motion on, skip the scaling. ### Source ```swift import SwiftUI /// A horizontally paging carousel that never ends: it wraps from the last /// card back to the first. Neighbours peek in from the sides, shrunk and /// faded, and it can advance on its own. /// /// The items are laid out three times; after scrolling settles, the /// position silently jumps back to the middle copy. public struct LoopingCarousel: View where Data.Index == Int { private let items: Data private let spacing: CGFloat private let peek: CGFloat private let autoplay: Double? private let content: (Data.Element) -> Content @State private var position: Int? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: The cards, in order. /// - spacing: Gap between cards, in points. /// - peek: How much of each neighbour shows at the sides, in points. /// - autoplay: Seconds between automatic advances; `nil` to disable. /// - content: Draws one card. public init(_ items: Data, spacing: CGFloat = 12, peek: CGFloat = 36, autoplay: Double? = nil, @ViewBuilder content: @escaping (Data.Element) -> Content) { self.items = items self.spacing = spacing self.peek = peek self.autoplay = autoplay self.content = content } public var body: some View { let count = items.count let reduceMotion = reduceMotion ScrollView(.horizontal) { LazyHStack(spacing: spacing) { ForEach(0..<(count * 3), id: \.self) { index in content(items[items.startIndex + index % max(1, count)]) .containerRelativeFrame(.horizontal) .scrollTransition(.interactive, axis: .horizontal) { view, phase in view .scaleEffect(reduceMotion ? 1 : 1 - abs(phase.value) * 0.12) .opacity(1 - abs(phase.value) * 0.4) } .id(index) } } .scrollTargetLayout() } .contentMargins(.horizontal, peek + spacing, for: .scrollContent) .scrollTargetBehavior(.viewAligned) .scrollIndicators(.hidden) .scrollPosition(id: $position) .onAppear { if position == nil { position = count } } .task(id: position) { // Once scrolling has settled, recentre on the middle copy. try? await Task.sleep(for: .seconds(0.45)) guard count > 0, let current = position, current < count || current >= count * 2 else { return } var jump = Transaction() jump.disablesAnimations = true withTransaction(jump) { position = count + current % count } } .task(id: autoplay) { guard let autoplay, count > 1 else { return } while !Task.isCancelled { try? await Task.sleep(for: .seconds(autoplay)) guard !Task.isCancelled else { return } withAnimation(.spring(duration: 0.6, bounce: 0.15)) { position = (position ?? count) + 1 } } } } } #Preview { LoopingCarousel([Color.pink, .orange, .teal, .indigo], autoplay: 2) { color in RoundedRectangle(cornerRadius: 20).fill(color.gradient).frame(height: 180) } .frame(height: 200) } ``` --- ## Drag to Dismiss (`drag-to-dismiss`) Fling a card away; it flies off with your throw or springs back. - API: `.dragToDismiss(isPresented:)` - Component: Card & Group - Trigger: Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/DragToDismiss.swift - Files: Sources/MotionLibrary/Card/DragToDismiss.swift ### Usage ```swift if showPhoto { PhotoView() .dragToDismiss(isPresented: $showPhoto) } ``` ### How it works Create a SwiftUI .dragToDismiss(isPresented:threshold:) view modifier (iOS 17+). While dragging, offset the view by the translation, scale it to 1 − min(0.2, distance / 1200) and rotate it by translation.width / 25 degrees. On release, if the drag distance exceeds threshold (120 pt) or the predicted end translation exceeds 2.5 × threshold, fly it off along the predicted direction normalised to 700 pt with .easeOut(0.3 s) while fading out, then set isPresented = false and reset the offset without animation. Otherwise spring back with .spring(duration: 0.45, bounce: 0.4). Add an accessibility action "Dismiss". With Reduce Motion on, skip the scale and rotation. ### Source ```swift import SwiftUI /// Lets the user fling a view away. While dragging it follows the finger, /// shrinking and tilting a little; released past a distance (or flicked fast /// enough) it flies off in the throw direction and `isPresented` turns /// false; otherwise it springs back. struct DragToDismissModifier: ViewModifier { @Binding var isPresented: Bool var threshold: CGFloat @State private var drag: CGSize = .zero @State private var isLeaving = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let distance = hypot(drag.width, drag.height) content .scaleEffect(reduceMotion ? 1 : 1 - min(0.2, distance / 1200)) .rotationEffect(.degrees(reduceMotion ? 0 : Double(drag.width) / 25)) .offset(drag) .opacity(isLeaving ? 0 : 1) .gesture( DragGesture() .onChanged { value in guard !isLeaving else { return } drag = value.translation } .onEnded { value in guard !isLeaving else { return } let predicted = value.predictedEndTranslation let thrown = hypot(predicted.width, predicted.height) > threshold * 2.5 if hypot(value.translation.width, value.translation.height) > threshold || thrown { dismiss(towards: predicted) } else { withAnimation(.spring(duration: 0.45, bounce: 0.4)) { drag = .zero } } } ) .accessibilityAction(named: "Dismiss") { isPresented = false } } private func dismiss(towards predicted: CGSize) { let length = max(1, hypot(predicted.width, predicted.height)) // Keep the throw direction but make sure it leaves the screen. let fly = CGSize(width: predicted.width / length * 700, height: predicted.height / length * 700) withAnimation(.easeOut(duration: 0.3)) { drag = fly isLeaving = true } Task { @MainActor in try? await Task.sleep(for: .seconds(0.3)) isPresented = false var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { drag = .zero isLeaving = false } } } } public extension View { /// Lets the user drag the view away to dismiss it, with a spring back if /// they don't drag far enough. /// /// ```swift /// if showPhoto { /// PhotoView() /// .dragToDismiss(isPresented: $showPhoto) /// } /// ``` /// /// - Parameters: /// - isPresented: Set to `false` once the view has flown off. /// - threshold: Drag distance, in points, that dismisses on release. func dragToDismiss(isPresented: Binding, threshold: CGFloat = 120) -> some View { modifier(DragToDismissModifier(isPresented: isPresented, threshold: threshold)) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack { if shown { RoundedRectangle(cornerRadius: 24) .fill(Color.indigo.gradient) .frame(width: 220, height: 300) .dragToDismiss(isPresented: $shown) } else { Button("Show again") { shown = true } } } .frame(maxWidth: .infinity, maxHeight: .infinity) } } return Demo() } ``` --- ## Shrinking Header (`shrinking-header`) The header sticks to the top and shrinks as content scrolls under it. - API: `.shrinkingHeader()` - Component: Card & Group - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollHeaders.swift - Files: Sources/MotionLibrary/Card/ScrollHeaders.swift ### Usage ```swift ScrollView { VStack(spacing: 0) { ProfileHeader().shrinkingHeader() ForEach(posts) { PostRow($0) } } } ``` ### How it works Create a SwiftUI .shrinkingHeader(minScale:) view modifier (iOS 17+) for the first view in a vertical ScrollView, built with visualEffect. From proxy.frame(in: .scrollView): scrolled = max(0, −minY), progress = min(1, scrolled / (height × (1 − minScale))). Apply scaleEffect(1 − (1 − minScale) × progress, anchor: .top) with minScale 0.6 first, then offset(y: scrolled) so it stays pinned at the top (offset first and the scale would shrink the offset too, letting the header drift away), and give the view .zIndex(1) so the content scrolls underneath it. With Reduce Motion on, pin it without scaling. ### Source ```swift import SwiftUI // Headers that react to the scroll position, driven by `visualEffect` so // they cost no extra layout passes. Apply them to the first view inside a // vertical `ScrollView`. // MARK: - Shrinking header struct ShrinkingHeaderModifier: ViewModifier { var minScale: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let minScale = minScale, reduceMotion = reduceMotion return content .visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let scrolled = max(0, -frame.minY) // Shrinks over the distance it loses in height, then stays put. let range = max(1, frame.height * (1 - minScale)) let progress = min(1, scrolled / range) // Scale first, then offset: the other way round the scale // would shrink the pinning offset too and the header would // drift off the top. return effect .scaleEffect(reduceMotion ? 1 : 1 - (1 - minScale) * progress, anchor: .top) .offset(y: scrolled) } // Stay above the content that scrolls underneath. .zIndex(1) } } // MARK: - Stretchy header struct StretchyHeaderModifier: ViewModifier { var parallax: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let parallax = reduceMotion ? 0 : parallax return content.visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let pull = max(0, frame.minY) let height = max(1, frame.height) return effect // Pulled down: grow from the bottom so it fills the gap. .scaleEffect(1 + pull / height, anchor: .bottom) // Scrolled up: lag behind the content for a parallax feel. .offset(y: min(0, frame.minY) * -parallax) } } } public extension View { /// Pins the header to the top of its scroll view and shrinks it as the /// content scrolls up underneath. /// /// ```swift /// ScrollView { /// VStack(spacing: 0) { /// ProfileHeader().shrinkingHeader() /// ForEach(posts) { PostRow($0) } /// } /// } /// ``` /// /// - Parameter minScale: The header's scale once fully collapsed. func shrinkingHeader(minScale: CGFloat = 0.6) -> some View { modifier(ShrinkingHeaderModifier(minScale: minScale)) } /// Makes a header image stretch when the scroll view is pulled down past /// the top, and drift with a parallax when scrolled up. /// /// ```swift /// ScrollView { /// Image("cover") /// .resizable() /// .scaledToFill() /// .frame(height: 260) /// .clipped() /// .stretchyHeader() /// ArticleBody() /// } /// ``` /// /// - Parameter parallax: How much slower than the content it moves when /// scrolled up (0 = none, 0.5 = half speed). func stretchyHeader(parallax: CGFloat = 0.5) -> some View { modifier(StretchyHeaderModifier(parallax: parallax)) } } #Preview { ScrollView { VStack(spacing: 0) { LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom) .frame(height: 200) .overlay(Text("Stretch me").font(.title.bold()).foregroundStyle(.white)) .stretchyHeader() ForEach(0..<20, id: \.self) { index in Text("Row \(index)") .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(.background) } } } } ``` --- ## Stretchy Header (`stretchy-header`) A header image stretches when pulled down and drifts with parallax when scrolled. - API: `.stretchyHeader()` - Component: Card & Group - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/ScrollHeaders.swift - Files: Sources/MotionLibrary/Card/ScrollHeaders.swift ### Usage ```swift ScrollView { Image("cover") .resizable() .scaledToFill() .frame(height: 260) .clipped() .stretchyHeader() ArticleBody() } ``` ### How it works Create a SwiftUI .stretchyHeader(parallax:) view modifier (iOS 17+) for a header image at the top of a vertical ScrollView, built with visualEffect. From proxy.frame(in: .scrollView): when pulled past the top (minY > 0), scaleEffect(1 + minY / height, anchor: .bottom) so it grows upward to fill the gap; when scrolled up (minY < 0), offset(y: minY × −parallax) with parallax 0.5 so it moves at half speed. With Reduce Motion on, skip the parallax. ### Source ```swift import SwiftUI // Headers that react to the scroll position, driven by `visualEffect` so // they cost no extra layout passes. Apply them to the first view inside a // vertical `ScrollView`. // MARK: - Shrinking header struct ShrinkingHeaderModifier: ViewModifier { var minScale: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let minScale = minScale, reduceMotion = reduceMotion return content .visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let scrolled = max(0, -frame.minY) // Shrinks over the distance it loses in height, then stays put. let range = max(1, frame.height * (1 - minScale)) let progress = min(1, scrolled / range) // Scale first, then offset: the other way round the scale // would shrink the pinning offset too and the header would // drift off the top. return effect .scaleEffect(reduceMotion ? 1 : 1 - (1 - minScale) * progress, anchor: .top) .offset(y: scrolled) } // Stay above the content that scrolls underneath. .zIndex(1) } } // MARK: - Stretchy header struct StretchyHeaderModifier: ViewModifier { var parallax: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let parallax = reduceMotion ? 0 : parallax return content.visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let pull = max(0, frame.minY) let height = max(1, frame.height) return effect // Pulled down: grow from the bottom so it fills the gap. .scaleEffect(1 + pull / height, anchor: .bottom) // Scrolled up: lag behind the content for a parallax feel. .offset(y: min(0, frame.minY) * -parallax) } } } public extension View { /// Pins the header to the top of its scroll view and shrinks it as the /// content scrolls up underneath. /// /// ```swift /// ScrollView { /// VStack(spacing: 0) { /// ProfileHeader().shrinkingHeader() /// ForEach(posts) { PostRow($0) } /// } /// } /// ``` /// /// - Parameter minScale: The header's scale once fully collapsed. func shrinkingHeader(minScale: CGFloat = 0.6) -> some View { modifier(ShrinkingHeaderModifier(minScale: minScale)) } /// Makes a header image stretch when the scroll view is pulled down past /// the top, and drift with a parallax when scrolled up. /// /// ```swift /// ScrollView { /// Image("cover") /// .resizable() /// .scaledToFill() /// .frame(height: 260) /// .clipped() /// .stretchyHeader() /// ArticleBody() /// } /// ``` /// /// - Parameter parallax: How much slower than the content it moves when /// scrolled up (0 = none, 0.5 = half speed). func stretchyHeader(parallax: CGFloat = 0.5) -> some View { modifier(StretchyHeaderModifier(parallax: parallax)) } } #Preview { ScrollView { VStack(spacing: 0) { LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom) .frame(height: 200) .overlay(Text("Stretch me").font(.title.bold()).foregroundStyle(.white)) .stretchyHeader() ForEach(0..<20, id: \.self) { index in Text("Row \(index)") .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(.background) } } } } ``` --- ## Island Pill (`island-pill`) A black pill springs open into a card and back, its contents blurring across. - API: `IslandPill(isExpanded:) { } expanded: { }` - Component: Card & Group - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/IslandPill.swift - Files: Sources/MotionLibrary/Card/IslandPill.swift ### Usage ```swift IslandPill(isExpanded: showsPlayer) { Image(systemName: "waveform") } expanded: { NowPlayingView() } .onTapGesture { showsPlayer.toggle() } ``` ### How it works Create a SwiftUI IslandPill(isExpanded:compactSize:expandedSize:color:compact:expanded:) view (iOS 17+): a black shape that morphs between a pill (default 126 × 37, corner radius half its height) and a card (default 340 × 160, corner radius min(44, height / 2)), using a continuous RoundedRectangle as background and clip shape. Inside, a ZStack holds both contents: the compact one fades out while blurring to 8 pt and scaling to 1.6; the expanded one fades in from blur 10 pt and scale 0.6 (anchored top). Force the dark colour scheme inside. Animate every change of isExpanded with .spring(duration: 0.5, bounce: 0.32). With Reduce Motion on, skip the blur and scale and use .easeInOut(duration: 0.25). ### Source ```swift import SwiftUI /// A black pill that springs open into a larger rounded card and back, like a /// live activity growing out of the top of the screen. The compact and /// expanded contents cross-fade with a blur while the shape morphs. /// /// ```swift /// IslandPill(isExpanded: showsPlayer) { /// Image(systemName: "waveform") /// } expanded: { /// NowPlayingView() /// } /// ``` public struct IslandPill: View { private let isExpanded: Bool private let compactSize: CGSize private let expandedSize: CGSize private let color: Color private let compact: Compact private let expanded: Expanded @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isExpanded: Shows the expanded content when true. Changes animate. /// - compactSize: Size of the closed pill. /// - expandedSize: Size of the open card. /// - color: Fill of the pill. /// - compact: Content of the closed pill. /// - expanded: Content of the open card. public init( isExpanded: Bool, compactSize: CGSize = CGSize(width: 126, height: 37), expandedSize: CGSize = CGSize(width: 340, height: 160), color: Color = .black, @ViewBuilder compact: () -> Compact, @ViewBuilder expanded: () -> Expanded ) { self.isExpanded = isExpanded self.compactSize = compactSize self.expandedSize = expandedSize self.color = color self.compact = compact() self.expanded = expanded() } public var body: some View { let size = isExpanded ? expandedSize : compactSize let radius = isExpanded ? min(44, expandedSize.height / 2) : compactSize.height / 2 ZStack { compact .opacity(isExpanded ? 0 : 1) .blur(radius: isExpanded && !reduceMotion ? 8 : 0) .scaleEffect(isExpanded && !reduceMotion ? 1.6 : 1) expanded .opacity(isExpanded ? 1 : 0) .blur(radius: isExpanded || reduceMotion ? 0 : 10) .scaleEffect(isExpanded || reduceMotion ? 1 : 0.6, anchor: .top) } .frame(width: size.width, height: size.height) .background(color, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) .environment(\.colorScheme, .dark) .animation(reduceMotion ? .easeInOut(duration: 0.25) : .spring(duration: 0.5, bounce: 0.32), value: isExpanded) } } #Preview { struct Demo: View { @State private var open = false var body: some View { IslandPill(isExpanded: open) { HStack { Image(systemName: "music.note") Spacer() Image(systemName: "waveform").foregroundStyle(.green) } .padding(.horizontal, 14) .foregroundStyle(.white) } expanded: { HStack(spacing: 14) { RoundedRectangle(cornerRadius: 12).fill(.orange.gradient).frame(width: 64, height: 64) VStack(alignment: .leading) { Text("Now Playing").font(.headline) Text("Motion Library").foregroundStyle(.secondary) } Spacer() } .padding(20) .foregroundStyle(.white) } .onTapGesture { open.toggle() } .frame(height: 220, alignment: .top) } } return Demo() } ``` --- ## Hover Lift (`hover-lift`) Cards rise, grow and lean towards the pointer on iPad and Mac. - API: `.hoverLift(isActive:)` - Component: Card & Group - Trigger: Hover - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/HoverLift.swift - Files: Sources/MotionLibrary/Card/HoverLift.swift ### Usage ```swift LazyVGrid(columns: columns) { ForEach(projects) { project in ProjectCard(project) .hoverLift() } } ``` ### How it works Create a SwiftUI .hoverLift(scale:tilt:cornerRadius:) view modifier (iOS 17+) for pointer hover on iPad and Mac. Track the pointer with .onContinuousHover(coordinateSpace: .local) and the view size with a background GeometryReader. While hovered, scale to 1.05, offset y −4 and deepen the shadow (black 22 %, radius 18, y 12; at rest 8 %, radius 6, y 3), all with .spring(duration: 0.35, bounce: 0.3). Lean towards the pointer: with lean = location / size × 2 − 1, apply rotation3DEffect(tilt × lean.x around y) and (−tilt × lean.y around x), tilt 6°, perspective 0.6, animated with .interactiveSpring(duration: 0.25). With Reduce Motion on, don't move or scale; brighten by 0.05 instead. Add isActive (default false) to lift the view without a pointer too, for the focused or selected card. On visionOS, where apps can't see the gaze, use .contentShape(.hoverEffect, RoundedRectangle(cornerRadius:)) and the system .hoverEffect(). ### Source ```swift import SwiftUI /// Lifts the view towards the pointer on hover (iPad trackpad or mouse, Mac): /// it grows a little, rises, casts a deeper shadow and leans slightly /// towards the pointer. On visionOS, where apps can't see the gaze, it uses /// the system hover effect instead. struct HoverLiftModifier: ViewModifier { var scale: CGFloat var tilt: Double var cornerRadius: CGFloat var isActive: Bool @State private var location: CGPoint? @State private var size: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { #if os(visionOS) content .contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .hoverEffect() .scaleEffect(isActive && !reduceMotion ? scale : 1) .animation(.spring(duration: 0.35, bounce: 0.3), value: isActive) #else let hovering = location != nil || isActive // -1…1 from the centre, for the lean. let lean = location.map { CGPoint(x: size.width > 0 ? $0.x / size.width * 2 - 1 : 0, y: size.height > 0 ? $0.y / size.height * 2 - 1 : 0) } ?? .zero let moves = !reduceMotion content .scaleEffect(hovering && moves ? scale : 1) .rotation3DEffect(.degrees(moves ? tilt * Double(lean.x) : 0), axis: (x: 0, y: 1, z: 0), perspective: 0.6) .rotation3DEffect(.degrees(moves ? -tilt * Double(lean.y) : 0), axis: (x: 1, y: 0, z: 0), perspective: 0.6) .offset(y: hovering && moves ? -4 : 0) .shadow(color: .black.opacity(hovering ? 0.22 : 0.08), radius: hovering ? 18 : 6, y: hovering ? 12 : 3) .brightness(hovering && !moves ? 0.05 : 0) .animation(.spring(duration: 0.35, bounce: 0.3), value: hovering) .animation(.interactiveSpring(duration: 0.25), value: lean) .background { GeometryReader { proxy in Color.clear .onAppear { size = proxy.size } .onChange(of: proxy.size) { _, new in size = new } } } .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(point): location = point case .ended: location = nil } } #endif } } public extension View { /// Lifts the view under the pointer: it scales up, rises, deepens its /// shadow and leans towards the pointer. For iPad pointers and the Mac; /// visionOS gets the system hover highlight. /// /// ```swift /// ProjectCard(project) /// .hoverLift() /// ``` /// /// - Parameters: /// - scale: Scale while hovered. /// - tilt: Maximum lean towards the pointer, in degrees. /// - cornerRadius: Corner radius of the hover shape on visionOS. /// - isActive: Also lifts the view while true, for example for the /// focused or selected card (without the lean, which follows the pointer). func hoverLift(scale: CGFloat = 1.05, tilt: Double = 6, cornerRadius: CGFloat = 16, isActive: Bool = false) -> some View { modifier(HoverLiftModifier(scale: scale, tilt: tilt, cornerRadius: cornerRadius, isActive: isActive)) } } #Preview { HStack(spacing: 20) { ForEach(0..<3) { index in RoundedRectangle(cornerRadius: 16, style: .continuous) .fill([Color.orange, .pink, .indigo][index].gradient) .frame(width: 90, height: 120) .hoverLift() } } .padding(40) } ``` --- ## Marquee Wall (`marquee-wall`) A tilted wall of cards, columns scrolling endlessly up and down. - API: `MarqueeWall(count:) { index in }` - Component: Card & Group - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Card/MarqueeWall.swift - Files: Sources/MotionLibrary/Card/MarqueeWall.swift ### Usage ```swift MarqueeWall(count: shots.count, columns: 4) { index in Image(shots[index]).resizable().scaledToFill() } .frame(height: 320) .clipped() ``` ### How it works Create a SwiftUI MarqueeWall(count:columns:itemSize:spacing:speed:angle:content:) view (iOS 17+), the scrolling showcase wall from landing pages. Each of the columns (default 4) shows perColumn = max(3, ceil(count / columns)) items twice in a VStack, cards of itemSize (110 × 140) with 14 pt continuous corners and 12 pt spacing, so the loop height is perColumn × (height + spacing). In TimelineView(.animation), distance = (time × speed × (1 + 0.15 × (column % 3))) mod loopHeight (24 pt/s × speed, a multiplier defaulting to 1); even columns offset by −distance (moving up), odd ones by distance − loopHeight (moving down). Lay the columns in an HStack, rotate the whole thing by −14° and let the caller clip it. Disable hit testing. With Reduce Motion on, pause the timeline. ### Source ```swift import SwiftUI /// A tilted wall of cards whose columns scroll endlessly in alternating /// directions, the showcase backdrop of portfolio and landing pages. /// /// ```swift /// MarqueeWall(count: 12, columns: 4) { index in /// Image("shot-\(index)").resizable().scaledToFill() /// } /// .frame(height: 300) /// .clipped() /// ``` public struct MarqueeWall: View { private let count: Int private let columns: Int private let itemSize: CGSize private let spacing: CGFloat private let speed: Double private let angle: Angle private let content: (Int) -> Content @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of distinct items; they're shared out between the columns. /// - columns: Number of columns. /// - itemSize: Size of each card. /// - spacing: Gap between cards. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - angle: Rotation of the whole wall. /// - content: Builds the card for an index in `0.. Content ) { self.count = max(1, count) self.columns = max(1, columns) self.itemSize = itemSize self.spacing = spacing self.speed = speed * 24 self.angle = angle self.content = content } public var body: some View { // Each column shows its items twice over, so the loop is seamless. let perColumn = max(3, Int(ceil(Double(count) / Double(columns)))) let loopHeight = CGFloat(perColumn) * (itemSize.height + spacing) TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate HStack(alignment: .top, spacing: spacing) { ForEach(0..: ViewModifier { var trigger: Trigger var duration: Double @State private var progress: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .modifier(DevelopEffect(progress: progress)) .onAppear(perform: develop) .onChange(of: trigger) { develop() } } private func develop() { var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { progress = 0 } withAnimation(reduceMotion ? .easeInOut(duration: 0.4) : .easeOut(duration: duration)) { progress = 1 } } } /// The look at a point of the development, 0 (blank) to 1 (developed). /// Animatable, so each quality keeps its own pace during the animation. struct DevelopEffect: ViewModifier, Animatable { var progress: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { // Each quality arrives at its own pace: light first, colour last. let light = min(1, progress * 1.6) let colour = max(0, (progress - 0.25) / 0.75) let focus = min(1, progress * 1.3) content .saturation(colour) .contrast(0.55 + 0.45 * light) .brightness(-0.45 * (1 - light)) .blur(radius: 5 * (1 - focus)) .overlay { // The murky blue-brown cast of an undeveloped print. LinearGradient(colors: [Color(red: 0.2, green: 0.25, blue: 0.35), Color(red: 0.35, green: 0.25, blue: 0.18)], startPoint: .topLeading, endPoint: .bottomTrailing) .opacity(0.75 * (1 - colour)) .blendMode(.multiply) .allowsHitTesting(false) } } } public extension View { /// Develops the view like an instant photo on appear, and again whenever /// `trigger` changes. /// /// ```swift /// Image("snapshot") /// .resizable() /// .scaledToFill() /// .photoDevelop() /// ``` /// /// - Parameters: /// - trigger: Develops the photo again whenever it changes. /// - duration: Seconds from blank to fully developed. func photoDevelop(trigger: some Equatable = 0, duration: Double = 3.5) -> some View { modifier(PhotoDevelopModifier(trigger: trigger, duration: duration)) } } #Preview { struct Demo: View { @State private var shots = 0 var body: some View { VStack(spacing: 0) { LinearGradient(colors: [.orange, .pink, .purple], startPoint: .top, endPoint: .bottom) .overlay(Image(systemName: "sun.max.fill").font(.system(size: 50)).foregroundStyle(.yellow)) .frame(width: 160, height: 160) .photoDevelop(trigger: shots) Color.white.frame(height: 40) } .padding([.top, .horizontal], 10) .background(.white) .shadow(radius: 6) .onTapGesture { shots += 1 } } } return Demo() } ``` --- ## Pulse Rings (`pulse-rings`) Concentric rings expand and fade continuously, like a radar or live dot. - API: `PulseRings()` - Component: Shape - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/PulseRings.swift - Files: Sources/MotionLibrary/Shape/PulseRings.swift ### Usage ```swift ZStack { PulseRings(color: .green, count: 3) .frame(width: 120, height: 120) Circle().fill(.green).frame(width: 24, height: 24) } ``` ### How it works Create a SwiftUI PulseRings view (iOS 17+). Draw N stroked circles (default 3, 2 pt line) that each grow from 20% to 100% of the frame while fading from opacity 1 to 0 over a 2 s cycle, evenly phase-offset so a new ring is always emerging. Drive it with TimelineView(.animation) and compute each ring's progress from the timeline date (no @State or repeatForever), so it never drifts and stops when off screen. Hide it from accessibility. ### Source ```swift import SwiftUI /// Concentric rings that continuously expand and fade, like a radar or a /// "live" indicator. Place it behind an icon or avatar. /// /// ```swift /// ZStack { /// PulseRings(color: .green) /// .frame(width: 120, height: 120) /// Circle().fill(.green).frame(width: 24, height: 24) /// } /// ``` public struct PulseRings: View { private let color: Color private let count: Int private let duration: Double private let lineWidth: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion public init(color: Color = .accentColor, count: Int = 3, duration: Double = 2, lineWidth: CGFloat = 2) { self.color = color self.count = max(1, count) self.duration = duration self.lineWidth = lineWidth } public var body: some View { // Reduce Motion: frozen rings at evenly spaced sizes. TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? duration * 0.5 : timeline.date.timeIntervalSinceReferenceDate ZStack { ForEach(0.. Double { let offset = Double(index) / Double(count) return (time / duration + offset).truncatingRemainder(dividingBy: 1) } private func ring(progress: Double) -> some View { Circle() .stroke(color, lineWidth: lineWidth) .scaleEffect(0.2 + progress * 0.8) .opacity(1 - progress) } } #Preview { ZStack { PulseRings(color: .green) .frame(width: 160, height: 160) Circle() .fill(.green) .frame(width: 28, height: 28) } } ``` --- ## Like Burst (`burst`) Particles fly outward from the centre when a value changes. - API: `.burst(trigger:)` - Component: Shape - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Burst.swift - Files: Sources/MotionLibrary/Shape/Burst.swift ### Usage ```swift Image(systemName: isLiked ? "heart.fill" : "heart") .contentTransition(.symbolEffect(.replace)) .burst(trigger: isLiked, color: .pink, isActive: isLiked) ``` ### How it works Create a SwiftUI .burst(trigger:) view modifier (iOS 17+) for "like" feedback. Every time the Equatable trigger changes (and isActive is true), spawn a burst in an overlay: 12 small circles (alternating 7 pt and 4 pt) placed evenly around a circle, which move outward from the centre to a ~36 pt radius while shrinking to 30% and fading out with easeOut over 0.6 s. Remove each burst after it finishes, allow overlapping bursts, and never intercept touches. Color, count and radius are parameters. ### Source ```swift import SwiftUI /// Small dots that fly outward from the centre of the view, e.g. on a "like" /// tap. Plays every time `trigger` changes. struct BurstModifier: ViewModifier { var trigger: Trigger var color: Color var count: Int var radius: CGFloat var isEnabled: Bool @State private var bursts: [UUID] = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { ZStack { ForEach(bursts, id: \.self) { _ in BurstParticles(color: color, count: count, radius: radius) } } .allowsHitTesting(false) } .onChange(of: trigger) { // Reduce Motion: skip the particles. guard isEnabled, !reduceMotion else { return } let id = UUID() bursts.append(id) DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { bursts.removeAll { $0 == id } } } } } private struct BurstParticles: View { let color: Color let count: Int let radius: CGFloat @State private var exploded = false var body: some View { ZStack { ForEach(0.. some View { // Alternate particle size and distance so the burst feels organic. let size: CGFloat = index.isMultiple(of: 2) ? 7 : 4 let distance = radius * (index.isMultiple(of: 3) ? 0.75 : 1) return Circle() .fill(color) .frame(width: size, height: size) // Scale before offsetting, otherwise the scale also shrinks the // travel distance and the particles never leave the icon. .scaleEffect(exploded ? 0.3 : 1) .offset( x: exploded ? CGFloat(cos(angle)) * distance : 0, y: exploded ? CGFloat(sin(angle)) * distance : 0 ) .opacity(exploded ? 0 : 1) } } public extension View { /// Emits a burst of particles every time `trigger` changes. /// /// ```swift /// Image(systemName: isLiked ? "heart.fill" : "heart") /// .burst(trigger: isLiked, color: .pink, isActive: isLiked) /// ``` func burst( trigger: Trigger, color: Color = .pink, count: Int = 12, radius: CGFloat = 36, isActive: Bool = true ) -> some View { modifier(BurstModifier(trigger: trigger, color: color, count: count, radius: radius, isEnabled: isActive)) } @available(*, deprecated, renamed: "burst(trigger:color:count:radius:isActive:)") func burst( trigger: Trigger, color: Color = .pink, count: Int = 12, radius: CGFloat = 36, isEnabled: Bool ) -> some View { burst(trigger: trigger, color: color, count: count, radius: radius, isActive: isEnabled) } } #Preview { struct Demo: View { @State private var liked = false var body: some View { Button { liked.toggle() } label: { Image(systemName: liked ? "heart.fill" : "heart") .font(.system(size: 44)) .foregroundStyle(liked ? Color.pink : Color.secondary) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) .burst(trigger: liked, isActive: liked) } } return Demo() } ``` --- ## Checkmark Draw (`checkmark`) A success mark draws itself: circle stroke, fill, then a popping check. - API: `AnimatedCheckmark(isChecked:)` - Component: Shape - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/AnimatedCheckmark.swift - Files: Sources/MotionLibrary/Shape/AnimatedCheckmark.swift, Sources/MotionLibrary/Support/CheckmarkShape.swift ### Usage ```swift AnimatedCheckmark(isChecked: isDone) .frame(width: 80, height: 80) AnimatedCheckmark(isChecked: saved, color: .blue, lineWidth: 3) ``` ### How it works Create a SwiftUI AnimatedCheckmark view (iOS 17+) with isChecked, color (green) and lineWidth (4 at 60 pt, scaled with size). It is square. Layer three parts in a ZStack: a circle stroke trimmed from 0 to 1 and rotated −90° so it draws clockwise from the top (easeInOut 0.45 s); a filled circle that scales from 0.6 and fades in with a spring after 0.35 s; and a white checkmark path (short stroke down-right, long stroke up-right, round caps and joins) trimmed from 0 to 1 with easeOut 0.3 s after 0.55 s. Finish with a small bouncy scale pop at 0.75 s. Unchecking reverses everything without delays. With Reduce Motion on, replace the sequence with a 0.2 s fade. ### Source ```swift import SwiftUI /// A success mark that draws itself: the circle strokes around, fills, then /// the checkmark is drawn on top with a small pop. Unchecking erases it. public struct AnimatedCheckmark: View { private let isChecked: Bool private let color: Color private let lineWidth: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - isChecked: Draws the mark when true, erases it when false. /// - color: Circle colour; the checkmark is white on top of it. /// - lineWidth: Stroke width, scaled with the view's size (at 60 pt). public init(isChecked: Bool, color: Color = .green, lineWidth: CGFloat = 4) { self.isChecked = isChecked self.color = color self.lineWidth = lineWidth } public var body: some View { GeometryReader { geometry in let side = min(geometry.size.width, geometry.size.height) let scale = side / 60 ZStack { Circle() .trim(from: 0, to: isChecked ? 1 : 0) .stroke(color, style: StrokeStyle(lineWidth: lineWidth * scale, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(timing(.easeInOut(duration: 0.45)), value: isChecked) Circle() .fill(color) .scaleEffect(isChecked ? 1 : 0.6) .opacity(isChecked ? 1 : 0) .animation(timing(.spring(response: 0.35, dampingFraction: 0.6).delay(isChecked ? 0.35 : 0)), value: isChecked) CheckmarkShape() .trim(from: 0, to: isChecked ? 1 : 0) .stroke(.white, style: StrokeStyle(lineWidth: lineWidth * 1.2 * scale, lineCap: .round, lineJoin: .round)) .frame(width: side * 0.42, height: side * 0.32) .offset(y: side * 0.02) .animation(timing(.easeOut(duration: 0.3).delay(isChecked ? 0.55 : 0)), value: isChecked) } .frame(width: side, height: side) .scaleEffect(isChecked && !reduceMotion ? 1 : 0.95) .animation(timing(.spring(response: 0.3, dampingFraction: 0.4).delay(isChecked ? 0.75 : 0)), value: isChecked) .frame(width: geometry.size.width, height: geometry.size.height) } .aspectRatio(1, contentMode: .fit) .accessibilityElement() .accessibilityLabel(isChecked ? Text("Done") : Text("Not done")) } /// Reduce Motion: skip the drawing and just fade. private func timing(_ animation: Animation) -> Animation { reduceMotion ? .easeInOut(duration: 0.2) : animation } } #Preview { struct Demo: View { @State private var checked = false var body: some View { AnimatedCheckmark(isChecked: checked) .frame(width: 80, height: 80) .onTapGesture { checked.toggle() } } } return Demo() } // MARK: - CheckmarkShape.swift // Needed by this effect: save it as its own file next to the one above. // Shared by StatusButton and AnimatedCheckmark. import SwiftUI /// A checkmark drawn left to right, sized to its frame. struct CheckmarkShape: Shape { nonisolated func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.midY)) path.addLine(to: CGPoint(x: rect.minX + rect.width * 0.38, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) return path } } ``` --- ## Confetti (`confetti`) Confetti bursts out of the view, tumbles under gravity and fades. - API: `.confetti(trigger:)` - Component: Shape - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Confetti.swift - Files: Sources/MotionLibrary/Shape/Confetti.swift ### Usage ```swift @State private var orders = 0 Button("Complete order") { orders += 1 } .confetti(trigger: orders) Text("🎉 Level up!") .confetti(trigger: level, colors: [.yellow, .orange], count: 120) ``` ### How it works Create a SwiftUI .confetti(trigger:colors:count:spread:) view modifier (iOS 17+). Each change of the equatable trigger appends a burst with a start date and 80 random pieces, removed after 2.4 s. Each piece has a velocity fanned ±60° around straight up (speed up to 3.2 × spread), a spin rate, a wobble phase, a 5–9 × 8–14 pt size, a colour index and every fourth one is a circle. Draw all bursts in one Canvas inside TimelineView(.animation), in an overlay enlarged by .padding(−spread) (160 pt) so confetti flies beyond the view, with hit testing off. Position = origin + velocity × (1 − e^(−1.6t)) / 1.6 + ½ × gravity × t² (air drag plus gravity), rotate by spin × t, scale x by cos(wobble + 8t) to fake turning over, and fade out over the last 0.6 s. With Reduce Motion on, skip the burst. ### Source ```swift import SwiftUI /// Confetti that bursts up and out of the view whenever `trigger` changes, /// then tumbles down under gravity and fades. Drawn in a single `Canvas`, so /// a few hundred pieces stay cheap. struct ConfettiModifier: ViewModifier { var trigger: Trigger var colors: [Color] var count: Int var spread: CGFloat @State private var bursts: [ConfettiBurst] = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { if !bursts.isEmpty { TimelineView(.animation) { timeline in Canvas { context, size in let now = timeline.date for burst in bursts { burst.draw(in: &context, size: size, time: now.timeIntervalSince(burst.start), colors: colors) } } } // Draw well beyond the view's own bounds. .padding(-spread) .allowsHitTesting(false) .accessibilityHidden(true) } } .onChange(of: trigger) { guard !reduceMotion else { return } let burst = ConfettiBurst(count: count, spread: spread) bursts.append(burst) DispatchQueue.main.asyncAfter(deadline: .now() + ConfettiBurst.lifetime) { bursts.removeAll { $0.id == burst.id } } } } } struct ConfettiBurst: Identifiable { static let lifetime: Double = 2.4 struct Piece { var velocity: CGVector var spin: Double var wobble: Double var size: CGSize var colorIndex: Int var isCircle: Bool } let id = UUID() let start = Date() let pieces: [Piece] init(count: Int, spread: CGFloat) { pieces = (0..= 0, time < Self.lifetime, !colors.isEmpty else { return } let origin = CGPoint(x: size.width / 2, y: size.height / 2) let gravity = 900.0 let drag = 1.6 // Velocity decays with air drag; position integrates it. let decay = (1 - exp(-drag * time)) / drag let fade = time > Self.lifetime - 0.6 ? (Self.lifetime - time) / 0.6 : 1 for piece in pieces { let x = origin.x + piece.velocity.dx * decay let y = origin.y + piece.velocity.dy * decay + 0.5 * gravity * time * time * 0.55 var copy = context copy.opacity = fade copy.translateBy(x: x, y: y) copy.rotate(by: .radians(piece.spin * time)) // Squash horizontally to fake the piece turning over in 3D. copy.scaleBy(x: CGFloat(cos(piece.wobble + time * 8)), y: 1) let rect = CGRect( x: -piece.size.width / 2, y: -piece.size.height / 2, width: piece.size.width, height: piece.isCircle ? piece.size.width : piece.size.height ) let shape = piece.isCircle ? Path(ellipseIn: rect) : Path(rect) copy.fill(shape, with: .color(colors[piece.colorIndex % colors.count])) } } } public extension View { /// Fires a confetti burst from the view each time `trigger` changes. /// /// ```swift /// Button("Complete order") { orders += 1 } /// .confetti(trigger: orders) /// ``` /// /// - Parameters: /// - trigger: Any equatable value; each change fires one burst. /// - colors: Colours the pieces cycle through. /// - count: Pieces per burst. /// - spread: How far beyond the view's bounds confetti may travel. func confetti( trigger: Trigger, colors: [Color] = [.pink, .orange, .yellow, .green, .blue, .purple], count: Int = 80, spread: CGFloat = 160 ) -> some View { modifier(ConfettiModifier(trigger: trigger, colors: colors, count: count, spread: spread)) } } #Preview { struct Demo: View { @State private var count = 0 var body: some View { Button("Celebrate 🎉") { count += 1 } .buttonStyle(.borderedProminent) .confetti(trigger: count) .frame(width: 400, height: 400) } } return Demo() } ``` --- ## Loaders (`loaders`) Five looping activity indicators: spinner, dots, bars, orbit and pulse. - API: `MotionLoader(.spinner)` - Component: Shape - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/MotionLoader.swift - Files: Sources/MotionLibrary/Shape/MotionLoader.swift ### Usage ```swift MotionLoader(.spinner) .frame(width: 32, height: 32) MotionLoader(.dots, color: .secondary) .frame(width: 44, height: 44) ``` ### How it works Create a SwiftUI MotionLoader view (iOS 17+) with a style enum (spinner, dots, bars, orbit, pulse), a color and a speed multiplier. It is square and drives everything from TimelineView(.animation) time, sizing parts relative to its side. Spinner: a round-capped arc whose trim length breathes between 15% and 75% while it rotates. Dots: three dots hopping up in sequence (max(0, sin(6t − i·0.9))) and brightening at the top. Bars: five capsules whose heights follow a sine wave offset per bar. Orbit: two dots circling each other, scaled and layered by a fake depth. Pulse: two rings expanding and fading in turn around a centre dot. Label it "Loading" for accessibility, and with Reduce Motion on, show a dot that slowly breathes in opacity. ### Source ```swift import SwiftUI /// A set of looping activity indicators. Pick a style; size it with `.frame`. public struct MotionLoader: View { /// The look of the loader. public enum Style: String, CaseIterable, Sendable { /// An arc that spins while its length breathes. case spinner /// Three dots hopping in sequence. case dots /// Equaliser bars rising and falling in a wave. case bars /// Two dots orbiting each other. case orbit /// A ring that pulses outward. case pulse } private let style: Style private let color: Color private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - style: Which loader to draw. /// - color: Fill or stroke colour. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(_ style: Style = .spinner, color: Color = .accentColor, speed: Double = 1) { self.style = style self.color = color self.speed = speed } public var body: some View { TimelineView(.animation(paused: speed == 0)) { timeline in // Reduce Motion: a slow opacity breathe instead of movement. let time = timeline.date.timeIntervalSinceReferenceDate * speed GeometryReader { geometry in let side = min(geometry.size.width, geometry.size.height) Group { if reduceMotion { Circle() .fill(color) .frame(width: side * 0.4, height: side * 0.4) .opacity(0.4 + 0.4 * (0.5 + 0.5 * sin(time * 2))) } else { loader(time: time, side: side) } } .frame(width: geometry.size.width, height: geometry.size.height) } } .aspectRatio(1, contentMode: .fit) .accessibilityElement() .accessibilityLabel("Loading") .accessibilityAddTraits(.updatesFrequently) } @ViewBuilder private func loader(time: Double, side: CGFloat) -> some View { switch style { case .spinner: let breathe = 0.5 + 0.5 * sin(time * 3) Circle() .trim(from: 0, to: 0.15 + 0.6 * breathe) .stroke(color, style: StrokeStyle(lineWidth: side * 0.1, lineCap: .round)) .rotationEffect(.radians(time * 5)) .padding(side * 0.05) case .dots: HStack(spacing: side * 0.1) { ForEach(0..<3, id: \.self) { index in let hop = max(0, sin(time * 6 - Double(index) * 0.9)) Circle() .fill(color) .frame(width: side * 0.22, height: side * 0.22) .offset(y: -side * 0.25 * hop) .opacity(0.5 + 0.5 * hop) } } .offset(y: side * 0.1) case .bars: HStack(alignment: .center, spacing: side * 0.07) { ForEach(0..<5, id: \.self) { index in let level = 0.5 + 0.5 * sin(time * 5 - Double(index) * 0.7) Capsule() .fill(color) .frame(width: side * 0.12, height: side * (0.25 + 0.65 * level)) } } case .orbit: ZStack { ForEach(0..<2, id: \.self) { index in let angle = time * 4 + Double(index) * .pi let depth = 0.5 + 0.5 * sin(angle) Circle() .fill(color) .frame(width: side * 0.3, height: side * 0.3) .scaleEffect(0.7 + 0.3 * depth) .opacity(0.6 + 0.4 * depth) .offset(x: cos(angle) * side * 0.3) .zIndex(depth) } } case .pulse: ZStack { ForEach(0..<2, id: \.self) { index in let phase = (time * 0.8 + Double(index) * 0.5).truncatingRemainder(dividingBy: 1) Circle() .stroke(color, lineWidth: side * 0.06) .scaleEffect(0.2 + 0.8 * phase) .opacity(1 - phase) } Circle() .fill(color) .frame(width: side * 0.2, height: side * 0.2) } } } } #Preview { HStack(spacing: 24) { ForEach(MotionLoader.Style.allCases, id: \.self) { style in MotionLoader(style) .frame(width: 44, height: 44) } } .padding() } ``` --- ## Morphing Blob (`morphing-blob`) An organic, lava-lamp blob whose outline never stops changing. - API: `MorphingBlob()` - Component: Shape - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/MorphingBlob.swift - Files: Sources/MotionLibrary/Shape/MorphingBlob.swift ### Usage ```swift MorphingBlob(colors: [.purple, .pink, .orange]) .frame(width: 200, height: 200) MorphingBlob(fill: Color.blue.opacity(0.3), points: 8, wobble: 0.1) .overlay(Image(systemName: "mic.fill")) ``` ### How it works Create a SwiftUI MorphingBlob view (iOS 17+) generic over a ShapeStyle fill, with a convenience initializer taking colours for a diagonal LinearGradient. Draw a custom Shape in TimelineView(.animation): place N control points (6) evenly around the centre, each at radius base × (1 + wobble × (0.6·sin(1.1t + 1.7i) + 0.4·sin(0.7t + 2.9i))), where base = half the side × (1 − wobble) and wobble is 0.18. Connect them into a smooth closed curve with a Catmull-Rom spline converted to cubic Béziers (control points p1 + (p2 − p0)/6 and p2 − (p3 − p1)/6). Keep a 1:1 aspect ratio and hide it from accessibility. With Reduce Motion on, freeze the shape. ### Source ```swift import SwiftUI /// An organic blob whose outline keeps morphing, like a lava lamp. Use it as /// an avatar backdrop, a "listening" indicator or a hero decoration. public struct MorphingBlob: View { private let fill: Fill private let points: Int private let wobble: CGFloat private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - fill: Any shape style: a colour, gradient or material. /// - points: Number of lobes around the outline (4…10 looks organic). /// - wobble: How far the outline moves in and out, as a fraction of the radius. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(fill: Fill, points: Int = 6, wobble: CGFloat = 0.18, speed: Double = 1) { self.fill = fill self.points = points self.wobble = wobble self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in BlobShape( time: reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed, points: points, wobble: wobble ) .fill(fill) } .aspectRatio(1, contentMode: .fit) .accessibilityHidden(true) } } public extension MorphingBlob where Fill == LinearGradient { /// A blob filled with a diagonal gradient. init(colors: [Color] = [.purple, .pink, .orange], points: Int = 6, wobble: CGFloat = 0.18, speed: Double = 1) { self.init( fill: LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing), points: points, wobble: wobble, speed: speed ) } } /// A closed, smooth outline through `points` control points whose distance /// from the centre is a sum of slow sine waves. struct BlobShape: Shape { var time: Double var points: Int var wobble: CGFloat nonisolated func path(in rect: CGRect) -> Path { let count = max(3, points) let centre = CGPoint(x: rect.midX, y: rect.midY) let baseRadius = min(rect.width, rect.height) / 2 * (1 - wobble) let vertices: [CGPoint] = (0.. Path { var path = Path() let n = p.count path.move(to: p[0]) for i in 0..: View { private let shape: S private let isDrawn: Bool private let style: AnyShapeStyle private let lineWidth: CGFloat private let duration: Double @State private var progress: CGFloat = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion init(shape: S, isDrawn: Bool, style: AnyShapeStyle, lineWidth: CGFloat, duration: Double) { self.shape = shape self.isDrawn = isDrawn self.style = style self.lineWidth = lineWidth self.duration = duration } public var body: some View { shape .trim(from: 0, to: progress) .stroke(style, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) .padding(lineWidth / 2) .onAppear { update(isDrawn) } .onChange(of: isDrawn) { _, newValue in update(newValue) } } private func update(_ drawn: Bool) { // Reduce Motion: the stroke appears all at once. withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .easeInOut(duration: duration)) { progress = drawn ? 1 : 0 } } } public extension Shape { /// Draws the shape's outline like a pen stroke. /// /// ```swift /// Circle() /// .drawOn() /// .frame(width: 80, height: 80) /// /// SignaturePath() /// .drawOn(isDrawn: signed, style: .blue, lineWidth: 3, duration: 1.6) /// ``` /// /// - Parameters: /// - isDrawn: Draws when true, erases when false. /// - style: Stroke colour or gradient. /// - lineWidth: Stroke width, in points. /// - duration: Time to draw the whole path, in seconds. @MainActor func drawOn( isDrawn: Bool = true, style: Style = Color.accentColor, lineWidth: CGFloat = 3, duration: Double = 1.2 ) -> DrawOnShape { DrawOnShape(shape: self, isDrawn: isDrawn, style: AnyShapeStyle(style), lineWidth: lineWidth, duration: duration) } } #Preview { HStack(spacing: 24) { Circle().drawOn().frame(width: 70, height: 70) RoundedRectangle(cornerRadius: 14).drawOn(style: .pink, duration: 2).frame(width: 90, height: 70) } .padding() } ``` --- ## Progress Ring (`progress-ring`) A gradient ring springs to each value as the percentage rolls. - API: `ProgressRing(progress:)` - Component: Shape - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/ProgressRing.swift - Files: Sources/MotionLibrary/Shape/ProgressRing.swift ### Usage ```swift ProgressRing(progress: uploaded) .frame(width: 120, height: 120) ProgressRing(progress: steps / goal, colors: [.green, .mint]) { Image(systemName: "figure.walk").font(.title) } ``` ### How it works Create a SwiftUI ProgressRing (iOS 17+) with progress (0…1), lineWidth (12 pt), colors (cyan, blue, purple) and an optional label view builder (default: the percentage). In a ZStack, stroke a full Circle in secondary at 18 % opacity, then a Circle().trim(from: 0, to: progress) stroked with AngularGradient(colors:) over 0–360° and a round line cap, rotated −90° so it starts at twelve o'clock. Pad by lineWidth / 2. Animate progress changes with .spring(response: 0.6, dampingFraction: 0.75). The default label is the percentage as .title2.bold().monospacedDigit() with .contentTransition(.numericText(value:)), so the digits roll. Expose the percentage as the accessibility value. With Reduce Motion on, use a short ease. ### Source ```swift import SwiftUI /// A circular progress ring with a gradient stroke and rounded cap that /// springs to each new value, with the percentage counting in the middle. public struct ProgressRing: View { private let progress: Double private let lineWidth: CGFloat private let colors: [Color] private let label: Label @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - progress: 0…1. /// - lineWidth: Ring thickness, in points. /// - colors: Gradient along the ring, from start to end. /// - label: What sits inside the ring. public init( progress: Double, lineWidth: CGFloat = 12, colors: [Color] = [.cyan, .blue, .purple], @ViewBuilder label: () -> Label ) { self.progress = progress self.lineWidth = lineWidth self.colors = colors self.label = label() } public var body: some View { let clamped = min(1, max(0, progress)) ZStack { Circle() .stroke(Color.secondary.opacity(0.18), lineWidth: lineWidth) Circle() .trim(from: 0, to: clamped) .stroke( AngularGradient(colors: colors, center: .center, startAngle: .degrees(0), endAngle: .degrees(360)), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round) ) .rotationEffect(.degrees(-90)) label } .padding(lineWidth / 2) .animation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(response: 0.6, dampingFraction: 0.75), value: clamped) .accessibilityElement(children: .ignore) .accessibilityValue(clamped.formatted(.percent.precision(.fractionLength(0)))) } } public extension ProgressRing where Label == ProgressRingPercent { /// A ring with the percentage in the middle. /// /// ```swift /// ProgressRing(progress: uploaded) /// .frame(width: 120, height: 120) /// ``` init(progress: Double, lineWidth: CGFloat = 12, colors: [Color] = [.cyan, .blue, .purple]) { self.init(progress: progress, lineWidth: lineWidth, colors: colors) { ProgressRingPercent(progress: progress) } } } /// The default label: a percentage that rolls to each new value. public struct ProgressRingPercent: View { let progress: Double public var body: some View { let value = min(1, max(0, progress)) Text(value.formatted(.percent.precision(.fractionLength(0)))) .font(.title2.bold().monospacedDigit()) .contentTransition(.numericText(value: value)) } } #Preview { struct Demo: View { @State private var progress = 0.35 var body: some View { ProgressRing(progress: progress) .frame(width: 120, height: 120) .onTapGesture { progress = progress >= 1 ? 0.1 : progress + 0.3 } } } return Demo().padding() } ``` --- ## Skeleton (`skeleton`) A shimmering placeholder of the view's own layout while it loads. - API: `.skeleton(isLoading:)` - Component: Shape - Trigger: Loop / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/Skeleton.swift - Files: Sources/MotionLibrary/Shape/Skeleton.swift, Sources/MotionLibrary/Text/Shimmer.swift ### Usage ```swift ArticleRow(article) .skeleton(isLoading: isLoading) ``` ### How it works Create a SwiftUI .skeleton(isLoading:) view modifier (iOS 17+). While loading, apply .redacted(reason: .placeholder) so text and images become grey blocks in the view's real layout, then the shimmer modifier (a white 80 % highlight band, 35 % of the width, sweeping left to right every 1.6 s, masked by the content), disable hit testing and hide the view from accessibility. When loading ends, remove both and animate with easeInOut(0.3 s) so the real content fades in. The shimmer pauses for Reduce Motion. ### Source ```swift import SwiftUI /// Swaps a view for a shimmering grey placeholder of the same layout while /// it loads, then fades the real content in. struct SkeletonModifier: ViewModifier { var isLoading: Bool func body(content: Content) -> some View { content .redacted(reason: isLoading ? .placeholder : []) // `.shimmer()` already pauses itself for Reduce Motion. .shimmer(isActive: isLoading) .allowsHitTesting(!isLoading) .animation(.easeInOut(duration: 0.3), value: isLoading) // Placeholder text is meaningless to VoiceOver. .accessibilityHidden(isLoading) } } public extension View { /// Shows a shimmering placeholder of the view's own layout while /// `isLoading` is true. /// /// ```swift /// ArticleRow(article) /// .skeleton(isLoading: article.isPlaceholder) /// ``` func skeleton(isLoading: Bool) -> some View { modifier(SkeletonModifier(isLoading: isLoading)) } } #Preview { struct Demo: View { @State private var loading = true var body: some View { HStack(spacing: 12) { Image(systemName: "person.crop.circle.fill").font(.largeTitle) VStack(alignment: .leading) { Text("Taylor Appleseed").font(.headline) Text("Shipped a new build").font(.subheadline) } } .skeleton(isLoading: loading) .onTapGesture { loading.toggle() } } } return Demo().padding() } // MARK: - Shimmer.swift // Needed by this effect: save it as its own file next to the one above. import SwiftUI /// A band of light that sweeps across the view, clipped to the view's own /// shape. Works on text, images, and skeleton placeholders. struct ShimmerModifier: ViewModifier { var isActive: Bool var duration: Double var bandWidth: CGFloat var highlight: Color @State private var animating = false @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { if isActive { GeometryReader { geometry in let width = geometry.size.width let band = max(1, width * bandWidth) LinearGradient( colors: [.clear, highlight, .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: band, height: geometry.size.height) .offset(x: animating ? width : -band) } .mask { content } .allowsHitTesting(false) .onAppear { animating = false // Reduce Motion: no sweep; the band stays parked off-screen. guard !reduceMotion else { return } withAnimation(.linear(duration: duration).repeatForever(autoreverses: false)) { animating = true } } } } } } public extension View { /// Sweeps a highlight across the view. /// /// ```swift /// Text("Loading…").font(.title).shimmer() /// RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.3)).shimmer() /// ``` func shimmer( isActive: Bool = true, duration: Double = 1.6, bandWidth: CGFloat = 0.35, highlight: Color = .white.opacity(0.8) ) -> some View { modifier(ShimmerModifier( isActive: isActive, duration: duration, bandWidth: bandWidth, highlight: highlight )) } } #Preview { VStack(spacing: 16) { Text("Shimmering title") .font(.largeTitle.bold()) .foregroundStyle(.gray) .shimmer() RoundedRectangle(cornerRadius: 12) .fill(.gray.opacity(0.25)) .frame(height: 60) .shimmer() } .padding() } ``` --- ## Sliding Tabs (`sliding-tabs`) A segmented control whose pill or underline glides to the selection. - API: `SlidingTabs([…], selection:)` - Component: Shape - Trigger: Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/SlidingTabs.swift - Files: Sources/MotionLibrary/Shape/SlidingTabs.swift ### Usage ```swift SlidingTabs(["Day", "Week", "Month"], selection: $range) SlidingTabs(Category.allCases, selection: $category, indicator: .underline) { $0.title } ``` ### How it works Create a generic SwiftUI SlidingTabs control (iOS 17+) over Hashable options with a selection binding, an indicator style (.pill or .underline), a color and a title closure (plus a convenience init for [String]). Show the options as plain Buttons in an HStack inside a 4 pt padded capsule tinted secondary at 12 % (pill style only). Behind the selected title only, draw the indicator — a capsule filled with the color (title turns white), or a 3 pt capsule at the bottom inset 10 pt (title turns tint) — with .matchedGeometryEffect(id: "indicator", in: namespace), so it glides between options. Change the selection inside withAnimation(.spring(response: 0.35, dampingFraction: 0.8)), and add the .isSelected trait. With Reduce Motion on, use a 0.15 s ease. ### Source ```swift import SwiftUI /// A segmented control whose selection indicator (a pill or an underline) /// glides to the tapped option with `matchedGeometryEffect`. public struct SlidingTabs: View { /// How the selected option is marked. public enum Indicator: Sendable { /// A filled capsule behind the selected title. case pill /// A short bar under the selected title. case underline } @Binding private var selection: Value private let options: [Value] private let title: (Value) -> String private let indicator: Indicator private let color: Color @Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - options: The options, in order. /// - selection: The selected option. /// - indicator: `.pill` or `.underline`. /// - color: Colour of the indicator. /// - title: The title of an option. public init( _ options: [Value], selection: Binding, indicator: Indicator = .pill, color: Color = .accentColor, title: @escaping (Value) -> String ) { self.options = options _selection = selection self.indicator = indicator self.color = color self.title = title } @available(*, deprecated, renamed: "init(_:selection:indicator:color:title:)") public init( _ options: [Value], selection: Binding, indicator: Indicator = .pill, tint: Color, title: @escaping (Value) -> String ) { self.init(options, selection: selection, indicator: indicator, color: tint, title: title) } public var body: some View { HStack(spacing: 4) { ForEach(options, id: \.self) { option in let selected = option == selection Button { withAnimation(reduceMotion ? .easeInOut(duration: 0.15) : .spring(response: 0.35, dampingFraction: 0.8)) { selection = option } } label: { Text(title(option)) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 14) .padding(.vertical, 8) .foregroundStyle(indicator == .pill && selected ? Color.white : selected ? color : Color.secondary) .background { marker(selected) } .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityAddTraits(selected ? .isSelected : []) } } .padding(4) .background { if indicator == .pill { Capsule().fill(Color.secondary.opacity(0.12)) } } } @ViewBuilder private func marker(_ selected: Bool) -> some View { if selected { switch indicator { case .pill: Capsule() .fill(color) .matchedGeometryEffect(id: "indicator", in: namespace) case .underline: VStack { Spacer() Capsule() .fill(color) .frame(height: 3) .padding(.horizontal, 10) } .matchedGeometryEffect(id: "indicator", in: namespace) } } } } public extension SlidingTabs where Value == String { /// Tabs whose options are their own titles. /// /// ```swift /// SlidingTabs(["Day", "Week", "Month"], selection: $range) /// ``` init(_ options: [String], selection: Binding, indicator: Indicator = .pill, color: Color = .accentColor) { self.init(options, selection: selection, indicator: indicator, color: color) { $0 } } @available(*, deprecated, renamed: "init(_:selection:indicator:color:)") init(_ options: [String], selection: Binding, indicator: Indicator = .pill, tint: Color) { self.init(options, selection: selection, indicator: indicator, color: tint) { $0 } } } #Preview { struct Demo: View { @State private var range = "Week" var body: some View { VStack(spacing: 24) { SlidingTabs(["Day", "Week", "Month"], selection: $range) SlidingTabs(["Day", "Week", "Month"], selection: $range, indicator: .underline) } } } return Demo().padding() } ``` --- ## Reading Progress (`reading-progress`) A bar along the top fills as you scroll through the content. - API: `ReadingProgressScrollView { … }` - Component: Shape - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Shape/ReadingProgress.swift - Files: Sources/MotionLibrary/Shape/ReadingProgress.swift ### Usage ```swift ReadingProgressScrollView(color: .pink) { ArticleBody() } ``` ### How it works Create a SwiftUI ReadingProgressScrollView(color:height:content:) (iOS 17+): a vertical ScrollView whose content gets a top overlay holding a Color.clear layer with a Capsule (4 pt tall, the colour, a soft glow shadow) at its top-leading edge. Drive the layer with visualEffect, not state or a PreferenceKey (preferences don't reliably leave an iOS scroll view): frame = proxy.frame(in: .scrollView), viewport = proxy.bounds(of: .scrollView)?.size, progress = clamp(−frame.minY / (frame.height − viewport.height), 0, 1); scaleEffect(x: viewport.width × progress / frame.width, anchor: .topLeading), then offset(x: −frame.minX, y: −frame.minY) to pin it to the top. Disable hit testing. ### Source ```swift import SwiftUI /// A vertical scroll view with a reading-progress bar along its top edge /// that fills as you scroll from the top to the bottom of the content. public struct ReadingProgressScrollView: View { private let color: Color private let height: CGFloat private let content: Content /// - Parameters: /// - color: Bar colour. /// - height: Bar thickness, in points. /// - content: The scrolling content. public init(color: Color = .accentColor, height: CGFloat = 4, @ViewBuilder content: () -> Content) { self.color = color self.height = height self.content = content() } public var body: some View { ScrollView { content.overlay(alignment: .top) { bar } } } /// The bar lives inside the scrolled content and is pinned and sized by /// `visualEffect`, so it needs no state, no preferences (they don't reach /// out of an iOS scroll view reliably) and no extra layout passes. private var bar: some View { Color.clear .overlay(alignment: .topLeading) { Capsule() .fill(color) .frame(height: height) .shadow(color: color.opacity(0.5), radius: 4) } .visualEffect { effect, proxy in let frame = proxy.frame(in: .scrollView) let viewport = proxy.bounds(of: .scrollView)?.size ?? frame.size let scrollable = max(1, frame.height - viewport.height) let progress = min(1, max(0, -frame.minY / scrollable)) return effect // Span the scroll view's width, filled to `progress`. .scaleEffect(x: viewport.width * progress / max(1, frame.width), y: 1, anchor: .topLeading) // Stay pinned to the top of the visible area. .offset(x: -frame.minX, y: -frame.minY) } .allowsHitTesting(false) .accessibilityHidden(true) } } #Preview { ReadingProgressScrollView(color: .pink) { VStack(alignment: .leading, spacing: 16) { ForEach(0..<30, id: \.self) { index in Text("Paragraph \(index + 1). Lorem ipsum dolor sit amet, consectetur adipiscing elit.") } } .padding() } } ``` --- ## Aurora (`aurora`) Soft, blurred colour blobs drift slowly behind your content. - API: `AuroraBackground()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/AuroraBackground.swift - Files: Sources/MotionLibrary/Background/AuroraBackground.swift ### Usage ```swift ZStack { AuroraBackground(colors: [.purple, .blue, .teal, .pink]) .ignoresSafeArea() Text("Welcome").font(.largeTitle.bold()) } ``` ### How it works Create a SwiftUI AuroraBackground view (iOS 17+). On a dark base color, draw one large circle per palette color (default purple, blue, teal, pink; 80% opacity, diameter 70% of the larger side). Each circle drifts on its own Lissajous path, x = cos(t·0.6 + phase)·30% width and y = sin(t·0.8 + phase·1.3)·30% height, where phase differs per index. Drive time from TimelineView(.animation) scaled by 0.4 × a speed multiplier (default 1), blur the whole group with a ~60 pt radius, clip to bounds and hide it from accessibility. ### Source ```swift import SwiftUI /// Soft, slowly drifting colour blobs, heavily blurred. A calm animated /// backdrop for onboarding screens, paywalls, or hero sections. /// /// ```swift /// ZStack { /// AuroraBackground() /// .ignoresSafeArea() /// Text("Welcome").font(.largeTitle.bold()) /// } /// ``` public struct AuroraBackground: View { private let colors: [Color] private let speed: Double private let blurRadius: CGFloat private let baseColor: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion public init( colors: [Color] = [.purple, .blue, .teal, .pink], speed: Double = 1, blurRadius: CGFloat = 60, baseColor: Color = .black ) { self.colors = colors self.speed = speed * 0.4 self.blurRadius = blurRadius self.baseColor = baseColor } public var body: some View { // Reduce Motion: a still frame of the same blobs. TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed GeometryReader { geometry in blobs(time: time, size: geometry.size) } } .background(baseColor) .clipped() .accessibilityHidden(true) } private func blobs(time: Double, size: CGSize) -> some View { ZStack { ForEach(colors.indices, id: \.self) { index in blob(color: colors[index], index: index, time: time, size: size) } } .frame(width: size.width, height: size.height) .blur(radius: blurRadius) } private func blob(color: Color, index: Int, time: Double, size: CGSize) -> some View { let phase = Double(index) * 1.7 let diameter = max(size.width, size.height) * 0.7 let x = CGFloat(cos(time * 0.6 + phase)) * size.width * 0.3 let y = CGFloat(sin(time * 0.8 + phase * 1.3)) * size.height * 0.3 return Circle() .fill(color.opacity(0.8)) .frame(width: diameter, height: diameter) .offset(x: x, y: y) } } #Preview { ZStack { AuroraBackground() .ignoresSafeArea() Text("Welcome") .font(.largeTitle.bold()) .foregroundStyle(.white) } } ``` --- ## Mesh Gradient (`mesh-gradient`) A 3×3 MeshGradient whose points drift so the colours flow like liquid. - API: `AnimatedMeshGradient()` - Component: Background - Trigger: Loop - Requires: iOS 18+ · macOS 15+ · visionOS 2+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/AnimatedMeshGradient.swift - Files: Sources/MotionLibrary/Background/AnimatedMeshGradient.swift ### Usage ```swift if #available(iOS 18, *) { AnimatedMeshGradient() .ignoresSafeArea() } ``` ### How it works Create a SwiftUI AnimatedMeshGradient view for iOS 18+ (mark it @available(iOS 18, macOS 15, visionOS 2, *)) with nine colours (indigo, purple, pink / blue, purple, orange / teal, mint, yellow by default; fewer are repeated) and a speed. Inside TimelineView(.animation), draw MeshGradient(width: 3, height: 3, points:colors:). Keep the four corners fixed; slide the four edge midpoints along their own edge by sin(t × 2π / period + offset) × 0.2, and move the centre point in both axes by 0.25, giving each a different period between 6 and 10 s so the motion never repeats visibly. Hide it from accessibility, and with Reduce Motion on, pause it. ### Source ```swift import SwiftUI /// A 3×3 `MeshGradient` whose inner points drift in slow loops, so the /// colours flow into each other like liquid. @available(iOS 18, macOS 15, visionOS 2, *) public struct AnimatedMeshGradient: View { private let colors: [Color] private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: Nine colours, row by row from the top left. Fewer are /// repeated; more are ignored. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init( colors: [Color] = [ .indigo, .purple, .pink, .blue, .purple, .orange, .teal, .mint, .yellow, ], speed: Double = 1 ) { let palette = colors.isEmpty ? [Color.blue] : colors self.colors = (0..<9).map { palette[$0 % palette.count] } self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let t = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed MeshGradient(width: 3, height: 3, points: Self.points(at: t), colors: colors) } .accessibilityHidden(true) } /// Corners stay put; edge midpoints slide along their edge and the /// centre wanders, each on its own period. static func points(at t: Double) -> [SIMD2] { func wave(_ period: Double, _ amount: Double, _ offset: Double = 0) -> Float { Float(sin(t * 2 * .pi / period + offset) * amount) } return [ [0, 0], [0.5 + wave(7, 0.2), 0], [1, 0], [0, 0.5 + wave(9, 0.2, 1)], [0.5 + wave(6, 0.25, 2), 0.5 + wave(8, 0.25)], [1, 0.5 + wave(10, 0.2, 3)], [0, 1], [0.5 + wave(8, 0.2, 4), 1], [1, 1], ] } } #Preview { if #available(iOS 18, macOS 15, visionOS 2, *) { AnimatedMeshGradient() .frame(width: 300, height: 200) .clipShape(RoundedRectangle(cornerRadius: 24)) } } ``` --- ## Retro Grid (`retro-grid`) A synthwave floor: a perspective grid scrolling toward you. - API: `RetroGrid()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/RetroGrid.swift - Files: Sources/MotionLibrary/Background/RetroGrid.swift ### Usage ```swift ZStack { Color.black RetroGrid(color: .pink) Text("NEON").font(.system(size: 60, weight: .black)) } ``` ### How it works Create a SwiftUI RetroGrid view (iOS 17+) with color (pink), spacing (40 pt), speed (a multiplier, default 1, on 0.8 rows per second) and horizon (0.35 of the height). In a Canvas inside TimelineView(.animation): with top = height × horizon and depth = height − top, draw horizontal lines at y = top + depth / z for z = n + 1 − fract(time × 0.8 × speed), n = 0…39, so the bottom edge is z = 1, rows bunch up toward the horizon and every row moves toward the viewer; draw lines from the vanishing point (centre, top) to (centre + k × spacing, bottom) for enough k to cover the width. Stroke the path twice: 3 pt at 35 % opacity for a glow, then 1 pt solid. Mask the view with a vertical gradient that is clear down to the horizon and opaque 45 % lower, so lines fade into the distance. Hide from accessibility; Reduce Motion pauses it. ### Source ```swift import SwiftUI /// A synthwave floor: a perspective grid that scrolls toward the viewer and /// fades into the horizon. public struct RetroGrid: View { private let color: Color private let spacing: CGFloat private let speed: Double private let horizon: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - color: Line colour. /// - spacing: Distance between lines at the bottom edge, in points. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - horizon: Height of the horizon, as a fraction of the view (0 = top). public init(color: Color = .pink, spacing: CGFloat = 40, speed: Double = 1, horizon: CGFloat = 0.35) { self.color = color self.spacing = spacing self.speed = speed * 0.8 self.horizon = horizon } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in draw(in: &context, size: size, time: time) } } .mask { LinearGradient( stops: [.init(color: .clear, location: horizon), .init(color: .black, location: min(1, horizon + 0.45))], startPoint: .top, endPoint: .bottom ) } .accessibilityHidden(true) } private func draw(in context: inout GraphicsContext, size: CGSize, time: Double) { let top = size.height * horizon let depth = size.height - top guard depth > 0, spacing > 0 else { return } let centre = size.width / 2 var path = Path() // Horizontal lines at depths z = 1, 2, 3…; y = top + depth / z puts // z = 1 at the bottom edge and z → ∞ at the horizon. Shifting z by the // fractional time moves every row toward the viewer. let shift = time * speed let scroll = shift - shift.rounded(.down) for row in 0..<40 { let z = CGFloat(Double(row) + 1 - scroll) guard z > 0.05 else { continue } let y = top + depth / z guard y <= size.height + 1 else { continue } path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: size.width, y: y)) } // Lines running into the distance all meet at the vanishing point. let columns = Int(size.width / spacing) + 4 for column in -columns...columns { let x = centre + CGFloat(column) * spacing path.move(to: CGPoint(x: centre, y: top)) path.addLine(to: CGPoint(x: x, y: size.height)) } context.stroke(path, with: .color(color.opacity(0.35)), lineWidth: 3) context.stroke(path, with: .color(color), lineWidth: 1) } } #Preview { RetroGrid() .background(Color(red: 0.08, green: 0.02, blue: 0.15)) .frame(width: 320, height: 200) } ``` --- ## Border Beam (`border-beam`) A short comet of light runs around the card's border forever. - API: `.borderBeam()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/BorderBeam.swift - Files: Sources/MotionLibrary/Background/BorderBeam.swift ### Usage ```swift PlanCard() .background(.black, in: RoundedRectangle(cornerRadius: 20)) .borderBeam(cornerRadius: 20) ``` ### How it works Create a SwiftUI .borderBeam(cornerRadius:colors:lineWidth:length:duration:) view modifier (iOS 17+). In an overlay TimelineView(.animation), compute head = fract(time / duration) (4 s per lap). Stroke RoundedRectangle(cornerRadius:, style: .continuous).trim(from: head − length, to: head) with a LinearGradient of the colours (orange, pink, purple), lineWidth 2 pt and round caps; when head − length is negative, also stroke trim(from: 1 + head − length, to: 1) so the beam wraps past the path's start seamlessly. Draw the beam twice, once blurred by 2 × lineWidth underneath for a glow. Pad by lineWidth / 2, disable hit testing and hide from accessibility. With Reduce Motion on, park the beam. ### Source ```swift import SwiftUI /// A short comet of light that runs around the view's rounded border forever. struct BorderBeamModifier: ViewModifier { var cornerRadius: CGFloat var colors: [Color] var lineWidth: CGFloat var length: CGFloat var duration: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content.overlay { // Reduce Motion: the beam is parked at the top-left corner. TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate let head = CGFloat((time / max(0.1, duration)).truncatingRemainder(dividingBy: 1)) ZStack { beam(head: head).blur(radius: lineWidth * 2) beam(head: head) } } .allowsHitTesting(false) .accessibilityHidden(true) } } /// The beam from `head - length` to `head`, split in two when it wraps /// past the start of the path. private func beam(head: CGFloat) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) let tail = head - length let style = StrokeStyle(lineWidth: lineWidth, lineCap: .round) let gradient = LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing) return ZStack { shape.trim(from: max(0, tail), to: head).stroke(gradient, style: style) if tail < 0 { shape.trim(from: 1 + tail, to: 1).stroke(gradient, style: style) } } .padding(lineWidth / 2) } } public extension View { /// Sends a beam of light around the view's border, forever. /// /// ```swift /// PlanCard() /// .background(.black, in: RoundedRectangle(cornerRadius: 20)) /// .borderBeam(cornerRadius: 20) /// ``` /// /// - Parameters: /// - cornerRadius: Match the view's own corner radius. /// - colors: Gradient of the beam. /// - lineWidth: Beam thickness, in points. /// - length: Fraction of the border the beam covers (0…1). /// - duration: Seconds per lap. func borderBeam( cornerRadius: CGFloat = 16, colors: [Color] = [.orange, .pink, .purple], lineWidth: CGFloat = 2, length: CGFloat = 0.2, duration: Double = 4 ) -> some View { modifier(BorderBeamModifier( cornerRadius: cornerRadius, colors: colors, lineWidth: lineWidth, length: min(1, max(0.01, length)), duration: duration )) } } #Preview { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(Color(white: 0.08)) .frame(width: 240, height: 140) .overlay(Text("Pro plan").font(.headline).foregroundStyle(.white)) .borderBeam(cornerRadius: 20) .padding() } ``` --- ## Waves (`waves`) Layered sine waves roll across the bottom, back layers slower and paler. - API: `WaveBackground()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/WaveBackground.swift - Files: Sources/MotionLibrary/Background/WaveBackground.swift ### Usage ```swift ZStack(alignment: .top) { WaveBackground() .ignoresSafeArea() Text("Ocean").font(.largeTitle.bold()).padding(.top, 80) } ``` ### How it works Create a SwiftUI WaveBackground view (iOS 17+) with colors (one per layer, back to front: cyan 35 %, blue 45 %, indigo 70 %), amplitude (12 pt) and speed. In a Canvas inside TimelineView(.animation), for each layer with depth d = layer / (count − 1): baseline at height × (0.45 + 0.2 d), wavelength width × (0.9 − 0.3 d), phase = time × (0.6 + 0.5 d), alternating direction per layer. Build a path from the bottom-left corner along y = baseline + sin(x / wavelength × 2π + phase + layer) × amplitude × (0.7 + 0.3 d) sampled every 4 pt, down to the bottom-right corner, close it and fill it with the layer's colour. Hide from accessibility; Reduce Motion pauses it. ### Source ```swift import SwiftUI /// Layered sine waves rolling across the bottom of the view, each layer a /// little slower and paler than the one in front, like an ocean horizon. public struct WaveBackground: View { private let colors: [Color] private let amplitude: CGFloat private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: One colour per layer, back to front. /// - amplitude: Wave height, in points. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init( colors: [Color] = [.cyan.opacity(0.35), .blue.opacity(0.45), .indigo.opacity(0.7)], amplitude: CGFloat = 12, speed: Double = 1 ) { self.colors = colors self.amplitude = amplitude self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed Canvas { context, size in for (layer, color) in colors.enumerated() { let depth = CGFloat(layer) / CGFloat(max(1, colors.count - 1)) // Back layers sit higher, move slower and have longer waves. let baseline = size.height * (0.45 + 0.2 * depth) let wavelength = size.width * (0.9 - 0.3 * depth) let phase = time * (0.6 + 0.5 * depth) * (layer.isMultiple(of: 2) ? 1 : -1) var path = Path() path.move(to: CGPoint(x: 0, y: size.height)) for x in stride(from: 0, through: size.width, by: 4) { let angle = Double(x / wavelength) * 2 * .pi + phase + Double(layer) let y = baseline + CGFloat(sin(angle)) * amplitude * (0.7 + 0.3 * depth) path.addLine(to: CGPoint(x: x, y: y)) } path.addLine(to: CGPoint(x: size.width, y: size.height)) path.closeSubpath() context.fill(path, with: .color(color)) } } } .accessibilityHidden(true) } } #Preview { WaveBackground() .frame(width: 320, height: 200) .background(Color.cyan.opacity(0.08)) } ``` --- ## Film Grain (`grain`) Flickering film grain over any view, from a tiled noise texture. - API: `.grain()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Grain.swift - Files: Sources/MotionLibrary/Background/Grain.swift, Sources/MotionLibrary/Support/SeededRandom.swift ### Usage ```swift LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom) .grain() HeroImage() .grain(opacity: 0.2, isAnimated: false) ``` ### How it works Create a SwiftUI .grain(opacity:isAnimated:) view modifier (iOS 17+). Generate a 128 × 128 grey noise CGImage once (8-bit DeviceGray, random bytes from a seeded generator, no interpolation). In an overlay, show it with Image(decorative:scale: 2).resizable(resizingMode: .tile) in a frame 64 pt larger than the view in each direction, clipped, and shift it by a different offset (frame × 37 mod 64, frame × 53 mod 64) 12 times a second using TimelineView(.periodic(by: 1/12)), so the grain flickers like film. Use .blendMode(.overlay) at opacity (0.35), disable hit testing and hide it from accessibility. With Reduce Motion on (or isAnimated false), keep the grain still. ### Source ```swift import CoreGraphics import SwiftUI /// Film grain: a tiled noise texture over the view, re-positioned a few times /// a second so it flickers like real film. struct GrainModifier: ViewModifier { var opacity: Double var isAnimated: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content.overlay { if let noise = NoiseTexture.image { // Reduce Motion: still grain. let animated = isAnimated && !reduceMotion TimelineView(.periodic(from: .now, by: 1.0 / 12)) { timeline in let frame = animated ? Int(timeline.date.timeIntervalSinceReferenceDate * 12) : 0 GeometryReader { geometry in Image(decorative: noise, scale: 2) .resizable(resizingMode: .tile) // One tile larger than the view in each direction so // the random shift never uncovers an edge. .frame(width: geometry.size.width + 64, height: geometry.size.height + 64) .offset(x: -CGFloat(frame * 37 % 64), y: -CGFloat(frame * 53 % 64)) } .clipped() } .blendMode(.overlay) .opacity(opacity) .allowsHitTesting(false) .accessibilityHidden(true) } } } } /// A 128×128 grey noise image, generated once. enum NoiseTexture { static let image: CGImage? = { let side = 128 var generator = SplitMix64(seed: 7) var pixels = [UInt8](repeating: 0, count: side * side) for index in pixels.indices { pixels[index] = UInt8(truncatingIfNeeded: generator.next() >> 56) } guard let provider = CGDataProvider(data: Data(pixels) as CFData) else { return nil } return CGImage( width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 8, bytesPerRow: side, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue), provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) }() } public extension View { /// Lays flickering film grain over the view. /// /// ```swift /// LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom) /// .grain() /// /// HeroImage() /// .grain(opacity: 0.25, isAnimated: false) /// ``` /// /// - Parameters: /// - opacity: Strength of the grain (0…1). /// - isAnimated: Re-rolls the grain 12 times a second when true. func grain(opacity: Double = 0.35, isAnimated: Bool = true) -> some View { modifier(GrainModifier(opacity: opacity, isAnimated: isAnimated)) } } #Preview { LinearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) .grain() .frame(width: 300, height: 200) } // MARK: - SeededRandom.swift // Needed by this effect: save it as its own file next to the one above. // Shared by the particle and background effects that need stable random // layouts. Copy this file along with any effect that uses `SplitMix64`. import SwiftUI /// Small deterministic random generator, so particle layouts are stable /// between launches and previews. struct SplitMix64: RandomNumberGenerator { private var state: UInt64 init(seed: UInt64) { state = seed } mutating func next() -> UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } /// A Double in 0..<1. mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) } } extension CGFloat { /// Wraps the value into 0.. CGFloat { let r = truncatingRemainder(dividingBy: length) return r < 0 ? r + length : r } } ``` --- ## Flashlight (`flashlight`) The view goes dark except a soft circle of light around the pointer. - API: `.flashlight()` - Component: Background - Trigger: Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Flashlight.swift - Files: Sources/MotionLibrary/Background/Flashlight.swift ### Usage ```swift TreasureMap() .flashlight(radius: 100) ``` ### How it works Create a SwiftUI .flashlight(radius:darkness:) view modifier (iOS 17+). Track the pointer with .onContinuousHover(coordinateSpace: .local) and a finger with a simultaneous DragGesture(minimumDistance: 0), storing the location in @State (nil on exit) plus the last location. In an overlay GeometryReader, draw a RadialGradient from clear to black at darkness (0.85) opacity, centred on the location as a UnitPoint, from startRadius 0.55 × radius to endRadius radius (90 pt), so everything outside the circle is dark with a soft edge. Fade the whole overlay out when the pointer leaves (lights back on) with easeOut(0.4 s), and follow the pointer with .interactiveSpring(response: 0.18). Disable hit testing. With Reduce Motion on, move without animation. ### Source ```swift import SwiftUI /// Darkens the view except for a soft circle of light around the pointer (or /// a dragging finger), like exploring a dark room with a torch. struct FlashlightModifier: ViewModifier { var radius: CGFloat var darkness: Double @State private var location: CGPoint? @State private var lastLocation: CGPoint? @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay { GeometryReader { geometry in let size = geometry.size let point = location ?? lastLocation ?? CGPoint(x: size.width / 2, y: size.height / 2) RadialGradient( colors: [.black.opacity(0), .black.opacity(darkness)], center: UnitPoint(x: size.width > 0 ? point.x / size.width : 0.5, y: size.height > 0 ? point.y / size.height : 0.5), startRadius: radius * 0.55, endRadius: radius ) // Lights come back on when the pointer leaves. .opacity(location == nil ? 0 : 1) } .allowsHitTesting(false) .accessibilityHidden(true) } .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(point): move(to: point) case .ended: release() } } .simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { move(to: $0.location) } .onEnded { _ in release() } ) } private func move(to point: CGPoint) { // Reduce Motion: the light jumps with the pointer instead of trailing it. withAnimation(reduceMotion ? nil : .interactiveSpring(response: 0.18, dampingFraction: 0.9)) { location = point lastLocation = point } } private func release() { withAnimation(.easeOut(duration: 0.4)) { location = nil } } } public extension View { /// Dims the view except around the pointer or finger. /// /// ```swift /// TreasureMap() /// .flashlight(radius: 100) /// ``` /// /// - Parameters: /// - radius: Radius of the lit circle, in points. /// - darkness: Opacity of the darkness outside the light (0…1). func flashlight(radius: CGFloat = 90, darkness: Double = 0.85) -> some View { modifier(FlashlightModifier(radius: radius, darkness: darkness)) } } #Preview { LinearGradient(colors: [.yellow, .orange, .red], startPoint: .topLeading, endPoint: .bottomTrailing) .overlay(Text("Find me").font(.largeTitle.bold())) .frame(width: 300, height: 200) .flashlight() } ``` --- ## Lava Lamp (`lava-lamp`) Soft blobs drift up and down, stretching and merging like hot wax. - API: `LavaLamp()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/LavaLamp.swift - Files: Sources/MotionLibrary/Background/LavaLamp.swift ### Usage ```swift LavaLamp(colors: [.yellow, .orange, .pink]) .ignoresSafeArea() ``` ### How it works Create a SwiftUI LavaLamp(colors:background:blobs:speed:) view (iOS 17+). In TimelineView(.animation), fill a vertical LinearGradient of the colours, masked by a Canvas that adds .alphaThreshold(min: 0.5, color: .black) then .blur(radius: 7 % of the smaller side), and in drawLayer fills 7 black ellipses: blob i (seed = i × 1.618) at x = width × (0.2 + 0.6 × (0.5 + 0.5 sin(seed × 3.1 + t × 0.13))), y = height × (0.5 + 0.48 sin(t × (0.18 + 0.05 × (i % 3)) + seed × 2.3)), radius = min side × (0.11 + 0.07 × (0.5 + 0.5 sin(seed × 5.7))), stretched vertically by 1 + 0.25 |cos(t × 0.2 + seed × 2.3)|, plus wide pools of wax at the bottom and top. The threshold fuses the blurred blobs into liquid shapes (metaballs). Put the background colour (deep purple) behind. With Reduce Motion on, show one still frame. ### Source ```swift import SwiftUI /// A lava lamp: soft blobs drift up and down, stretching and merging when /// they meet. The blobs are blurred and cut back with an alpha threshold /// (metaballs), then used as a mask over a gradient. public struct LavaLamp: View { private let colors: [Color] private let background: Color private let blobs: Int private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: Gradient of the wax, top to bottom. /// - background: Colour behind the wax. /// - blobs: Number of blobs. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(colors: [Color] = [.yellow, .orange, .pink], background: Color = Color(red: 0.16, green: 0.05, blue: 0.25), blobs: Int = 7, speed: Double = 1) { self.colors = colors self.background = background self.blobs = max(1, blobs) self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in // Reduce Motion: a still frame. let time = reduceMotion ? 12 : timeline.date.timeIntervalSinceReferenceDate * speed LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom) .mask { Canvas { context, size in context.addFilter(.alphaThreshold(min: 0.5, color: .black)) context.addFilter(.blur(radius: min(size.width, size.height) * 0.07)) context.drawLayer { layer in let unit = min(size.width, size.height) for index in 0..= 0 && row < rows else { continue } // Characters change a few times a second. let flicker = Int(time * (2 + seed * 4)) let pick = Int(Self.hash(column * 131 + row, flicker) * Double(glyphs.count)) % glyphs.count var copy = context copy.opacity = offset == 0 ? 1 : 0.85 * (1 - Double(offset) / Double(length)) copy.draw(offset == 0 ? head[pick] : trail[pick], at: CGPoint(x: CGFloat(column) * columnWidth + columnWidth / 2, y: CGFloat(row) * rowHeight + rowHeight / 2)) } } } } .accessibilityHidden(true) } /// A stable pseudo-random value in 0..<1. static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 374_761_393 &+ b &* 668_265_263) x = (x ^ (x >> 13)) &* 1_274_126_177 x ^= x >> 16 return Double(x % 10_000) / 10_000 } } #Preview { MatrixRain() .frame(width: 300, height: 220) .background(.black) } ``` --- ## Interactive Dot Grid (`dot-grid`) A grid of dots scatters away from your finger and eases back. - API: `InteractiveDotGrid()` - Component: Background - Trigger: Drag / Hover / Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/InteractiveDotGrid.swift - Files: Sources/MotionLibrary/Background/InteractiveDotGrid.swift ### Usage ```swift InteractiveDotGrid(spacing: 18) .ignoresSafeArea() ``` ### How it works Create a SwiftUI InteractiveDotGrid(spacing:color:highlight:radius:) view (iOS 17+). Track the pointer from a DragGesture(minimumDistance: 0) and onContinuousHover (nil when released). In a Canvas inside TimelineView(.animation), smooth the pointer and a 0…1 strength each frame with exponential easing (k = 1 − e^(−14 dt) while active, strength decays with e^(−6 dt) after release) in a plain reference-type model. Draw a centred grid of dots every spacing points (18 pt): within radius (70 pt) of the pointer, near = (1 − d / radius)² × strength; push the dot away by near × spacing × 1.6, grow it by 2 × near and tint it with the highlight colour above 0.15. At rest, a faint wave sin((column + row) × 0.45 − time × 2) varies the radius by 0.5 pt. With Reduce Motion on, keep the highlight but don't move dots or run the wave. ### Source ```swift import SwiftUI /// A grid of dots that scatter away from your finger or pointer and ease /// back when you let go. At rest a faint wave ripples through the dot sizes. public struct InteractiveDotGrid: View { private let spacing: CGFloat private let color: Color private let highlight: Color private let radius: CGFloat @State private var pointer: CGPoint? @State private var model = DotGridModel() @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - spacing: Distance between dots, in points. /// - color: Dot colour at rest. /// - highlight: Dot colour near the pointer. /// - radius: How far the pointer's push reaches, in points. public init(spacing: CGFloat = 18, color: Color = .secondary.opacity(0.45), highlight: Color = .accentColor, radius: CGFloat = 70) { self.spacing = max(4, spacing) self.color = color self.highlight = highlight self.radius = radius } public var body: some View { TimelineView(.animation) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in let state = model.step(towards: pointer) let columns = Int(size.width / spacing) + 1 let rows = Int(size.height / spacing) + 1 let originX = (size.width - CGFloat(columns - 1) * spacing) / 2 let originY = (size.height - CGFloat(rows - 1) * spacing) / 2 for row in 0.. 0.001 { let dx = p.x - state.point.x, dy = p.y - state.point.y let distance = max(0.001, hypot(dx, dy)) if distance < radius { near = pow(1 - distance / radius, 2) * state.strength let push = reduceMotion ? 0 : near * spacing * 1.6 p.x += dx / distance * push p.y += dy / distance * push } } let wave = reduceMotion ? 0 : 0.5 + 0.5 * sin(Double(column + row) * 0.45 - time * 2) let r = 1.2 + 0.5 * CGFloat(wave) + 2 * near let shading: GraphicsContext.Shading = near > 0.15 ? .color(highlight.opacity(0.4 + 0.6 * Double(near))) : .color(color) context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2)), with: shading) } } } } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { pointer = $0.location } .onEnded { _ in pointer = nil } ) .onContinuousHover { phase in switch phase { case .active(let location): pointer = location case .ended: pointer = nil } } .accessibilityHidden(true) } } /// Smooths the pointer and the push strength from frame to frame, so dots /// glide out and back instead of snapping. Not observed: it's only read and /// written while drawing. final class DotGridModel { private var point: CGPoint = .zero private var strength: CGFloat = 0 private var last = Date() func step(towards target: CGPoint?) -> (point: CGPoint, strength: CGFloat) { let now = Date() let dt = min(0.05, now.timeIntervalSince(last)) last = now let k = CGFloat(1 - exp(-dt * 14)) if let target { if strength < 0.01 { point = target } point.x += (target.x - point.x) * k point.y += (target.y - point.y) * k strength += (1 - strength) * k } else { strength += (0 - strength) * CGFloat(1 - exp(-dt * 6)) } return (point, strength) } } #Preview { InteractiveDotGrid() .frame(width: 300, height: 220) } ``` --- ## Meteors (`meteors`) Meteors streak across a starry sky with glowing heads and fading tails. - API: `Meteors()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/Meteors.swift - Files: Sources/MotionLibrary/Background/Meteors.swift ### Usage ```swift Meteors(count: 10) .background(Color(red: 0.03, green: 0.04, blue: 0.12)) .ignoresSafeArea() ``` ### How it works Create a SwiftUI Meteors(count:angle:color:showsStars:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Scatter 60 faint static stars (0.4–1.2 pt, 25–65 % opacity) from a stable hash. Each meteor i has a flight time of 1.6–3.8 s and a pause of 0.5–3 s; when in flight, its head moves from a start point spread along the top edge and the side it comes from along the direction (angle 135° in y-down coordinates, so they travel down and to the left) by (width + height) × 1.2 × progress. Stroke its 50–110 pt tail from the tail point to the head with a linear gradient clear → 90 % colour, 1.5 pt wide, round caps, and draw a 5 pt head with a 2 pt blur. With Reduce Motion on, draw the stars only. ### Source ```swift import SwiftUI /// Meteors streak diagonally across a starry sky, each with a glowing head /// and a fading tail, at random intervals. public struct Meteors: View { private let count: Int private let angle: Angle private let color: Color private let showsStars: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of meteors in flight over a cycle. /// - angle: Direction of travel (0° = right, 90° = down). /// - color: Colour of heads and tails. /// - showsStars: Scatter faint static stars behind the meteors. public init(count: Int = 10, angle: Angle = .degrees(135), color: Color = .white, showsStars: Bool = true) { self.count = max(1, count) self.angle = angle self.color = color self.showsStars = showsStars } public var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in if showsStars { for index in 0..<60 { let x = Self.hash(index, 1) * size.width, y = Self.hash(index, 2) * size.height let r = 0.4 + Self.hash(index, 3) * 0.8 context.fill(Path(ellipseIn: CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2)), with: .color(color.opacity(0.25 + 0.4 * Self.hash(index, 4)))) } } // Reduce Motion: stars only. guard !reduceMotion else { return } let direction = CGPoint(x: cos(angle.radians), y: sin(angle.radians)) let travel = (size.width + size.height) * 1.2 for index in 0..= 0 ? (spread < size.width ? CGPoint(x: spread - size.width * 0.3, y: -20) : CGPoint(x: -20, y: spread - size.width)) : (spread < size.width ? CGPoint(x: spread + size.width * 0.3, y: -20) : CGPoint(x: size.width + 20, y: spread - size.width)) let head = CGPoint(x: start.x + direction.x * travel * progress, y: start.y + direction.y * travel * progress) let tailLength = 50 + Self.hash(index, 9) * 60 let tail = CGPoint(x: head.x - direction.x * tailLength, y: head.y - direction.y * tailLength) var line = Path() line.move(to: tail) line.addLine(to: head) context.stroke(line, with: .linearGradient(Gradient(colors: [color.opacity(0), color.opacity(0.9)]), startPoint: tail, endPoint: head), style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) var glow = context glow.addFilter(.blur(radius: 2)) glow.fill(Path(ellipseIn: CGRect(x: head.x - 2.5, y: head.y - 2.5, width: 5, height: 5)), with: .color(color)) } } } .accessibilityHidden(true) } static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x9E37) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } #Preview { Meteors() .frame(width: 320, height: 220) .background(Color(red: 0.03, green: 0.04, blue: 0.12)) } ``` --- ## Flow Field (`flow-field`) Generative curves follow a shifting vector field with dashes flowing along them. - API: `FlowField()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/FlowField.swift - Files: Sources/MotionLibrary/Background/FlowField.swift ### Usage ```swift FlowField(colors: [.cyan, .blue, .purple, .pink]) .background(Color(white: 0.06)) .ignoresSafeArea() ``` ### How it works Create a SwiftUI FlowField(lines:colors:speed:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Define a flow angle field(x, y, t) = (sin(x × 0.012 + t) × cos(y × 0.014 − 0.7t) + sin((x + y) × 0.007 + 0.5t)) × π, with coordinates scaled so the smaller side maps to about 220 units and t = time × 0.25. For each of 140 lines, start at a stable random point and integrate 22 steps of 3.2 pt along the field into a Path. Stroke it in a colour picked from the list at 18 % opacity, then again at 85 % with a dash [5, 40] and dashPhase −((time × 30 + random × 45) mod 45), so short dashes travel along the curves (wrap the phase: a huge dash phase makes Core Graphics hang). With Reduce Motion on, show one still frame. ### Source ```swift import SwiftUI /// Generative art: short curves follow a slowly shifting vector field, like /// wind or water, with dashes travelling along them to show the flow. public struct FlowField: View { private let lines: Int private let colors: [Color] private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - lines: Number of curves. /// - colors: Curves are tinted along this list, left to right. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(lines: Int = 140, colors: [Color] = [.cyan, .blue, .purple, .pink], speed: Double = 1) { self.lines = max(1, lines) self.colors = colors.isEmpty ? [.primary] : colors self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 3 : timeline.date.timeIntervalSinceReferenceDate * speed Canvas { context, size in let scale = 220 / max(1, min(size.width, size.height)) for index in 0.. Double { let a = sin(x * 0.012 + t) * cos(y * 0.014 - t * 0.7) let b = sin((x + y) * 0.007 + t * 0.5) return (a + b) * .pi } static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 2_246_822_519 &+ b &* 3_266_489_917) x = (x ^ (x >> 15)) &* 0x2c1b_3c6d x ^= x >> 12 return Double(x % 10_000) / 10_000 } } #Preview { FlowField() .frame(width: 320, height: 220) .background(Color(white: 0.06)) } ``` --- ## Network (`network`) Drifting points link up with lines when close, and gather at your finger. - API: `NetworkBackground()` - Component: Background - Trigger: Loop / Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/NetworkBackground.swift - Files: Sources/MotionLibrary/Background/NetworkBackground.swift ### Usage ```swift NetworkBackground(color: .cyan) .background(.black) .ignoresSafeArea() ``` ### How it works Create a SwiftUI NetworkBackground(count:linkDistance:color:isInteractive:) view (iOS 17+): a plexus of drifting points. In TimelineView(.animation) + Canvas, point i starts at a hashed position, moves at 6–20 pt/s in a hashed direction, and wraps around the edges with 20 pt of slack. Join every pair closer than linkDistance (70 pt) with a line of opacity 0.55 × (1 − d / linkDistance) and width 0.6 + 0.6 × that strength, then draw each point as a 3.6 pt dot. Track the pointer with onContinuousHover and a DragGesture(minimumDistance: 0); points within 110 pt are pulled towards it by (1 − d / 110) × 0.45 of the gap, so the web gathers under the finger. With Reduce Motion on, pause the timeline. Hide it from accessibility. ### Source ```swift import SwiftUI /// Drifting points that link up with thin lines whenever two come close, like /// a constellation or a network map. Points near the pointer or finger are /// pulled towards it, so the web gathers where you touch. /// /// ```swift /// NetworkBackground(color: .cyan) /// .background(.black) /// .ignoresSafeArea() /// ``` public struct NetworkBackground: View { private let count: Int private let linkDistance: CGFloat private let color: Color private let isInteractive: Bool @State private var pointer: CGPoint? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of points. /// - linkDistance: Points closer than this are joined, in points. /// - color: Colour of points and lines. /// - isInteractive: Pull points towards the pointer or a drag. public init(count: Int = 46, linkDistance: CGFloat = 70, color: Color = .white, isInteractive: Bool = true) { self.count = max(2, count) self.linkDistance = linkDistance self.color = color self.isInteractive = isInteractive } @available(*, deprecated, renamed: "init(count:linkDistance:color:isInteractive:)") public init(count: Int = 46, linkDistance: CGFloat = 70, color: Color = .white, interactive: Bool) { self.init(count: count, linkDistance: linkDistance, color: color, isInteractive: interactive) } public var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in let points = (0.. CGPoint { let speed = 6 + Self.hash(index, 3) * 14 // points per second let heading = Self.hash(index, 4) * 2 * .pi let x = wrap(Self.hash(index, 1) * size.width + cos(heading) * speed * time, size.width) let y = wrap(Self.hash(index, 2) * size.height + sin(heading) * speed * time, size.height) var point = CGPoint(x: x, y: y) if let pointer { let dx = pointer.x - point.x, dy = pointer.y - point.y let distance = hypot(dx, dy) let reach: CGFloat = 110 if distance < reach, distance > 0.1 { let pull = (1 - distance / reach) * 0.45 point.x += dx * pull point.y += dy * pull } } return point } /// Wraps into -20…length+20, so points leave one edge and enter the other. private func wrap(_ value: Double, _ length: CGFloat) -> CGFloat { let span = Double(length) + 40 let r = (value + 20).truncatingRemainder(dividingBy: span) return CGFloat((r < 0 ? r + span : r) - 20) } private static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x51ED) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } #Preview { NetworkBackground(color: .cyan) .frame(width: 340, height: 240) .background(Color(red: 0.03, green: 0.05, blue: 0.12)) } ``` --- ## Topographic Lines (`topographic`) Contour lines of a shifting landscape flow like a living map. - API: `TopographicLines()` - Component: Background - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/TopographicLines.swift - Files: Sources/MotionLibrary/Background/TopographicLines.swift ### Usage ```swift TopographicLines(color: .green) .background(Color(white: 0.06)) ``` ### How it works Create a SwiftUI TopographicLines(levels:scale:color:speed:) view (iOS 17+). In TimelineView(.animation(minimumInterval: 1/30)) + Canvas, sample a height field on an 8 pt grid: h = 0.65 × noise(x + t, y − 0.6t) + 0.35 × noise(2.1x − 0.8t, 2.1y + 7), with x, y in units of scale (90 pt), t = time × 0.12 × speed (a multiplier defaulting to 1), and noise a smoothstep-interpolated value noise from an integer hash. For each of levels (14) thresholds spread over 0.18…0.82, trace the contour with marching squares (16 cases, edge crossings linearly interpolated, saddles drawn as two segments) into one Path and stroke it: every fifth line at 1.4 pt and 85 % opacity, the others 0.7 pt at 45 %. With Reduce Motion on, pause the timeline. ### Source ```swift import SwiftUI /// Contour lines of a slowly shifting landscape, like a topographic map /// coming alive: hills swell and sink and the lines flow around them, with /// every fifth line drawn heavier. /// /// ```swift /// TopographicLines(color: .green) /// .background(Color(white: 0.06)) /// ``` public struct TopographicLines: View { private let levels: Int private let scale: Double private let color: Color private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - levels: Number of contour lines between the lowest and highest ground. /// - scale: Size of the hills, in points. /// - color: Colour of the lines. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(levels: Int = 14, scale: Double = 90, color: Color = .primary, speed: Double = 1) { self.levels = max(1, levels) self.scale = max(10, scale) self.color = color self.speed = speed * 0.12 } public var body: some View { TimelineView(.animation(minimumInterval: 1.0 / 30, paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 10_000) * speed Canvas { context, size in let cell: CGFloat = 8 let columns = Int(size.width / cell) + 2, rows = Int(size.height / cell) + 2 // Height field on the grid corners. var field = [Double](repeating: 0, count: columns * rows) for row in 0.. t ? 8 : 0) | (b > t ? 4 : 0) | (c > t ? 2 : 0) | (d > t ? 1 : 0) guard index != 0, index != 15 else { return } let x = CGFloat(column) * cell, y = CGFloat(row) * cell func lerp(_ v1: Double, _ v2: Double) -> CGFloat { CGFloat((t - v1) / (v2 - v1)) } let top = CGPoint(x: x + cell * lerp(a, b), y: y) let right = CGPoint(x: x + cell, y: y + cell * lerp(b, c)) let bottom = CGPoint(x: x + cell * lerp(d, c), y: y + cell) let left = CGPoint(x: x, y: y + cell * lerp(a, d)) func segment(_ p: CGPoint, _ q: CGPoint) { path.move(to: p); path.addLine(to: q) } switch index { case 1, 14: segment(left, bottom) case 2, 13: segment(bottom, right) case 3, 12: segment(left, right) case 4, 11: segment(top, right) case 5: segment(left, top); segment(bottom, right) case 6, 9: segment(top, bottom) case 7, 8: segment(left, top) case 10: segment(top, right); segment(left, bottom) default: break } } /// Smooth value noise in 0…1. private static func noise(_ x: Double, _ y: Double) -> Double { let ix = floor(x), iy = floor(y) let fx = x - ix, fy = y - iy let ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy) let a = hash(ix, iy), b = hash(ix + 1, iy), c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1) return (a + (b - a) * ux) + ((c + (d - c) * ux) - (a + (b - a) * ux)) * uy } private static func hash(_ x: Double, _ y: Double) -> Double { var h = UInt64(bitPattern: Int64(x) &* 374_761_393 &+ Int64(y) &* 668_265_263) h = (h ^ (h >> 13)) &* 1_274_126_177 h ^= h >> 16 return Double(h % 10_000) / 10_000 } } #Preview { TopographicLines(color: .mint) .frame(width: 340, height: 240) .background(Color(white: 0.06)) } ``` --- ## Day & Night Sky (`day-night-sky`) A sky runs from starry night through dawn, noon and sunset, sun and moon arcing over. - API: `DayNightSky(time:)` - Component: Background - Trigger: Loop / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Background/DayNightSky.swift - Files: Sources/MotionLibrary/Background/DayNightSky.swift ### Usage ```swift DayNightSky() // loops a day every 16 s .ignoresSafeArea() DayNightSky(time: hour / 24) // follows the time of day ``` ### How it works Create a SwiftUI DayNightSky(time:cycle:) view (iOS 17+). time is 0…1 (0 midnight, 0.25 sunrise, 0.5 noon, 0.75 sunset); nil loops it with TimelineView every cycle (16 s). Draw an Animatable scene in a Canvas: a vertical gradient whose top and horizon colours are interpolated between stops (night navy, deep purple dawn, pink-orange sunrise horizon, bright blue noon, golden afternoon, orange sunset horizon under violet, dusk). Fade in 50 hashed stars near night, twinkling. Put the sun and moon on opposite sides of an arc: x = mid − cos(angle) × 0.45w, y = horizon − sin(angle) × 0.75h, sun angle = (t − 0.25) × 2π, each with a blurred halo; the sun turns orange near the horizon. Finish with a band of dark hills tinted from the sky colour. Animate a fixed time with .easeInOut(duration: 1.2). With Reduce Motion on, hold at early evening (0.7). ### Source ```swift import SwiftUI /// A sky that runs through a whole day: deep night with stars, a pink and /// orange dawn, blue noon, a burning sunset, and back, with the sun and moon /// arcing across. Loops on its own, or follows a time of day you set. /// /// ```swift /// DayNightSky() // loops a day every 16 s /// DayNightSky(time: 0.75) // fixed at sunset /// ``` public struct DayNightSky: View { private let time: Double? private let cycle: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - time: Time of day, 0…1 (0 midnight, 0.25 sunrise, 0.5 noon, /// 0.75 sunset). Changes animate. Leave nil to loop. /// - cycle: Seconds per day when looping. public init(time: Double? = nil, cycle: Double = 16) { self.time = time self.cycle = max(1, cycle) } public var body: some View { if let time { SkyScene(time: time) .animation(.easeInOut(duration: 1.2), value: time) .accessibilityHidden(true) } else { TimelineView(.animation(minimumInterval: 1.0 / 30, paused: reduceMotion)) { timeline in // Reduce Motion: hold at a calm early evening. let t = reduceMotion ? 0.7 : (timeline.date.timeIntervalSinceReferenceDate / cycle).truncatingRemainder(dividingBy: 1) SkyScene(time: t) } .accessibilityHidden(true) } } } /// The sky at one time of day. Animatable, so a fixed `time` animates /// through the colours in between. struct SkyScene: View, Animatable { var time: Double nonisolated var animatableData: Double { get { time } set { time = newValue } } private typealias RGB = (Double, Double, Double) /// Sky colours (top, horizon) at times of day. private static let stops: [(time: Double, top: RGB, horizon: RGB)] = [ (0.00, (0.02, 0.03, 0.10), (0.06, 0.08, 0.20)), (0.20, (0.05, 0.06, 0.20), (0.25, 0.15, 0.35)), (0.27, (0.30, 0.35, 0.65), (0.98, 0.60, 0.45)), (0.35, (0.30, 0.60, 0.95), (0.70, 0.85, 1.00)), (0.50, (0.20, 0.50, 0.95), (0.65, 0.85, 1.00)), (0.68, (0.30, 0.50, 0.90), (0.95, 0.80, 0.60)), (0.75, (0.35, 0.25, 0.55), (1.00, 0.45, 0.20)), (0.82, (0.08, 0.08, 0.25), (0.40, 0.20, 0.35)), (1.00, (0.02, 0.03, 0.10), (0.06, 0.08, 0.20)), ] var body: some View { let t = time - floor(time) let (top, horizon) = Self.colours(at: t) Canvas { context, size in let rect = CGRect(origin: .zero, size: size) context.fill(Path(rect), with: .linearGradient( Gradient(colors: [Self.color(top), Self.color(horizon)]), startPoint: .zero, endPoint: CGPoint(x: 0, y: size.height))) // Stars fade in at night and twinkle with the time. let night = max(0, 1 - min(abs(t - 0) , abs(t - 1)) / 0.22) // near midnight let starOpacity = max(night, max(0, 1 - abs(t - 0.1) / 0.15), max(0, 1 - abs(t - 0.9) / 0.15)) if starOpacity > 0.01 { for index in 0..<50 { let x = Self.hash(index, 1) * size.width, y = Self.hash(index, 2) * size.height * 0.75 let r = 0.5 + Self.hash(index, 3) * 1 let twinkle = 0.6 + 0.4 * sin(t * 300 + Double(index)) context.fill(Path(ellipseIn: CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2)), with: .color(.white.opacity(starOpacity * twinkle * 0.9))) } } // Sun and moon on opposite sides of a wide arc. let horizonY = size.height * 0.92 func orb(at angle: Double, radius: Double, color: Color, glow: Double) { let p = CGPoint(x: size.width / 2 - cos(angle) * size.width * 0.45, y: horizonY - sin(angle) * size.height * 0.75) guard p.y < horizonY + radius else { return } var halo = context halo.addFilter(.blur(radius: radius * 0.9)) halo.fill(Path(ellipseIn: CGRect(x: p.x - radius * 1.6, y: p.y - radius * 1.6, width: radius * 3.2, height: radius * 3.2)), with: .color(color.opacity(glow))) context.fill(Path(ellipseIn: CGRect(x: p.x - radius, y: p.y - radius, width: radius * 2, height: radius * 2)), with: .color(color)) } let sunAngle = (t - 0.25) * 2 * .pi orb(at: sunAngle, radius: 14, color: Color(red: 1, green: 0.85 - 0.3 * max(0, 1 - sin(sunAngle) * 3), blue: 0.4), glow: 0.6) orb(at: sunAngle + .pi, radius: 10, color: Color(red: 0.95, green: 0.95, blue: 0.85), glow: 0.3) // A dark band of hills in front. var hills = Path() hills.move(to: CGPoint(x: 0, y: size.height)) for step in 0...40 { let x = size.width * Double(step) / 40 hills.addLine(to: CGPoint(x: x, y: horizonY - 6 - 8 * sin(Double(step) * 0.45) - 5 * sin(Double(step) * 1.3))) } hills.addLine(to: CGPoint(x: size.width, y: size.height)) hills.closeSubpath() context.fill(hills, with: .color(Self.color((top.0 * 0.35, top.1 * 0.35, top.2 * 0.4)))) } } private static func colours(at t: Double) -> (RGB, RGB) { let upper = stops.firstIndex { $0.time >= t } ?? stops.count - 1 let lower = max(0, upper - 1) let a = stops[lower], b = stops[upper] let span = b.time - a.time let f = span > 0 ? (t - a.time) / span : 0 func mix(_ x: RGB, _ y: RGB) -> RGB { (x.0 + (y.0 - x.0) * f, x.1 + (y.1 - x.1) * f, x.2 + (y.2 - x.2) * f) } return (mix(a.top, b.top), mix(a.horizon, b.horizon)) } private static func color(_ rgb: RGB) -> Color { Color(red: rgb.0, green: rgb.1, blue: rgb.2) } private static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x5C1E) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } #Preview { DayNightSky(cycle: 10) .frame(width: 340, height: 220) } ``` --- ## Rolling Number (`rolling-number`) Digits roll like an odometer when the value changes. - API: `RollingNumber(value:)` - Component: Number - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/RollingNumber.swift - Files: Sources/MotionLibrary/Number/RollingNumber.swift ### Usage ```swift RollingNumber(value: score) .font(.system(size: 48, weight: .bold, design: .rounded)) ``` ### How it works Create a SwiftUI RollingNumber view (iOS 17+) that displays a number with odometer-style digit rolling. Render Text(value, format:) with monospacedDigit(), apply .contentTransition(.numericText(value:)) so digits roll up when the value increases and down when it decreases, and animate changes with .snappy. Provide initialisers for Int and for Double with a FloatingPointFormatStyle. ### Source ```swift import SwiftUI /// A number whose digits roll up or down when the value changes, like an /// odometer. Built on `contentTransition(.numericText)`. /// /// ```swift /// RollingNumber(value: score) /// .font(.system(size: 48, weight: .bold, design: .rounded)) /// ``` public struct RollingNumber: View { private let value: Double private let format: FloatingPointFormatStyle private let animation: Animation public init( value: Double, format: FloatingPointFormatStyle = .number.precision(.fractionLength(0)), animation: Animation = .snappy ) { self.value = value self.format = format self.animation = animation } public init(value: Int, animation: Animation = .snappy) { self.init(value: Double(value), animation: animation) } public var body: some View { Text(value, format: format) .monospacedDigit() .contentTransition(.numericText(value: value)) .animation(animation, value: value) } } #Preview { struct Demo: View { @State private var value = 1280 var body: some View { VStack(spacing: 20) { RollingNumber(value: value) .font(.system(size: 56, weight: .bold, design: .rounded)) HStack { Button("-") { value -= Int.random(in: 1...300) } Button("+") { value += Int.random(in: 1...300) } } .buttonStyle(.bordered) } } } return Demo() } ``` --- ## Split-Flap Display (`split-flap`) Characters flip through the alphabet like an airport departures board. - API: `SplitFlapText(_:)` - Component: Number - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/SplitFlap.swift - Files: Sources/MotionLibrary/Number/SplitFlap.swift ### Usage ```swift SplitFlapText(departure.time) // "12:45" ``` ### How it works Create a SwiftUI SplitFlapText(_ text:size:tile:ink:) view (iOS 17+), upper-casing the text, with one flap cell per character in an HStack. Each cell is size × 0.78 wide and size × 1.3 tall (size 36), drawn from four halves of a monospaced bold character on a rounded dark tile, each half clipped to the top or bottom half: static top = next character, static bottom = current, a top flap showing current rotated with rotation3DEffect about the x axis anchored at .bottom, and a bottom flap showing next anchored at .top; a thin dark line covers the seam. When the target changes, after a 0.05 s × index delay, step through the characters of the board alphabet " A…Z0…9:.-/" from current towards the target (at most the last 6 steps); each step folds the top flap 0 → −90° (.easeIn, 0.07 s), then the bottom flap 90° → 0 (.easeOut, 0.07 s), then commits next as current and resets the angles without animation. With Reduce Motion on, change instantly. ### Source ```swift import SwiftUI /// A split-flap display, like an airport departures board. When the text /// changes, each character flips through a few intermediate characters /// before landing, with the top flap folding down over the bottom half. public struct SplitFlapText: View { private let text: String private let size: CGFloat private let tile: Color private let ink: Color /// - Parameters: /// - text: The characters to show. Letters are shown in upper case. /// - size: Font size of each character; tiles are sized from it. /// - tile: Tile colour. /// - ink: Character colour. public init(_ text: String, size: CGFloat = 36, tile: Color = Color(white: 0.13), ink: Color = .white) { self.text = text.uppercased() self.size = size self.tile = tile self.ink = ink } public var body: some View { let characters = Array(text) HStack(spacing: size * 0.08) { ForEach(characters.indices, id: \.self) { index in FlapCell(target: characters[index], size: size, tile: tile, ink: ink, delay: Double(index) * 0.05) } } .accessibilityElement() .accessibilityLabel(text) } } /// The order a real board cycles through. private let flapAlphabet = Array(" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/") private struct FlapCell: View { let target: Character let size: CGFloat let tile: Color let ink: Color let delay: Double @State private var current: Character = " " @State private var next: Character = " " @State private var topAngle: Double = 0 @State private var bottomAngle: Double = 90 @Environment(\.accessibilityReduceMotion) private var reduceMotion private let flipTime = 0.07 var body: some View { let width = size * 0.78, height = size * 1.3 ZStack { half(next, top: true) half(current, top: false) // The flap: the old top folds down… half(current, top: true) .rotation3DEffect(.degrees(topAngle), axis: (1, 0, 0), anchor: .bottom, perspective: 0.5) .opacity(topAngle > -89 ? 1 : 0) // …revealing the new bottom as it lands. half(next, top: false) .rotation3DEffect(.degrees(bottomAngle), axis: (1, 0, 0), anchor: .top, perspective: 0.5) .opacity(bottomAngle < 89 ? 1 : 0) } .frame(width: width, height: height) .overlay { Rectangle().fill(Color.black.opacity(0.5)).frame(height: max(1, size * 0.03)) } .task(id: target) { await flip(to: target) } } private func half(_ character: Character, top: Bool) -> some View { let width = size * 0.78, height = size * 1.3 return Text(String(character)) .font(.system(size: size, weight: .bold, design: .monospaced)) .foregroundStyle(ink) .frame(width: width, height: height) .background(tile, in: RoundedRectangle(cornerRadius: size * 0.12)) .frame(height: height / 2, alignment: top ? .top : .bottom) .clipped() .frame(height: height, alignment: top ? .top : .bottom) } /// Steps through up to six characters of the alphabet on the way to `target`. private func flip(to target: Character) async { guard target != current else { return } if reduceMotion { current = target next = target return } try? await Task.sleep(for: .seconds(delay)) var steps: [Character] = [] if let from = flapAlphabet.firstIndex(of: current), let to = flapAlphabet.firstIndex(of: target) { var index = from while index != to && steps.count < 40 { index = (index + 1) % flapAlphabet.count steps.append(flapAlphabet[index]) } } if steps.isEmpty { steps = [target] } for step in steps.suffix(6) { guard !Task.isCancelled else { return } await flipOnce(to: step) } } private func flipOnce(to character: Character) async { next = character withAnimation(.easeIn(duration: flipTime)) { topAngle = -90 } try? await Task.sleep(for: .seconds(flipTime)) withAnimation(.easeOut(duration: flipTime)) { bottomAngle = 0 } try? await Task.sleep(for: .seconds(flipTime)) var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { current = character topAngle = 0 bottomAngle = 90 } } } #Preview { struct Demo: View { @State private var index = 0 let words = ["12:45", "BOARD", "08:10"] var body: some View { SplitFlapText(words[index]) .onTapGesture { index = (index + 1) % words.count } .padding() } } return Demo() } ``` --- ## Slot Machine (`slot-machine`) Number reels spin with motion blur and land one after another. - API: `SlotMachineNumber(value:)` - Component: Number - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/SlotMachineNumber.swift - Files: Sources/MotionLibrary/Number/SlotMachineNumber.swift ### Usage ```swift SlotMachineNumber(value: score, digits: 3) ``` ### How it works Create a SwiftUI SlotMachineNumber(value:digits:size:) view (iOS 17+) that zero-pads the value and shows one reel per digit. A reel is a VStack of 40 rows (row % 10, heavy rounded monospaced digits, size × 1.25 tall) offset by −index × rowHeight and clipped to one row, on a rounded secondary-12 % tile with a top/bottom shading gradient like a drum. When its digit changes from old to new: jump (no animation) to index old, set a blur of size × 0.06, and animate index to 30 + new with .timingCurve(0.3, 0, 0.2, 1.08) over 0.9 s + 0.25 s × reel order, so reels land left to right with a small overshoot; ease the blur off over the last 30 %, and on completion jump back to index new without animation. With Reduce Motion on, animate straight to the new digit in 0.3 s. ### Source ```swift import SwiftUI /// A number shown on slot-machine reels. When the value changes, every reel /// spins a few full turns, blurred with speed, and the reels land one after /// another from left to right with a small bounce. public struct SlotMachineNumber: View { private let value: Int private let digits: Int private let size: CGFloat /// - Parameters: /// - value: The number to show. Negative values show as 0. /// - digits: Minimum number of reels; the number is zero-padded. /// - size: Font size of the digits. public init(value: Int, digits: Int = 3, size: CGFloat = 40) { self.value = max(0, value) self.digits = max(1, digits) self.size = size } public var body: some View { let text = String(value) let padded = String(repeating: "0", count: max(0, digits - text.count)) + text let numbers = padded.compactMap(\.wholeNumberValue) HStack(spacing: size * 0.12) { ForEach(numbers.indices, id: \.self) { index in SlotReel(digit: numbers[index], size: size, order: index) } } .accessibilityElement() .accessibilityLabel(String(value)) } } private struct SlotReel: View { let digit: Int let size: CGFloat let order: Int /// Index into the strip of 40 digits (four turns of 0–9). @State private var index: Int = 0 @State private var isSpinning = false @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let height = size * 1.25 VStack(spacing: 0) { ForEach(0..<40, id: \.self) { row in Text("\(row % 10)") .font(.system(size: size, weight: .heavy, design: .rounded)) .monospacedDigit() .frame(height: height) } } .offset(y: -CGFloat(index) * height) .blur(radius: isSpinning ? size * 0.06 : 0) .frame(height: height, alignment: .top) .padding(.horizontal, size * 0.12) .clipped() .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: size * 0.18)) .overlay { // Shading so the strip looks like it wraps around a drum. LinearGradient(stops: [ .init(color: .black.opacity(0.25), location: 0), .init(color: .clear, location: 0.3), .init(color: .clear, location: 0.7), .init(color: .black.opacity(0.25), location: 1), ], startPoint: .top, endPoint: .bottom) .clipShape(RoundedRectangle(cornerRadius: size * 0.18)) .allowsHitTesting(false) } .onAppear { index = digit } .onChange(of: digit) { old, new in spin(from: old, to: new) } } private func spin(from old: Int, to new: Int) { guard !reduceMotion else { withAnimation(.easeInOut(duration: 0.3)) { index = new } return } // Start from the old digit on the first turn, land on the fourth. var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { index = old } let duration = 0.9 + Double(order) * 0.25 isSpinning = true withAnimation(.timingCurve(0.3, 0, 0.2, 1.08, duration: duration)) { index = 30 + new } completion: { isSpinning = false var settle = Transaction() settle.disablesAnimations = true withTransaction(settle) { index = new } } Task { @MainActor in // Sharpen up a little before the reel stops. try? await Task.sleep(for: .seconds(duration * 0.7)) withAnimation(.easeOut(duration: duration * 0.3)) { isSpinning = false } } } } #Preview { struct Demo: View { @State private var value = 777 var body: some View { SlotMachineNumber(value: value) .onTapGesture { value = Int.random(in: 0...999) } .padding() } } return Demo() } ``` --- ## Price Ticker (`price-ticker`) Digits roll to the new price as the background flashes green or red. - API: `PriceTicker(value:)` - Component: Number - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/PriceTicker.swift - Files: Sources/MotionLibrary/Number/PriceTicker.swift ### Usage ```swift PriceTicker(value: stock.price) .font(.title2.bold()) ``` ### How it works Create a SwiftUI PriceTicker(value:fractionDigits:prefix:) view (iOS 17+): an HStack of a 10 pt "arrowtriangle.up.fill" / "arrowtriangle.down.fill" (hidden when flat) and the prefix + value formatted with fractionDigits decimals, monospacedDigit, with .contentTransition(.numericText(value:)) and .snappy(0.35 s) animation so digits roll. On change, record the direction (green up, red down) for the arrow and text colour, and bump a counter that drives keyframeAnimator on a rounded background in that colour: opacity 0 → 0.3 (0.05 s) → 0 (0.9 s), a quick flash. With Reduce Motion on, skip the roll animation and the flash. ### Source ```swift import SwiftUI /// A live price: digits roll to the new value, the background flashes green /// or red, and an arrow shows which way it moved. public struct PriceTicker: View { private let value: Double private let fractionDigits: Int private let prefix: String @State private var direction: Direction = .flat @State private var flashes = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion private enum Direction { case up, down, flat } /// - Parameters: /// - value: The current price. /// - fractionDigits: Decimal places to show. /// - prefix: Text before the number, such as a currency symbol. public init(value: Double, fractionDigits: Int = 2, prefix: String = "$") { self.value = value self.fractionDigits = fractionDigits self.prefix = prefix } public var body: some View { let color: Color = direction == .up ? .green : direction == .down ? .red : .secondary HStack(spacing: 6) { Image(systemName: direction == .down ? "arrowtriangle.down.fill" : "arrowtriangle.up.fill") .font(.system(size: 10)) .foregroundStyle(color) .opacity(direction == .flat ? 0 : 1) .contentTransition(.symbolEffect(.replace)) Text(prefix + value.formatted(.number.precision(.fractionLength(fractionDigits)))) .monospacedDigit() .contentTransition(.numericText(value: value)) .foregroundStyle(direction == .flat ? Color.primary : color) } .padding(.horizontal, 10) .padding(.vertical, 6) .background { RoundedRectangle(cornerRadius: 8) .fill(color) .keyframeAnimator(initialValue: 0.0, trigger: flashes) { view, opacity in view.opacity(opacity) } keyframes: { _ in LinearKeyframe(0.3, duration: 0.05) LinearKeyframe(0, duration: 0.9) } } .animation(reduceMotion ? nil : .snappy(duration: 0.35), value: value) .onChange(of: value) { old, new in direction = new > old ? .up : new < old ? .down : .flat if !reduceMotion { flashes += 1 } } } } #Preview { struct Demo: View { @State private var price = 182.40 var body: some View { PriceTicker(value: price) .font(.title2.bold()) .task { while !Task.isCancelled { try? await Task.sleep(for: .seconds(1.2)) price += Double.random(in: -2...2) } } .padding() } } return Demo() } ``` --- ## Animated Chart (`animated-chart`) Bars grow up one after another; a line draws itself left to right. - API: `GrowingBarChart(values:) / DrawingLineChart(values:)` - Component: Number - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/AnimatedChart.swift - Files: Sources/MotionLibrary/Number/AnimatedChart.swift ### Usage ```swift GrowingBarChart(values: weekly) .frame(height: 120) DrawingLineChart(values: trend, color: .pink) .frame(height: 120) ``` ### How it works Create two SwiftUI chart views without Swift Charts (iOS 17+). GrowingBarChart(values:color:spacing:): in a GeometryReader, an HStack(alignment: .bottom) of bars with top-rounded UnevenRoundedRectangles filled with the colour's gradient, bar width = width / (n + (n − 1) × spacing) with spacing 0.35; heights are 0 until onAppear sets grown, then height × value / max with .spring(duration: 0.6, bounce: 0.3) delayed 0.06 s × index, and springing to new heights when values change. DrawingLineChart(values:color:duration:): map values into the rect (5–6 pt inset), smooth with midpoint quadratic curves, stroke it 3 pt with round caps trimmed to progress, fill the closed area under it with a colour 35 % → clear gradient at opacity progress, and pop a 9 pt dot with a white ring onto the last point with a bouncy spring once progress reaches 1. In .task(id: values), reset progress to 0 without animation and animate to 1 with .easeInOut(1.2 s). With Reduce Motion on, use short eases. ### Source ```swift import SwiftUI /// A bar chart whose bars grow up from the baseline one after another on /// appear, and spring to their new heights when the values change. public struct GrowingBarChart: View { private let values: [Double] private let color: Color private let spacing: CGFloat @State private var grown = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - values: Bar values; the tallest fills the height. /// - color: Bar colour. /// - spacing: Gap between bars, as a fraction of a bar's width. public init(values: [Double], color: Color = .accentColor, spacing: CGFloat = 0.35) { self.values = values self.color = color self.spacing = spacing } public var body: some View { let top = max(values.max() ?? 1, 0.0001) GeometryReader { geometry in let count = CGFloat(max(1, values.count)) let barWidth = geometry.size.width / (count + (count - 1) * spacing) HStack(alignment: .bottom, spacing: barWidth * spacing) { ForEach(values.indices, id: \.self) { index in let fraction = grown ? max(0, values[index]) / top : 0 UnevenRoundedRectangle(topLeadingRadius: barWidth * 0.25, topTrailingRadius: barWidth * 0.25) .fill(color.gradient) .frame(width: barWidth, height: max(2, geometry.size.height * fraction)) .animation( reduceMotion ? .easeOut(duration: 0.3) : .spring(duration: 0.6, bounce: 0.3).delay(grown ? Double(index) * 0.06 : 0), value: grown ) .animation(reduceMotion ? nil : .spring(duration: 0.6, bounce: 0.3), value: values[index]) } } .frame(maxHeight: .infinity, alignment: .bottom) } .onAppear { grown = true } .accessibilityElement() .accessibilityLabel("Bar chart") .accessibilityValue(values.map { $0.formatted() }.joined(separator: ", ")) } } /// A line chart that draws itself from left to right, with a gradient area /// that fades in underneath and a dot that lands on the last point. Redraws /// when the values change. public struct DrawingLineChart: View { private let values: [Double] private let color: Color private let duration: Double @State private var progress: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - values: The points, left to right. /// - color: Line colour. /// - duration: Seconds to draw the line. public init(values: [Double], color: Color = .accentColor, duration: Double = 1.2) { self.values = values self.color = color self.duration = duration } public var body: some View { ZStack { ChartLine(values: values, closed: true) .fill(LinearGradient(colors: [color.opacity(0.35), color.opacity(0)], startPoint: .top, endPoint: .bottom)) .opacity(progress) ChartLine(values: values, closed: false) .trim(from: 0, to: progress) .stroke(color, style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)) GeometryReader { geometry in if let last = ChartLine.points(values, in: CGRect(origin: .zero, size: geometry.size)).last { Circle() .fill(color) .frame(width: 9, height: 9) .overlay(Circle().stroke(.white, lineWidth: 2)) .position(last) .scaleEffect(progress >= 1 ? 1 : 0.01, anchor: UnitPoint(x: last.x / max(1, geometry.size.width), y: last.y / max(1, geometry.size.height))) .animation(.spring(duration: 0.35, bounce: 0.5), value: progress >= 1) } } } .task(id: values) { var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { progress = 0 } try? await Task.sleep(for: .milliseconds(16)) withAnimation(reduceMotion ? .easeOut(duration: 0.3) : .easeInOut(duration: duration)) { progress = 1 } } .accessibilityElement() .accessibilityLabel("Line chart") .accessibilityValue(values.map { $0.formatted() }.joined(separator: ", ")) } } struct ChartLine: Shape { var values: [Double] var closed: Bool static func points(_ values: [Double], in rect: CGRect) -> [CGPoint] { guard values.count > 1, let low = values.min(), let high = values.max() else { return [] } let range = max(high - low, 0.0001) // A little headroom so the stroke and dot aren't clipped. let inset = rect.insetBy(dx: 5, dy: 6) return values.enumerated().map { index, value in CGPoint( x: inset.minX + inset.width * CGFloat(index) / CGFloat(values.count - 1), y: inset.maxY - inset.height * CGFloat((value - low) / range) ) } } nonisolated func path(in rect: CGRect) -> Path { let points = Self.points(values, in: rect) var path = Path() guard let first = points.first, let last = points.last else { return path } path.move(to: first) // Smooth the line with midpoint quadratic curves. for index in 1.. some Shape { GaugeArc(sweep: sweep, fraction: fraction) } } struct GaugeArc: Shape { var sweep: Double var fraction: Double nonisolated var animatableData: Double { get { fraction } set { fraction = newValue } } nonisolated func path(in rect: CGRect) -> Path { let radius = min(rect.width, rect.height) * 0.42 let start = 90 + (360 - sweep) / 2 var path = Path() path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: .degrees(start), endAngle: .degrees(start + sweep * fraction), clockwise: false) return path } } #Preview { struct Demo: View { @State private var value = 0.3 var body: some View { NeedleGauge(value: value) .frame(width: 200, height: 200) .onTapGesture { value = Double.random(in: 0...1) } } } return Demo() } ``` --- ## Contribution Grid (`contribution-grid`) Activity squares pop in column by column, shaded by how busy each day was. - API: `ContributionGrid(values:)` - Component: Number - Trigger: Appear / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/ContributionGrid.swift - Files: Sources/MotionLibrary/Number/ContributionGrid.swift ### Usage ```swift ContributionGrid(values: commitsPerDay, color: .green) ``` ### How it works Create a SwiftUI ContributionGrid(values:rows:color:cellSize:spacing:) view (iOS 17+) like a contributions calendar: values fill columns of rows (7) top to bottom, left to right, as 11 pt squares with 3 pt gaps and corner radius 0.22 × size. Shade each square by its share of the largest value, rounded up to quarters: 0 is secondary at 15 %, otherwise the colour at 0.25 + 0.75 × level. On appear, scale each square from 0.2 and fade it in with .spring(duration: 0.4, bounce: 0.45) delayed by 0.035 s per column + 0.02 s per row, so it sweeps in diagonally; changes of shade use the same staggered spring. With Reduce Motion on, no scaling and a plain 0.3 s ease. Make it one accessibility element whose value is "N of M days active". ### Source ```swift import SwiftUI /// A grid of small squares shaded by value, like a contributions calendar. /// On appear the squares pop in column by column in a diagonal wave, and /// when the values change the new shades ripple through the same way. /// /// ```swift /// ContributionGrid(values: commitsPerDay, color: .green) /// ``` public struct ContributionGrid: View { private let values: [Double] private let rows: Int private let color: Color private let cellSize: CGFloat private let spacing: CGFloat @State private var shown = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - values: One value per day, filled top to bottom, then left to right. /// Shades are relative to the largest value. /// - rows: Squares per column (7 for a week). /// - color: Colour of the busiest squares. /// - cellSize: Side of a square, in points. /// - spacing: Gap between squares. public init(values: [Double], rows: Int = 7, color: Color = .green, cellSize: CGFloat = 11, spacing: CGFloat = 3) { self.values = values self.rows = max(1, rows) self.color = color self.cellSize = cellSize self.spacing = spacing } public var body: some View { let columns = Int(ceil(Double(values.count) / Double(rows))) let peak = max(values.max() ?? 0, .leastNonzeroMagnitude) HStack(alignment: .top, spacing: spacing) { ForEach(0.. 0 }.count) of \(values.count) days active") } /// Rounds a 0…1 share to one of five shades, like a contributions calendar. private func shade(_ share: Double) -> Double { guard share > 0 else { return 0 } return min(1, ceil(share * 4) / 4) } private func wave(column: Int, row: Int) -> Animation { if reduceMotion { return .easeInOut(duration: 0.3) } return .spring(duration: 0.4, bounce: 0.45).delay(Double(column) * 0.035 + Double(row) * 0.02) } } #Preview { struct Demo: View { @State private var seed = 1 var body: some View { ContributionGrid(values: (0..<(7 * 20)).map { index in let x = sin(Double(index * seed) * 12.9898) * 43758.5453 let r = x - floor(x) return r < 0.3 ? 0 : r * 10 }) .onTapGesture { seed += 1 } } } return Demo() } ``` --- ## Analog Clock (`analog-clock`) A clock with a sweeping second hand; set a time and the hands spring to it. - API: `AnalogClock(date:)` - Component: Number - Trigger: Loop / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Number/AnalogClock.swift - Files: Sources/MotionLibrary/Number/AnalogClock.swift ### Usage ```swift AnalogClock() // live .frame(width: 160, height: 160) AnalogClock(date: meeting, accent: .pink) ``` ### How it works Create a SwiftUI AnalogClock(date:color:accent:timeZone:) view (iOS 17+) drawn in a Canvas at a 1:1 aspect ratio. Convert the date to local seconds = timeIntervalSinceReferenceDate + the zone's offset, so the hands only ever move forward. Face: a ring at 25 % opacity and 60 ticks (every fifth 2.5 pt and longer). Hands from s = seconds mod 43 200: hour angle s / 43 200 × 2π (length 0.5r, width 4.5 % of the side), minute (s mod 3600) / 3600 × 2π (0.75r, 3 %), second (s mod 60) / 60 × 2π in the accent colour with a short tail and a centre dot. With no date, update from TimelineView(.animation) so the second hand sweeps; with a date, make the face Animatable on seconds and animate changes with .spring(duration: 0.9, bounce: 0.3) so the hands turn forward to the new time. With Reduce Motion on, tick once per second with TimelineView(.periodic). Label it "Clock" with the time as its value. ### Source ```swift import SwiftUI /// A clock face with a smoothly sweeping second hand. Live by default; give /// it a date and the hands spring forward to the new time when it changes, /// handy for time zones, alarms or "time travel" pickers. /// /// ```swift /// AnalogClock() // live /// AnalogClock(date: meetingTime) // hands spring to each new time /// .frame(width: 160, height: 160) /// ``` public struct AnalogClock: View { private let date: Date? private let color: Color private let accent: Color private let timeZone: TimeZone @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - date: The time to show. Leave nil for the current time, live. /// - color: Colour of the face markings and the hour and minute hands. /// - accent: Colour of the second hand. /// - timeZone: Time zone the hands show. public init(date: Date? = nil, color: Color = .primary, accent: Color = .orange, timeZone: TimeZone = .current) { self.date = date self.color = color self.accent = accent self.timeZone = timeZone } public var body: some View { Group { if let date { ClockFace(seconds: localSeconds(date), color: color, accent: accent) .animation(reduceMotion ? .easeInOut(duration: 0.3) : .spring(duration: 0.9, bounce: 0.3), value: date) } else if reduceMotion { // Reduce Motion: the second hand ticks instead of sweeping. TimelineView(.periodic(from: .now, by: 1)) { timeline in ClockFace(seconds: floor(localSeconds(timeline.date)), color: color, accent: accent) } } else { TimelineView(.animation) { timeline in ClockFace(seconds: localSeconds(timeline.date), color: color, accent: accent) } } } .aspectRatio(1, contentMode: .fit) .accessibilityElement() .accessibilityLabel("Clock") .accessibilityValue((date ?? .now).formatted(date: .omitted, time: .shortened)) } /// Seconds since a local midnight long ago, so the hands only ever move /// forward as time goes on. private func localSeconds(_ date: Date) -> Double { date.timeIntervalSinceReferenceDate + Double(timeZone.secondsFromGMT(for: date)) } } /// The face and hands for a count of local seconds. Animatable so a change of /// time turns the hands rather than jumping. struct ClockFace: View, Animatable { var seconds: Double var color: Color var accent: Color nonisolated var animatableData: Double { get { seconds } set { seconds = newValue } } var body: some View { Canvas { context, size in let side = min(size.width, size.height) let centre = CGPoint(x: size.width / 2, y: size.height / 2) let radius = side / 2 context.stroke(Path(ellipseIn: CGRect(x: centre.x - radius + 1, y: centre.y - radius + 1, width: side - 2, height: side - 2)), with: .color(color.opacity(0.25)), lineWidth: 2) for tick in 0..<60 { let major = tick % 5 == 0 let angle = Double(tick) / 60 * 2 * .pi let outer = radius * 0.92, inner = radius * (major ? 0.8 : 0.87) var path = Path() path.move(to: point(centre, angle, inner)) path.addLine(to: point(centre, angle, outer)) context.stroke(path, with: .color(color.opacity(major ? 0.9 : 0.35)), style: StrokeStyle(lineWidth: major ? 2.5 : 1, lineCap: .round)) } let s = seconds.truncatingRemainder(dividingBy: 43_200) // 12 h hand(&context, centre, angle: s / 43_200 * 2 * .pi, length: radius * 0.5, width: side * 0.045, color: color) hand(&context, centre, angle: s.truncatingRemainder(dividingBy: 3600) / 3600 * 2 * .pi, length: radius * 0.75, width: side * 0.03, color: color) let secondAngle = s.truncatingRemainder(dividingBy: 60) / 60 * 2 * .pi var second = Path() second.move(to: point(centre, secondAngle + .pi, radius * 0.18)) second.addLine(to: point(centre, secondAngle, radius * 0.85)) context.stroke(second, with: .color(accent), style: StrokeStyle(lineWidth: max(1, side * 0.012), lineCap: .round)) let dot = side * 0.035 context.fill(Path(ellipseIn: CGRect(x: centre.x - dot, y: centre.y - dot, width: dot * 2, height: dot * 2)), with: .color(accent)) } } /// A point `distance` from the centre at a clock angle (0 = 12 o'clock). private func point(_ centre: CGPoint, _ angle: Double, _ distance: Double) -> CGPoint { CGPoint(x: centre.x + sin(angle) * distance, y: centre.y - cos(angle) * distance) } private func hand(_ context: inout GraphicsContext, _ centre: CGPoint, angle: Double, length: Double, width: Double, color: Color) { var path = Path() path.move(to: point(centre, angle + .pi, length * 0.12)) path.addLine(to: point(centre, angle, length)) context.stroke(path, with: .color(color), style: StrokeStyle(lineWidth: width, lineCap: .round)) } } #Preview { struct Demo: View { @State private var date = Date() var body: some View { HStack(spacing: 30) { AnalogClock().frame(width: 120) AnalogClock(date: date, accent: .pink) .frame(width: 120) .onTapGesture { date.addTimeInterval(3 * 3600 + 1200) } } } } return Demo() } ``` --- ## Particle Field (`particle-field`) Drifting particles linked by faint lines, like a living constellation. - API: `ParticleField()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/ParticleField.swift - Files: Sources/MotionLibrary/Particle/ParticleField.swift, Sources/MotionLibrary/Support/SeededRandom.swift ### Usage ```swift ZStack { Color.black.ignoresSafeArea() ParticleField(count: 60, color: .cyan) .ignoresSafeArea() Text("Connected").font(.largeTitle.bold()) } ``` ### How it works Create a SwiftUI ParticleField view (iOS 17+) with count (50), color, speed (a multiplier, default 1, on 14 pt/s) and linkDistance (70 pt). Generate each particle's traits once from a seeded SplitMix64 generator: start position (0…1 in each axis), direction, speed factor (0.4–1.2), radius (1–2.8 pt) and opacity (0.4–1). In TimelineView(.animation) with a Canvas, compute each position as start + direction × time × 14 × speed × factor, wrapped around the canvas edges, so there is no stored state. Stroke a 0.8 pt line between every pair closer than linkDistance with opacity 0.35 × (1 − distance / linkDistance), then fill each particle as a circle. Hide it from accessibility, and with Reduce Motion on, freeze time. ### Source ```swift import SwiftUI /// Particles drifting slowly through the view, joined by faint lines when they /// come close, like a constellation. A calm, techy background. public struct ParticleField: View { private let count: Int private let color: Color private let speed: Double private let linkDistance: CGFloat private let seeds: [ParticleSeed] @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of particles. /// - color: Particle and line colour. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - linkDistance: Particles closer than this are joined by a line (0 turns lines off). public init(count: Int = 50, color: Color = .white, speed: Double = 1, linkDistance: CGFloat = 70) { self.count = count self.color = color self.speed = speed * 14 self.linkDistance = linkDistance var generator = SplitMix64(seed: 42) seeds = (0.. 0 { for i in positions.indices { for j in positions.indices where j > i { let distance = hypot(positions[i].x - positions[j].x, positions[i].y - positions[j].y) guard distance < linkDistance else { continue } var line = Path() line.move(to: positions[i]) line.addLine(to: positions[j]) context.stroke(line, with: .color(color.opacity(0.35 * (1 - distance / linkDistance))), lineWidth: 0.8) } } } for (seed, point) in zip(seeds, positions) { let r = seed.radius context.fill( Path(ellipseIn: CGRect(x: point.x - r, y: point.y - r, width: r * 2, height: r * 2)), with: .color(color.opacity(seed.opacity)) ) } } } .accessibilityHidden(true) } } /// Fixed random traits of one particle. Its position is a pure function of /// time, so the field needs no state and never drifts out of the view. struct ParticleSeed { var start: CGPoint // 0…1 in both axes var direction: Double // radians var speedFactor: Double var radius: CGFloat var opacity: Double init(using generator: inout SplitMix64) { start = CGPoint(x: generator.unit(), y: generator.unit()) direction = generator.unit() * 2 * .pi speedFactor = 0.4 + generator.unit() * 0.8 radius = 1 + generator.unit() * 1.8 opacity = 0.4 + generator.unit() * 0.6 } func position(at time: Double, speed: Double, in size: CGSize) -> CGPoint { guard size.width > 0, size.height > 0 else { return .zero } let travel = time * speed * speedFactor let x = (start.x * size.width + CGFloat(cos(direction) * travel)).wrapped(to: size.width) let y = (start.y * size.height + CGFloat(sin(direction) * travel)).wrapped(to: size.height) return CGPoint(x: x, y: y) } } #Preview { ParticleField() .background(Color(red: 0.05, green: 0.07, blue: 0.15)) .frame(width: 320, height: 220) } // MARK: - SeededRandom.swift // Needed by this effect: save it as its own file next to the one above. // Shared by the particle and background effects that need stable random // layouts. Copy this file along with any effect that uses `SplitMix64`. import SwiftUI /// Small deterministic random generator, so particle layouts are stable /// between launches and previews. struct SplitMix64: RandomNumberGenerator { private var state: UInt64 init(seed: UInt64) { state = seed } mutating func next() -> UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } /// A Double in 0..<1. mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) } } extension CGFloat { /// Wraps the value into 0.. CGFloat { let r = truncatingRemainder(dividingBy: length) return r < 0 ? r + length : r } } ``` --- ## Starfield (`starfield`) Stars stream out of the centre and stretch into streaks: warp speed. - API: `Starfield()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Starfield.swift - Files: Sources/MotionLibrary/Particle/Starfield.swift, Sources/MotionLibrary/Support/SeededRandom.swift ### Usage ```swift Starfield() .background(.black) .ignoresSafeArea() Starfield(count: 300, speed: 4) // warp jump ``` ### How it works Create a SwiftUI Starfield view (iOS 17+) with count (200), speed (a multiplier, default 1, on 0.35 depth units per second) and color. Give each star a seeded random x and y in −1…1 and a depth in 0…1. In a Canvas inside TimelineView(.animation), compute z = 0.05 + 0.95 × fract(depth − time × 0.35 × speed), so stars approach the camera and wrap back to the far plane. Project with perspective: point = centre + (x, y) / z × 0.15 × half the larger side. Draw each star as a round-capped streak from its projection at a slightly larger z (z + min(0.08, 0.35 × speed × 0.05)) to its current projection, with opacity and line width growing as it gets closer. Hide it from accessibility, and with Reduce Motion on, freeze time. ### Source ```swift import SwiftUI /// Flying through space: stars stream out from the centre and stretch into /// streaks as they pass the camera. Raise `speed` for a warp jump. public struct Starfield: View { private let speed: Double private let color: Color private let stars: [Star] @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of stars. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - color: Star colour. public init(count: Int = 200, speed: Double = 1, color: Color = .white) { self.speed = speed * 0.35 self.color = color var generator = SplitMix64(seed: 7) stars = (0.. UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } /// A Double in 0..<1. mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) } } extension CGFloat { /// Wraps the value into 0.. CGFloat { let r = truncatingRemainder(dividingBy: length) return r < 0 ? r + length : r } } ``` --- ## Cover Flow (`cover-flow`) Side items turn away in 3D, shrink and dim; the centre faces you. - API: `CoverFlow(items) { item in }` - Component: Particle & 3D - Trigger: Scroll - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/CoverFlow.swift - Files: Sources/MotionLibrary/Particle/CoverFlow.swift ### Usage ```swift CoverFlow(albums) { album in Image(album.artwork) .resizable() .aspectRatio(1, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 12)) } .frame(height: 220) ``` ### How it works Create a generic SwiftUI CoverFlow carousel (iOS 17+) over Identifiable items with an optional selection binding, itemWidth (140), spacing (−10) and maxAngle (50°). Use a horizontal ScrollView + LazyHStack with .scrollTargetLayout(), .scrollTargetBehavior(.viewAligned), hidden indicators, .scrollPosition(id:anchor: .center) and contentMargins that centre the first and last items. In .visualEffect, read the item's frame in the .scrollView coordinate space, compute p = clamp((midX − viewport centre) / itemWidth, −1, 1) and apply rotation3DEffect of −p × maxAngle around the y axis (perspective 0.6), scale 1 − |p| × 0.18 and brightness −|p| × 0.25. Raise the selected item's zIndex. With Reduce Motion on, drop the rotation. ### Source ```swift import SwiftUI /// A horizontal carousel in the style of Cover Flow: the centred item faces /// you, and items to either side turn away in 3D, recede and dim. Items snap /// to the centre. public struct CoverFlow: View where Data.Element: Identifiable { private let items: Data private let selection: Binding? private let itemWidth: CGFloat private let spacing: CGFloat private let maxAngle: Double private let content: (Data.Element) -> Content @State private var internalSelection: Data.Element.ID? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - items: One item per element. /// - selection: Optional binding to the centred item; set it to scroll. /// - itemWidth: Width of each item in points. /// - spacing: Gap between items (negative values overlap them). /// - maxAngle: How far side items turn, in degrees. /// - content: Builds one item, usually artwork. public init( _ items: Data, selection: Binding? = nil, itemWidth: CGFloat = 140, spacing: CGFloat = -10, maxAngle: Double = 50, @ViewBuilder content: @escaping (Data.Element) -> Content ) { self.items = items self.selection = selection self.itemWidth = itemWidth self.spacing = spacing self.maxAngle = maxAngle self.content = content } public var body: some View { GeometryReader { geometry in // Plain values for the visualEffect closure, which can't touch the view. let fallbackWidth = geometry.size.width let itemWidth = itemWidth let maxAngle = reduceMotion ? 0 : maxAngle ScrollView(.horizontal) { LazyHStack(spacing: spacing) { ForEach(items) { item in content(item) .frame(width: itemWidth) .visualEffect { view, proxy in // -1 (one item left of centre) … 0 (centred) … 1. let frame = proxy.frame(in: .scrollView(axis: .horizontal)) let viewport = proxy.bounds(of: .scrollView(axis: .horizontal))?.width ?? fallbackWidth let clamped = max(-1, min(1, (frame.midX - viewport / 2) / itemWidth)) let angle = -clamped * maxAngle return view .rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.6) .scaleEffect(1 - abs(clamped) * 0.18) .brightness(-abs(clamped) * 0.25) } // Centred item draws on top of its neighbours. .zIndex(isSelected(item) ? 1 : 0) .id(item.id) } } .scrollTargetLayout() .frame(height: geometry.size.height) } .contentMargins(.horizontal, max(0, (geometry.size.width - itemWidth) / 2), for: .scrollContent) .scrollTargetBehavior(.viewAligned) .scrollIndicators(.hidden) .scrollPosition(id: selection ?? $internalSelection, anchor: .center) } } private func isSelected(_ item: Data.Element) -> Bool { (selection?.wrappedValue ?? internalSelection) == item.id } } #Preview { struct Album: Identifiable { let id: Int let color: Color } let albums = [Color.red, .orange, .yellow, .green, .teal, .blue, .indigo, .purple] .enumerated().map { Album(id: $0, color: $1) } return CoverFlow(albums) { album in RoundedRectangle(cornerRadius: 12) .fill(album.color.gradient) .aspectRatio(1, contentMode: .fit) .overlay(Image(systemName: "music.note").font(.largeTitle).foregroundStyle(.white)) } .frame(height: 200) } ``` --- ## Float 3D (`float-3d`) The view hovers: a gentle bob, a slow 3D sway and a breathing shadow. - API: `.float3D()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Float3D.swift - Files: Sources/MotionLibrary/Particle/Float3D.swift ### Usage ```swift Image("product") .resizable() .scaledToFit() .frame(width: 180) .float3D() BadgeView() .float3D(height: 6, angle: 8, period: 4, shadow: false) ``` ### How it works Create a SwiftUI .float3D(height:angle:period:shadow:) view modifier (iOS 17+). Inside TimelineView(.animation), let t = time × 2π / period (3 s) and lift = 0.5 + 0.5·sin t. Rotate the content in 3D by sin(t/2) × angle (12°) around the y axis and cos(0.7t) × angle/2 around the x axis (perspective 0.5), and move it up by height (10 pt) × lift. Behind it, at the bottom, draw a black ellipse 12 pt tall, 18 pt below, that gets lighter (opacity 0.25 − 0.1·lift), narrower (scale x 0.75 − 0.15·lift) and blurrier (6 + 4·lift) as the view rises. With Reduce Motion on, pause the timeline. ### Source ```swift import SwiftUI /// Makes a view hover in space: it bobs up and down and slowly sways in 3D, /// while its shadow shrinks and grows underneath. For hero images, product /// shots, badges and empty-state illustrations. struct Float3DModifier: ViewModifier { var height: CGFloat var angle: Double var period: Double var shadow: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { TimelineView(.animation(paused: reduceMotion)) { timeline in let t = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * 2 * .pi / period // Bob: 0 at rest height, 1 at the top. let lift = 0.5 + 0.5 * sin(t) content .rotation3DEffect(.degrees(sin(t * 0.5) * angle), axis: (x: 0, y: 1, z: 0), perspective: 0.5) .rotation3DEffect(.degrees(cos(t * 0.7) * angle * 0.5), axis: (x: 1, y: 0, z: 0), perspective: 0.5) .offset(y: -height * lift) .background(alignment: .bottom) { if shadow { Ellipse() .fill(.black.opacity(0.25 - 0.1 * lift)) .frame(height: 12) .scaleEffect(x: 0.75 - 0.15 * lift, y: 1) .blur(radius: 6 + 4 * lift) .offset(y: 18) } } } } } public extension View { /// Floats the view in 3D: a gentle bob, a slow sway and a breathing shadow. /// /// ```swift /// Image("product") /// .resizable() /// .scaledToFit() /// .frame(width: 180) /// .float3D() /// ``` /// /// - Parameters: /// - height: How high the view rises, in points. /// - angle: Maximum sway angle, in degrees. /// - period: Seconds for one bob up and down. /// - shadow: Draws a soft ground shadow under the view. func float3D(height: CGFloat = 10, angle: Double = 12, period: Double = 3, shadow: Bool = true) -> some View { modifier(Float3DModifier(height: height, angle: angle, period: period, shadow: shadow)) } } #Preview { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(LinearGradient(colors: [.cyan, .blue], startPoint: .top, endPoint: .bottom)) .frame(width: 140, height: 140) .overlay(Image(systemName: "cube.fill").font(.system(size: 56)).foregroundStyle(.white)) .float3D() .padding(60) } ``` --- ## Fireworks (`fireworks`) Rockets climb and burst into rings of falling, fading sparks. - API: `Fireworks()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Fireworks.swift - Files: Sources/MotionLibrary/Particle/Fireworks.swift, Sources/MotionLibrary/Support/SeededRandom.swift ### Usage ```swift ZStack { Color.black.ignoresSafeArea() Fireworks() .ignoresSafeArea() Text("Happy New Year").font(.largeTitle.bold()) } ``` ### How it works Create a SwiftUI Fireworks view (iOS 17+) with colors, interval (0.9 s between launches) and sparks (36 per burst), drawn statelessly in a Canvas inside TimelineView(.animation). Rocket k launches at k × interval; for every rocket younger than 0.8 s climb + 1.5 s fade, seed a SplitMix64 generator with k to pick x (15–85 % of the width), peak height (15–45 %), colour and reach (22–34 % of the smaller side). Climbing: ease-out from the bottom to the peak and draw a 14 pt trail. Bursting, with t = time since the burst / 1.5: place each spark at angle i / sparks × 2π (plus jitter), distance reach × (1 − (1 − min(1, 1.6 t))³) × random 0.75–1, add gravity fall t² × 0.6 × reach, draw a circle of radius 1.8 × (1 − 0.6 t) at opacity 1 − t, and add a white flash for the first 15 %. Hide from accessibility; with Reduce Motion on, freeze on a frame with a burst in bloom. ### Source ```swift import SwiftUI /// Rockets climb from the bottom of the view and burst into rings of falling, /// fading sparks, one after another. public struct Fireworks: View { private let colors: [Color] private let interval: Double private let sparks: Int @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: Burst colours, picked per rocket. /// - interval: Seconds between launches. /// - sparks: Sparks per burst. public init( colors: [Color] = [.pink, .orange, .yellow, .mint, .cyan, .purple], interval: Double = 0.9, sparks: Int = 36 ) { self.colors = colors.isEmpty ? [.white] : colors self.interval = max(0.2, interval) self.sparks = max(1, sparks) } /// Seconds from launch to burst, and from burst to fully faded. private static let climb = 0.8 private static let fade = 1.5 public var body: some View { // Reduce Motion: a still frame with a burst in full bloom. TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 1.3 : timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in let lifetime = Self.climb + Self.fade let newest = Int((time / interval).rounded(.down)) let oldest = newest - Int((lifetime / interval).rounded(.up)) for rocket in max(0, oldest)...max(0, newest) { let age = time - Double(rocket) * interval guard age >= 0, age < lifetime else { continue } draw(rocket: rocket, age: age, in: &context, size: size) } } } .accessibilityHidden(true) } private func draw(rocket: Int, age: Double, in context: inout GraphicsContext, size: CGSize) { var random = SplitMix64(seed: UInt64(truncatingIfNeeded: rocket) &* 2_654_435_761) let x = size.width * CGFloat(0.15 + 0.7 * random.unit()) let peak = size.height * CGFloat(0.15 + 0.3 * random.unit()) let color = colors[Int(random.unit() * Double(colors.count)) % colors.count] let reach = min(size.width, size.height) * CGFloat(0.22 + 0.12 * random.unit()) if age < Self.climb { // Climbing: ease out toward the peak, with a short trail. let t = age / Self.climb let eased = 1 - pow(1 - t, 2) let y = size.height - (size.height - peak) * CGFloat(eased) var trail = Path() trail.move(to: CGPoint(x: x, y: y + 14)) trail.addLine(to: CGPoint(x: x, y: y)) context.stroke(trail, with: .color(color.opacity(0.8)), style: StrokeStyle(lineWidth: 2, lineCap: .round)) return } // Burst: sparks fly out, slow down, fall under gravity and fade. let t = (age - Self.climb) / Self.fade let spread = CGFloat(1 - pow(1 - min(1, t * 1.6), 3)) let fall = CGFloat(t * t) * reach * 0.6 let opacity = max(0, 1 - t) for spark in 0.. UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } /// A Double in 0..<1. mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) } } extension CGFloat { /// Wraps the value into 0.. CGFloat { let r = truncatingRemainder(dividingBy: length) return r < 0 ? r + length : r } } ``` --- ## Snowfall & Rain (`snowfall`) Drifting, swaying snowflakes or fast slanted rain, with depth. - API: `Snowfall(.snow / .rain)` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Snowfall.swift - Files: Sources/MotionLibrary/Particle/Snowfall.swift, Sources/MotionLibrary/Support/SeededRandom.swift ### Usage ```swift Snowfall() .background(.indigo) .ignoresSafeArea() Snowfall(.rain, count: 150, color: .white.opacity(0.8)) ``` ### How it works Create a SwiftUI Snowfall view (iOS 17+) with a style (.snow or .rain), count (80), color and speed. Give each particle seeded random traits: start x and y (0–1), depth (0–1) and phase. In a Canvas inside TimelineView(.animation), compute positions as pure functions of time, wrapped around the view so nothing is stored. Snow: fall speed 18 + 30 × depth pt/s, horizontal sway sin(time × (0.8 + depth) + phase) × (6 + 10 × depth) plus a slow 4 pt/s drift, radius 1 + 2.4 × depth, opacity 0.35 + 0.6 × depth, so small flakes look far away. Rain: fall speed 380 + 320 × depth, a streak 8 + 14 × depth long slanted by 0.25, width 0.8 + 0.8 × depth, opacity 0.25 + 0.5 × depth. Hide from accessibility; Reduce Motion pauses it. ### Source ```swift import SwiftUI /// Falling weather: snowflakes that drift and sway, or slanted streaks of rain. public struct Snowfall: View { /// What falls. public enum Style: Sendable { /// Soft round flakes that sway side to side. case snow /// Fast, thin, slanted streaks. case rain } private let style: Style private let color: Color private let speed: Double private let seeds: [FallSeed] @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - style: `.snow` or `.rain`. /// - count: Number of flakes or drops. /// - color: Flake or drop colour. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(_ style: Style = .snow, count: Int = 80, color: Color = .white, speed: Double = 1) { self.style = style self.color = color self.speed = speed var generator = SplitMix64(seed: style == .snow ? 11 : 23) seeds = (0.. UInt64 { state &+= 0x9E37_79B9_7F4A_7C15 var z = state z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB return z ^ (z >> 31) } /// A Double in 0..<1. mutating func unit() -> Double { Double.random(in: 0..<1, using: &self) } } extension CGFloat { /// Wraps the value into 0.. CGFloat { let r = truncatingRemainder(dividingBy: length) return r < 0 ? r + length : r } } ``` --- ## Floating Reactions (`floating-reactions`) Live-stream style emoji float up, swaying, every time you react. - API: `.floatingReactions(trigger:)` - Component: Particle & 3D - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/FloatingReactions.swift - Files: Sources/MotionLibrary/Particle/FloatingReactions.swift ### Usage ```swift LiveVideo() .floatingReactions(trigger: reactionCount) Button("❤️") { reactionCount += 1 } ``` ### How it works Create a SwiftUI .floatingReactions(trigger:emojis:height:) view modifier (iOS 17+). Every time trigger changes, append a reaction (UUID, a random emoji from the list, a random sway of ±8–18 pt and drift of ±20 pt) to a @State array and remove it 2.3 s later. In a bottom-aligned, hit-testing-disabled overlay, show each emoji (28 pt) with keyframeAnimator(initialValue:trigger:), triggered by a @State flag set in onAppear so it plays once, over {y, x, scale, opacity}: y goes to −height (160 pt) over 2.2 s; x weaves sway → −sway + drift → sway + drift → drift in four 0.55 s cubic keyframes; scale springs from 0.3 to 1.15 and settles at 1; opacity holds for 1.4 s then fades over 0.8 s. With Reduce Motion on, don't spawn reactions. ### Source ```swift import SwiftUI /// Live-stream style reactions: every time `trigger` changes, an emoji pops /// up from the bottom of the view, floats upward swaying, and fades out. struct FloatingReactionsModifier: ViewModifier { var trigger: Trigger var emojis: [String] var height: CGFloat @State private var reactions: [FloatingReaction] = [] @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .overlay(alignment: .bottom) { ZStack { ForEach(reactions) { reaction in FloatingEmoji(reaction: reaction, height: height) } } .allowsHitTesting(false) .accessibilityHidden(true) } .onChange(of: trigger) { // Reduce Motion: no floating emoji. guard !reduceMotion, let emoji = emojis.randomElement() else { return } let reaction = FloatingReaction( emoji: emoji, sway: .random(in: 8...18) * (Bool.random() ? 1 : -1), drift: .random(in: -20...20) ) reactions.append(reaction) DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) { reactions.removeAll { $0.id == reaction.id } } } } } /// One emoji on its way up, with its own random sway. struct FloatingReaction: Identifiable { let id = UUID() let emoji: String let sway: CGFloat let drift: CGFloat } private struct FloatValues { var y: CGFloat = 0 var x: CGFloat = 0 var scale: CGFloat = 0.3 var opacity: Double = 1 } private struct FloatingEmoji: View { let reaction: FloatingReaction let height: CGFloat /// Flipped on appear to play the keyframes once (the `repeating: false` /// variant never plays them at all). @State private var launched = false var body: some View { let sway = reaction.sway let drift = reaction.drift let height = height Text(reaction.emoji) .font(.system(size: 28)) .onAppear { launched = true } .keyframeAnimator(initialValue: FloatValues(), trigger: launched) { view, value in view .scaleEffect(value.scale) .offset(x: value.x, y: value.y) .opacity(value.opacity) } keyframes: { _ in KeyframeTrack(\.y) { CubicKeyframe(-height, duration: 2.2) } KeyframeTrack(\.x) { CubicKeyframe(sway, duration: 0.55) CubicKeyframe(-sway + drift, duration: 0.55) CubicKeyframe(sway + drift, duration: 0.55) CubicKeyframe(drift, duration: 0.55) } KeyframeTrack(\.scale) { SpringKeyframe(1.15, duration: 0.25, spring: .bouncy) CubicKeyframe(1, duration: 0.3) } KeyframeTrack(\.opacity) { LinearKeyframe(1, duration: 1.4) LinearKeyframe(0, duration: 0.8) } } } } public extension View { /// Floats an emoji up from the bottom of the view every time `trigger` /// changes. /// /// ```swift /// LiveVideo() /// .floatingReactions(trigger: reactionCount) /// /// Button("❤️") { reactionCount += 1 } /// ``` /// /// - Parameters: /// - emojis: One is picked at random for each reaction. /// - height: How far each reaction rises, in points. func floatingReactions( trigger: Trigger, emojis: [String] = ["❤️", "🔥", "👏", "😂", "😍"], height: CGFloat = 160 ) -> some View { modifier(FloatingReactionsModifier(trigger: trigger, emojis: emojis, height: height)) } } #Preview { struct Demo: View { @State private var count = 0 var body: some View { Button("React") { count += 1 } .buttonStyle(.borderedProminent) .floatingReactions(trigger: count) .padding(.top, 180) } } return Demo().padding() } ``` --- ## Warp Tunnel (`warp-tunnel`) Rings rush out of the vanishing point toward you: flying down a tunnel. - API: `WarpTunnel()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/WarpTunnel.swift - Files: Sources/MotionLibrary/Particle/WarpTunnel.swift ### Usage ```swift WarpTunnel() .background(.black) .ignoresSafeArea() WarpTunnel(colors: [.green, .mint], sides: 6) // hexagons ``` ### How it works Create a SwiftUI WarpTunnel view (iOS 17+) with rings (18), colors (cyan, blue, purple, pink), speed (a multiplier, default 1, on 1.2 rings per second) and sides (0 for circles, 3+ for polygons). In a Canvas inside TimelineView(.animation), for ring i let depth = 1 − (i + fract(time × 1.2 × speed)) / rings and skip depths below 0.02. Its radius is half the view's diagonal × 0.06 / depth (perspective), skipped once it's 1.5 × larger than that. Stroke a circle, or a polygon rotated by i × 0.12 + time × 0.1 rad, centred in the view, with opacity closeness² and line width 0.5 + 3 × closeness (closeness = 1 − depth), cycling colours by ring. Hide from accessibility; Reduce Motion pauses it. ### Source ```swift import SwiftUI /// Flying through a tunnel: rings rush out of the vanishing point toward the /// viewer, growing, brightening and turning as they pass. public struct WarpTunnel: View { private let rings: Int private let colors: [Color] private let speed: Double private let sides: Int @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - rings: Number of rings in the tunnel. /// - colors: Ring colours, cycled from the back of the tunnel. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. /// - sides: 0 for circles, 3 or more for polygons (6 = hexagons). public init( rings: Int = 18, colors: [Color] = [.cyan, .blue, .purple, .pink], speed: Double = 1, sides: Int = 0 ) { self.rings = max(2, rings) self.colors = colors.isEmpty ? [.white] : colors self.speed = speed * 1.2 self.sides = sides } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed Canvas { context, size in let centre = CGPoint(x: size.width / 2, y: size.height / 2) let reach = hypot(size.width, size.height) / 2 let offset = time - time.rounded(.down) for ring in 0.. 0.02 else { continue } let radius = reach * CGFloat(0.06 / depth) guard radius < reach * 1.5 else { continue } let closeness = 1 - depth let index = (Int(time.rounded(.down)) + ring) % colors.count let path = ringPath(centre: centre, radius: radius, rotation: Double(ring) * 0.12 + time * 0.1) context.stroke( path, with: .color(colors[index].opacity(closeness * closeness)), lineWidth: 0.5 + 3 * CGFloat(closeness) ) } } } .accessibilityHidden(true) } private func ringPath(centre: CGPoint, radius: CGFloat, rotation: Double) -> Path { guard sides >= 3 else { return Path(ellipseIn: CGRect(x: centre.x - radius, y: centre.y - radius, width: radius * 2, height: radius * 2)) } var path = Path() for corner in 0...sides { let angle = Double(corner) / Double(sides) * 2 * .pi + rotation let point = CGPoint(x: centre.x + CGFloat(cos(angle)) * radius, y: centre.y + CGFloat(sin(angle)) * radius) corner == 0 ? path.move(to: point) : path.addLine(to: point) } path.closeSubpath() return path } } #Preview { WarpTunnel(sides: 6) .background(.black) .frame(width: 320, height: 220) } ``` --- ## Holographic Card (`holo-card`) A trading card that tilts toward you with a rainbow foil and moving glare. - API: `.holographic()` - Component: Particle & 3D - Trigger: Hover / Drag - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Holographic.swift - Files: Sources/MotionLibrary/Particle/Holographic.swift ### Usage ```swift Image("card-art") .resizable() .aspectRatio(5 / 7, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 16)) .holographic(cornerRadius: 16) ``` ### How it works Create a SwiftUI .holographic(cornerRadius:maxAngle:) view modifier (iOS 17+). Track the pointer with .onContinuousHover and a finger with a simultaneous DragGesture(minimumDistance: 0), normalising the location to p in −1…1 on both axes (nil when released). Overlay, clipped to a continuous rounded rectangle: (1) a rainbow LinearGradient (red → … → purple → red) from (−0.5 + 0.6 px, −0.5 + 0.3 py) to (1.5 + 0.6 px, 1.5 + 0.3 py) with .blendMode(.overlay), at 55 % opacity while touched and 15 % at rest; (2) a white 70 % → clear RadialGradient centred at (0.5 + 0.5 px, 0.5 + 0.5 py) with .blendMode(.softLight), visible only while touched. Tilt with rotation3DEffect: −py × maxAngle (14°) around x and px × maxAngle around y, perspective 0.6, and scale to 1.03 while touched. Follow with .interactiveSpring(response: 0.25) and release with .spring(response: 0.5, dampingFraction: 0.6). With Reduce Motion on, keep the foil and glare but don't tilt or scale. ### Source ```swift import SwiftUI /// A collectible-card shine: the view tilts toward the pointer or finger, a /// rainbow foil slides across it and a white glare follows the light source. /// Springs back flat on release. struct HolographicModifier: ViewModifier { var cornerRadius: CGFloat var maxAngle: Double /// Pointer position in -1…1 on both axes; nil when not touching. @State private var pointer: CGPoint? @State private var size: CGSize = .zero @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let p = pointer ?? .zero let active = pointer != nil let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) content .overlay { ZStack { // Rainbow foil, shifted by the tilt. LinearGradient( colors: [.red, .orange, .yellow, .green, .cyan, .blue, .purple, .red], startPoint: UnitPoint(x: -0.5 + p.x * 0.6, y: -0.5 + p.y * 0.3), endPoint: UnitPoint(x: 1.5 + p.x * 0.6, y: 1.5 + p.y * 0.3) ) .blendMode(.overlay) .opacity(active ? 0.55 : 0.15) // Glare where the light hits. RadialGradient( colors: [.white.opacity(0.7), .white.opacity(0)], center: UnitPoint(x: 0.5 + p.x * 0.5, y: 0.5 + p.y * 0.5), startRadius: 0, endRadius: max(size.width, size.height) * 0.6 ) .blendMode(.softLight) .opacity(active ? 1 : 0) } .clipShape(shape) .allowsHitTesting(false) .accessibilityHidden(true) } .background { GeometryReader { geometry in Color.clear .onAppear { size = geometry.size } .onChange(of: geometry.size) { _, newSize in size = newSize } } } // Reduce Motion: the foil and glare still react, but the card stays flat. .rotation3DEffect(.degrees(reduceMotion ? 0 : Double(p.y) * -maxAngle), axis: (x: 1, y: 0, z: 0), perspective: 0.6) .rotation3DEffect(.degrees(reduceMotion ? 0 : Double(p.x) * maxAngle), axis: (x: 0, y: 1, z: 0), perspective: 0.6) .scaleEffect(active && !reduceMotion ? 1.03 : 1) .onContinuousHover(coordinateSpace: .local) { phase in switch phase { case let .active(location): track(location) case .ended: release() } } .simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .local) .onChanged { track($0.location) } .onEnded { _ in release() } ) } private func track(_ location: CGPoint) { guard size.width > 0, size.height > 0 else { return } let x = min(1, max(-1, location.x / size.width * 2 - 1)) let y = min(1, max(-1, location.y / size.height * 2 - 1)) withAnimation(.interactiveSpring(response: 0.25, dampingFraction: 0.8)) { pointer = CGPoint(x: x, y: y) } } private func release() { withAnimation(.spring(response: 0.5, dampingFraction: 0.6)) { pointer = nil } } } public extension View { /// Turns the view into a holographic trading card that tilts and shines /// under the pointer or finger. /// /// ```swift /// Image("card-art") /// .resizable() /// .aspectRatio(5 / 7, contentMode: .fit) /// .clipShape(RoundedRectangle(cornerRadius: 16)) /// .holographic(cornerRadius: 16) /// ``` /// /// - Parameters: /// - cornerRadius: Match the view's own corner radius. /// - maxAngle: Tilt at the edges, in degrees. func holographic(cornerRadius: CGFloat = 16, maxAngle: Double = 14) -> some View { modifier(HolographicModifier(cornerRadius: cornerRadius, maxAngle: maxAngle)) } } #Preview { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(LinearGradient(colors: [.indigo, .purple], startPoint: .top, endPoint: .bottom)) .overlay(Image(systemName: "bolt.fill").font(.system(size: 50)).foregroundStyle(.yellow)) .frame(width: 150, height: 210) .holographic() .padding(40) } ``` --- ## Physics Emoji (`physics-emoji`) Emoji tumble in with gravity, bounce off each other and pile up. - API: `PhysicsEmoji(trigger:)` - Component: Particle & 3D - Trigger: Value change / Tap - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/PhysicsEmoji.swift - Files: Sources/MotionLibrary/Particle/PhysicsEmoji.swift ### Usage ```swift PhysicsEmoji(trigger: drops) .frame(height: 300) .onTapGesture { drops += 1 } ``` ### How it works Create a SwiftUI PhysicsEmoji(trigger:emojis:batch:size:) view (iOS 17+) with a tiny physics world in a plain reference type stepped from a Canvas inside TimelineView(.animation). Each change of trigger drops batch (6) bodies of radius size / 2 (30 pt emoji) at random x above the top with small random velocities and spin; keep at most 36, removing the oldest. Each frame (dt capped at 1/30 s, two substeps): add gravity 1400 pt/s², integrate, bounce off the floor with restitution 0.45 (stopping tiny bounces below 30 pt/s), damp sideways speed by 0.9, bounce off the side walls, then resolve circle–circle overlaps by pushing pairs apart along the normal and exchanging the normal impulse −(1 + e) × relative / 2. Decay spin by 0.97 per frame, then for emoji on the floor set spin to the distance actually moved sideways this frame / r / dt, so one wedged against a wall or a neighbour doesn't spin in place. Pause the timeline once no emoji has moved or turned more than 1 pt in 0.5 s (measured from where they were, since stacks jitter by fractions of a point; or there are no emoji), and wake it on the next drop or a size change. Draw each emoji (resolved once per frame) translated and rotated. Disable hit testing and hide from accessibility. With Reduce Motion on, place new emoji directly on the floor without falling. ### Source ```swift import SwiftUI /// Emoji that fall in with gravity, bounce off the floor, walls and each /// other, roll a little, and pile up. Each change of `trigger` drops a /// new handful; the oldest are removed once there are too many. public struct PhysicsEmoji: View { private let trigger: Trigger private let emojis: [String] private let batch: Int private let size: CGFloat @State private var world = EmojiWorld() /// The clock only runs while something is moving; once the pile has /// settled the view stops redrawing until the next drop. @State private var isAwake = false @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - trigger: Drops a batch whenever it changes. /// - emojis: Picked at random for each body. /// - batch: How many drop per trigger. /// - size: Emoji size in points (also the collision diameter). public init(trigger: Trigger, emojis: [String] = ["🎉", "⭐️", "❤️", "🍀", "🔥", "😍"], batch: Int = 6, size: CGFloat = 30) { self.trigger = trigger self.emojis = emojis.isEmpty ? ["⭐️"] : emojis self.batch = max(1, batch) self.size = size } public var body: some View { TimelineView(.animation(paused: !isAwake)) { timeline in Canvas { context, canvasSize in world.step(to: timeline.date, size: canvasSize) var resolved: [String: GraphicsContext.ResolvedText] = [:] for body in world.bodies { let text = resolved[body.emoji] ?? context.resolve(Text(body.emoji).font(.system(size: size * 0.86))) resolved[body.emoji] = text var copy = context copy.opacity = body.opacity copy.translateBy(x: body.position.x, y: body.position.y) copy.rotate(by: .radians(body.angle)) copy.draw(text, at: .zero) } } .onChange(of: timeline.date) { if world.isAtRest { isAwake = false } } } .background { // A new size moves the walls and floor, so let the pile resettle. GeometryReader { proxy in Color.clear.onChange(of: proxy.size) { isAwake = true } } } .onChange(of: trigger) { world.drop(count: batch, emojis: emojis, radius: size / 2, settled: reduceMotion) isAwake = true } .allowsHitTesting(false) .accessibilityHidden(true) } } /// A tiny physics world, stepped once per frame. Not observed: the /// `TimelineView` redraws every frame anyway. final class EmojiWorld { struct Body { var emoji: String var position: CGPoint var velocity: CGVector var angle: Double var spin: Double var radius: CGFloat var opacity: Double = 1 } private(set) var bodies: [Body] = [] private var size: CGSize = .zero private var last: Date? private let maximum = 36 /// Where every body was when the pile last moved, and how long ago. /// Stacked bodies jitter by a fraction of a point each frame, so rest is /// judged by how far they get, not by their speed. private var anchor: [Body] = [] private var stillFor: Double = 0 /// True once no emoji has moved or turned more than a point in half a /// second. var isAtRest: Bool { bodies.isEmpty || stillFor >= 0.5 } func drop(count: Int, emojis: [String], radius: CGFloat, settled: Bool) { let width = max(size.width, radius * 4) for index in 0.. maximum { bodies.removeFirst(bodies.count - maximum) } anchor = [] stillFor = 0 } func step(to date: Date, size: CGSize) { self.size = size let dt = min(1.0 / 30, date.timeIntervalSince(last ?? date)) last = date guard dt > 0, !bodies.isEmpty else { return } let before = bodies let gravity: CGFloat = 1400 let restitution: CGFloat = 0.45 // Two substeps keep stacks stable. for _ in 0..<2 { let h = CGFloat(dt / 2) for i in bodies.indices { bodies[i].velocity.dy += gravity * h bodies[i].position.x += bodies[i].velocity.dx * h bodies[i].position.y += bodies[i].velocity.dy * h bodies[i].angle += bodies[i].spin * Double(h) let r = bodies[i].radius if bodies[i].position.y > size.height - r { bodies[i].position.y = size.height - r bodies[i].velocity.dy = -abs(bodies[i].velocity.dy) * restitution bodies[i].velocity.dx *= 0.9 if abs(bodies[i].velocity.dy) < 30 { bodies[i].velocity.dy = 0 } } if bodies[i].position.x < r { bodies[i].position.x = r bodies[i].velocity.dx = abs(bodies[i].velocity.dx) * restitution } else if bodies[i].position.x > size.width - r { bodies[i].position.x = size.width - r bodies[i].velocity.dx = -abs(bodies[i].velocity.dx) * restitution } } // Circle-circle collisions: separate, then bounce along the normal. for i in bodies.indices { for j in (i + 1).. 0.0001 else { continue } let distance = sqrt(distanceSquared) let nx = dx / distance, ny = dy / distance let overlap = (minDistance - distance) / 2 bodies[i].position.x -= nx * overlap bodies[i].position.y -= ny * overlap bodies[j].position.x += nx * overlap bodies[j].position.y += ny * overlap let relative = (bodies[j].velocity.dx - bodies[i].velocity.dx) * nx + (bodies[j].velocity.dy - bodies[i].velocity.dy) * ny guard relative < 0 else { continue } let impulse = -(1 + restitution) * relative / 2 bodies[i].velocity.dx -= impulse * nx bodies[i].velocity.dy -= impulse * ny bodies[j].velocity.dx += impulse * nx bodies[j].velocity.dy += impulse * ny } } } // Spin decays so resting emoji stop turning. for i in bodies.indices { bodies[i].spin *= 0.97 } // Rolling: on the floor, turn by the distance actually rolled. An // emoji wedged against a wall or a neighbour keeps some sideways // velocity but can't move, and shouldn't spin in place. for i in bodies.indices where bodies[i].position.y >= size.height - bodies[i].radius - 0.5 { bodies[i].spin = Double((bodies[i].position.x - before[i].position.x) / bodies[i].radius) / dt } let drift = anchor.count == bodies.count ? zip(anchor, bodies).reduce(0.0) { drift, pair in let moved = hypot(pair.1.position.x - pair.0.position.x, pair.1.position.y - pair.0.position.y) return max(drift, Double(moved), abs(pair.1.angle - pair.0.angle) * Double(pair.1.radius)) } : .infinity if drift > 1 { anchor = bodies stillFor = 0 } else { stillFor += dt } } } #Preview { struct Demo: View { @State private var drops = 0 var body: some View { PhysicsEmoji(trigger: drops) .frame(width: 300, height: 260) .background(Color.secondary.opacity(0.08)) .onTapGesture { drops += 1 } } } return Demo() } ``` --- ## Shatter (`shatter`) The view breaks into triangular shards that fly, spin and fall away. - API: `.transition(.shatter)` - Component: Particle & 3D - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Shatter.swift - Files: Sources/MotionLibrary/Particle/Shatter.swift ### Usage ```swift if !isBroken { GlassPane() .transition(.shatter) } ``` ### How it works Create a SwiftUI ShatterTransition (iOS 17+, Transition) with a static .shatter accessor. Build shards in unit coordinates from a 4 × 3 grid whose interior points are jittered by ±30 % of a cell, splitting each cell into two triangles along alternating diagonals. Removal applies an Animatable ViewModifier with progress 0 → 1 that renders the plain content at 0 (so an intact view isn't drawn once per shard) and otherwise a ZStack of the content masked by each triangle Shape (with a 0.8 pt stroke bleed to hide seams), rotated around its centroid by (r − 0.5) × 220° × p, offset along the centre-to-centroid direction by 110 pt × (0.6 + 0.6r) × p horizontally and 0.6 × 110 × p plus a falling 0.9 × 110 × p² vertically, with opacity 1 − p². Insertion just scales up from 0.9 and fades in. Read Reduce Motion in an inner View (Transition structs can't hold @Environment) and then use distance and spin 0 so shards only fade. ### Source ```swift import SwiftUI /// A removal transition that breaks the view into triangular shards that fly /// outward, spin, fall and fade, like breaking glass. On insertion the view /// simply scales up from 90 % and fades in. public struct ShatterTransition: Transition { public var columns: Int public var rows: Int public var distance: CGFloat /// - Parameters: /// - columns: Grid columns; each cell becomes two shards. /// - rows: Grid rows. /// - distance: How far shards fly, in points. public init(columns: Int = 4, rows: Int = 3, distance: CGFloat = 110) { self.columns = max(1, columns) self.rows = max(1, rows) self.distance = distance } public func body(content: Content, phase: TransitionPhase) -> some View { ShatterBody(content: content, phase: phase, columns: columns, rows: rows, distance: distance) } } private struct ShatterBody: View { let content: Content let phase: TransitionPhase let columns: Int let rows: Int let distance: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { content .modifier(ShatterEffect( progress: phase == .didDisappear ? 1 : 0, columns: columns, rows: rows, // Reduce Motion: shards fade in place. distance: reduceMotion ? 0 : distance, spin: reduceMotion ? 0 : 220 )) .scaleEffect(phase == .willAppear ? 0.9 : 1) .opacity(phase == .willAppear ? 0 : 1) } } /// Draws the plain view at rest and the shards only while breaking, so an /// intact view isn't rendered once per shard. struct ShatterEffect: ViewModifier, Animatable { var progress: Double var columns: Int var rows: Int var distance: CGFloat var spin: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { if progress <= 0.001 { content } else { let shards = ShatterGrid.shards(columns: columns, rows: rows) let p = CGFloat(progress) ZStack { ForEach(shards.indices, id: \.self) { index in let centre = shards[index].centroid let dx = centre.x - 0.5, dy = centre.y - 0.5 let length = max(0.05, hypot(dx, dy)) let random = ShatterGrid.random(index) content .mask(ShardShape(points: shards[index].points)) .rotationEffect(.degrees((random - 0.5) * spin * progress), anchor: UnitPoint(x: centre.x, y: centre.y)) // Out along the crack direction, then falling (gravity grows with p²). .offset( x: dx / length * distance * (0.6 + random * 0.6) * p, y: dy / length * distance * 0.6 * p + distance * 0.9 * p * p ) .opacity(1 - progress * progress) } } .allowsHitTesting(false) } } } /// A triangle in unit coordinates (0…1 across the view). struct Shard { var points: [CGPoint] var centroid: CGPoint { CGPoint(x: points.map(\.x).reduce(0, +) / 3, y: points.map(\.y).reduce(0, +) / 3) } } enum ShatterGrid { /// Splits a jittered grid into triangles, two per cell. static func shards(columns: Int, rows: Int) -> [Shard] { func point(_ c: Int, _ r: Int) -> CGPoint { var x = CGFloat(c) / CGFloat(columns), y = CGFloat(r) / CGFloat(rows) // Jitter interior points so the cracks look natural. if c > 0 && c < columns { x += (random(c * 31 + r * 7) - 0.5) * 0.6 / CGFloat(columns) } if r > 0 && r < rows { y += (random(c * 13 + r * 29 + 5) - 0.5) * 0.6 / CGFloat(rows) } return CGPoint(x: x, y: y) } var shards: [Shard] = [] for r in 0.. Double { var x = UInt64(truncatingIfNeeded: n &* 2_654_435_761 &+ 12_345) x = (x ^ (x >> 31)) &* 0x9E37_79B9_7F4A_7C15 x ^= x >> 29 return Double(x % 10_000) / 10_000 } } struct ShardShape: Shape { var points: [CGPoint] nonisolated func path(in rect: CGRect) -> Path { var path = Path() let mapped = points.map { CGPoint(x: rect.minX + $0.x * rect.width, y: rect.minY + $0.y * rect.height) } path.addLines(mapped) path.closeSubpath() // Bleed a hair so shards don't show seams before they separate. return path.strokedPath(StrokeStyle(lineWidth: 0.8, lineJoin: .round)).union(path) } } public extension Transition where Self == ShatterTransition { /// Breaks the view into flying shards when it's removed. /// /// ```swift /// if !isBroken { /// GlassPane().transition(.shatter) /// } /// ``` static var shatter: ShatterTransition { ShatterTransition() } /// Breaks the view into `columns` × `rows` × 2 shards. static func shatter(columns: Int, rows: Int, distance: CGFloat = 110) -> ShatterTransition { ShatterTransition(columns: columns, rows: rows, distance: distance) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack { if shown { RoundedRectangle(cornerRadius: 16) .fill(Color.cyan.gradient) .frame(width: 180, height: 130) .transition(.shatter) } } .frame(width: 300, height: 300) .onTapGesture { withAnimation(.easeOut(duration: 0.8)) { shown.toggle() } } } } return Demo() } ``` --- ## 3D Cube (`cube`) Views swap by turning a cube: one face folds away as the next rotates in. - API: `.transition(.cube)` - Component: Particle & 3D - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/CubeTransition.swift - Files: Sources/MotionLibrary/Particle/CubeTransition.swift ### Usage ```swift ZStack { PageView(page) .id(page) .transition(.cube) } .clipped() ``` ### How it works Create a SwiftUI CubeTransition (iOS 17+, Transition) with .cube and .cube(edge:) accessors. Map the phase to a position: willAppear = +1 (waiting on the trailing side), identity = 0, didDisappear = −1 (flip the signs for edge .leading). Inside visualEffect (to read the view's width), apply offset(x: width × position) and rotation3DEffect(90° × position, axis y, anchor .leading when position > 0 else .trailing, perspective 0.5). Halfway through, the outgoing face's trailing edge and the incoming face's leading edge meet in the middle like two faces of a cube. Read Reduce Motion in an inner View and cross-fade instead. ### Source ```swift import SwiftUI /// A 3D cube turn between two views: the outgoing view folds away on one /// face of a cube while the incoming view rotates in on the next face. /// Use it on views swapped with an `.id` or an `if`, inside a container /// that clips. public struct CubeTransition: Transition { /// The side the incoming view enters from. public var edge: HorizontalEdge public init(edge: HorizontalEdge = .trailing) { self.edge = edge } public func body(content: Content, phase: TransitionPhase) -> some View { CubeBody(content: content, phase: phase, edge: edge) } } private struct CubeBody: View { let content: Content let phase: TransitionPhase let edge: HorizontalEdge @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // Reduce Motion: a cross-fade. let reduceMotion = reduceMotion // -1 = fully gone to the leading side, 0 = in place, 1 = waiting on the trailing side. let sign: Double = edge == .trailing ? 1 : -1 let position: Double = switch phase { case .willAppear: sign case .identity: 0 case .didDisappear: -sign } return content .visualEffect { effect, proxy in let width = proxy.size.width return effect .offset(x: reduceMotion ? 0 : width * position) .rotation3DEffect( .degrees(reduceMotion ? 0 : 90 * position), axis: (x: 0, y: 1, z: 0), anchor: position > 0 ? .leading : .trailing, perspective: 0.5 ) } .opacity(reduceMotion ? (phase.isIdentity ? 1 : 0) : 1) } } public extension Transition where Self == CubeTransition { /// Turns a cube: the new view rotates in from the trailing side. /// /// ```swift /// ZStack { /// PageView(page) /// .id(page) /// .transition(.cube) /// } /// .clipped() /// ``` static var cube: CubeTransition { CubeTransition() } /// Turns a cube with the new view entering from `edge`. static func cube(edge: HorizontalEdge) -> CubeTransition { CubeTransition(edge: edge) } } #Preview { struct Demo: View { @State private var page = 0 let colors: [Color] = [.pink, .orange, .teal, .indigo] var body: some View { ZStack { RoundedRectangle(cornerRadius: 16) .fill(colors[page].gradient) .overlay(Text("\(page + 1)").font(.largeTitle.bold()).foregroundStyle(.white)) .id(page) .transition(.cube) } .frame(width: 200, height: 140) .onTapGesture { withAnimation(.easeInOut(duration: 0.7)) { page = (page + 1) % colors.count } } } } return Demo() } ``` --- ## Dot Globe (`dot-globe`) A globe of evenly spread dots spins on a tilted axis, with pulsing cities. - API: `DotGlobe()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/DotGlobe.swift - Files: Sources/MotionLibrary/Particle/DotGlobe.swift ### Usage ```swift DotGlobe(dots: 700, color: .cyan) .frame(width: 260, height: 260) ``` ### How it works Create a SwiftUI DotGlobe(dots:color:accent:speed:) view (iOS 17+), square, drawn in a Canvas inside TimelineView(.animation). Place 700 points on a unit sphere with the Fibonacci method (y = 1 − 2(i + 0.5)/n, ring = √(1 − y²), θ = golden angle × i + time × 0.6), tilt them 0.35 rad around the x axis, and project orthographically onto a circle of radius 45 % of the side. With depth = (z + 1) / 2, draw each dot with radius (0.6 + 1.2 × depth) and opacity 0.12 + 0.88 × depth², so the back is faint. Every 61st dot is a "city" in the accent colour (on the front half) whose radius pulses with sin(time × 3 + i). Add a faint 15 % rim circle. With Reduce Motion on, don't rotate. ### Source ```swift import SwiftUI /// A globe made of evenly spread dots, spinning slowly on a tilted axis. /// Dots on the far side are smaller and fainter, which sells the depth, /// and a few "cities" pulse. public struct DotGlobe: View { private let dots: Int private let color: Color private let accent: Color private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - dots: Number of dots on the sphere. /// - color: Dot colour. /// - accent: Colour of the pulsing cities. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(dots: Int = 700, color: Color = .cyan, accent: Color = .pink, speed: Double = 1) { self.dots = max(10, dots) self.color = color self.accent = accent self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate * speed Canvas { context, size in let radius = min(size.width, size.height) * 0.45 let centre = CGPoint(x: size.width / 2, y: size.height / 2) let spin = time * 0.6 let tilt = 0.35 let golden = Double.pi * (3 - sqrt(5)) for index in 0.. 0.5 ? accent : color context.fill( Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: r * 2, height: r * 2)), with: .color(tint.opacity(0.12 + 0.88 * depth * depth)) ) } // A faint rim light. context.stroke( Path(ellipseIn: CGRect(x: centre.x - radius, y: centre.y - radius, width: radius * 2, height: radius * 2)), with: .color(color.opacity(0.15)), lineWidth: 1 ) } } .aspectRatio(1, contentMode: .fit) .accessibilityHidden(true) } } #Preview { DotGlobe() .frame(width: 260, height: 260) .background(Color(red: 0.03, green: 0.05, blue: 0.12)) } ``` --- ## Bubbles (`bubbles`) Soap bubbles rise and wobble with rainbow rims and glossy highlights. - API: `Bubbles()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Bubbles.swift - Files: Sources/MotionLibrary/Particle/Bubbles.swift ### Usage ```swift Bubbles(count: 18) .background(LinearGradient(colors: [.blue.opacity(0.5), .indigo], startPoint: .top, endPoint: .bottom)) ``` ### How it works Create a SwiftUI Bubbles(count:color:speed:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Each of 18 bubbles has a stable random radius (6–22 pt), rise time (4–8 s), phase and x; progress = ((time + phase) / duration) mod 1 moves it from below the bottom to above the top while x wobbles by 14 pt × sin(time × (1 + r) + i). Fade in over the first ~17 % and out over the last 20 %. Fill each with a radial gradient (2 % → 12 % → 35 % of the tint, transparent centre), stroke a 1 pt rim with a diagonal pink → tint → purple gradient, and add a small white oval highlight at the top-left. With Reduce Motion on, show one still frame. ### Source ```swift import SwiftUI /// Soap bubbles rise from the bottom, wobbling side to side, with a /// rainbow-tinted rim and a glossy highlight, and fade out near the top. public struct Bubbles: View { private let count: Int private let color: Color private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of bubbles in flight. /// - color: Rim tint. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(count: Int = 18, color: Color = .cyan, speed: Double = 1) { self.count = max(1, count) self.color = color self.speed = speed } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 5 : timeline.date.timeIntervalSinceReferenceDate * speed Canvas { context, size in for index in 0.. 0.8 ? (1 - progress) / 0.2 : min(1, progress * 6) let rect = CGRect(x: x - r, y: y - r, width: r * 2, height: r * 2) var bubble = context bubble.opacity = fade bubble.fill(Path(ellipseIn: rect), with: .radialGradient( Gradient(colors: [color.opacity(0.02), color.opacity(0.12), color.opacity(0.35)]), center: CGPoint(x: x, y: y), startRadius: 0, endRadius: r )) bubble.stroke(Path(ellipseIn: rect), with: .linearGradient( Gradient(colors: [.pink.opacity(0.7), color.opacity(0.8), .purple.opacity(0.6)]), startPoint: CGPoint(x: rect.minX, y: rect.minY), endPoint: CGPoint(x: rect.maxX, y: rect.maxY) ), lineWidth: 1) let shine = CGRect(x: x - r * 0.55, y: y - r * 0.6, width: r * 0.45, height: r * 0.3) bubble.fill(Path(ellipseIn: shine), with: .color(.white.opacity(0.75))) } } } .accessibilityHidden(true) } static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 1_103_515_245 &+ b &* 12_345 &+ 777) x = (x ^ (x >> 16)) &* 0x45d9_f3b x ^= x >> 16 return Double(x % 10_000) / 10_000 } } #Preview { Bubbles() .frame(width: 300, height: 220) .background(LinearGradient(colors: [.blue.opacity(0.5), .indigo], startPoint: .top, endPoint: .bottom)) } ``` --- ## Fireflies (`fireflies`) Glowing fireflies drift lazily and blink on their own rhythms. - API: `Fireflies()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Fireflies.swift - Files: Sources/MotionLibrary/Particle/Fireflies.swift ### Usage ```swift Fireflies(count: 24) .background(Color(red: 0.02, green: 0.06, blue: 0.05)) ``` ### How it works Create a SwiftUI Fireflies(count:color:speed:) view (iOS 17+) drawn in a Canvas inside TimelineView(.animation). Each firefly has three stable random values a, b, c; its position is the view centre plus two layered sines per axis (x: 0.38 sin(t(0.13 + 0.12a) + 20a) + 0.1 sin(t(0.5 + 0.3b) + 9b); y: 0.36 sin(t(0.11 + 0.1b) + 17b) + 0.1 cos(t(0.45 + 0.3c) + 5c), as fractions of the size), giving slow wandering loops. Its blink = (0.5 + 0.5 sin(t(0.8 + 1.6c) + 30a))³. Draw a glow circle 8 × r wide in a context with .blur(radius: 6) at opacity 0.25 + 0.75 × blink, then a white core of radius r (1.6–2.8 pt) at 0.35 + 0.65 × blink. Warm yellow by default. With Reduce Motion on, freeze positions but keep the blinking. ### Source ```swift import SwiftUI /// Fireflies drift lazily on looping paths, each glowing and dimming on its /// own rhythm. Lovely over dark, quiet backgrounds. public struct Fireflies: View { private let count: Int private let color: Color private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of fireflies. /// - color: Glow colour. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(count: Int = 24, color: Color = Color(red: 1, green: 0.9, blue: 0.45), speed: Double = 1) { self.count = max(1, count) self.color = color self.speed = speed } public var body: some View { TimelineView(.animation) { timeline in let real = timeline.date.timeIntervalSinceReferenceDate // Reduce Motion: they stay put but keep glowing softly. let time = reduceMotion ? 0 : real * speed Canvas { context, size in var glow = context glow.addFilter(.blur(radius: 6)) for index in 0.. Double { var x = UInt64(truncatingIfNeeded: a &* 9_301 &+ b &* 49_297 &+ 233_280) x = (x ^ (x >> 15)) &* 0x2c1b_3c6d x ^= x >> 12 return Double(x % 10_000) / 10_000 } } #Preview { Fireflies() .frame(width: 320, height: 220) .background(Color(red: 0.02, green: 0.06, blue: 0.05)) } ``` --- ## Flock (`flock`) Little arrows swim together like birds or fish, and chase your finger. - API: `Flock()` - Component: Particle & 3D - Trigger: Loop / Drag / Hover - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Flock.swift - Files: Sources/MotionLibrary/Particle/Flock.swift ### Usage ```swift Flock(count: 60, color: .white) .background(.indigo.gradient) ``` ### How it works Create a SwiftUI Flock(count:color:speed:) view (iOS 17+) that simulates boids. Keep the boids (position, velocity) in a reference-type model stored in @State and advance it from a Canvas inside TimelineView(.animation), with dt = time since the last frame capped at 1/20 s. For each boid, look at neighbours within 46 pt: steer 5 % towards their average velocity, 1.2 % towards their centre, and away from any closer than 16 pt by 0.14 × the overlap. If there's a target (from onContinuousHover or a DragGesture(minimumDistance: 0)), add 2 % of the gap to it. Push back by 9 within 24 pt of an edge. Clamp speed between 0.6 and 1.3 × cruise (90 pt/s × speed, a multiplier defaulting to 1; 1.5× when chasing). Draw each boid as a small arrowhead path rotated to atan2 of its velocity. With Reduce Motion on, freeze them. Hide it from accessibility. ### Source ```swift import SwiftUI /// A flock of little arrows that swim together like birds or fish: each one /// steers to keep its distance, match its neighbours' heading and stay with /// the group (boids). Drag or hover and the flock chases the pointer. /// /// ```swift /// Flock(count: 60, color: .white) /// .background(.indigo.gradient) /// ``` public struct Flock: View { private let count: Int private let color: Color private let speed: Double @State private var model = FlockModel() @State private var target: CGPoint? @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - count: Number of boids. /// - color: Colour of the boids. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(count: Int = 50, color: Color = .white, speed: Double = 1) { self.count = max(1, count) self.color = color self.speed = speed * 90 } public var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in Canvas { context, size in model.step(to: timeline.date, size: size, count: count, speed: speed, target: target, frozen: reduceMotion) for boid in model.boids { let angle = atan2(boid.velocity.dy, boid.velocity.dx) var arrow = Path() arrow.move(to: CGPoint(x: 7, y: 0)) arrow.addLine(to: CGPoint(x: -5, y: 4)) arrow.addLine(to: CGPoint(x: -2.5, y: 0)) arrow.addLine(to: CGPoint(x: -5, y: -4)) arrow.closeSubpath() let transform = CGAffineTransform(translationX: boid.position.x, y: boid.position.y).rotated(by: angle) context.fill(arrow.applying(transform), with: .color(color)) } } } .contentShape(Rectangle()) .onContinuousHover { phase in if case let .active(location) = phase { target = location } else { target = nil } } .gesture(DragGesture(minimumDistance: 0) .onChanged { target = $0.location } .onEnded { _ in target = nil }) .accessibilityHidden(true) } } /// The simulation. A class so the canvas can advance it every frame without /// invalidating the view. final class FlockModel { struct Boid { var position: CGPoint var velocity: CGVector } private(set) var boids: [Boid] = [] private var last: Date? func step(to date: Date, size: CGSize, count: Int, speed: Double, target: CGPoint?, frozen: Bool) { if boids.count != count { seed(count: count, size: size, speed: speed) } let dt = min(1.0 / 20, last.map { date.timeIntervalSince($0) } ?? 0) last = date guard !frozen, dt > 0 else { return } let neighbourhood: CGFloat = 46, personalSpace: CGFloat = 16 var next = boids for i in boids.indices { var separation = CGVector.zero, alignment = CGVector.zero, centre = CGVector.zero var neighbours: CGFloat = 0 for j in boids.indices where j != i { let dx = boids[j].position.x - boids[i].position.x let dy = boids[j].position.y - boids[i].position.y let distance = hypot(dx, dy) guard distance < neighbourhood else { continue } neighbours += 1 alignment.dx += boids[j].velocity.dx; alignment.dy += boids[j].velocity.dy centre.dx += dx; centre.dy += dy if distance < personalSpace, distance > 0.01 { separation.dx -= dx / distance * (personalSpace - distance) separation.dy -= dy / distance * (personalSpace - distance) } } var v = boids[i].velocity if neighbours > 0 { v.dx += (alignment.dx / neighbours - v.dx) * 0.05 + centre.dx / neighbours * 0.012 + separation.dx * 0.14 v.dy += (alignment.dy / neighbours - v.dy) * 0.05 + centre.dy / neighbours * 0.012 + separation.dy * 0.14 } let p = boids[i].position if let target { v.dx += (target.x - p.x) * 0.02 v.dy += (target.y - p.y) * 0.02 } // Turn back from the edges. let margin: CGFloat = 24, turn: CGFloat = 9 if p.x < margin { v.dx += turn } if p.x > size.width - margin { v.dx -= turn } if p.y < margin { v.dy += turn } if p.y > size.height - margin { v.dy -= turn } // Hold the cruising speed (a little faster when chasing). let cruise = CGFloat(speed) * (target == nil ? 1 : 1.5) let length = max(0.01, hypot(v.dx, v.dy)) let clamped = min(max(length, cruise * 0.6), cruise * 1.3) v = CGVector(dx: v.dx / length * clamped, dy: v.dy / length * clamped) next[i].velocity = v next[i].position = CGPoint(x: p.x + v.dx * dt, y: p.y + v.dy * dt) } boids = next } private func seed(count: Int, size: CGSize, speed: Double) { var generator = SystemRandomNumberGenerator() boids = (0.. some View { content .overlay { TimelineView(.animation(paused: reduceMotion || puffs.isEmpty)) { timeline in Canvas { context, _ in context.addFilter(.blur(radius: size * 0.35)) for puff in puffs { let age = timeline.date.timeIntervalSince(puff.time) / lifetime guard age >= 0, age < 1 else { continue } let radius = size * (0.35 + age * 0.9) // Drift up and sideways, a little differently per puff. let x = puff.point.x + CGFloat(sin(puff.seed * 9 + age * 3) * 14 * age) let y = puff.point.y - CGFloat(age * 34) let opacity = (1 - age) * (1 - age) * 0.85 context.fill(Path(ellipseIn: CGRect(x: x - radius, y: y - radius, width: radius * 2, height: radius * 2)), with: .color(color.opacity(opacity))) } } } .allowsHitTesting(false) .accessibilityHidden(true) } .onContinuousHover { phase in if case let .active(location) = phase { add(location) } } .simultaneousGesture(DragGesture(minimumDistance: 0).onChanged { add($0.location) }) } private func add(_ point: CGPoint) { guard !reduceMotion else { return } let now = Date() // Space puffs out so a slow drag doesn't stack hundreds in one spot. if let last = puffs.last, hypot(last.point.x - point.x, last.point.y - point.y) < size * 0.18, now.timeIntervalSince(last.time) < 0.05 { return } puffs.removeAll { now.timeIntervalSince($0.time) > lifetime } puffs.append(Puff(point: point, time: now, seed: Double.random(in: 0...1))) } } public extension View { /// Leaves a fading trail of ink or smoke wherever the pointer moves or a /// finger drags over the view. /// /// ```swift /// Color.black /// .inkTrail(color: .purple) /// ``` /// /// - Parameters: /// - color: Colour of the ink. /// - lifetime: Seconds before a puff fades away. /// - size: Diameter of a fresh puff, in points. func inkTrail(color: Color = .purple, lifetime: Double = 1.2, size: CGFloat = 26) -> some View { modifier(InkTrailModifier(color: color, lifetime: lifetime, size: size)) } } #Preview { Color(red: 0.06, green: 0.05, blue: 0.1) .frame(width: 340, height: 240) .inkTrail(color: .pink) } ``` --- ## Rain on Glass (`rain-on-glass`) Raindrops cling to a window while bigger drops slide down in fits and starts. - API: `RainOnGlass()` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/RainOnGlass.swift - Files: Sources/MotionLibrary/Particle/RainOnGlass.swift ### Usage ```swift Image("city-night") .blur(radius: 6) .overlay(RainOnGlass()) ``` ### How it works Create a SwiftUI RainOnGlass(drops:color:) overlay (iOS 17+) in TimelineView(.animation) + Canvas. Scatter one static bead per 900 pt² at hashed positions with radius 0.8–2.6 pt. Each of the sliding drops (9) has a cycle of 3.5–7.5 s; its progress l through the cycle becomes stick-slip motion with five steps, (floor(5l) + frac(5l)³) / 5, carrying it from a hashed start near the top past the bottom edge, with a 3 pt sideways wobble and radius 3.2–5.8 pt, stretched 1.25× vertically. Behind it, leave a trail of tiny beads every 9 pt with hashed jitter. Draw every drop like a tiny lens: an ellipse filled with a diagonal gradient (tint 35 % → tint 5 % → black 35 %) plus a bright fleck at the top-left. Disable hit testing. With Reduce Motion on, pause the timeline. ### Source ```swift import SwiftUI /// Raindrops on a window: tiny beads cling to the glass while bigger drops /// slide down in fits and starts, leaving a dotted trail behind them. Put it /// over a blurred photo or a dark gradient. /// /// ```swift /// Image("city-night") /// .blur(radius: 6) /// .overlay(RainOnGlass()) /// ``` public struct RainOnGlass: View { private let drops: Int private let color: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - drops: Number of sliding drops. /// - color: Colour of the drops' highlights. public init(drops: Int = 9, color: Color = .white) { self.drops = max(0, drops) self.color = color } @available(*, deprecated, renamed: "init(drops:color:)") public init(drops: Int = 9, tint: Color) { self.init(drops: drops, color: tint) } public var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in // Beads that cling to the glass. let beads = Int(size.width * size.height / 900) for index in 0.. Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x7A1D) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } #Preview { LinearGradient(colors: [Color(red: 0.1, green: 0.15, blue: 0.3), Color(red: 0.35, green: 0.2, blue: 0.4)], startPoint: .top, endPoint: .bottom) .overlay(RainOnGlass()) .frame(width: 340, height: 240) } ``` --- ## Orbits (`orbits`) Planets circle a glowing star on tilted orbits, passing behind it and in front. - API: `Orbits(colors:)` - Component: Particle & 3D - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Particle/Orbits.swift - Files: Sources/MotionLibrary/Particle/Orbits.swift ### Usage ```swift Orbits(colors: [.orange, .cyan, .pink]) .frame(width: 240, height: 160) ``` ### How it works Create a SwiftUI Orbits(colors:tilt:centerColor:) view (iOS 17+) in TimelineView(.animation) + Canvas. Each colour is a planet on its own orbit, radius spread from 0.38 to 1 × the largest radius that fits (an ellipse squashed vertically by tilt, 0.38). Period = 2.4 × (2r / maxR)^1.5 s so inner planets move faster; angle = time / period × 2π + 2.1 × index; position = centre + (cos a × r, sin a × r × tilt); depth = sin a. Stroke each orbit at white 14 %. Draw the planets with depth < 0 first, then the star (a 20 pt circle over a 32 pt blurred halo at 70 %), then the ones in front, so they pass behind and in front of it. Scale planets by 0.75 + 0.25 × (depth + 1) / 2 and dim the far ones. With Reduce Motion on, pause the timeline. ### Source ```swift import SwiftUI /// Planets circle a glowing star on tilted orbits, passing behind it and in /// front of it, getting smaller and dimmer on the far side, like a little /// solar system or an atom. /// /// ```swift /// Orbits(colors: [.orange, .cyan, .pink]) /// .frame(width: 240, height: 160) /// ``` public struct Orbits: View { private let colors: [Color] private let tilt: Double private let centerColor: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: One planet per colour, from the innermost orbit out. /// - tilt: How flat the orbits look: 0 is seen edge-on, 1 from above. /// - centerColor: Colour of the central star. public init(colors: [Color] = [.orange, .cyan, .pink, .mint], tilt: Double = 0.38, centerColor: Color = .yellow) { self.colors = colors self.tilt = min(1, max(0.05, tilt)) self.centerColor = centerColor } public var body: some View { TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate Canvas { context, size in let centre = CGPoint(x: size.width / 2, y: size.height / 2) let maxRadius = min(size.width / 2, size.height / 2 / tilt) * 0.92 let planets = colors.enumerated().map { index, color in let radius = maxRadius * (0.38 + 0.62 * Double(index + 1) / Double(colors.count)) // Inner planets go round faster (roughly Kepler's third law). let period = 2.4 * pow(radius / maxRadius * 2, 1.5) let angle = time / period * 2 * .pi + Double(index) * 2.1 let depth = sin(angle) // > 0: in front of the star let point = CGPoint(x: centre.x + cos(angle) * radius, y: centre.y + sin(angle) * radius * tilt) return (radius: radius, point: point, depth: depth, color: color, size: 5 + Double(index % 3) * 2) } // Orbit rings. for planet in planets { let r = planet.radius context.stroke(Path(ellipseIn: CGRect(x: centre.x - r, y: centre.y - r * tilt, width: r * 2, height: r * 2 * tilt)), with: .color(.white.opacity(0.14)), lineWidth: 1) } func draw(_ planet: (radius: Double, point: CGPoint, depth: Double, color: Color, size: Double)) { let scale = 0.75 + 0.25 * (planet.depth + 1) / 2 let s = planet.size * scale context.fill(Path(ellipseIn: CGRect(x: planet.point.x - s, y: planet.point.y - s, width: s * 2, height: s * 2)), with: .color(planet.color.opacity(0.55 + 0.45 * (planet.depth + 1) / 2))) } for planet in planets where planet.depth < 0 { draw(planet) } // The star, with a soft glow. var glow = context glow.addFilter(.blur(radius: 10)) glow.fill(Path(ellipseIn: CGRect(x: centre.x - 16, y: centre.y - 16, width: 32, height: 32)), with: .color(centerColor.opacity(0.7))) context.fill(Path(ellipseIn: CGRect(x: centre.x - 10, y: centre.y - 10, width: 20, height: 20)), with: .color(centerColor)) for planet in planets where planet.depth >= 0 { draw(planet) } } } .accessibilityHidden(true) } } #Preview { Orbits() .frame(width: 320, height: 200) .background(Color(red: 0.04, green: 0.05, blue: 0.12)) } ``` --- ## Iris (`iris`) A circle opens from a point to reveal the view, like a camera iris. - API: `.transition(.iris)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/MaskTransitions.swift - Files: Sources/MotionLibrary/Transition/MaskTransitions.swift ### Usage ```swift if showPhoto { PhotoView() .transition(.iris) } DetailView() .transition(.iris(origin: .topTrailing)) ``` ### How it works Create a custom SwiftUI Transition (iOS 17+, the Transition protocol) called IrisTransition with an origin UnitPoint (centre), exposed as .iris and .iris(origin:). In body(content:phase:), mask the content with a GeometryReader containing a Circle whose radius is the distance from the origin point to the furthest corner, positioned at the origin, with scaleEffect(phase.isIdentity ? 1 : 0.001, anchor: origin). The circle grows from the origin to cover the view on insertion and closes back on removal, timed by whatever animation drives the change, e.g. withAnimation(.easeInOut(duration: 0.6)). It only masks, so it needs no Reduce Motion fallback. ### Source ```swift import SwiftUI // Transitions that reveal or hide a view through an animated mask. Masks // don't move the content, so they need no Reduce Motion fallback. // MARK: - Iris /// A circle that opens from a point to reveal the view, and closes back to /// it on removal, like a camera iris. public struct IrisTransition: Transition { public var origin: UnitPoint public init(origin: UnitPoint = .center) { self.origin = origin } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { GeometryReader { geometry in let size = geometry.size let o = CGPoint(x: size.width * origin.x, y: size.height * origin.y) // Far enough to cover the corner furthest from the origin. let radius = [CGPoint.zero, CGPoint(x: size.width, y: 0), CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height)] .map { hypot($0.x - o.x, $0.y - o.y) } .max() ?? 0 Circle() .frame(width: radius * 2, height: radius * 2) .position(o) .scaleEffect(phase.isIdentity ? 1 : 0.001, anchor: origin) } } } } public extension Transition where Self == IrisTransition { /// Opens the view in a growing circle from its centre. /// /// ```swift /// if showPhoto { /// PhotoView().transition(.iris) /// } /// ``` static var iris: IrisTransition { IrisTransition() } /// Opens the view in a growing circle from `origin`. static func iris(origin: UnitPoint) -> IrisTransition { IrisTransition(origin: origin) } } // MARK: - Blinds /// Horizontal slats that open together, like Venetian blinds. public struct BlindsTransition: Transition { public var slats: Int public init(slats: Int = 8) { self.slats = max(1, slats) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { VStack(spacing: 0) { ForEach(0.. BlindsTransition { BlindsTransition(slats: slats) } } // MARK: - Clock wipe /// A wedge that sweeps clockwise from twelve o'clock, like a clock hand. public struct ClockWipeTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { ClockWipeShape(progress: phase.isIdentity ? 1 : 0) } } } struct ClockWipeShape: Shape { var progress: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } guard progress > 0 else { return Path() } let centre = CGPoint(x: rect.midX, y: rect.midY) let radius = hypot(rect.width, rect.height) var path = Path() path.move(to: centre) // In SwiftUI's y-down space, clockwise: false sweeps clockwise on screen. path.addArc(center: centre, radius: radius, startAngle: .degrees(-90), endAngle: .degrees(-90 + 360 * progress), clockwise: false) path.closeSubpath() return path } } public extension Transition where Self == ClockWipeTransition { /// Sweeps the view in like the hand of a clock. /// /// ```swift /// Badge().transition(.clockWipe) /// ``` static var clockWipe: ClockWipeTransition { ClockWipeTransition() } } // MARK: - Dissolve /// The view appears and disappears in square pixels, in random order. public struct DissolveTransition: Transition { public var cellSize: CGFloat public init(cellSize: CGFloat = 10) { self.cellSize = max(2, cellSize) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { DissolveShape(progress: phase.isIdentity ? 1 : 0, cellSize: cellSize) } } } struct DissolveShape: Shape { var progress: Double var cellSize: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } var path = Path() guard progress > 0 else { return path } let columns = Int((rect.width / cellSize).rounded(.up)) let rows = Int((rect.height / cellSize).rounded(.up)) for row in 0.. Double { var x = UInt64(truncatingIfNeeded: column &* 73_856_093 ^ row &* 19_349_663) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == DissolveTransition { /// Dissolves the view in and out in random square pixels. /// /// ```swift /// Avatar().transition(.dissolve) /// ``` static var dissolve: DissolveTransition { DissolveTransition() } /// Dissolves the view in and out in random pixels of `cellSize` points. static func dissolve(cellSize: CGFloat) -> DissolveTransition { DissolveTransition(cellSize: cellSize) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<4, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.blue.gradient) .transition(index == 0 ? AnyTransition(.iris) : index == 1 ? AnyTransition(.blinds) : index == 2 ? AnyTransition(.clockWipe) : AnyTransition(.dissolve)) } } .frame(width: 64, height: 64) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.8)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Blinds (`blinds`) Horizontal slats open together to reveal the view, like Venetian blinds. - API: `.transition(.blinds)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/MaskTransitions.swift - Files: Sources/MotionLibrary/Transition/MaskTransitions.swift ### Usage ```swift Banner() .transition(.blinds) Poster() .transition(.blinds(slats: 12)) ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called BlindsTransition with a slats count (8), exposed as .blinds and .blinds(slats:). In body(content:phase:), mask the content with a VStack(spacing: 0) of slats Rectangles, each with scaleEffect(y: phase.isIdentity ? 1 : 0.001, anchor: .top), so every slat grows down from its top edge at the same time on insertion and folds back up on removal. It only masks, so it needs no Reduce Motion fallback. ### Source ```swift import SwiftUI // Transitions that reveal or hide a view through an animated mask. Masks // don't move the content, so they need no Reduce Motion fallback. // MARK: - Iris /// A circle that opens from a point to reveal the view, and closes back to /// it on removal, like a camera iris. public struct IrisTransition: Transition { public var origin: UnitPoint public init(origin: UnitPoint = .center) { self.origin = origin } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { GeometryReader { geometry in let size = geometry.size let o = CGPoint(x: size.width * origin.x, y: size.height * origin.y) // Far enough to cover the corner furthest from the origin. let radius = [CGPoint.zero, CGPoint(x: size.width, y: 0), CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height)] .map { hypot($0.x - o.x, $0.y - o.y) } .max() ?? 0 Circle() .frame(width: radius * 2, height: radius * 2) .position(o) .scaleEffect(phase.isIdentity ? 1 : 0.001, anchor: origin) } } } } public extension Transition where Self == IrisTransition { /// Opens the view in a growing circle from its centre. /// /// ```swift /// if showPhoto { /// PhotoView().transition(.iris) /// } /// ``` static var iris: IrisTransition { IrisTransition() } /// Opens the view in a growing circle from `origin`. static func iris(origin: UnitPoint) -> IrisTransition { IrisTransition(origin: origin) } } // MARK: - Blinds /// Horizontal slats that open together, like Venetian blinds. public struct BlindsTransition: Transition { public var slats: Int public init(slats: Int = 8) { self.slats = max(1, slats) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { VStack(spacing: 0) { ForEach(0.. BlindsTransition { BlindsTransition(slats: slats) } } // MARK: - Clock wipe /// A wedge that sweeps clockwise from twelve o'clock, like a clock hand. public struct ClockWipeTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { ClockWipeShape(progress: phase.isIdentity ? 1 : 0) } } } struct ClockWipeShape: Shape { var progress: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } guard progress > 0 else { return Path() } let centre = CGPoint(x: rect.midX, y: rect.midY) let radius = hypot(rect.width, rect.height) var path = Path() path.move(to: centre) // In SwiftUI's y-down space, clockwise: false sweeps clockwise on screen. path.addArc(center: centre, radius: radius, startAngle: .degrees(-90), endAngle: .degrees(-90 + 360 * progress), clockwise: false) path.closeSubpath() return path } } public extension Transition where Self == ClockWipeTransition { /// Sweeps the view in like the hand of a clock. /// /// ```swift /// Badge().transition(.clockWipe) /// ``` static var clockWipe: ClockWipeTransition { ClockWipeTransition() } } // MARK: - Dissolve /// The view appears and disappears in square pixels, in random order. public struct DissolveTransition: Transition { public var cellSize: CGFloat public init(cellSize: CGFloat = 10) { self.cellSize = max(2, cellSize) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { DissolveShape(progress: phase.isIdentity ? 1 : 0, cellSize: cellSize) } } } struct DissolveShape: Shape { var progress: Double var cellSize: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } var path = Path() guard progress > 0 else { return path } let columns = Int((rect.width / cellSize).rounded(.up)) let rows = Int((rect.height / cellSize).rounded(.up)) for row in 0.. Double { var x = UInt64(truncatingIfNeeded: column &* 73_856_093 ^ row &* 19_349_663) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == DissolveTransition { /// Dissolves the view in and out in random square pixels. /// /// ```swift /// Avatar().transition(.dissolve) /// ``` static var dissolve: DissolveTransition { DissolveTransition() } /// Dissolves the view in and out in random pixels of `cellSize` points. static func dissolve(cellSize: CGFloat) -> DissolveTransition { DissolveTransition(cellSize: cellSize) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<4, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.blue.gradient) .transition(index == 0 ? AnyTransition(.iris) : index == 1 ? AnyTransition(.blinds) : index == 2 ? AnyTransition(.clockWipe) : AnyTransition(.dissolve)) } } .frame(width: 64, height: 64) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.8)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Clock Wipe (`clock-wipe`) A wedge sweeps clockwise from twelve o'clock, like a clock hand. - API: `.transition(.clockWipe)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/MaskTransitions.swift - Files: Sources/MotionLibrary/Transition/MaskTransitions.swift ### Usage ```swift Badge() .transition(.clockWipe) ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called ClockWipeTransition, exposed as .clockWipe. Mask the content with an animatable Shape whose animatableData is progress (0…1): it returns the full rect at 1, an empty path at 0, and otherwise a pie wedge from the centre with radius equal to the rect's diagonal, from −90° to −90° + 360° × progress (clockwise: false, which sweeps clockwise on screen in SwiftUI's y-down space). Pass progress 1 when phase.isIdentity and 0 otherwise, so the animation interpolates the angle. It only masks, so it needs no Reduce Motion fallback. ### Source ```swift import SwiftUI // Transitions that reveal or hide a view through an animated mask. Masks // don't move the content, so they need no Reduce Motion fallback. // MARK: - Iris /// A circle that opens from a point to reveal the view, and closes back to /// it on removal, like a camera iris. public struct IrisTransition: Transition { public var origin: UnitPoint public init(origin: UnitPoint = .center) { self.origin = origin } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { GeometryReader { geometry in let size = geometry.size let o = CGPoint(x: size.width * origin.x, y: size.height * origin.y) // Far enough to cover the corner furthest from the origin. let radius = [CGPoint.zero, CGPoint(x: size.width, y: 0), CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height)] .map { hypot($0.x - o.x, $0.y - o.y) } .max() ?? 0 Circle() .frame(width: radius * 2, height: radius * 2) .position(o) .scaleEffect(phase.isIdentity ? 1 : 0.001, anchor: origin) } } } } public extension Transition where Self == IrisTransition { /// Opens the view in a growing circle from its centre. /// /// ```swift /// if showPhoto { /// PhotoView().transition(.iris) /// } /// ``` static var iris: IrisTransition { IrisTransition() } /// Opens the view in a growing circle from `origin`. static func iris(origin: UnitPoint) -> IrisTransition { IrisTransition(origin: origin) } } // MARK: - Blinds /// Horizontal slats that open together, like Venetian blinds. public struct BlindsTransition: Transition { public var slats: Int public init(slats: Int = 8) { self.slats = max(1, slats) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { VStack(spacing: 0) { ForEach(0.. BlindsTransition { BlindsTransition(slats: slats) } } // MARK: - Clock wipe /// A wedge that sweeps clockwise from twelve o'clock, like a clock hand. public struct ClockWipeTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { ClockWipeShape(progress: phase.isIdentity ? 1 : 0) } } } struct ClockWipeShape: Shape { var progress: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } guard progress > 0 else { return Path() } let centre = CGPoint(x: rect.midX, y: rect.midY) let radius = hypot(rect.width, rect.height) var path = Path() path.move(to: centre) // In SwiftUI's y-down space, clockwise: false sweeps clockwise on screen. path.addArc(center: centre, radius: radius, startAngle: .degrees(-90), endAngle: .degrees(-90 + 360 * progress), clockwise: false) path.closeSubpath() return path } } public extension Transition where Self == ClockWipeTransition { /// Sweeps the view in like the hand of a clock. /// /// ```swift /// Badge().transition(.clockWipe) /// ``` static var clockWipe: ClockWipeTransition { ClockWipeTransition() } } // MARK: - Dissolve /// The view appears and disappears in square pixels, in random order. public struct DissolveTransition: Transition { public var cellSize: CGFloat public init(cellSize: CGFloat = 10) { self.cellSize = max(2, cellSize) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { DissolveShape(progress: phase.isIdentity ? 1 : 0, cellSize: cellSize) } } } struct DissolveShape: Shape { var progress: Double var cellSize: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } var path = Path() guard progress > 0 else { return path } let columns = Int((rect.width / cellSize).rounded(.up)) let rows = Int((rect.height / cellSize).rounded(.up)) for row in 0.. Double { var x = UInt64(truncatingIfNeeded: column &* 73_856_093 ^ row &* 19_349_663) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == DissolveTransition { /// Dissolves the view in and out in random square pixels. /// /// ```swift /// Avatar().transition(.dissolve) /// ``` static var dissolve: DissolveTransition { DissolveTransition() } /// Dissolves the view in and out in random pixels of `cellSize` points. static func dissolve(cellSize: CGFloat) -> DissolveTransition { DissolveTransition(cellSize: cellSize) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<4, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.blue.gradient) .transition(index == 0 ? AnyTransition(.iris) : index == 1 ? AnyTransition(.blinds) : index == 2 ? AnyTransition(.clockWipe) : AnyTransition(.dissolve)) } } .frame(width: 64, height: 64) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.8)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Pixel Dissolve (`dissolve`) The view appears and vanishes in square pixels, in random order. - API: `.transition(.dissolve)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/MaskTransitions.swift - Files: Sources/MotionLibrary/Transition/MaskTransitions.swift ### Usage ```swift Avatar() .transition(.dissolve) Artwork() .transition(.dissolve(cellSize: 6)) ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called DissolveTransition with a cellSize (10 pt), exposed as .dissolve and .dissolve(cellSize:). Mask the content with an animatable Shape whose animatableData is progress (0…1): divide the rect into a grid of cellSize squares, give each cell a stable pseudo-random threshold in 0…1 from an integer hash of (column, row), and add the cells whose threshold is below progress (outset by 0.25 pt so neighbours don't leave seams); return the full rect at 1 and an empty path at 0. Pass progress 1 when phase.isIdentity and 0 otherwise. It only masks, so it needs no Reduce Motion fallback. ### Source ```swift import SwiftUI // Transitions that reveal or hide a view through an animated mask. Masks // don't move the content, so they need no Reduce Motion fallback. // MARK: - Iris /// A circle that opens from a point to reveal the view, and closes back to /// it on removal, like a camera iris. public struct IrisTransition: Transition { public var origin: UnitPoint public init(origin: UnitPoint = .center) { self.origin = origin } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { GeometryReader { geometry in let size = geometry.size let o = CGPoint(x: size.width * origin.x, y: size.height * origin.y) // Far enough to cover the corner furthest from the origin. let radius = [CGPoint.zero, CGPoint(x: size.width, y: 0), CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height)] .map { hypot($0.x - o.x, $0.y - o.y) } .max() ?? 0 Circle() .frame(width: radius * 2, height: radius * 2) .position(o) .scaleEffect(phase.isIdentity ? 1 : 0.001, anchor: origin) } } } } public extension Transition where Self == IrisTransition { /// Opens the view in a growing circle from its centre. /// /// ```swift /// if showPhoto { /// PhotoView().transition(.iris) /// } /// ``` static var iris: IrisTransition { IrisTransition() } /// Opens the view in a growing circle from `origin`. static func iris(origin: UnitPoint) -> IrisTransition { IrisTransition(origin: origin) } } // MARK: - Blinds /// Horizontal slats that open together, like Venetian blinds. public struct BlindsTransition: Transition { public var slats: Int public init(slats: Int = 8) { self.slats = max(1, slats) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { VStack(spacing: 0) { ForEach(0.. BlindsTransition { BlindsTransition(slats: slats) } } // MARK: - Clock wipe /// A wedge that sweeps clockwise from twelve o'clock, like a clock hand. public struct ClockWipeTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { ClockWipeShape(progress: phase.isIdentity ? 1 : 0) } } } struct ClockWipeShape: Shape { var progress: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } guard progress > 0 else { return Path() } let centre = CGPoint(x: rect.midX, y: rect.midY) let radius = hypot(rect.width, rect.height) var path = Path() path.move(to: centre) // In SwiftUI's y-down space, clockwise: false sweeps clockwise on screen. path.addArc(center: centre, radius: radius, startAngle: .degrees(-90), endAngle: .degrees(-90 + 360 * progress), clockwise: false) path.closeSubpath() return path } } public extension Transition where Self == ClockWipeTransition { /// Sweeps the view in like the hand of a clock. /// /// ```swift /// Badge().transition(.clockWipe) /// ``` static var clockWipe: ClockWipeTransition { ClockWipeTransition() } } // MARK: - Dissolve /// The view appears and disappears in square pixels, in random order. public struct DissolveTransition: Transition { public var cellSize: CGFloat public init(cellSize: CGFloat = 10) { self.cellSize = max(2, cellSize) } public func body(content: Content, phase: TransitionPhase) -> some View { content.mask { DissolveShape(progress: phase.isIdentity ? 1 : 0, cellSize: cellSize) } } } struct DissolveShape: Shape { var progress: Double var cellSize: CGFloat nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } var path = Path() guard progress > 0 else { return path } let columns = Int((rect.width / cellSize).rounded(.up)) let rows = Int((rect.height / cellSize).rounded(.up)) for row in 0.. Double { var x = UInt64(truncatingIfNeeded: column &* 73_856_093 ^ row &* 19_349_663) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == DissolveTransition { /// Dissolves the view in and out in random square pixels. /// /// ```swift /// Avatar().transition(.dissolve) /// ``` static var dissolve: DissolveTransition { DissolveTransition() } /// Dissolves the view in and out in random pixels of `cellSize` points. static func dissolve(cellSize: CGFloat) -> DissolveTransition { DissolveTransition(cellSize: cellSize) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<4, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.blue.gradient) .transition(index == 0 ? AnyTransition(.iris) : index == 1 ? AnyTransition(.blinds) : index == 2 ? AnyTransition(.clockWipe) : AnyTransition(.dissolve)) } } .frame(width: 64, height: 64) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.8)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Poof (`poof`) The view puffs away like smoke: it swells and blurs out. - API: `.transition(.poof)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/StyleTransitions.swift - Files: Sources/MotionLibrary/Transition/StyleTransitions.swift ### Usage ```swift ForEach(notes) { note in NoteRow(note) .transition(.poof) } ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called PoofTransition, exposed as .poof. Return a small view that reads Reduce Motion and applies: scale 1.35 when phase == .didDisappear, 0.7 when .willAppear, 1 at identity; blur radius 14 when not identity, 0 at identity; opacity 0 when not identity. Removal swells and blurs away like smoke, insertion condenses in from a smaller blur. With Reduce Motion on, keep the scale at 1 (blur and fade only). ### Source ```swift import SwiftUI // Transitions that change how the view itself looks (scale, blur, skew, // exposure) on its way in and out. // MARK: - Poof /// The view puffs away like smoke: it swells and blurs out on removal, and /// condenses from a blur on insertion. public struct PoofTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { PoofBody(content: content, phase: phase) } } private struct PoofBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // Reduce Motion: blur and fade only, no scaling. let scale: CGFloat = reduceMotion ? 1 : phase == .didDisappear ? 1.35 : phase == .willAppear ? 0.7 : 1 content .scaleEffect(scale) .blur(radius: phase.isIdentity ? 0 : 14) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == PoofTransition { /// Puffs the view away like smoke (and condenses it back in). /// /// ```swift /// ForEach(notes) { note in /// NoteRow(note).transition(.poof) /// } /// ``` static var poof: PoofTransition { PoofTransition() } } // MARK: - Swoosh /// The view whooshes in from the leading edge leaning forward, and out to /// the trailing edge, with a motion blur. public struct SwooshTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { SwooshBody(content: content, phase: phase) } } private struct SwooshBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // TransitionPhase.value is -1 before insertion, 0 at rest and 1 after removal. let value = CGFloat(phase.value) content // Reduce Motion: fade only. .modifier(SkewSlide(progress: reduceMotion ? 0 : value)) .blur(radius: abs(value) * 6) .opacity(1 - abs(Double(value))) } } /// Slides the view by `progress` × its width while skewing it in the /// direction of travel. struct SkewSlide: GeometryEffect { var progress: CGFloat nonisolated var animatableData: CGFloat { get { progress } set { progress = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { let skew = -progress * 0.6 // Skew around the vertical centre so the view doesn't drift sideways. let transform = CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: progress * size.width - skew * size.height / 2, ty: 0) return ProjectionTransform(transform) } } public extension Transition where Self == SwooshTransition { /// Whooshes the view in from the leading edge and out to the trailing edge. /// /// ```swift /// Toast().transition(.swoosh) /// ``` static var swoosh: SwooshTransition { SwooshTransition() } } // MARK: - Film exposure /// The view develops like a photo: it starts blown out to white and /// colourless, then the exposure and colour settle in. public struct FilmExposureTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content .brightness(phase.isIdentity ? 0 : 0.9) .saturation(phase.isIdentity ? 1 : 0) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == FilmExposureTransition { /// Develops the view from an over-exposed white, like a photo. /// /// ```swift /// AsyncImage(url: url) { image in /// image.resizable().transition(.filmExposure) /// } placeholder: { ProgressView() } /// ``` static var filmExposure: FilmExposureTransition { FilmExposureTransition() } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<3, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.orange.gradient) .transition(index == 0 ? AnyTransition(.poof) : index == 1 ? AnyTransition(.swoosh) : AnyTransition(.filmExposure)) } } .frame(width: 70, height: 70) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.6)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Swoosh (`swoosh`) The view whooshes in leaning forward, with a motion blur. - API: `.transition(.swoosh)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/StyleTransitions.swift - Files: Sources/MotionLibrary/Transition/StyleTransitions.swift ### Usage ```swift if let toast { ToastView(toast) .transition(.swoosh) } ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called SwooshTransition, exposed as .swoosh. Use phase.value (−1 before insertion, 0 at rest, +1 after removal) as v and apply a GeometryEffect whose animatableData is v: a CGAffineTransform with c = −0.6 v (a horizontal skew) and tx = v × width − c × height / 2 (slide by the view's own width, skewing around the vertical centre). Add blur 6 × |v| and opacity 1 − |v|, so it whooshes in from the leading edge leaning into the motion and leaves to the trailing edge. With Reduce Motion on, skip the slide and skew (fade only). ### Source ```swift import SwiftUI // Transitions that change how the view itself looks (scale, blur, skew, // exposure) on its way in and out. // MARK: - Poof /// The view puffs away like smoke: it swells and blurs out on removal, and /// condenses from a blur on insertion. public struct PoofTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { PoofBody(content: content, phase: phase) } } private struct PoofBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // Reduce Motion: blur and fade only, no scaling. let scale: CGFloat = reduceMotion ? 1 : phase == .didDisappear ? 1.35 : phase == .willAppear ? 0.7 : 1 content .scaleEffect(scale) .blur(radius: phase.isIdentity ? 0 : 14) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == PoofTransition { /// Puffs the view away like smoke (and condenses it back in). /// /// ```swift /// ForEach(notes) { note in /// NoteRow(note).transition(.poof) /// } /// ``` static var poof: PoofTransition { PoofTransition() } } // MARK: - Swoosh /// The view whooshes in from the leading edge leaning forward, and out to /// the trailing edge, with a motion blur. public struct SwooshTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { SwooshBody(content: content, phase: phase) } } private struct SwooshBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // TransitionPhase.value is -1 before insertion, 0 at rest and 1 after removal. let value = CGFloat(phase.value) content // Reduce Motion: fade only. .modifier(SkewSlide(progress: reduceMotion ? 0 : value)) .blur(radius: abs(value) * 6) .opacity(1 - abs(Double(value))) } } /// Slides the view by `progress` × its width while skewing it in the /// direction of travel. struct SkewSlide: GeometryEffect { var progress: CGFloat nonisolated var animatableData: CGFloat { get { progress } set { progress = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { let skew = -progress * 0.6 // Skew around the vertical centre so the view doesn't drift sideways. let transform = CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: progress * size.width - skew * size.height / 2, ty: 0) return ProjectionTransform(transform) } } public extension Transition where Self == SwooshTransition { /// Whooshes the view in from the leading edge and out to the trailing edge. /// /// ```swift /// Toast().transition(.swoosh) /// ``` static var swoosh: SwooshTransition { SwooshTransition() } } // MARK: - Film exposure /// The view develops like a photo: it starts blown out to white and /// colourless, then the exposure and colour settle in. public struct FilmExposureTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content .brightness(phase.isIdentity ? 0 : 0.9) .saturation(phase.isIdentity ? 1 : 0) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == FilmExposureTransition { /// Develops the view from an over-exposed white, like a photo. /// /// ```swift /// AsyncImage(url: url) { image in /// image.resizable().transition(.filmExposure) /// } placeholder: { ProgressView() } /// ``` static var filmExposure: FilmExposureTransition { FilmExposureTransition() } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<3, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.orange.gradient) .transition(index == 0 ? AnyTransition(.poof) : index == 1 ? AnyTransition(.swoosh) : AnyTransition(.filmExposure)) } } .frame(width: 70, height: 70) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.6)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Film Exposure (`film-exposure`) The view develops like a photo, from over-exposed white to full colour. - API: `.transition(.filmExposure)` - Component: Transition - Trigger: Appear - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/StyleTransitions.swift - Files: Sources/MotionLibrary/Transition/StyleTransitions.swift ### Usage ```swift AsyncImage(url: url) { image in image.resizable() .transition(.filmExposure) } placeholder: { ProgressView() } ``` ### How it works Create a custom SwiftUI Transition (iOS 17+) called FilmExposureTransition, exposed as .filmExposure. When the phase isn't identity, apply brightness 0.9, saturation 0 and opacity 0; at identity, brightness 0, saturation 1 and opacity 1. Inserting the view develops it like a photo, from blown-out colourless white to full colour, and removing it fades it back out to white. Nothing moves, so it needs no Reduce Motion fallback. ### Source ```swift import SwiftUI // Transitions that change how the view itself looks (scale, blur, skew, // exposure) on its way in and out. // MARK: - Poof /// The view puffs away like smoke: it swells and blurs out on removal, and /// condenses from a blur on insertion. public struct PoofTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { PoofBody(content: content, phase: phase) } } private struct PoofBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // Reduce Motion: blur and fade only, no scaling. let scale: CGFloat = reduceMotion ? 1 : phase == .didDisappear ? 1.35 : phase == .willAppear ? 0.7 : 1 content .scaleEffect(scale) .blur(radius: phase.isIdentity ? 0 : 14) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == PoofTransition { /// Puffs the view away like smoke (and condenses it back in). /// /// ```swift /// ForEach(notes) { note in /// NoteRow(note).transition(.poof) /// } /// ``` static var poof: PoofTransition { PoofTransition() } } // MARK: - Swoosh /// The view whooshes in from the leading edge leaning forward, and out to /// the trailing edge, with a motion blur. public struct SwooshTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { SwooshBody(content: content, phase: phase) } } private struct SwooshBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // TransitionPhase.value is -1 before insertion, 0 at rest and 1 after removal. let value = CGFloat(phase.value) content // Reduce Motion: fade only. .modifier(SkewSlide(progress: reduceMotion ? 0 : value)) .blur(radius: abs(value) * 6) .opacity(1 - abs(Double(value))) } } /// Slides the view by `progress` × its width while skewing it in the /// direction of travel. struct SkewSlide: GeometryEffect { var progress: CGFloat nonisolated var animatableData: CGFloat { get { progress } set { progress = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { let skew = -progress * 0.6 // Skew around the vertical centre so the view doesn't drift sideways. let transform = CGAffineTransform(a: 1, b: 0, c: skew, d: 1, tx: progress * size.width - skew * size.height / 2, ty: 0) return ProjectionTransform(transform) } } public extension Transition where Self == SwooshTransition { /// Whooshes the view in from the leading edge and out to the trailing edge. /// /// ```swift /// Toast().transition(.swoosh) /// ``` static var swoosh: SwooshTransition { SwooshTransition() } } // MARK: - Film exposure /// The view develops like a photo: it starts blown out to white and /// colourless, then the exposure and colour settle in. public struct FilmExposureTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { content .brightness(phase.isIdentity ? 0 : 0.9) .saturation(phase.isIdentity ? 1 : 0) .opacity(phase.isIdentity ? 1 : 0) } } public extension Transition where Self == FilmExposureTransition { /// Develops the view from an over-exposed white, like a photo. /// /// ```swift /// AsyncImage(url: url) { image in /// image.resizable().transition(.filmExposure) /// } placeholder: { ProgressView() } /// ``` static var filmExposure: FilmExposureTransition { FilmExposureTransition() } } #Preview { struct Demo: View { @State private var shown = true var body: some View { VStack(spacing: 20) { HStack(spacing: 12) { ForEach(0..<3, id: \.self) { index in ZStack { if shown { RoundedRectangle(cornerRadius: 12) .fill(Color.orange.gradient) .transition(index == 0 ? AnyTransition(.poof) : index == 1 ? AnyTransition(.swoosh) : AnyTransition(.filmExposure)) } } .frame(width: 70, height: 70) } } Button("Toggle") { withAnimation(.easeInOut(duration: 0.6)) { shown.toggle() } } } } } return Demo().padding() } ``` --- ## Circular Reveal (`circular-reveal`) New content spreads out in a circle from where you tapped. - API: `CircularReveal(value:) { value in }` - Component: Transition - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/CircularReveal.swift - Files: Sources/MotionLibrary/Transition/CircularReveal.swift ### Usage ```swift CircularReveal(value: isDark, origin: tapPoint) { dark in ThemePreview(dark: dark) } ``` ### How it works Create a generic SwiftUI CircularReveal(value:origin:duration:content:) view (iOS 17+) where origin is a UnitPoint. Keep @State shown (initially value), incoming (optional) and progress. When value changes: if a reveal is in flight, commit it; set incoming = new and progress = 0 without animation, then animate progress to 1 with .easeInOut(0.7 s) and, on completion, set shown = new and incoming = nil without animation. Render ZStack { content(shown); content(incoming) masked by an animatable circle Shape centred at origin whose radius is progress × the distance to the furthest corner }, with hit testing off on the incoming layer. To start at a tap, convert the location with UnitPoint(x: x / width, y: y / height). With Reduce Motion on, cross-fade the incoming layer instead of growing the circle. ### Source ```swift import SwiftUI /// Swaps content with a circle that grows from a point (typically where the /// user tapped) until it covers everything, like switching to dark mode by /// "painting" it in. The old content stays underneath until it's covered. public struct CircularReveal: View { private let value: Value private let origin: UnitPoint private let duration: Double private let content: (Value) -> Content @State private var shown: Value @State private var incoming: Value? @State private var progress: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - value: Whenever it changes, the content for the new value is revealed. /// - origin: Where the circle starts, in unit coordinates. Convert a tap /// location with `UnitPoint(x: x / width, y: y / height)`. /// - duration: Seconds for the circle to cover the view. /// - content: Builds the content for a value. public init(value: Value, origin: UnitPoint = .center, duration: Double = 0.7, @ViewBuilder content: @escaping (Value) -> Content) { self.value = value self.origin = origin self.duration = duration self.content = content _shown = State(initialValue: value) } public var body: some View { ZStack { content(shown) if let incoming { content(incoming) .mask { RevealCircle(progress: reduceMotion ? 1 : progress, origin: origin) } .opacity(reduceMotion ? progress : 1) .allowsHitTesting(false) } } .onChange(of: value) { _, new in reveal(new) } } private func reveal(_ new: Value) { // A reveal in flight finishes instantly so the next one starts clean. if let current = incoming { shown = current } var reset = Transaction() reset.disablesAnimations = true withTransaction(reset) { incoming = new progress = 0 } withAnimation(reduceMotion ? .easeInOut(duration: 0.3) : .easeInOut(duration: duration)) { progress = 1 } completion: { guard incoming == new else { return } var settle = Transaction() settle.disablesAnimations = true withTransaction(settle) { shown = new incoming = nil } } } } /// A circle around `origin` whose radius reaches the furthest corner at 1. struct RevealCircle: Shape { var progress: Double var origin: UnitPoint nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { let o = CGPoint(x: rect.minX + rect.width * origin.x, y: rect.minY + rect.height * origin.y) let corners = [CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.minY), CGPoint(x: rect.minX, y: rect.maxY), CGPoint(x: rect.maxX, y: rect.maxY)] let reach = corners.map { hypot($0.x - o.x, $0.y - o.y) }.max() ?? 0 let r = reach * progress return Path(ellipseIn: CGRect(x: o.x - r, y: o.y - r, width: r * 2, height: r * 2)) } } #Preview { struct Demo: View { @State private var dark = false @State private var origin = UnitPoint.center var body: some View { GeometryReader { geometry in CircularReveal(value: dark, origin: origin) { isDark in ZStack { isDark ? Color.black : Color.white Image(systemName: isDark ? "moon.stars.fill" : "sun.max.fill") .font(.system(size: 60)) .foregroundStyle(isDark ? .yellow : .orange) } } .onTapGesture { location in origin = UnitPoint(x: location.x / geometry.size.width, y: location.y / geometry.size.height) dark.toggle() } } } } return Demo() } ``` --- ## Liquid Swipe (`liquid-swipe`) The next page pours in from the edge behind a bulging liquid wave. - API: `.transition(.liquidSwipe)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/LiquidSwipe.swift - Files: Sources/MotionLibrary/Transition/LiquidSwipe.swift ### Usage ```swift ZStack { OnboardingPage(page) .id(page) .transition(.liquidSwipe) } ``` ### How it works Create a SwiftUI LiquidSwipeTransition (iOS 17+, Transition) with a .liquidSwipe accessor. Mask the content with an animatable Shape whose progress is 0 on willAppear and 1 otherwise: at progress p it covers the view from the trailing edge to edge = maxX − (width + a) × p, where the bulge a = 0.35 × width × sin(πp); the leading boundary is straight except for a wave from midY − 0.45h to midY + 0.45h drawn with two cubic curves that bulge out to edge − a at midY. Darken the outgoing view with brightness −0.15 and give the incoming view zIndex 1 so it draws on top. Read Reduce Motion in an inner View and use no bulge. ### Source ```swift import SwiftUI /// The incoming view pours in from the trailing edge behind a liquid wave /// with a bulge in the middle, covering the outgoing view, which darkens /// slightly as it disappears. Great for onboarding pages. public struct LiquidSwipeTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { LiquidSwipeBody(content: content, phase: phase) } } private struct LiquidSwipeBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { content .mask { LiquidEdge(progress: phase == .willAppear ? 0 : 1, bulge: reduceMotion ? 0 : 1) } .brightness(phase == .didDisappear ? -0.15 : 0) // The incoming page always draws on top of the outgoing one. .zIndex(phase == .didDisappear ? 0 : 1) } } /// Covers the view from the trailing edge; the leading edge of the covered /// area is a wave whose bulge peaks halfway through. struct LiquidEdge: Shape { var progress: Double var bulge: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } nonisolated func path(in rect: CGRect) -> Path { guard progress < 1 else { return Path(rect) } var path = Path() guard progress > 0 else { return path } let p = CGFloat(progress) let amplitude = rect.width * 0.35 * CGFloat(sin(progress * .pi) * bulge) // The straight part of the edge, pushed far enough left that the // bulge has fully left the view by the end. let edge = rect.maxX - (rect.width + amplitude) * p let midY = rect.midY let reach = rect.height * 0.45 path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: edge, y: rect.minY)) path.addLine(to: CGPoint(x: edge, y: midY - reach)) path.addCurve( to: CGPoint(x: edge - amplitude, y: midY), control1: CGPoint(x: edge, y: midY - reach * 0.45), control2: CGPoint(x: edge - amplitude, y: midY - reach * 0.5) ) path.addCurve( to: CGPoint(x: edge, y: midY + reach), control1: CGPoint(x: edge - amplitude, y: midY + reach * 0.5), control2: CGPoint(x: edge, y: midY + reach * 0.45) ) path.addLine(to: CGPoint(x: edge, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.closeSubpath() return path } } public extension Transition where Self == LiquidSwipeTransition { /// Pours the new view in from the trailing edge behind a liquid wave. /// /// ```swift /// ZStack { /// OnboardingPage(page) /// .id(page) /// .transition(.liquidSwipe) /// } /// ``` static var liquidSwipe: LiquidSwipeTransition { LiquidSwipeTransition() } } #Preview { struct Demo: View { @State private var page = 0 let colors: [Color] = [.pink, .orange, .teal, .indigo] var body: some View { ZStack { colors[page] .overlay(Text("Page \(page + 1)").font(.largeTitle.bold()).foregroundStyle(.white)) .id(page) .transition(.liquidSwipe) } .frame(width: 260, height: 360) .clipShape(RoundedRectangle(cornerRadius: 24)) .onTapGesture { withAnimation(.easeInOut(duration: 0.9)) { page = (page + 1) % colors.count } } } } return Demo() } ``` --- ## Page Turn (`page-turn`) The old page swings away like a book page, revealing the next one. - API: `.transition(.pageTurn)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/PageTurn.swift - Files: Sources/MotionLibrary/Transition/PageTurn.swift ### Usage ```swift ZStack { ChapterView(chapter) .id(chapter) .transition(.pageTurn) } ``` ### How it works Create a SwiftUI PageTurnTransition (iOS 17+, Transition) with a .pageTurn accessor. The outgoing view gets zIndex 1 and an Animatable ViewModifier with progress 0 → 1: rotation3DEffect(−90° × p, axis y, anchor .leading, perspective 0.6), an overlay LinearGradient from clear to black at 45 % × p (leading → trailing) so the free edge darkens as it turns, a shadow of opacity 0.3 × sin(πp), radius 12, x 6, and opacity 0 at the very end. The incoming view sits underneath and brightens from −0.25 to 0. Read Reduce Motion in an inner View and simply fade the page out. ### Source ```swift import SwiftUI /// Like turning a page in a book: the outgoing view swings away around its /// leading edge in 3D, shading as it turns, and reveals the incoming view /// underneath, which brightens up from the page's shadow. public struct PageTurnTransition: Transition { public init() {} public func body(content: Content, phase: TransitionPhase) -> some View { PageTurnBody(content: content, phase: phase) } } private struct PageTurnBody: View { let content: Content let phase: TransitionPhase @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { content .modifier(PageTurnEffect(progress: phase == .didDisappear ? 1 : 0, reduceMotion: reduceMotion)) .brightness(phase == .willAppear ? -0.25 : 0) // The turning page is on top of the one it reveals. .zIndex(phase == .didDisappear ? 1 : 0) } } struct PageTurnEffect: ViewModifier, Animatable { var progress: Double var reduceMotion: Bool nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { if reduceMotion { // Reduce Motion: fade the page away. content.opacity(1 - progress) } else { content .overlay { // The page darkens towards its free edge as it turns. LinearGradient(colors: [.black.opacity(0), .black.opacity(0.45 * progress)], startPoint: .leading, endPoint: .trailing) .allowsHitTesting(false) } .rotation3DEffect(.degrees(-90 * progress), axis: (x: 0, y: 1, z: 0), anchor: .leading, perspective: 0.6) .shadow(color: .black.opacity(0.3 * sin(progress * .pi)), radius: 12, x: 6) .opacity(progress > 0.98 ? 0 : 1) } } } public extension Transition where Self == PageTurnTransition { /// Turns the old view away like a book page, revealing the new one. /// /// ```swift /// ZStack { /// ChapterView(chapter) /// .id(chapter) /// .transition(.pageTurn) /// } /// ``` static var pageTurn: PageTurnTransition { PageTurnTransition() } } #Preview { struct Demo: View { @State private var page = 0 let colors: [Color] = [.orange, .teal, .indigo] var body: some View { ZStack { RoundedRectangle(cornerRadius: 12) .fill(colors[page].gradient) .overlay(Text("Chapter \(page + 1)").font(.title.bold()).foregroundStyle(.white)) .id(page) .transition(.pageTurn) } .frame(width: 220, height: 300) .onTapGesture { withAnimation(.easeInOut(duration: 0.9)) { page = (page + 1) % colors.count } } } } return Demo() } ``` --- ## Glitch Transition (`glitch-transition`) The view breaks up like a bad signal on its way in or out. - API: `.transition(.glitch)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/GlitchTransition.swift - Files: Sources/MotionLibrary/Transition/GlitchTransition.swift, Sources/MotionLibrary/Support/GlitchSlice.swift ### Usage ```swift if showAlert { AlertBanner() .transition(.glitch) } ``` ### How it works Create a SwiftUI GlitchTransition (iOS 17+, Transition) with .glitch and .glitch(slices:) accessors that applies an Animatable ViewModifier with amount 0 (identity) → 1 (inserted or removed). At 0 render the content as is. Otherwise, with frame = Int(amount × 16) so the pattern changes in steps: stack a red copy (colorMultiply(.red), offset x −amount × 10, 60 %), a cyan copy (offset +amount × 10, 60 %) and 7 horizontal slices of the content, each masked to its band and offset sideways by (random(slice, frame) − 0.5) × 60 × amount; composite them and set opacity to 1 − 0.6 × amount, or 0.15 when random(frame) ≤ 0.9 × amount, reaching 0 at the end. Read Reduce Motion in an inner View and fade instead. ### Source ```swift import SwiftUI /// The view breaks up like a bad signal on its way in or out: horizontal /// slices jump sideways, red and cyan copies split apart, and it flickers in /// steps rather than fading smoothly. public struct GlitchTransition: Transition { public var slices: Int /// - Parameter slices: Number of horizontal slices that jump around. public init(slices: Int = 7) { self.slices = max(1, slices) } public func body(content: Content, phase: TransitionPhase) -> some View { GlitchTransitionBody(content: content, phase: phase, slices: slices) } } private struct GlitchTransitionBody: View { let content: Content let phase: TransitionPhase let slices: Int @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { // Reduce Motion: a plain fade. content.opacity(phase.isIdentity ? 1 : 0) } else { content.modifier(GlitchEffect(amount: phase.isIdentity ? 0 : 1, slices: slices)) } } } struct GlitchEffect: ViewModifier, Animatable { /// 0 = intact, 1 = gone. var amount: Double var slices: Int nonisolated var animatableData: Double { get { amount } set { amount = newValue } } func body(content: Content) -> some View { if amount <= 0.001 { content } else { // The glitch pattern changes in discrete "frames", not smoothly. let frame = Int(amount * 16) let split = CGFloat(amount) * 10 // Stepped flicker on the way out. let visible = Self.random(frame, 99) > amount * 0.9 ? 1 - amount * 0.6 : 0.15 ZStack { content .colorMultiply(.red) .offset(x: -split) .opacity(0.6) content .colorMultiply(.cyan) .offset(x: split) .opacity(0.6) ForEach(0..= 0.999 ? 0 : visible) .allowsHitTesting(false) } } static func random(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == GlitchTransition { /// Glitches the view in and out like a broken signal. /// /// ```swift /// if showAlert { /// AlertBanner().transition(.glitch) /// } /// ``` static var glitch: GlitchTransition { GlitchTransition() } /// Glitches the view in and out with `slices` jumping bands. static func glitch(slices: Int) -> GlitchTransition { GlitchTransition(slices: slices) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack { if shown { Text("SIGNAL") .font(.system(size: 40, weight: .black, design: .monospaced)) .padding() .background(Color.black, in: RoundedRectangle(cornerRadius: 12)) .foregroundStyle(.white) .transition(.glitch) } } .frame(width: 280, height: 160) .onTapGesture { withAnimation(.linear(duration: 0.6)) { shown.toggle() } } } } return Demo() } // MARK: - GlitchSlice.swift // Needed by this effect: save it as its own file next to the one above. // Shared by GlitchTransition and OrigamiFold. import SwiftUI /// One horizontal band of the view. struct GlitchSlice: Shape { var index: Int var count: Int nonisolated func path(in rect: CGRect) -> Path { let height = rect.height / CGFloat(count) return Path(CGRect(x: rect.minX - rect.width, y: rect.minY + CGFloat(index) * height, width: rect.width * 3, height: height + 0.5)) } } ``` --- ## Origami Fold (`origami`) The view folds up into creased panels like a paper map. - API: `.transition(.origami)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/OrigamiFold.swift - Files: Sources/MotionLibrary/Transition/OrigamiFold.swift, Sources/MotionLibrary/Support/GlitchSlice.swift ### Usage ```swift if showTicket { TicketView() .transition(.origami) } ``` ### How it works Create a SwiftUI OrigamiTransition (iOS 17+, Transition) with .origami and .origami(folds:) accessors applying an Animatable ViewModifier with progress 0 (flat) → 1 (folded). At 0 render the content. Otherwise keep the footprint with content.hidden() and, in a top-aligned overlay GeometryReader, draw 4 copies of the content, each masked to its horizontal panel. With angle = 90° × p and c = cos(angle): even panels hinge on their top edge (rotation3DEffect +angle about x, anchor at y = i / n), odd panels on their bottom edge (−angle, anchor (i + 1) / n), and each is offset by hinge × panelHeight × (c − 1) so it starts where the previous one ends, collapsing like an accordion towards the top. Shade panels with black at (0.1 even, 0.4 odd) × (1 − c), perspective 0.4, and fade out over the last 15 %. Read Reduce Motion in an inner View and fade instead. ### Source ```swift import SwiftUI /// The view folds up like a paper map: it's split into horizontal panels /// that crease in alternating directions and collapse towards the top, with /// the panels facing away shaded darker. Insertion unfolds it again. public struct OrigamiTransition: Transition { public var folds: Int /// - Parameter folds: Number of panels (2…8 looks right). public init(folds: Int = 4) { self.folds = max(2, folds) } public func body(content: Content, phase: TransitionPhase) -> some View { OrigamiBody(content: content, phase: phase, folds: folds) } } private struct OrigamiBody: View { let content: Content let phase: TransitionPhase let folds: Int @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { // Reduce Motion: a plain fade. content.opacity(phase.isIdentity ? 1 : 0) } else { content.modifier(OrigamiEffect(progress: phase.isIdentity ? 0 : 1, folds: folds)) } } } struct OrigamiEffect: ViewModifier, Animatable { /// 0 = flat, 1 = folded away. var progress: Double var folds: Int nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { if progress <= 0.001 { content } else { // Keep the original footprint while panels are drawn on top. content .hidden() .overlay(alignment: .top) { GeometryReader { geometry in let panel = geometry.size.height / CGFloat(folds) let angle = 90 * min(progress, 0.999) let c = CGFloat(cos(angle * .pi / 180)) ZStack(alignment: .top) { ForEach(0.. 0.85 ? (1 - progress) / 0.15 : 1) .allowsHitTesting(false) } } } public extension Transition where Self == OrigamiTransition { /// Folds the view up like a paper map (and unfolds it on insertion). /// /// ```swift /// if showTicket { /// TicketView().transition(.origami) /// } /// ``` static var origami: OrigamiTransition { OrigamiTransition() } /// Folds the view into `folds` panels. static func origami(folds: Int) -> OrigamiTransition { OrigamiTransition(folds: folds) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack(alignment: .top) { if shown { RoundedRectangle(cornerRadius: 12) .fill(LinearGradient(colors: [.orange, .pink], startPoint: .top, endPoint: .bottom)) .frame(width: 200, height: 240) .transition(.origami) } } .frame(width: 280, height: 300) .onTapGesture { withAnimation(.easeInOut(duration: 0.9)) { shown.toggle() } } } } return Demo() } // MARK: - GlitchSlice.swift // Needed by this effect: save it as its own file next to the one above. // Shared by GlitchTransition and OrigamiFold. import SwiftUI /// One horizontal band of the view. struct GlitchSlice: Shape { var index: Int var count: Int nonisolated func path(in rect: CGRect) -> Path { let height = rect.height / CGFloat(count) return Path(CGRect(x: rect.minX - rect.width, y: rect.minY + CGFloat(index) * height, width: rect.width * 3, height: height + 0.5)) } } ``` --- ## Ink Bleed (`ink-bleed`) New content spreads in like a blot of ink on wet paper, with a ragged edge. - API: `.transition(.inkBleed)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/InkBleed.swift - Files: Sources/MotionLibrary/Transition/InkBleed.swift ### Usage ```swift if showsDetail { DetailCard() .transition(.inkBleed(origin: .topLeading)) } ``` ### How it works Create a SwiftUI Transition .inkBleed / .inkBleed(origin: UnitPoint) (iOS 17+) that masks the content with an Animatable blot view whose progress is 1 when the phase is identity and 0 otherwise. The blot is a Canvas with .alphaThreshold(min: 0.5) and a blur of max(2, 0.06r), drawing into a layer: a circle of radius 0.82r at the origin plus 26 circles around it, at angle i / 26 × 2π + hashed jitter, distance r × (0.62–0.92) and radius r × (0.12–0.32). r = (1.25 × distance to the furthest corner + 30) × progress, so blurring then thresholding melts them into one ragged blot that clears the corners at 1. With Reduce Motion on, fade instead. ### Source ```swift import SwiftUI /// Reveals the view like ink dropped on wet paper: a blot spreads from a /// point with a blotchy, organic edge until it covers everything. public struct InkBleedTransition: Transition { public var origin: UnitPoint public init(origin: UnitPoint = .center) { self.origin = origin } public func body(content: Content, phase: TransitionPhase) -> some View { InkBleedBody(content: content, progress: phase.isIdentity ? 1 : 0, origin: origin) } } private struct InkBleedBody: View { let content: Content let progress: Double let origin: UnitPoint @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { content.opacity(progress) } else { content.mask { InkBlot(progress: progress, origin: origin) } } } } /// The blot: a circle ringed by smaller circles, merged into one ragged /// shape by blurring and thresholding the alpha. struct InkBlot: View, Animatable { var progress: Double var origin: UnitPoint nonisolated var animatableData: Double { get { progress } set { progress = newValue } } var body: some View { Canvas { context, size in let o = CGPoint(x: size.width * origin.x, y: size.height * origin.y) let corners = [CGPoint.zero, CGPoint(x: size.width, y: 0), CGPoint(x: 0, y: size.height), CGPoint(x: size.width, y: size.height)] let reach = corners.map { hypot($0.x - o.x, $0.y - o.y) }.max() ?? 0 // Overshoot so the ragged edge clears the corners at 1. let r = (reach * 1.25 + 30) * progress guard r > 0.5 else { return } context.addFilter(.alphaThreshold(min: 0.5)) context.addFilter(.blur(radius: max(2, r * 0.06))) context.drawLayer { layer in layer.fill(Path(ellipseIn: CGRect(x: o.x - r * 0.82, y: o.y - r * 0.82, width: r * 1.64, height: r * 1.64)), with: .color(.black)) for index in 0..<26 { let angle = Double(index) / 26 * 2 * .pi + Self.hash(index, 1) * 0.4 let distance = r * (0.62 + 0.3 * Self.hash(index, 2)) let blob = r * (0.12 + 0.2 * Self.hash(index, 3)) let c = CGPoint(x: o.x + cos(angle) * distance, y: o.y + sin(angle) * distance) layer.fill(Path(ellipseIn: CGRect(x: c.x - blob, y: c.y - blob, width: blob * 2, height: blob * 2)), with: .color(.black)) } } } } private static func hash(_ a: Int, _ b: Int) -> Double { var x = UInt64(truncatingIfNeeded: a &* 73_856_093 ^ b &* 19_349_663 &+ 0x1A4B) x = (x ^ (x >> 33)) &* 0xff51_afd7_ed55_8ccd x ^= x >> 33 return Double(x % 10_000) / 10_000 } } public extension Transition where Self == InkBleedTransition { /// Spreads the view in (and out) like a blot of ink on wet paper. /// /// ```swift /// if showsDetail { /// DetailCard().transition(.inkBleed) /// } /// ``` static var inkBleed: InkBleedTransition { InkBleedTransition() } /// Spreads the view in from `origin` like a blot of ink. static func inkBleed(origin: UnitPoint) -> InkBleedTransition { InkBleedTransition(origin: origin) } @available(*, deprecated, renamed: "inkBleed(origin:)") static func inkBleed(from origin: UnitPoint) -> InkBleedTransition { InkBleedTransition(origin: origin) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack { Color(white: 0.95) if shown { LinearGradient(colors: [.indigo, .purple], startPoint: .top, endPoint: .bottom) .overlay(Text("Ink").font(.largeTitle.bold()).foregroundStyle(.white)) .transition(.inkBleed(origin: .topLeading)) } } .frame(width: 280, height: 180) .clipShape(RoundedRectangle(cornerRadius: 16)) .onTapGesture { withAnimation(.easeInOut(duration: 1.1)) { shown.toggle() } } } } return Demo() } ``` --- ## Zoom Blur (`zoom-blur`) Content rushes in from the camera, blurred and trailed by echoes, then snaps sharp. - API: `.transition(.zoomBlur)` - Component: Transition - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionLibrary/Transition/ZoomBlur.swift - Files: Sources/MotionLibrary/Transition/ZoomBlur.swift ### Usage ```swift Text(title) .id(title) .transition(.zoomBlur) ``` ### How it works Create a SwiftUI Transition .zoomBlur / .zoomBlur(scale:) (iOS 17+). In an Animatable modifier with amount 0 (in place) … 1 (away), scale the content to 1 + (scale − 1) × amount (scale 1.6), blur it by 14 × amount and fade it to 1 − amount. While 0 < amount < 1, add three echo copies behind it, each a little larger (+ 0.12 × step × amount), blurrier (6 + 6 × step × amount) and fainter ((1 − amount) × 0.28 / step), as a cheap radial zoom blur. The phase maps identity to 0 and both insertion and removal to 1, so it rushes in from the camera and out past it. With Reduce Motion on, fade instead. ### Source ```swift import SwiftUI /// Rushes the view in from the camera with a zoom blur: it arrives large and /// out of focus, trailed by fading echoes, and snaps sharp as it lands. On /// removal it flies past the viewer the same way. public struct ZoomBlurTransition: Transition { public var scale: CGFloat public init(scale: CGFloat = 1.6) { self.scale = scale } public func body(content: Content, phase: TransitionPhase) -> some View { ZoomBlurBody(content: content, phase: phase, scale: scale) } } private struct ZoomBlurBody: View { let content: Content let phase: TransitionPhase let scale: CGFloat @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let away = phase.isIdentity ? 0.0 : 1.0 if reduceMotion { content.opacity(1 - away) } else { content.modifier(ZoomBlurEffect(amount: away, scale: scale, content: content)) } } } /// The look at `amount` 0 (in place) to 1 (fully zoomed away). The echoes /// are fainter, larger copies, a cheap stand-in for a radial blur. private struct ZoomBlurEffect: ViewModifier, Animatable { var amount: Double var scale: CGFloat let content: Echo nonisolated var animatableData: Double { get { amount } set { amount = newValue } } func body(content view: Content) -> some View { let a = CGFloat(amount) view .scaleEffect(1 + (scale - 1) * a) .blur(radius: 14 * a) .opacity(1 - amount) .background { if amount > 0.01 && amount < 0.99 { ZStack { ForEach(1...3, id: \.self) { step in content .scaleEffect(1 + (scale - 1) * a + CGFloat(step) * 0.12 * a) .blur(radius: 6 + 6 * CGFloat(step) * a) .opacity((1 - amount) * 0.28 / Double(step)) } } .allowsHitTesting(false) .accessibilityHidden(true) } } } } public extension Transition where Self == ZoomBlurTransition { /// Zooms the view in from the camera, blurred and trailed by echoes, /// and out past the viewer on removal. /// /// ```swift /// if let photo = selected { /// PhotoView(photo).transition(.zoomBlur) /// } /// ``` static var zoomBlur: ZoomBlurTransition { ZoomBlurTransition() } /// Zooms the view in from `scale` times its size. static func zoomBlur(scale: CGFloat) -> ZoomBlurTransition { ZoomBlurTransition(scale: scale) } } #Preview { struct Demo: View { @State private var shown = true var body: some View { ZStack { if shown { Text("WARP") .font(.system(size: 54, weight: .black, design: .rounded)) .foregroundStyle(.indigo) .transition(.zoomBlur) } } .frame(width: 280, height: 180) .onTapGesture { withAnimation(.easeOut(duration: 0.6)) { shown.toggle() } } } } return Demo() } ``` --- ## Water Ripple (`water-ripple`) A real ripple distorts the view's pixels outward from a tap. - API: `.waterRippleOnTap() / .waterRipple(at:trigger:)` - Component: Shader - Trigger: Tap / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/WaterRipple.swift - Files: Sources/MotionShaders/WaterRipple.swift, Sources/MotionShaders/Shaders/WaterRipple.metal ### Usage ```swift import MotionShaders Image("pond") .waterRippleOnTap() Card() .waterRipple(at: tapLocation, trigger: tapCount) ``` ### How it works Create a SwiftUI .waterRipple(at:trigger:amplitude:duration:) view modifier (iOS 17+) using a Metal [[stitchable]] layer shader. The shader takes (float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed): distance = length(position − origin), local = max(0, time − distance / speed), wave = amplitude × sin(frequency × local) × exp(−decay × local); sample the layer at position + wave × normalize(position − origin) and add 0.25 × wave / amplitude to rgb (times alpha) so crests brighten. In Swift, run keyframeAnimator(initialValue: 0, trigger:) with MoveKeyframe(0) and a LinearKeyframe to duration (3 s) over duration, and apply .visualEffect { $0.layerEffect(ShaderLibrary.motionRipple(.float2(origin), .float(elapsed), .float(12), .float(15), .float(8), .float(1200)), maxSampleOffset: CGSize(width: 12, height: 12), isEnabled: 0 < elapsed < duration) }. Add .waterRippleOnTap() that records a SpatialTapGesture location (as a simultaneous gesture) and bumps the trigger. With Reduce Motion on, disable the effect. ### Source ```swift import SwiftUI /// A water ripple that spreads from a point across the view's pixels: a /// ring wave pushes them in and out and brightens the crests, ringing a /// few times before it fades. Rendered with a Metal `layerEffect` /// (`Shaders/WaterRipple.metal`). struct WaterRippleModifier: ViewModifier { var origin: CGPoint var trigger: Trigger var amplitude: Double var duration: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let origin = origin, amplitude = amplitude, duration = duration let enabled = !reduceMotion return content.keyframeAnimator(initialValue: 0.0, trigger: trigger) { view, elapsed in view.visualEffect { effect, _ in effect.layerEffect( motionShaders.motionRipple( .float2(origin), .float(elapsed), .float(amplitude), .float(15), // frequency .float(8), // decay .float(1200) // speed, points per second ), maxSampleOffset: CGSize(width: amplitude, height: amplitude), isEnabled: enabled && elapsed > 0 && elapsed < duration ) } } keyframes: { _ in MoveKeyframe(0) LinearKeyframe(duration, duration: duration) } } } /// Starts a ripple wherever the view is tapped. struct WaterRippleOnTapModifier: ViewModifier { var amplitude: Double @State private var origin: CGPoint = .zero @State private var taps = 0 func body(content: Content) -> some View { content .modifier(WaterRippleModifier(origin: origin, trigger: taps, amplitude: amplitude, duration: 3)) .simultaneousGesture( SpatialTapGesture().onEnded { value in origin = value.location taps += 1 } ) } } public extension View { /// Sends a water ripple across the view from `origin` every time /// `trigger` changes. /// /// ```swift /// Image("photo") /// .waterRipple(at: tapLocation, trigger: tapCount) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - origin: Where the ripple starts, in the view's coordinates. /// - trigger: Starts a new ripple whenever it changes. /// - amplitude: How far pixels are pushed, in points. /// - duration: How long the ripple lasts, in seconds. func waterRipple(at origin: CGPoint, trigger: Trigger, amplitude: Double = 12, duration: Double = 3) -> some View { modifier(WaterRippleModifier(origin: origin, trigger: trigger, amplitude: amplitude, duration: duration)) } /// Sends a water ripple out from wherever the view is tapped. /// /// ```swift /// Image("pond") /// .waterRippleOnTap() /// ``` func waterRippleOnTap(amplitude: Double = 12) -> some View { modifier(WaterRippleOnTapModifier(amplitude: amplitude)) } } #Preview { LinearGradient(colors: [.cyan, .blue, .indigo], startPoint: .top, endPoint: .bottom) .overlay(Image(systemName: "fish.fill").font(.system(size: 60)).foregroundStyle(.white)) .frame(width: 280, height: 200) .clipShape(RoundedRectangle(cornerRadius: 20)) .waterRippleOnTap() } // MARK: - WaterRipple.metal // Save the shader below as WaterRipple.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for WaterRipple.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// A ring wave travels out from `origin`, pushing pixels along the radius; /// it rings at `frequency`, fades with `decay`, and brightens its crests. [[ stitchable ]] half4 motionRipple(float2 position, SwiftUI::Layer layer, float2 origin, float time, float amplitude, float frequency, float decay, float speed) { float distance = length(position - origin); // Each pixel starts moving once the wave front reaches it. float local = max(0.0, time - distance / speed); float wave = amplitude * sin(frequency * local) * exp(-decay * local); float2 direction = distance > 0.001 ? (position - origin) / distance : float2(0); half4 color = layer.sample(position + wave * direction); color.rgb += half(0.25 * (wave / max(amplitude, 0.001))) * color.a; return color; } */ ``` --- ## Pixellate (`pixellate`) The view melts into a mosaic of square blocks and back. - API: `.pixellate(_:)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Pixellate.swift - Files: Sources/MotionShaders/Pixellate.swift, Sources/MotionShaders/Shaders/Pixellate.metal ### Usage ```swift import MotionShaders Avatar() .pixellate(isHidden ? 18 : 1) .animation(.easeInOut, value: isHidden) ``` ### How it works Create a SwiftUI .pixellate(_ size:) view modifier (iOS 17+) as an Animatable ViewModifier over size, so withAnimation interpolates the block size. It applies .layerEffect(ShaderLibrary.motionPixellate(.float(size)), maxSampleOffset: CGSize(width: size, height: size), isEnabled: size > 1). The Metal [[stitchable]] shader (float2 position, SwiftUI::Layer layer, float size) returns layer.sample(position) when size ≤ 1, otherwise layer.sample((floor(position / size) + 0.5) × size), one colour per block. ### Source ```swift import SwiftUI /// Breaks the view into square blocks of one colour each. The block size is /// animatable, so the view can melt into or out of a mosaic. Rendered with a /// Metal `layerEffect` (`Shaders/Pixellate.metal`). struct PixellateModifier: ViewModifier, Animatable { var size: Double nonisolated var animatableData: Double { get { size } set { size = newValue } } func body(content: Content) -> some View { content.layerEffect( motionShaders.motionPixellate(.float(size)), maxSampleOffset: CGSize(width: size, height: size), isEnabled: size > 1 ) } } public extension View { /// Shows the view as a mosaic of `size`-point blocks. Animate `size` /// to pixellate in or out. /// /// ```swift /// Avatar() /// .pixellate(isHidden ? 18 : 1) /// .animation(.easeInOut, value: isHidden) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameter size: Block size in points; 1 or less shows the view as is. func pixellate(_ size: Double) -> some View { modifier(PixellateModifier(size: size)) } } #Preview { struct Demo: View { @State private var blocky = false var body: some View { Image(systemName: "photo.artframe") .font(.system(size: 120)) .foregroundStyle(.orange, .blue) .pixellate(blocky ? 16 : 1) .onTapGesture { withAnimation(.easeInOut(duration: 0.8)) { blocky.toggle() } } } } return Demo() } // MARK: - Pixellate.metal // Save the shader below as Pixellate.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Pixellate.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// Samples one colour per `size` × `size` cell. [[ stitchable ]] half4 motionPixellate(float2 position, SwiftUI::Layer layer, float size) { if (size <= 1.0) { return layer.sample(position); } float2 cell = (floor(position / size) + 0.5) * size; return layer.sample(cell); } */ ``` --- ## Chromatic Aberration (`chromatic-aberration`) Red and blue split sideways like a cheap lens; animate it on a beat. - API: `.chromaticAberration(_:)` - Component: Shader - Trigger: Value change / Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/ChromaticAberration.swift - Files: Sources/MotionShaders/ChromaticAberration.swift, Sources/MotionShaders/Shaders/ChromaticAberration.metal ### Usage ```swift import MotionShaders Text("BASS DROP") .font(.largeTitle.weight(.black)) .chromaticAberration(isPlaying ? 6 : 0) ``` ### How it works Create a SwiftUI .chromaticAberration(_ amount:) view modifier (iOS 17+) as an Animatable ViewModifier over amount, applying .layerEffect(ShaderLibrary.motionChromatic(.float(amount)), maxSampleOffset: CGSize(width: |amount|, height: 0), isEnabled: |amount| > 0.01). The Metal [[stitchable]] shader (float2 position, SwiftUI::Layer layer, float amount) samples red at position + (amount, 0), green at position and blue at position − (amount, 0), with alpha = the max of the three samples' alpha. For a beat, spike amount to 7 pt with withAnimation(.easeOut(duration: 0.12)) and, in its completion, ease back to 1.5 over 0.5 s. ### Source ```swift import SwiftUI /// Splits the red and blue channels sideways like a cheap lens or a glitchy /// screen. The amount is animatable, so it can pulse on a beat or spike on /// an event. Rendered with a Metal `layerEffect` /// (`Shaders/ChromaticAberration.metal`). struct ChromaticAberrationModifier: ViewModifier, Animatable { var amount: Double nonisolated var animatableData: Double { get { amount } set { amount = newValue } } func body(content: Content) -> some View { content.layerEffect( motionShaders.motionChromatic(.float(amount)), maxSampleOffset: CGSize(width: abs(amount), height: 0), isEnabled: abs(amount) > 0.01 ) } } public extension View { /// Shifts the red channel right and the blue channel left by `amount` /// points. Animate it for a pulsing RGB split. /// /// ```swift /// Text("BASS DROP") /// .chromaticAberration(isPlaying ? 6 : 0) /// ``` /// /// Requires the `MotionShaders` product (Metal). func chromaticAberration(_ amount: Double = 4) -> some View { modifier(ChromaticAberrationModifier(amount: amount)) } } #Preview { Text("RGB SPLIT") .font(.system(size: 44, weight: .black)) .chromaticAberration(5) .padding() } // MARK: - ChromaticAberration.metal // Save the shader below as ChromaticAberration.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for ChromaticAberration.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// Splits red and blue apart horizontally by `amount` points, like a cheap lens. [[ stitchable ]] half4 motionChromatic(float2 position, SwiftUI::Layer layer, float amount) { half4 base = layer.sample(position); half4 red = layer.sample(position + float2(amount, 0)); half4 blue = layer.sample(position - float2(amount, 0)); half alpha = max(base.a, max(red.a, blue.a)); return half4(red.r, base.g, blue.b, alpha); } */ ``` --- ## CRT Screen (`crt`) Curved glass, scanlines, a rolling band and flicker, like an old monitor. - API: `.crtScreen()` - Component: Shader - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/CRT.swift - Files: Sources/MotionShaders/CRT.swift, Sources/MotionShaders/Shaders/CRT.metal ### Usage ```swift import MotionShaders GameView() .crtScreen() .clipShape(RoundedRectangle(cornerRadius: 24)) ``` ### How it works Create a SwiftUI .crtScreen(curvature:) view modifier (iOS 17+). Inside TimelineView(.animation), apply .visualEffect { $0.layerEffect(ShaderLibrary.motionCRT(.float2(proxy.size), .float(time), .float(curvature)), maxSampleOffset: size × curvature) } with curvature 0.06. The Metal [[stitchable]] shader (position, SwiftUI::Layer layer, float2 size, float time, float curvature): map to centred uv in −1…1, barrel-distort with uv × (1 + curvature × dot(uv, uv)), return black outside 0…1; sample red at +0.8 pt and blue at −0.8 pt for fringing; multiply by scanlines 0.8 + 0.2 sin(y × π × 0.66), a rolling band 1 + 0.08 × smoothstep(0.92, 1, sin(uv.y × 3 − time × 1.5)), a vignette smoothstep(1.25, 0.4, length(centred)) and a flicker 0.97 + 0.03 sin(time × 60). With Reduce Motion on, pause the timeline at time 0. ### Source ```swift import SwiftUI /// Makes the view look like it's on an old CRT monitor: curved glass, /// scanlines, a slow rolling band, slight colour fringing, a vignette and a /// faint flicker. Rendered with a Metal `layerEffect` (`Shaders/CRT.metal`). struct CRTModifier: ViewModifier { var curvature: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { let curvature = curvature // Reduce Motion: the screen stays, the band and flicker stop. return TimelineView(.animation(paused: reduceMotion)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1000) content.visualEffect { effect, proxy in effect.layerEffect( motionShaders.motionCRT(.float2(proxy.size), .float(time), .float(curvature)), maxSampleOffset: CGSize(width: proxy.size.width * curvature, height: proxy.size.height * curvature) ) } } } } public extension View { /// Shows the view on a retro CRT screen. /// /// ```swift /// GameView() /// .crtScreen() /// .clipShape(RoundedRectangle(cornerRadius: 24)) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameter curvature: How much the glass bulges (0 = flat). func crtScreen(curvature: Double = 0.06) -> some View { modifier(CRTModifier(curvature: curvature)) } } #Preview { VStack(spacing: 8) { Text("READY.").font(.system(size: 28, weight: .bold, design: .monospaced)) Text("LOAD \"*\",8,1").font(.system(size: 18, design: .monospaced)) } .foregroundStyle(.green) .frame(width: 300, height: 200) .background(Color(red: 0.02, green: 0.08, blue: 0.04)) .crtScreen() .clipShape(RoundedRectangle(cornerRadius: 20)) } // MARK: - CRT.metal // Save the shader below as CRT.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for CRT.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// An old CRT screen: barrel-curved glass, scanlines, a rolling bright band, /// slight colour fringing, a dark vignette and a faint flicker. [[ stitchable ]] half4 motionCRT(float2 position, SwiftUI::Layer layer, float2 size, float time, float curvature) { float2 uv = position / size; float2 centred = uv * 2.0 - 1.0; // Barrel distortion: push points outward by their squared distance. centred *= 1.0 + curvature * dot(centred, centred); uv = (centred + 1.0) * 0.5; if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { return half4(0, 0, 0, 1); } float2 p = uv * size; half4 color; color.r = layer.sample(p + float2(0.8, 0)).r; color.g = layer.sample(p).g; color.b = layer.sample(p - float2(0.8, 0)).b; color.a = 1.0; float scanline = 0.8 + 0.2 * sin(p.y * 3.14159 * 0.66); float band = 1.0 + 0.08 * smoothstep(0.92, 1.0, sin(uv.y * 3.0 - time * 1.5)); float vignette = smoothstep(1.25, 0.4, length(centred)); float flicker = 0.97 + 0.03 * sin(time * 60.0); color.rgb *= half(scanline * band * vignette * flicker); return color; } */ ``` --- ## Heat Haze (`heat-haze`) The view shimmers like hot air over a desert road. - API: `.heatHaze(strength:)` - Component: Shader - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/HeatHaze.swift - Files: Sources/MotionShaders/HeatHaze.swift, Sources/MotionShaders/Shaders/HeatHaze.metal ### Usage ```swift import MotionShaders Image("desert-road") .heatHaze() ``` ### How it works Create a SwiftUI .heatHaze(strength:) view modifier (iOS 17+). Inside TimelineView(.animation), apply .visualEffect { $0.distortionEffect(ShaderLibrary.motionHeatHaze(.float2(proxy.size), .float(time), .float(strength)), maxSampleOffset: CGSize(width: strength, height: strength)) } with strength 6 pt. The Metal [[stitchable]] distortion shader (float2 position, float2 size, float time, float strength) uses smooth value noise: rise = y × 0.05 + time × 2, n = noise(x × 0.04, rise) − 0.5, m = noise(x × 0.09 + 10, rise × 1.7) − 0.5, weight = 0.35 + 0.65 × y / height (stronger at the bottom), and returns position + ((n + 0.5m) × strength × weight, n × strength × 0.3 × weight). With Reduce Motion on, use strength 0. ### Source ```swift import SwiftUI /// Makes the view shimmer like air over hot asphalt or a desert road: /// layered noise wobbles pixels sideways, rising over time, stronger towards /// the bottom. Rendered with a Metal `distortionEffect` /// (`Shaders/HeatHaze.metal`). struct HeatHazeModifier: ViewModifier { var strength: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { // Reduce Motion: no shimmer. let strength = reduceMotion ? 0 : strength return TimelineView(.animation(paused: reduceMotion)) { timeline in let time = timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1000) content.visualEffect { effect, proxy in effect.distortionEffect( motionShaders.motionHeatHaze(.float2(proxy.size), .float(time), .float(strength)), maxSampleOffset: CGSize(width: strength, height: strength), isEnabled: strength > 0 ) } } } } public extension View { /// Makes the view shimmer like hot air. /// /// ```swift /// Image("desert-road") /// .heatHaze() /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameter strength: How far pixels wobble, in points. func heatHaze(strength: Double = 6) -> some View { modifier(HeatHazeModifier(strength: strength)) } } #Preview { LinearGradient(colors: [.orange, .yellow, .brown], startPoint: .top, endPoint: .bottom) .overlay(Image(systemName: "sun.max.fill").font(.system(size: 70)).foregroundStyle(.white)) .frame(width: 280, height: 200) .heatHaze(strength: 8) .clipShape(RoundedRectangle(cornerRadius: 20)) } // MARK: - HeatHaze.metal // Save the shader below as HeatHaze.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for HeatHaze.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// A cheap, stable hash of a 2D point into 0...1. static float motionHash(float2 p) { p = fract(p * float2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } /// Smooth value noise in 0...1. static float motionNoise(float2 p) { float2 i = floor(p); float2 f = fract(p); float a = motionHash(i); float b = motionHash(i + float2(1, 0)); float c = motionHash(i + float2(0, 1)); float d = motionHash(i + float2(1, 1)); float2 u = f * f * (3.0 - 2.0 * f); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } /// Wobbles pixels sideways with layered noise rising over time, like air /// shimmering over hot asphalt. Stronger towards the bottom. [[ stitchable ]] float2 motionHeatHaze(float2 position, float2 size, float time, float strength) { float rise = position.y * 0.05 + time * 2.0; float n = motionNoise(float2(position.x * 0.04, rise)) - 0.5; float m = motionNoise(float2(position.x * 0.09 + 10.0, rise * 1.7)) - 0.5; float weight = 0.35 + 0.65 * (position.y / max(size.y, 1.0)); return position + float2((n + 0.5 * m) * strength * weight, n * strength * 0.3 * weight); } */ ``` --- ## Noise Dissolve (`noise-dissolve`) The view burns away in organic blotches with a glowing edge. - API: `.noiseDissolve(_:) / .transition(.noiseDissolve)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/NoiseDissolve.swift - Files: Sources/MotionShaders/NoiseDissolve.swift, Sources/MotionShaders/Shaders/NoiseDissolve.metal ### Usage ```swift import MotionShaders Card() .noiseDissolve(isDeleting ? 1 : 0) .animation(.easeIn(duration: 1), value: isDeleting) if !isDeleted { NoteCard().transition(.noiseDissolve) } ``` ### How it works Create a SwiftUI .noiseDissolve(_ progress:scale:edge:) view modifier (iOS 17+) as an Animatable ViewModifier over progress, applying .colorEffect(ShaderLibrary.motionDissolve(.float(progress), .float(scale), .color(edge)), isEnabled: progress > 0) with scale 18 pt and an orange edge, plus a NoiseDissolveTransition (.noiseDissolve) that uses progress 1 when not identity. The Metal [[stitchable]] colour shader (float2 position, half4 color, float progress, float scale, half4 edge): n = 0.7 × noise(position / scale) + 0.3 × noise(position / (0.35 × scale)) using smooth value noise; threshold = progress × 1.1 − 0.05; return transparent where n < threshold, and mix towards edge × alpha by 1 − smoothstep(threshold, threshold + 0.06, n) so a thin band glows just before it burns. With Reduce Motion on, the transition fades instead. ### Source ```swift import SwiftUI /// Burns the view away in organic noise blotches with a glowing edge, like /// paper catching fire. `progress` is animatable, and there's a matching /// transition. Rendered with a Metal `colorEffect` /// (`Shaders/NoiseDissolve.metal`). struct NoiseDissolveModifier: ViewModifier, Animatable { var progress: Double var scale: Double var edge: Color nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { content.colorEffect( motionShaders.motionDissolve(.float(progress), .float(scale), .color(edge)), isEnabled: progress > 0 ) } } /// Burns the view away on removal and un-burns it on insertion. public struct NoiseDissolveTransition: Transition { public var edge: Color public init(edge: Color = .orange) { self.edge = edge } public func body(content: Content, phase: TransitionPhase) -> some View { NoiseDissolveBody(content: content, phase: phase, edge: edge) } } private struct NoiseDissolveBody: View { let content: Content let phase: TransitionPhase let edge: Color @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { // Reduce Motion: a plain fade. content.opacity(phase.isIdentity ? 1 : 0) } else { content.modifier(NoiseDissolveModifier(progress: phase.isIdentity ? 0 : 1, scale: 18, edge: edge)) } } } public extension View { /// Dissolves the view in noise: 0 shows it all, 1 hides it all. /// Animate `progress` to burn it away. /// /// ```swift /// Card() /// .noiseDissolve(isDeleting ? 1 : 0) /// .animation(.easeIn(duration: 1), value: isDeleting) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - progress: 0…1. /// - scale: Size of the noise blotches, in points. /// - edge: Colour of the burning edge. func noiseDissolve(_ progress: Double, scale: Double = 18, edge: Color = .orange) -> some View { modifier(NoiseDissolveModifier(progress: progress, scale: scale, edge: edge)) } } public extension Transition where Self == NoiseDissolveTransition { /// Burns the view away in glowing noise (and back in on insertion). /// /// ```swift /// if !isDeleted { /// NoteCard().transition(.noiseDissolve) /// } /// ``` static var noiseDissolve: NoiseDissolveTransition { NoiseDissolveTransition() } /// Burns the view away with an `edge`-coloured glow. static func noiseDissolve(edge: Color) -> NoiseDissolveTransition { NoiseDissolveTransition(edge: edge) } } #Preview { struct Demo: View { @State private var burnt = false var body: some View { RoundedRectangle(cornerRadius: 16) .fill(Color.indigo.gradient) .frame(width: 220, height: 150) .noiseDissolve(burnt ? 1 : 0) .onTapGesture { withAnimation(.easeInOut(duration: 1.2)) { burnt.toggle() } } } } return Demo() } // MARK: - NoiseDissolve.metal // Save the shader below as NoiseDissolve.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for NoiseDissolve.swift (SwiftUI layer/distortion/colour effect). #include #include using namespace metal; /// A cheap, stable hash of a 2D point into 0...1. static float motionHash(float2 p) { p = fract(p * float2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } /// Smooth value noise in 0...1. static float motionNoise(float2 p) { float2 i = floor(p); float2 f = fract(p); float a = motionHash(i); float b = motionHash(i + float2(1, 0)); float c = motionHash(i + float2(0, 1)); float d = motionHash(i + float2(1, 1)); float2 u = f * f * (3.0 - 2.0 * f); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } /// Burns the view away in organic noise blotches. Pixels whose noise is /// below `progress` vanish; a thin band just above it glows in `edge`. [[ stitchable ]] half4 motionDissolve(float2 position, half4 color, float progress, float scale, half4 edge) { float n = motionNoise(position / scale) * 0.7 + motionNoise(position / (scale * 0.35)) * 0.3; // Stretch the range so 0 shows everything and 1 hides everything. float threshold = progress * 1.1 - 0.05; if (n < threshold) { return half4(0); } float glow = 1.0 - smoothstep(threshold, threshold + 0.06, n); if (progress <= 0.0) { glow = 0.0; } half4 burnt = mix(color, edge * color.a, half(glow)); return burnt; } */ ``` --- ## Disintegrate (`disintegrate`) The view crumbles into grains of sand that blow away on the wind. - API: `.disintegrate(_:) / .transition(.disintegrate)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Disintegrate.swift - Files: Sources/MotionShaders/Disintegrate.swift, Sources/MotionShaders/Shaders/Disintegrate.metal ### Usage ```swift import MotionShaders Avatar() .disintegrate(isGone ? 1 : 0) .animation(.easeIn(duration: 1.6), value: isGone) if !isDeleted { MessageRow().transition(.disintegrate) } ``` ### How it works Create a SwiftUI .disintegrate(_ progress:wind:grain:) view modifier and matching Transition (iOS 17+) using a Metal [[stitchable]] layer shader with (position, layer, float2 size, float progress, float grain, float2 wind). Split the view into grain-sized cells (2 pt) with two hashes n, n2 per cell. along = dot(position / size, normalize(wind)) × 0.5 + 0.5; delay = (1 − along) × 0.55 + 0.3n; local = clamp((1.4 × progress − delay) / 0.5, 0, 1). If local is 0, return the layer unchanged; if n2 < 1.15 × local, return clear (the grain is gone); otherwise sample the layer at position − (wind × local² + (sin 40n, cos 31n) × local × 6 × grain) and multiply by 1 − local. Apply it through .visualEffect so the shader gets the size, with maxSampleOffset |wind| + 6 × grain, in an Animatable modifier on progress. The transition maps identity to 0 and removal/insertion to 1 (default wind (140, −60)); with Reduce Motion on it fades instead. ### Source ```swift import SwiftUI /// Crumbles the view into grains of sand that blow away on the wind, the /// grains nearest the wind's source going first. `progress` is animatable, /// and there's a matching transition. Rendered with a Metal `layerEffect` /// (`Shaders/Disintegrate.metal`). struct DisintegrateModifier: ViewModifier, Animatable { var progress: Double var wind: CGVector var grain: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { let progress = Float(progress), grain = Float(max(1, grain)) let wind = CGPoint(x: wind.dx, y: wind.dy) let reach = CGSize(width: abs(wind.x) + Double(grain) * 6, height: abs(wind.y) + Double(grain) * 6) return content.visualEffect { effect, proxy in effect.layerEffect( motionShaders.motionDisintegrate(.float2(proxy.size), .float(progress), .float(grain), .float2(wind)), maxSampleOffset: reach, isEnabled: progress > 0 ) } } } /// Blows the view away as sand on removal and gathers it back on insertion. public struct DisintegrateTransition: Transition { public var wind: CGVector public init(wind: CGVector = CGVector(dx: 140, dy: -60)) { self.wind = wind } public func body(content: Content, phase: TransitionPhase) -> some View { DisintegrateBody(content: content, phase: phase, wind: wind) } } private struct DisintegrateBody: View { let content: Content let phase: TransitionPhase let wind: CGVector @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { content.opacity(phase.isIdentity ? 1 : 0) } else { content.modifier(DisintegrateModifier(progress: phase.isIdentity ? 0 : 1, wind: wind, grain: 2)) } } } public extension View { /// Crumbles the view into blowing sand: 0 shows it whole, 1 has blown /// it all away. Animate `progress` to make it go. /// /// ```swift /// Avatar() /// .disintegrate(isGone ? 1 : 0) /// .animation(.easeIn(duration: 1.6), value: isGone) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - progress: 0…1. /// - wind: Where the grains blow, and how far, in points. /// - grain: Size of a grain, in points. func disintegrate(_ progress: Double, wind: CGVector = CGVector(dx: 140, dy: -60), grain: Double = 2) -> some View { modifier(DisintegrateModifier(progress: progress, wind: wind, grain: grain)) } } public extension Transition where Self == DisintegrateTransition { /// Blows the view away as sand (and gathers it back in on insertion). /// /// ```swift /// if !isDeleted { /// MessageRow().transition(.disintegrate) /// } /// ``` static var disintegrate: DisintegrateTransition { DisintegrateTransition() } /// Blows the view away as sand along `wind`, in points. static func disintegrate(wind: CGVector) -> DisintegrateTransition { DisintegrateTransition(wind: wind) } } #Preview { struct Demo: View { @State private var gone = false var body: some View { RoundedRectangle(cornerRadius: 20) .fill(Color.orange.gradient) .overlay(Image(systemName: "person.fill").font(.system(size: 60)).foregroundStyle(.white)) .frame(width: 160, height: 160) .disintegrate(gone ? 1 : 0) .onTapGesture { withAnimation(.easeIn(duration: 1.6)) { gone.toggle() } } } } return Demo() } // MARK: - Disintegrate.metal // Save the shader below as Disintegrate.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Disintegrate.swift (SwiftUI layer effect). #include #include using namespace metal; /// A cheap, stable hash of a 2D point into 0...1. static float motionHash(float2 p) { p = fract(p * float2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); } /// Crumbles the layer into grains of sand that blow away on the wind, /// sweeping from the leading edge to the trailing one as `progress` goes /// from 0 to 1. [[ stitchable ]] half4 motionDisintegrate(float2 position, SwiftUI::Layer layer, float2 size, float progress, float grain, float2 wind) { float2 cell = floor(position / grain); float n = motionHash(cell); float n2 = motionHash(cell + 17.0); // Grains leave in a sweep along the wind, with some randomness. float2 direction = normalize(wind); float along = dot(position / max(size, float2(1.0)), direction) * 0.5 + 0.5; float delay = (1.0 - along) * 0.55 + n * 0.3; float local = clamp((progress * 1.4 - delay) / 0.5, 0.0, 1.0); if (local <= 0.0) { return layer.sample(position); } // A grain that has left: some vanish, the rest are drawn from upwind. if (n2 < local * 1.15) { return half4(0); } float2 drift = wind * local * local + float2(sin(n * 40.0), cos(n * 31.0)) * local * grain * 6.0; half4 colour = layer.sample(position - drift); return colour * half(1.0 - local); } */ ``` --- ## Genie (`genie`) The view pinches and pours into a point below it, like minimising a window. - API: `.genie(_:toward:) / .transition(.genie)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Genie.swift - Files: Sources/MotionShaders/Genie.swift, Sources/MotionShaders/Shaders/Genie.metal ### Usage ```swift import MotionShaders Panel() .genie(isMinimised ? 1 : 0, toward: 0.8) .animation(.easeInOut(duration: 0.7), value: isMinimised) ``` ### How it works Create a SwiftUI .genie(_ progress:toward:) view modifier and matching Transition (iOS 17+) using a Metal [[stitchable]] distortion shader (position, float2 size, float progress, float target) that returns where to sample. pinch = clamp(2p, 0, 1), slide = clamp(2p − 1, 0, 1). Return an off-view point below the bottom edge (that part has gone into the target, and maxSampleOffset widens the drawn area). y = position.y − slide × height (return an off-view point if y < 0). bend = smoothstep(0, 1, clamp(y / height + slide, 0, 1)) and squeeze = pinch × bend; the row's edges move from 0 and width towards target × width ∓ 3 pt by squeeze. Return off-view for x outside them, else x = (position.x − left) / (right − left) × width. So the bottom first pinches towards the target in an S-curve, then the whole shape slides down into it. Apply through .visualEffect for the size, maxSampleOffset = size, in an Animatable modifier on progress. The transition defaults to the middle of the bottom edge; with Reduce Motion on it fades instead. ### Source ```swift import SwiftUI /// The "genie" minimise: the view pinches towards a point on its bottom edge /// and pours down into it. `progress` is animatable, and there's a matching /// transition. Rendered with a Metal `distortionEffect` /// (`Shaders/Genie.metal`). struct GenieModifier: ViewModifier, Animatable { var progress: Double var target: Double nonisolated var animatableData: Double { get { progress } set { progress = newValue } } func body(content: Content) -> some View { let progress = Float(progress), target = Float(target) return content.visualEffect { effect, proxy in effect.distortionEffect( motionShaders.motionGenie(.float2(proxy.size), .float(progress), .float(target)), maxSampleOffset: proxy.size, isEnabled: progress > 0 ) } } } /// Sucks the view into a point below it on removal, and pours it back out on /// insertion. public struct GenieTransition: Transition { public var target: Double public init(target: Double = 0.5) { self.target = target } public func body(content: Content, phase: TransitionPhase) -> some View { GenieBody(content: content, phase: phase, target: target) } } private struct GenieBody: View { let content: Content let phase: TransitionPhase let target: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { if reduceMotion { content.opacity(phase.isIdentity ? 1 : 0) } else { content.modifier(GenieModifier(progress: phase.isIdentity ? 0 : 1, target: target)) } } } public extension View { /// Pours the view into a point on its bottom edge: 0 shows it, 1 has it /// fully minimised. /// /// ```swift /// Window() /// .genie(isMinimised ? 1 : 0, toward: 0.8) /// .animation(.easeIn(duration: 0.6), value: isMinimised) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - progress: 0…1. /// - target: Where along the bottom edge it goes, 0 (left) to 1 (right). func genie(_ progress: Double, toward target: Double = 0.5) -> some View { modifier(GenieModifier(progress: progress, target: min(1, max(0, target)))) } } public extension Transition where Self == GenieTransition { /// Minimises the view into the middle of its bottom edge, genie style. /// /// ```swift /// if isOpen { /// Panel().transition(.genie) /// } /// ``` static var genie: GenieTransition { GenieTransition() } /// Minimises the view into a point on its bottom edge, 0 (left) to 1 (right). static func genie(toward target: Double) -> GenieTransition { GenieTransition(target: min(1, max(0, target))) } } #Preview { struct Demo: View { @State private var minimised = false var body: some View { RoundedRectangle(cornerRadius: 14) .fill(Color.blue.gradient) .overlay(Text("Window").font(.title.bold()).foregroundStyle(.white)) .frame(width: 220, height: 150) .genie(minimised ? 1 : 0, toward: 0.8) .frame(height: 260, alignment: .top) .onTapGesture { withAnimation(.easeInOut(duration: 0.8)) { minimised.toggle() } } } } return Demo() } // MARK: - Genie.metal // Save the shader below as Genie.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Genie.swift (SwiftUI distortion effect). #include #include using namespace metal; /// The macOS-style "genie" minimise: the bottom of the view pinches towards /// `target` (0...1 along the bottom edge) while the rest bends after it, /// then the whole shape slides down and into that point. [[ stitchable ]] float2 motionGenie(float2 position, float2 size, float progress, float target) { float pinch = clamp(progress * 2.0, 0.0, 1.0); float slide = clamp(progress * 2.0 - 1.0, 0.0, 1.0); // Anything that has slid past the bottom edge has gone into the target. if (position.y > size.y) { return float2(-1e5); } // Slide: content moves down by up to the full height. float y = position.y - slide * size.y; if (y < 0.0) { return float2(-1e5); } float v = clamp(y / size.y, 0.0, 1.0); // The pinch grows towards the bottom with an S-curve, and reaches higher // up as the slide progresses. float bend = smoothstep(0.0, 1.0, clamp(v + slide, 0.0, 1.0)); float squeeze = pinch * bend; float targetX = target * size.x; float narrow = 6.0; float left = mix(0.0, targetX - narrow * 0.5, squeeze); float right = mix(size.x, targetX + narrow * 0.5, squeeze); if (position.x < left || position.x > right) { return float2(-1e5); } float x = (position.x - left) / max(right - left, 0.001) * size.x; return float2(x, y); } */ ``` --- ## Glass Lens (`glass-lens`) A round glass lens magnifies what's under it, bending light at its rim. - API: `.glassLensOnDrag() / .glassLens(at:)` - Component: Shader - Trigger: Drag / Hover - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/GlassLens.swift - Files: Sources/MotionShaders/GlassLens.swift, Sources/MotionShaders/Shaders/GlassLens.metal ### Usage ```swift import MotionShaders ArticleView() .glassLensOnDrag(radius: 60) ``` ### How it works Create SwiftUI .glassLens(at:radius:magnification:) and .glassLensOnDrag() view modifiers (iOS 17+) using a Metal [[stitchable]] layer shader (position, layer, float2 centre, float radius, float magnification). u = distance from centre / radius. Outside (u ≥ 1), darken the layer by up to 22 % in a soft ring that fades out by u = 1.12. Inside, sample at centre + offset × scale with scale = mix(1 / magnification, 1, u³), so the middle is magnified (1.8×) and the rim isn't; sample red at scale + 0.035u⁴ and blue at scale − 0.035u⁴ for colour fringing. Brighten towards white by a rim term smoothstep(0.86, 1, u) × 0.35 plus a specular spot centred at (−0.38, −0.42) × radius that fades over 0.32 × radius. maxSampleOffset = radius. The drag version follows a DragGesture(minimumDistance: 0) and the hover location, starting in the middle. ### Source ```swift import SwiftUI /// A round glass lens over the view: it magnifies what's under it, bends the /// light harder towards the rim with a hint of colour fringing, and casts a /// soft shadow. Rendered with a Metal `layerEffect` /// (`Shaders/GlassLens.metal`). struct GlassLensModifier: ViewModifier { var centre: CGPoint var radius: Double var magnification: Double func body(content: Content) -> some View { content.layerEffect( motionShaders.motionGlassLens(.float2(centre), .float(radius), .float(max(1, magnification))), maxSampleOffset: CGSize(width: radius, height: radius) ) } } /// A lens that follows a dragging finger or the pointer. struct GlassLensOnDragModifier: ViewModifier { var radius: Double var magnification: Double @State private var centre: CGPoint? @State private var size: CGSize = .zero func body(content: Content) -> some View { content .modifier(GlassLensModifier(centre: centre ?? CGPoint(x: size.width / 2, y: size.height / 2), radius: radius, magnification: magnification)) .background { GeometryReader { proxy in Color.clear .onAppear { size = proxy.size } .onChange(of: proxy.size) { _, new in size = new } } } .gesture(DragGesture(minimumDistance: 0).onChanged { value in withAnimation(.interactiveSpring(duration: 0.2)) { centre = value.location } }) .onContinuousHover { phase in if case let .active(location) = phase { centre = location } } } } public extension View { /// Puts a glass lens over the view at `centre`. /// /// ```swift /// MapView() /// .glassLens(at: point, radius: 60) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - centre: Centre of the lens, in the view's coordinates. /// - radius: Radius of the lens, in points. /// - magnification: Zoom at the middle of the lens. func glassLens(at centre: CGPoint, radius: Double = 56, magnification: Double = 1.8) -> some View { modifier(GlassLensModifier(centre: centre, radius: radius, magnification: magnification)) } /// Puts a glass lens over the view that follows a dragging finger or the /// pointer. It starts in the middle. /// /// ```swift /// Text(article).glassLensOnDrag() /// ``` func glassLensOnDrag(radius: Double = 56, magnification: Double = 1.8) -> some View { modifier(GlassLensOnDragModifier(radius: radius, magnification: magnification)) } } #Preview { VStack(alignment: .leading, spacing: 6) { ForEach(0..<6) { _ in Text("Drag the lens over this text to read it.").font(.caption) } } .padding() .frame(width: 280, height: 180) .background(.white) .glassLensOnDrag() } // MARK: - GlassLens.metal // Save the shader below as GlassLens.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for GlassLens.swift (SwiftUI layer effect). #include #include using namespace metal; /// A round glass lens over the layer: magnifies the middle, bends the light /// harder towards the rim (splitting the colours a little), adds a bright rim /// and specular highlight, and drops a soft shadow just outside. [[ stitchable ]] half4 motionGlassLens(float2 position, SwiftUI::Layer layer, float2 centre, float radius, float magnification) { float2 offset = position - centre; float d = length(offset); float u = d / radius; if (u >= 1.0) { half4 colour = layer.sample(position); // Soft shadow ring outside the rim. float shadow = (1.0 - smoothstep(1.0, 1.12, u)) * 0.22; return half4(colour.rgb * half(1.0 - shadow), colour.a); } // Strong magnification in the middle easing to none at the rim. float scale = mix(1.0 / magnification, 1.0, pow(u, 3.0)); float split = 0.035 * pow(u, 4.0); half4 g = layer.sample(centre + offset * scale); half r = layer.sample(centre + offset * (scale + split)).r; half b = layer.sample(centre + offset * (scale - split)).b; half4 colour = half4(r, g.g, b, g.a); // Bright rim, and a highlight up and to the left. float rim = smoothstep(0.86, 1.0, u) * 0.35; float2 light = centre + float2(-0.38, -0.42) * radius; float spec = (1.0 - smoothstep(0.0, radius * 0.32, length(position - light))) * 0.45; half glow = half(rim + spec); colour.rgb = colour.rgb + glow * (1.0h - colour.rgb); colour.a = max(colour.a, glow); return colour; } */ ``` --- ## Swirl (`swirl`) The view twists around a point like water going down a drain. - API: `.swirlOnDrag() / .swirl(at:strength:)` - Component: Shader - Trigger: Drag / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Swirl.swift - Files: Sources/MotionShaders/Swirl.swift, Sources/MotionShaders/Shaders/Swirl.metal ### Usage ```swift import MotionShaders Image("galaxy") .swirlOnDrag() Logo() .swirl(at: centre, strength: isStirred ? 4 : 0) .animation(.spring(duration: 1), value: isStirred) ``` ### How it works Create SwiftUI .swirl(at:radius:strength:) and .swirlOnDrag(radius:strength:) view modifiers (iOS 17+) using a Metal [[stitchable]] distortion shader (position, float2 centre, float radius, float strength): within radius (110 pt), rotate the offset from the centre by strength × t² where t = 1 − distance / radius, so it twists most at the centre and not at all at the edge; outside, return the position unchanged. Animate strength in an Animatable modifier, maxSampleOffset = radius. The drag version moves the centre to the finger and sets strength to 4 × min(1, 0.35 + travel / 160) with an interactive spring, then springs back to 0 with .spring(duration: 0.8, bounce: 0.45) on release. With Reduce Motion on, the drag doesn't twist. ### Source ```swift import SwiftUI /// Twists the view around a point like water going down a drain. /// `strength` is animatable. Rendered with a Metal `distortionEffect` /// (`Shaders/Swirl.metal`). struct SwirlModifier: ViewModifier, Animatable { var centre: CGPoint var radius: Double var strength: Double nonisolated var animatableData: Double { get { strength } set { strength = newValue } } func body(content: Content) -> some View { content.distortionEffect( motionShaders.motionSwirl(.float2(centre), .float(radius), .float(strength)), maxSampleOffset: CGSize(width: radius, height: radius), isEnabled: abs(strength) > 0.001 ) } } /// Swirls wherever a finger drags, and untwists with a spring on release. struct SwirlOnDragModifier: ViewModifier { var radius: Double var strength: Double @State private var centre: CGPoint = .zero @State private var amount: Double = 0 @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { content .modifier(SwirlModifier(centre: centre, radius: radius, strength: amount)) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in guard !reduceMotion else { return } centre = value.location // Twist harder the further the finger travels. let travel = hypot(value.translation.width, value.translation.height) withAnimation(.interactiveSpring(duration: 0.3)) { amount = strength * min(1, 0.35 + travel / 160) } } .onEnded { _ in withAnimation(.spring(duration: 0.8, bounce: 0.45)) { amount = 0 } } ) } } public extension View { /// Twists the view around `centre`. Animate `strength` to stir it. /// /// ```swift /// Image("latte") /// .swirl(at: point, strength: isStirred ? 4 : 0) /// .animation(.spring(duration: 1), value: isStirred) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - centre: Centre of the twist, in the view's coordinates. /// - radius: Reach of the twist, in points. /// - strength: Twist at the centre, in radians (negative turns the other way). func swirl(at centre: CGPoint, radius: Double = 110, strength: Double) -> some View { modifier(SwirlModifier(centre: centre, radius: radius, strength: strength)) } /// Swirls the view under a dragging finger and springs back on release. /// /// ```swift /// Image("galaxy") /// .swirlOnDrag() /// ``` func swirlOnDrag(radius: Double = 110, strength: Double = 4) -> some View { modifier(SwirlOnDragModifier(radius: radius, strength: strength)) } } #Preview { LinearGradient(colors: [.blue, .purple, .pink], startPoint: .top, endPoint: .bottom) .overlay(Text("SWIRL").font(.system(size: 50, weight: .black)).foregroundStyle(.white)) .frame(width: 280, height: 200) .swirlOnDrag() } // MARK: - Swirl.metal // Save the shader below as Swirl.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Swirl.swift (SwiftUI distortion effect). #include #include using namespace metal; /// Twists the view around `centre` like water going down a drain: pixels /// within `radius` are rotated, most at the centre and none at the edge. [[ stitchable ]] float2 motionSwirl(float2 position, float2 centre, float radius, float strength) { float2 offset = position - centre; float d = length(offset); if (d >= radius) { return position; } float t = 1.0 - d / radius; float angle = strength * t * t; float c = cos(angle), s = sin(angle); return centre + float2(c * offset.x - s * offset.y, s * offset.x + c * offset.y); } */ ``` --- ## Kaleidoscope (`kaleidoscope`) One wedge of the view mirrored round the centre, turning into new patterns. - API: `.kaleidoscope(segments:)` - Component: Shader - Trigger: Loop / Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Kaleidoscope.swift - Files: Sources/MotionShaders/Kaleidoscope.swift, Sources/MotionShaders/Shaders/Kaleidoscope.metal ### Usage ```swift import MotionShaders Image("flowers") .kaleidoscope(segments: 8) .clipShape(Circle()) ``` ### How it works Create a SwiftUI .kaleidoscope(segments:rotation:spins:) view modifier (iOS 17+) using a Metal [[stitchable]] layer shader (position, layer, float2 size, float segments, float rotation). Around the centre, take the polar angle modulo the wedge 2π / segments and mirror the second half of each wedge (a > wedge / 2 → wedge − a), add rotation, and sample at centre + r' × (cos a, sin a), where r' folds the radius back into the view (reach − |r mod 2 reach − reach|, reach = half the shorter side) so the corners aren't empty. Pass the size through .visualEffect with maxSampleOffset = size, in an Animatable modifier on rotation. When spins is on, add time × 0.25 rad from TimelineView(.animation) so the pattern keeps blooming. With Reduce Motion on, don't spin. ### Source ```swift import SwiftUI /// Turns the view into a kaleidoscope: one wedge is mirrored around the /// centre, and turning the wedge makes the pattern bloom and fold. /// Rendered with a Metal `layerEffect` (`Shaders/Kaleidoscope.metal`). struct KaleidoscopeModifier: ViewModifier { var segments: Int var rotation: Angle var spins: Bool @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { if spins && !reduceMotion { TimelineView(.animation) { timeline in let turn = timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 3600) * 0.25 content.modifier(KaleidoscopeEffect(segments: segments, rotation: rotation.radians + turn)) } } else { content.modifier(KaleidoscopeEffect(segments: segments, rotation: rotation.radians)) } } } struct KaleidoscopeEffect: ViewModifier, Animatable { var segments: Int var rotation: Double nonisolated var animatableData: Double { get { rotation } set { rotation = newValue } } func body(content: Content) -> some View { let segments = Float(max(2, segments)), rotation = Float(rotation) return content.visualEffect { effect, proxy in effect.layerEffect( motionShaders.motionKaleidoscope(.float2(proxy.size), .float(segments), .float(rotation)), maxSampleOffset: proxy.size ) } } } public extension View { /// Mirrors the view into a kaleidoscope with `segments` reflections. /// /// ```swift /// Image("flowers") /// .kaleidoscope(segments: 8) // turns on its own /// Pattern() /// .kaleidoscope(segments: 6, rotation: angle, spins: false) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - segments: Number of mirrored wedges. /// - rotation: Which part of the view the wedge samples; animatable. /// - spins: Keep turning the wedge, so the pattern keeps changing. func kaleidoscope(segments: Int = 8, rotation: Angle = .zero, spins: Bool = true) -> some View { modifier(KaleidoscopeModifier(segments: segments, rotation: rotation, spins: spins)) } } #Preview { AngularGradient(colors: [.red, .orange, .yellow, .green, .blue, .purple, .red], center: .topLeading) .overlay(Image(systemName: "leaf.fill").font(.system(size: 60)).foregroundStyle(.white).offset(x: 40, y: 20)) .frame(width: 240, height: 240) .kaleidoscope(segments: 8) } // MARK: - Kaleidoscope.metal // Save the shader below as Kaleidoscope.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Kaleidoscope.swift (SwiftUI layer effect). #include #include using namespace metal; /// Mirrors one wedge of the layer around the centre, `segments` times, like /// a kaleidoscope. `rotation` turns the wedge being sampled, so animating it /// makes the pattern bloom and fold. [[ stitchable ]] half4 motionKaleidoscope(float2 position, SwiftUI::Layer layer, float2 size, float segments, float rotation) { float2 centre = size * 0.5; float2 p = position - centre; float r = length(p); float wedge = 2.0 * M_PI_F / max(segments, 1.0); float a = atan2(p.y, p.x); a = a - wedge * floor(a / wedge); if (a > wedge * 0.5) { a = wedge - a; } // mirror every other half a += rotation; // Fold the sample radius back into the view so the edges don't go blank. float reach = min(size.x, size.y) * 0.5; float folded = reach - abs(fmod(r, 2.0 * reach) - reach); return layer.sample(centre + folded * float2(cos(a), sin(a))); } */ ``` --- ## Halftone (`halftone`) The view is printed in round dots, bigger where it's darker, like newsprint. - API: `.halftone(dotSize:)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Halftone.swift - Files: Sources/MotionShaders/Halftone.swift, Sources/MotionShaders/Shaders/Halftone.metal ### Usage ```swift import MotionShaders Image("portrait") .halftone(dotSize: isPrinted ? 8 : 1) .animation(.easeInOut, value: isPrinted) ``` ### How it works Create a SwiftUI .halftone(dotSize:angle:paper:) view modifier (iOS 17+) using a Metal [[stitchable]] layer shader (position, layer, float dotSize, float angle, half4 paper). Rotate the position into a grid at angle (45°), find the centre of its dotSize cell (8 pt), rotate that back and sample the layer there for the dot's colour. Un-premultiply, take luminance L, coverage = (1 − 0.85L) × alpha, dot radius = √coverage × 0.72 × dotSize, and draw the dot with a 0.8 pt smoothstep edge over the paper colour, keeping the alpha of the layer at the pixel so transparent areas stay transparent. Animate dotSize in an Animatable modifier and disable the effect below 1.5 pt, so animating from 1 brings the dots in. maxSampleOffset = dotSize. ### Source ```swift import SwiftUI /// Redraws the view as round printing dots on paper, like newsprint or a /// comic: darker areas get bigger dots, each in the colour underneath. /// `dotSize` is animatable. Rendered with a Metal `layerEffect` /// (`Shaders/Halftone.metal`). struct HalftoneModifier: ViewModifier, Animatable { var dotSize: Double var angle: Angle var paper: Color nonisolated var animatableData: Double { get { dotSize } set { dotSize = newValue } } func body(content: Content) -> some View { let size = max(1, dotSize) content.layerEffect( motionShaders.motionHalftone(.float(size), .float(angle.radians), .color(paper)), maxSampleOffset: CGSize(width: size, height: size), isEnabled: dotSize >= 1.5 ) } } public extension View { /// Prints the view in halftone dots. Animate `dotSize` from 1 (off) up /// to bring the dots in. /// /// ```swift /// Image("portrait") /// .halftone(dotSize: 8) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - dotSize: Spacing of the dot grid, in points. Below 1.5 the effect is off. /// - angle: Rotation of the dot grid (print screens use 45°). /// - paper: Colour between the dots. func halftone(dotSize: Double = 8, angle: Angle = .degrees(45), paper: Color = .white) -> some View { modifier(HalftoneModifier(dotSize: dotSize, angle: angle, paper: paper)) } } #Preview { LinearGradient(colors: [.orange, .pink, .purple], startPoint: .topLeading, endPoint: .bottomTrailing) .overlay(Image(systemName: "star.fill").font(.system(size: 80)).foregroundStyle(.yellow)) .frame(width: 260, height: 180) .halftone(dotSize: 7) } // MARK: - Halftone.metal // Save the shader below as Halftone.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Halftone.swift (SwiftUI layer effect). #include #include using namespace metal; /// Redraws the layer as a grid of round dots on paper, like newsprint: the /// grid is rotated by `angle`, each dot takes the colour under its centre, /// and darker areas get bigger dots. [[ stitchable ]] half4 motionHalftone(float2 position, SwiftUI::Layer layer, float dotSize, float angle, half4 paper) { float c = cos(angle), s = sin(angle); // Into the rotated grid and back out again. float2 p = float2(c * position.x + s * position.y, -s * position.x + c * position.y); float2 cell = (floor(p / dotSize) + 0.5) * dotSize; float2 centre = float2(c * cell.x - s * cell.y, s * cell.x + c * cell.y); half4 ink = layer.sample(centre); half alpha = layer.sample(position).a; if (alpha <= 0.0h) { return half4(0); } float3 rgb = ink.a > 0.0h ? float3(ink.rgb / ink.a) : float3(1); float luminance = dot(rgb, float3(0.299, 0.587, 0.114)); float coverage = (1.0 - luminance * 0.85) * float(ink.a); float radius = sqrt(coverage) * dotSize * 0.72; float d = length(p - cell); float inside = 1.0 - smoothstep(radius - 0.8, radius + 0.8, d); half3 colour = mix(paper.rgb, half3(rgb), half(inside)); return half4(colour * alpha, alpha); } */ ``` --- ## Dither (`dither`) Chunky pixels in a few flat colours with a Bayer cross-hatch, like a retro handheld. - API: `.dither(pixelSize:palette:)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Dither.swift - Files: Sources/MotionShaders/Dither.swift, Sources/MotionShaders/Shaders/Dither.metal ### Usage ```swift import MotionShaders GameScene() .dither(pixelSize: 3) // four greens Avatar() .dither(pixelSize: 2, palette: DitherPalette.mono) // 1-bit ``` ### How it works Create a SwiftUI .dither(pixelSize:palette:) view modifier (iOS 17+) using a Metal [[stitchable]] layer shader (position, layer, float pixelSize, device const half4 *palette, int count). Sample the layer at the centre of the position's pixelSize block (3 pt), un-premultiply, take luminance, and add a 4 × 4 Bayer threshold ((bayer[i] + 0.5) / 16 − 0.5) / (levels − 1) indexed by the block coordinates mod 4. Round to the nearest of the palette's colours (darkest first) and return it premultiplied by the sample's alpha. Pass the palette with .colorArray. Ship palettes: four greens like an early handheld (0.06, 0.22, 0.06) … (0.61, 0.74, 0.06), mono black/white, and sepia. Animate pixelSize in an Animatable modifier; maxSampleOffset = pixelSize. ### Source ```swift import SwiftUI /// Retro ordered dithering: the view is cut into chunky pixels and drawn in /// a few flat colours with a Bayer cross-hatch between them, like an old /// handheld console screen. Rendered with a Metal `layerEffect` /// (`Shaders/Dither.metal`). struct DitherModifier: ViewModifier, Animatable { var pixelSize: Double var palette: [Color] nonisolated var animatableData: Double { get { pixelSize } set { pixelSize = newValue } } func body(content: Content) -> some View { let size = max(1, pixelSize) content.layerEffect( motionShaders.motionDither(.float(size), .colorArray(palette)), maxSampleOffset: CGSize(width: size, height: size), isEnabled: palette.count >= 2 ) } } public extension View { /// Draws the view in a handful of colours with ordered (Bayer) dithering. /// /// ```swift /// Image("level") /// .dither(pixelSize: 3) // four-shade green /// Avatar() /// .dither(pixelSize: 2, palette: [.black, .white]) // 1-bit /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - pixelSize: Size of one chunky pixel, in points. /// - palette: Two or more colours from darkest to lightest. The default /// is a four-shade green, like an early handheld console. func dither(pixelSize: Double = 3, palette: [Color] = DitherPalette.green) -> some View { modifier(DitherModifier(pixelSize: pixelSize, palette: palette)) } } /// Palettes for `.dither(pixelSize:palette:)`, darkest first. public enum DitherPalette { /// Four greens, like an early handheld console's screen. public static let green: [Color] = [ Color(red: 0.06, green: 0.22, blue: 0.06), Color(red: 0.19, green: 0.38, blue: 0.19), Color(red: 0.55, green: 0.67, blue: 0.06), Color(red: 0.61, green: 0.74, blue: 0.06), ] /// Black and white. public static let mono: [Color] = [.black, .white] /// Four warm browns, like an old photograph. public static let sepia: [Color] = [ Color(red: 0.17, green: 0.11, blue: 0.07), Color(red: 0.44, green: 0.3, blue: 0.2), Color(red: 0.75, green: 0.6, blue: 0.43), Color(red: 0.96, green: 0.9, blue: 0.78), ] } #Preview { LinearGradient(colors: [.black, .white], startPoint: .leading, endPoint: .trailing) .overlay(Image(systemName: "gamecontroller.fill").font(.system(size: 70)).foregroundStyle(.gray)) .frame(width: 260, height: 160) .dither(pixelSize: 3) } // MARK: - Dither.metal // Save the shader below as Dither.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Dither.swift (SwiftUI layer effect). #include #include using namespace metal; /// Retro ordered dithering: the layer is cut into `pixelSize` blocks, each /// block's brightness is nudged by a 4x4 Bayer pattern and snapped to one of /// the palette's colours (darkest first). [[ stitchable ]] half4 motionDither(float2 position, SwiftUI::Layer layer, float pixelSize, device const half4 *palette, int count) { float2 block = floor(position / pixelSize); half4 colour = layer.sample((block + 0.5) * pixelSize); if (colour.a <= 0.01h) { return half4(0); } float3 rgb = float3(colour.rgb / colour.a); float luminance = dot(rgb, float3(0.299, 0.587, 0.114)); const float bayer[16] = { 0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5 }; int2 b = int2(fmod(block, 4.0)); float threshold = (bayer[b.y * 4 + b.x] + 0.5) / 16.0 - 0.5; int levels = max(count, 2); float level = clamp(luminance + threshold / float(levels - 1), 0.0, 1.0); int index = clamp(int(round(level * float(levels - 1))), 0, levels - 1); half4 shade = palette[min(index, count - 1)]; return half4(shade.rgb * colour.a, colour.a); } */ ``` --- ## Thermal Vision (`thermal`) Brightness turns into heat colours, from cold indigo to white hot. - API: `.thermal(_:)` - Component: Shader - Trigger: Value change - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Thermal.swift - Files: Sources/MotionShaders/Thermal.swift, Sources/MotionShaders/Shaders/Thermal.metal ### Usage ```swift import MotionShaders CameraFeed() .thermal(isScanning ? 1 : 0) .animation(.easeInOut(duration: 0.6), value: isScanning) ``` ### How it works Create a SwiftUI .thermal(_ intensity:) view modifier (iOS 17+) using a Metal [[stitchable]] colour shader (position, half4 colour, float intensity). Un-premultiply, take luminance as heat, and map it through seven evenly spaced stops: (0, 0, 0.05), (0.2, 0.05, 0.45), (0.65, 0.05, 0.6), (0.95, 0.15, 0.2), (1, 0.5, 0), (1, 0.9, 0.2), (1, 1, 1), interpolating linearly. Mix the original colour with the heat colour by intensity and premultiply again. Animate intensity in an Animatable modifier and disable the effect at 0, so the view can switch into heat vision. ### Source ```swift import SwiftUI /// False-colour "heat vision": brightness becomes temperature, from cold /// black and indigo through magenta and red to hot yellow and white. /// `intensity` is animatable, so the view can switch into heat vision. /// Rendered with a Metal `colorEffect` (`Shaders/Thermal.metal`). struct ThermalModifier: ViewModifier, Animatable { var intensity: Double nonisolated var animatableData: Double { get { intensity } set { intensity = newValue } } func body(content: Content) -> some View { content.colorEffect(motionShaders.motionThermal(.float(intensity)), isEnabled: intensity > 0) } } public extension View { /// Shows the view through a heat camera. Animate `intensity` between 0 /// and 1 to switch it on and off. /// /// ```swift /// Image("street") /// .thermal(isScanning ? 1 : 0) /// .animation(.easeInOut(duration: 0.6), value: isScanning) /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameter intensity: 0 shows the original colours, 1 the full heat palette. func thermal(_ intensity: Double = 1) -> some View { modifier(ThermalModifier(intensity: intensity)) } } #Preview { RadialGradient(colors: [.white, .gray, .black], center: .center, startRadius: 10, endRadius: 140) .frame(width: 260, height: 180) .thermal() } // MARK: - Thermal.metal // Save the shader below as Thermal.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Thermal.swift (SwiftUI colour effect). #include #include using namespace metal; /// Maps brightness to a heat-camera palette, from cold black and indigo /// through magenta and red to hot yellow and white. `intensity` blends from /// the original colours (0) to the full false colour (1). [[ stitchable ]] half4 motionThermal(float2 position, half4 colour, float intensity) { if (colour.a <= 0.0h) { return colour; } float3 rgb = float3(colour.rgb / colour.a); float heat = dot(rgb, float3(0.299, 0.587, 0.114)); const float3 stops[7] = { float3(0.0, 0.0, 0.05), float3(0.2, 0.05, 0.45), float3(0.65, 0.05, 0.6), float3(0.95, 0.15, 0.2), float3(1.0, 0.5, 0.0), float3(1.0, 0.9, 0.2), float3(1.0, 1.0, 1.0) }; float scaled = clamp(heat, 0.0, 1.0) * 6.0; int i = min(int(floor(scaled)), 5); float3 thermal = mix(stops[i], stops[i + 1], scaled - float(i)); float3 result = mix(rgb, thermal, clamp(intensity, 0.0, 1.0)); return half4(half3(result) * colour.a, colour.a); } */ ``` --- ## Flag Wave (`flag-wave`) The view ripples like a flag on a pole, its folds catching the light. - API: `.flagWave()` - Component: Shader - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/FlagWave.swift - Files: Sources/MotionShaders/FlagWave.swift, Sources/MotionShaders/Shaders/FlagWave.metal ### Usage ```swift import MotionShaders Image("banner") .flagWave(amplitude: 10) ``` ### How it works Create a SwiftUI .flagWave(amplitude:wavelength:speed:) view modifier (iOS 17+) using a Metal [[stitchable]] layer shader (position, layer, float2 size, float time, float amplitude, float wavelength). reach = x / width (0 at the pole on the leading edge, 1 at the free end); phase = x / wavelength × 2π − 4 × time; wave = sin(phase) + 0.35 sin(2.3 phase + 1.1). Sample at (x + 0.25 × amplitude × reach × cos(phase), y − amplitude × reach × wave) and multiply the colour by 1 + 0.18 × reach × (cos(phase) + 0.8 cos(2.3 phase + 1.1)) so slopes catch light and shade, clamped to alpha. Drive time from TimelineView(.animation), pad the view vertically by 1.4 × amplitude (and take it back with negative padding) so the ripples have room, and apply through .visualEffect for the size. With Reduce Motion on, leave the view still. ### Source ```swift import SwiftUI /// Waves the view like a flag in the wind, held at its leading edge: ripples /// run along it, growing towards the free end, and the folds catch light /// and shadow. Rendered with a Metal `layerEffect` /// (`Shaders/FlagWave.metal`). struct FlagWaveModifier: ViewModifier { var amplitude: Double var wavelength: Double var speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion func body(content: Content) -> some View { if reduceMotion { content } else { TimelineView(.animation(paused: speed == 0)) { timeline in let time = Float(timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 3600) * speed) let amplitude = Float(amplitude), wavelength = Float(max(10, wavelength)) let reach = CGSize(width: Double(amplitude), height: Double(amplitude) * 1.4) content .padding(.vertical, Double(amplitude) * 1.4) .visualEffect { effect, proxy in effect.layerEffect( motionShaders.motionFlagWave(.float2(proxy.size), .float(time), .float(amplitude), .float(wavelength)), maxSampleOffset: reach ) } .padding(.vertical, -Double(amplitude) * 1.4) } } } } public extension View { /// Waves the view like a flag held at its leading edge. /// /// ```swift /// Image("banner") /// .flagWave() /// ``` /// /// Requires the `MotionShaders` product (Metal). /// /// - Parameters: /// - amplitude: Height of the ripples at the free end, in points. /// - wavelength: Length of one ripple, in points. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. func flagWave(amplitude: Double = 9, wavelength: Double = 120, speed: Double = 1) -> some View { modifier(FlagWaveModifier(amplitude: amplitude, wavelength: wavelength, speed: speed)) } } #Preview { VStack(spacing: 0) { Color.red Color.white Color.blue } .frame(width: 240, height: 150) .flagWave() } // MARK: - FlagWave.metal // Save the shader below as FlagWave.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for FlagWave.swift (SwiftUI layer effect). #include #include using namespace metal; /// Waves the layer like a flag on a pole at its leading edge: ripples run /// along it, growing away from the pole, and the folds catch light and /// shadow. [[ stitchable ]] half4 motionFlagWave(float2 position, SwiftUI::Layer layer, float2 size, float time, float amplitude, float wavelength) { float reach = clamp(position.x / max(size.x, 1.0), 0.0, 1.0); float phase = position.x / wavelength * 2.0 * M_PI_F - time * 4.0; float wave = sin(phase) + 0.35 * sin(phase * 2.3 + 1.1); float dy = amplitude * reach * wave; float dx = amplitude * 0.25 * reach * cos(phase); half4 colour = layer.sample(float2(position.x + dx, position.y - dy)); // Light on the slopes facing up, shade on the ones facing down. float slope = cos(phase) + 0.8 * cos(phase * 2.3 + 1.1); half shade = half(1.0 + 0.18 * reach * slope); return half4(clamp(colour.rgb * shade, 0.0h, colour.a), colour.a); } */ ``` --- ## Voronoi Cells (`voronoi`) Slowly drifting cells like stained glass, with soft seams between them. - API: `VoronoiCells(colors:)` - Component: Shader - Trigger: Loop - Requires: iOS 17+ · macOS 14+ · visionOS 1+ - Source: https://github.com/BigTsung/SwiftUI-Motion-Library/blob/main/Sources/MotionShaders/Voronoi.swift - Files: Sources/MotionShaders/Voronoi.swift, Sources/MotionShaders/Shaders/Voronoi.metal ### Usage ```swift import MotionShaders VoronoiCells(colors: [.indigo, .purple, .pink, .orange, .teal]) .ignoresSafeArea() ``` ### How it works Create a SwiftUI VoronoiCells(colors:cellSize:speed:) view (iOS 17+): a Rectangle with a Metal [[stitchable]] colour shader (position, colour, float time, float scale, device const half4 *palette, int count) driven by TimelineView(.animation). Work in cells of scale (60 pt); for the 3 × 3 neighbouring cells, the seed is cell + 0.5 + 0.4 × sin(0.6 × speed × time + 2π × hash2(cell)) (speed is a multiplier defaulting to 1), so seeds drift. Track the nearest and second-nearest distances; colour the pixel with palette[hash(nearest cell) × count], darkened by smoothstep(0, 0.08, second − nearest) for soft seams and by 1 − 0.35 × nearest for a gentle glow at each seed. Pass the colours with .colorArray. With Reduce Motion on, pause the timeline. Hide it from accessibility. ### Source ```swift import SwiftUI /// A background of slowly drifting Voronoi cells, like stained glass or /// living tissue: every point takes the colour of its nearest moving seed, /// with soft dark seams between cells. Rendered with a Metal `colorEffect` /// (`Shaders/Voronoi.metal`). public struct VoronoiCells: View { private let colors: [Color] private let cellSize: Double private let speed: Double @Environment(\.accessibilityReduceMotion) private var reduceMotion /// - Parameters: /// - colors: Colours the cells are picked from. /// - cellSize: Typical size of a cell, in points. /// - speed: Multiplier on the default pace: 1 is normal, 2 twice as fast, 0.5 half as fast. public init(colors: [Color] = [.indigo, .purple, .pink, .orange, .teal], cellSize: Double = 60, speed: Double = 1) { self.colors = colors.isEmpty ? [.gray] : colors self.cellSize = max(8, cellSize) self.speed = speed * 0.6 } public var body: some View { TimelineView(.animation(paused: reduceMotion || speed == 0)) { timeline in let time = reduceMotion ? 0 : timeline.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 3600) * speed Rectangle() .fill(.white) .colorEffect(motionShaders.motionVoronoi(.float(time), .float(cellSize), .colorArray(colors))) } .accessibilityHidden(true) } } #Preview { VoronoiCells() .frame(width: 320, height: 220) } // MARK: - Voronoi.metal // Save the shader below as Voronoi.metal in your app target, and replace // `motionShaders` with `ShaderLibrary.default`. /* // Stitchable shader for Voronoi.swift (SwiftUI colour effect). #include #include using namespace metal; /// A cheap, stable hash of a 2D point into 0...1, two values at once. static float2 motionHash2(float2 p) { p = float2(dot(p, float2(127.1, 311.7)), dot(p, float2(269.5, 183.3))); return fract(sin(p) * 43758.5453); } /// Slowly drifting Voronoi cells: each pixel takes the colour of its nearest /// moving seed (picked from `palette`), and the borders between cells are /// drawn as soft dark seams. [[ stitchable ]] half4 motionVoronoi(float2 position, half4 colour, float time, float scale, device const half4 *palette, int count) { float2 p = position / scale; float2 cell = floor(p); float nearest = 8.0, second = 8.0; float2 nearestCell = float2(0); for (int y = -1; y <= 1; y++) { for (int x = -1; x <= 1; x++) { float2 neighbour = cell + float2(x, y); float2 h = motionHash2(neighbour); float2 seed = neighbour + 0.5 + 0.4 * sin(time + 6.2831 * h); float d = length(p - seed); if (d < nearest) { second = nearest; nearest = d; nearestCell = neighbour; } else if (d < second) { second = d; } } } int index = int(motionHash2(nearestCell + 3.0).x * float(count)) % max(count, 1); half4 fill = palette[index]; float seam = smoothstep(0.0, 0.08, second - nearest); float glow = 1.0 - nearest * 0.35; return half4(fill.rgb * half(seam * glow), 1.0h) * colour.a; } */ ```