CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

80

Quality

100%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

animation-advanced.mdskills/swiftui-animation/references/

SwiftUI Animation Advanced Reference

Detailed API reference for SwiftUI animation types, protocols, and patterns. Covers material beyond the SKILL.md summary.

Contents

CustomAnimation Protocol (iOS 17+)

Create entirely custom animation curves by conforming to CustomAnimation.

@preconcurrency protocol CustomAnimation: Hashable, Sendable

Required Method

func animate<V: VectorArithmetic>(
    value: V,
    time: TimeInterval,
    context: inout AnimationContext<V>
) -> V?

Return the interpolated value at the given time. Return nil when the animation is complete.

Optional Methods

func velocity<V: VectorArithmetic>(
    value: V,
    time: TimeInterval,
    context: AnimationContext<V>
) -> V?

func shouldMerge<V: VectorArithmetic>(
    previous: Animation,
    value: V,
    time: TimeInterval,
    context: inout AnimationContext<V>
) -> Bool

Full Example: Elastic Ease-In-Out

struct ElasticAnimation: CustomAnimation {
    let duration: TimeInterval

    func animate<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V? {
        guard time <= duration else { return nil }
        let p = time / duration
        let s = sin((20 * p - 11.125) * ((2 * .pi) / 4.5))
        let progress: Double
        if p < 0.5 {
            progress = -(pow(2, 20 * p - 10) * s) / 2
        } else {
            progress = (pow(2, -20 * p + 10) * s) / 2 + 1
        }
        return value.scaled(by: progress)
    }
}

Ergonomic Extension Pattern

Expose custom animations as static members on Animation.

extension Animation {
    static var elastic: Animation {
        elastic(duration: 0.35)
    }

    static func elastic(duration: TimeInterval) -> Animation {
        Animation(ElasticAnimation(duration: duration))
    }
}

// Usage
withAnimation(.elastic(duration: 0.5)) { isActive.toggle() }

Supporting Types

TypeRole
AnimationContext<V>Carries environment and per-animation state
AnimationStateKey-value storage for persisted state
AnimationStateKeyProtocol for defining custom state keys

Spring Type -- All Initializer Variants

Perceptual (Preferred)

Spring(duration: 0.5, bounce: 0.0)
  • duration -- Perceptual duration controlling pace. Default 0.5.
  • bounce -- Bounciness. 0.0 = no bounce, 1.0 = undamped. Negative values produce overdamped springs. Default 0.0.

Physical Parameters

Spring(mass: 1.0, stiffness: 100.0, damping: 10.0, allowOverDamping: false)
  • mass -- Mass at end of spring. Default 1.0.
  • stiffness -- Spring stiffness coefficient.
  • damping -- Friction-like drag force.
  • allowOverDamping -- Permit damping ratio > 1. Default false.

Response-Based

Spring(response: 0.5, dampingRatio: 0.7)
  • response -- Stiffness expressed as approximate duration in seconds.
  • dampingRatio -- Fraction of critical damping. 1.0 = critically damped.

Settling-Based

Spring(settlingDuration: 1.0, dampingRatio: 0.8, epsilon: 0.001)
  • settlingDuration -- Estimated time to come to rest.
  • dampingRatio -- Fraction of critical damping.
  • epsilon -- Threshold for considering the spring at rest. Default 0.001.

Presets

Spring.smooth                                  // no bounce
Spring.smooth(duration: 0.5, extraBounce: 0.0)
Spring.snappy                                  // small bounce
Spring.snappy(duration: 0.4, extraBounce: 0.1)
Spring.bouncy                                  // visible bounce
Spring.bouncy(duration: 0.5, extraBounce: 0.2)

Querying State

let spring = Spring(duration: 0.5, bounce: 0.3)
let v = spring.value(target: 1.0, initialVelocity: 0.0, time: 0.25)
let vel = spring.velocity(target: 1.0, initialVelocity: 0.0, time: 0.25)
let settle = spring.settlingDuration(target: 1.0, initialVelocity: 0.0, epsilon: 0.001)

Parameter Conversion

let spring = Spring(duration: 0.5, bounce: 0.3)
// Access physical equivalents:
spring.mass       // 1.0
spring.stiffness  // 157.9
spring.damping    // 17.6
spring.response
spring.dampingRatio
spring.settlingDuration

UnitCurve Types (iOS 17+)

Map input progress [0,1] to output progress [0,1]. Used with .timingCurve(_:duration:).

Built-in Curves

UnitCurve.linear
UnitCurve.easeIn
UnitCurve.easeOut
UnitCurve.easeInOut
UnitCurve.circularEaseIn
UnitCurve.circularEaseOut
UnitCurve.circularEaseInOut

Custom Bezier Curve

UnitCurve.bezier(
    startControlPoint: UnitPoint(x: 0.42, y: 0.0),
    endControlPoint: UnitPoint(x: 0.58, y: 1.0)
)

Instance Members

let curve = UnitCurve.easeInOut
curve.value(at: 0.5)    // output progress at midpoint
curve.velocity(at: 0.5) // rate of change at midpoint
curve.inverse            // swaps x and y components

Usage with Animation

.animation(.timingCurve(UnitCurve.circularEaseIn, duration: 0.4), value: x)

// Cubic bezier control points
.animation(.timingCurve(0.68, -0.55, 0.27, 1.55, duration: 0.5), value: x)

PhaseAnimator Deep Patterns

Multi-Phase with Complex State

enum LoadPhase: CaseIterable {
    case ready, loading, spinning, complete

    var scale: Double {
        switch self {
        case .ready: 1.0
        case .loading: 0.9
        case .spinning: 1.0
        case .complete: 1.1
        }
    }

    var rotation: Angle {
        switch self {
        case .spinning: .degrees(360)
        default: .zero
        }
    }

    var opacity: Double {
        self == .loading ? 0.7 : 1.0
    }
}

struct LoadingIndicator: View {
    var body: some View {
        PhaseAnimator(LoadPhase.allCases) { phase in
            Image(systemName: "arrow.triangle.2.circlepath")
                .font(.title)
                .scaleEffect(phase.scale)
                .rotationEffect(phase.rotation)
                .opacity(phase.opacity)
        } animation: { phase in
            switch phase {
            case .ready: .smooth(duration: 0.2)
            case .loading: .easeIn(duration: 0.15)
            case .spinning: .linear(duration: 0.6)
            case .complete: .spring(duration: 0.3, bounce: 0.4)
            }
        }
    }
}

Trigger-Based Phase Advance

Each trigger change advances to the next phase. It does not run the full phase array as a one-shot sequence; use KeyframeAnimator or explicit phase state when each tap should play a complete timeline.

struct FeedbackDot: View {
    @State private var feedbackTrigger = 0

    var body: some View {
        Button { feedbackTrigger += 1 } label: {
            Circle()
                .frame(width: 20, height: 20)
                .phaseAnimator(
                    [false, true],
                    trigger: feedbackTrigger
                ) { content, phase in
                    content.scaleEffect(phase ? 1.5 : 1.0)
                } animation: { _ in
                    .spring(duration: 0.25, bounce: 0.5)
                }
        }
        .buttonStyle(.plain)
    }
}

View Modifier Form

Text("Hello")
    .phaseAnimator([0.0, 1.0, 0.0]) { content, phase in
        content.opacity(phase)
    } animation: { _ in .easeInOut(duration: 0.8) }

KeyframeAnimator Multi-Track Examples

Bounce-and-Fade

struct BounceValues {
    var yOffset: Double = 0
    var scale: Double = 1.0
    var opacity: Double = 1.0
    var rotation: Angle = .zero
}

struct BouncingBadge: View {
    @State private var trigger = false

    var body: some View {
        Button { trigger.toggle() } label: {
            Text("NEW")
                .font(.caption.bold())
                .padding(.horizontal)
                .background(.red, in: Capsule())
                .keyframeAnimator(
                    initialValue: BounceValues(),
                    trigger: trigger
                ) { content, value in
                    content
                        .offset(y: value.yOffset)
                        .scaleEffect(value.scale)
                        .opacity(value.opacity)
                        .rotationEffect(value.rotation)
                } keyframes: { _ in
                    KeyframeTrack(\.yOffset) {
                        SpringKeyframe(-20, duration: 0.2)
                        CubicKeyframe(5, duration: 0.15)
                        SpringKeyframe(0, duration: 0.3)
                    }
                    KeyframeTrack(\.scale) {
                        CubicKeyframe(1.3, duration: 0.2)
                        CubicKeyframe(0.95, duration: 0.15)
                        SpringKeyframe(1.0, duration: 0.3)
                    }
                    KeyframeTrack(\.rotation) {
                        LinearKeyframe(.degrees(-5), duration: 0.1)
                        LinearKeyframe(.degrees(5), duration: 0.1)
                        SpringKeyframe(.zero, duration: 0.2)
                    }
                    KeyframeTrack(\.opacity) {
                        MoveKeyframe(1.0)
                    }
                }
        }
        .buttonStyle(.plain)
    }
}

Repeating Keyframe Animation

KeyframeAnimator(
    initialValue: PulseValues(),
    repeating: true
) { value in
    Circle()
        .fill(.blue)
        .frame(width: 40, height: 40)
        .scaleEffect(value.scale)
        .opacity(value.opacity)
} keyframes: { _ in
    KeyframeTrack(\.scale) {
        CubicKeyframe(1.3, duration: 0.5)
        CubicKeyframe(1.0, duration: 0.5)
    }
    KeyframeTrack(\.opacity) {
        CubicKeyframe(0.6, duration: 0.5)
        CubicKeyframe(1.0, duration: 0.5)
    }
}

Keyframe Type Reference

TypeInterpolationUse case
LinearKeyframe(value, duration:)Straight line between valuesSteady movement
CubicKeyframe(value, duration:)Cubic bezier curveSmooth easing
SpringKeyframe(value, duration:, spring:)Spring physicsNatural settle
MoveKeyframe(value)Instant jumpReset to value immediately

Swift 6 Sendable Closure Captures

keyframeAnimator content and keyframe closures are @Sendable. In Swift 6, avoid direct reads of @State or @Environment from nested helper closures inside the modifier; capture plain values in body before the modifier or pass them through the animated value model.

KeyframeTimeline for Manual Evaluation

let timeline = KeyframeTimeline(initialValue: AnimValues()) {
    KeyframeTrack(\.scale) {
        CubicKeyframe(1.5, duration: 0.3)
        CubicKeyframe(1.0, duration: 0.4)
    }
}

let totalDuration = timeline.duration
let valueAtHalf = timeline.value(time: totalDuration / 2)

Transaction and TransactionKey

Transaction Basics

A Transaction carries the animation context for a state change. Every withAnimation call creates a transaction internally.

// Explicit transaction
var transaction = Transaction(animation: .spring)
withTransaction(transaction) {
    isExpanded = true
}

Overriding Animations with Transaction

// Remove the incoming transaction animation for this scoped content
SomeView()
    .transaction { transaction in
        transaction.animation = nil
    }

// Override the scoped transaction animation when a value changes
SomeView()
    .transaction(value: selectedTab) { transaction in
        transaction.animation = .smooth(duration: 0.3)
    }

Custom TransactionKey

Store custom metadata in transactions.

struct IsInteractiveKey: TransactionKey {
    static let defaultValue = false
}

extension Transaction {
    var isInteractive: Bool {
        get { self[IsInteractiveKey.self] }
        set { self[IsInteractiveKey.self] = newValue }
    }
}

// Usage
var transaction = Transaction(animation: .interactiveSpring)
transaction.isInteractive = true
withTransaction(transaction) { dragOffset = newOffset }

Scoped Transaction Override

// Apply transaction only within a body closure
ParentView()
    .transaction { $0.animation = .spring } body: { content in
        content.scaleEffect(scale)
    }

Scoped Implicit Animation

Use .animation(_:body:) when only selected modifiers should animate. Use .animation(_:value:) when a single value change should drive the view's animatable modifiers together. Use .transaction(_:body:) when you need to scope transaction overrides rather than attach one animation.

CardView(isExpanded: isExpanded)
    .animation(.smooth) { content in
        content
            .scaleEffect(isExpanded ? 1.05 : 1.0)
            .shadow(radius: isExpanded ? 12 : 4)
    }

All Transition Types (iOS 17+)

Built-in Transitions

TransitionDescriptionExample
.opacityFade in/out.transition(.opacity)
.slideSlide from leading, exit trailing.transition(.slide)
.scaleScale from zero.transition(.scale)
.scale(_:anchor:)Scale with amount and anchor.transition(.scale(0.5, anchor: .bottom))
.move(edge:)Move from specified edge.transition(.move(edge: .top))
.push(from:)Push from edge with fade.transition(.push(from: .trailing))
.offset(_:)Offset by CGSize.transition(.offset(CGSize(width: 0, height: 50)))
.offset(x:y:)Offset by x and y.transition(.offset(x: 0, y: -100))
.identityNo visual change.transition(.identity)
.blurReplaceBlur and scale combined.transition(.blurReplace)
.blurReplace(_:)Configurable blur replace.transition(.blurReplace(.downUp))
.symbolEffectDefault symbol effect.transition(.symbolEffect)
.symbolEffect(_:options:)Custom symbol effect.transition(.symbolEffect(.appear))

Combining Transitions

// Slide + fade
.transition(.slide.combined(with: .opacity))

// Move from top + scale
.transition(.move(edge: .top).combined(with: .scale))

Asymmetric Transitions

Different animation for insertion vs removal.

.transition(.asymmetric(
    insertion: .push(from: .bottom).combined(with: .opacity),
    removal: .scale.combined(with: .opacity)
))

Custom Transition

struct RotateTransition: Transition {
    func body(content: Content, phase: TransitionPhase) -> some View {
        content
            .rotationEffect(phase.isIdentity ? .zero : .degrees(90))
            .opacity(phase.isIdentity ? 1 : 0)
    }
}

extension AnyTransition {
    static var rotate: AnyTransition {
        .init(RotateTransition())
    }
}

TransitionPhase

enum TransitionPhase {
    case willAppear   // View is about to be inserted
    case identity     // View is fully presented
    case didDisappear // View is being removed
}

// Check current phase
phase.isIdentity  // true when fully presented

Attaching Animation to Transition

.transition(
    .move(edge: .bottom)
        .combined(with: .opacity)
        .animation(.spring(duration: 0.4, bounce: 0.2))
)

All Symbol Effect Types

Availability: .bounce, .pulse, .variableColor, .scale, .appear, .disappear, and .replace are iOS 17+. .wiggle, .breathe, and .rotate are iOS 18+.

Discrete Effects (trigger with value:)

EffectAvailabilityScopeDirection
.bounceiOS 17+.byLayer, .wholeSymbol--
.wiggleiOS 18+.byLayer, .wholeSymbol.up, .down, .left, .right, .forward, .backward, .clockwise, .counterClockwise, .custom(angle:)
Image(systemName: "bell.fill")
    .symbolEffect(.bounce.byLayer, value: count)

// iOS 18+
Image(systemName: "arrow.left.arrow.right")
    .symbolEffect(.wiggle.left, value: swapCount)

Indefinite Effects (toggle with isActive:)

EffectAvailabilityScopeDirection
.pulseiOS 17+.byLayer, .wholeSymbol--
.variableColoriOS 17+.byLayer, .wholeSymbolChaining: .cumulative/.iterative, .reversing/.nonReversing, .dimInactiveLayers/.hideInactiveLayers
.scaleiOS 17+.byLayer, .wholeSymbol.up, .down
.breatheiOS 18+.byLayer, .wholeSymbol--
.rotateiOS 18+.byLayer, .wholeSymbol.clockwise, .counterClockwise
Image(systemName: "wifi")
    .symbolEffect(.pulse.byLayer, isActive: isConnecting)

// iOS 18+
Image(systemName: "gear")
    .symbolEffect(.rotate.clockwise, isActive: isProcessing)

Image(systemName: "speaker.wave.3.fill")
    .symbolEffect(
        .variableColor.cumulative.nonReversing.dimInactiveLayers,
        options: .repeating,
        isActive: isPlaying
    )

Image(systemName: "magnifyingglass")
    .symbolEffect(.scale.up, isActive: isHighlighted)

// iOS 18+
Image(systemName: "heart.fill")
    .symbolEffect(.breathe, isActive: isFavorite)

Transition Effects (appear/disappear)

Image(systemName: "checkmark.circle.fill")
    .symbolEffect(.appear, isActive: showCheck)

Image(systemName: "xmark.circle")
    .symbolEffect(.disappear, isActive: shouldHide)

Content Transition Effects (replace)

Image(systemName: isMuted ? "speaker.slash" : "speaker.wave.3")
    .contentTransition(.symbolEffect(.replace.downUp))

// Magic replace (iOS 18+, morphs between symbols)
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
    .contentTransition(.symbolEffect(.replace.magic(fallback: .downUp)))

Replace directions: .downUp, .offUp, .upUp.

SymbolEffectOptions

.symbolEffect(.pulse, options: .default, isActive: true)
.symbolEffect(.bounce, options: .repeating, value: count)
.symbolEffect(.pulse, options: .nonRepeating, isActive: true)
.symbolEffect(.bounce, options: .repeat(3), value: count)
.symbolEffect(.pulse, options: .speed(2.0), isActive: true)

// RepeatBehavior
.symbolEffect(.bounce, options: .repeat(.periodic(3, delay: 0.5)), value: count)
.symbolEffect(.pulse, options: .repeat(.continuous), isActive: true)

Removing Effects

Image(systemName: "star.fill")
    .symbolEffect(.pulse, isActive: true)
    .symbolEffectsRemoved(reduceMotion)

Reduce Motion Implementation Patterns

Environment Variable

@Environment(\.accessibilityReduceMotion) private var reduceMotion

Pattern 1: Conditional Animation

withAnimation(reduceMotion ? .none : .bouncy) {
    isExpanded.toggle()
}

Pattern 2: Simplified Animation

Replace bouncy/spring with crossfade when reduce motion is on.

withAnimation(reduceMotion ? .easeInOut(duration: 0.2) : .spring(duration: 0.4, bounce: 0.3)) {
    selectedTab = newTab
}

Pattern 3: Disable Repeating Animations

// WRONG: Ignores reduce motion
PhaseAnimator(phases) { phase in /* ... */ }

// CORRECT: Use trigger-based or skip entirely
if !reduceMotion {
    PhaseAnimator(phases) { phase in /* ... */ }
} else {
    StaticView()
}

Pattern 4: Symbol Effects

Image(systemName: "wifi")
    .symbolEffect(.pulse, isActive: isSearching)
    .symbolEffectsRemoved(reduceMotion)

Pattern 5: Reusable Helper

extension Animation {
    static func adaptive(
        _ animation: Animation,
        reduceMotion: Bool
    ) -> Animation? {
        reduceMotion ? nil : animation
    }
}

// Usage
withAnimation(.adaptive(.bouncy, reduceMotion: reduceMotion)) {
    isVisible = true
}

Animation Performance Tips

Keep Content Closures Light

The content closure in KeyframeAnimator and PhaseAnimator runs every frame while animating. Keep it to simple view modifiers.

// WRONG: Expensive computation per frame
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
    let result = heavyComputation(value.progress)
    return content.opacity(result)
} keyframes: { _ in /* ... */ }

// CORRECT: Only apply view modifiers
.keyframeAnimator(initialValue: v, trigger: t) { content, value in
    content.opacity(value.opacity)
} keyframes: { _ in /* ... */ }

Prefer Modifier-Based Animations

Animating view modifiers (opacity, scaleEffect, offset, rotationEffect) is highly optimized. Avoid animating layout-triggering properties when possible.

Use drawingGroup for Complex Compositing

ComplexAnimatedView()
    .drawingGroup()

Flattens the view hierarchy into a single Metal-backed layer. Use when compositing many overlapping animated views.

Limit Concurrent Animations

Avoid animating dozens of views simultaneously. Use staggered delays.

ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
    ItemView(item: item)
        .transition(.move(edge: .bottom).combined(with: .opacity))
        .animation(.spring.delay(Double(index) * 0.05), value: isVisible)
}

Avoid Re-creating Views During Animation

Ensure animated views maintain stable identity. Use explicit id() modifiers or stable ForEach identifiers.

// WRONG: View identity changes, breaks animation
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
    ItemView(item: item)
}

// CORRECT: Stable identity from model
ForEach(items) { item in
    ItemView(item: item)
}

Use geometryGroup() for Nested Geometry

Isolate child geometry from parent animations when they conflict.

ParentView()
    .scaleEffect(parentScale)
    .geometryGroup()  // children see stable geometry

Layout-Driven Height Changes

transition describes child insertion/removal, but a List row's changing height may still snap instead of interpolate. Keep this skill focused on the animation trigger, curve, and Reduce Motion behavior; route custom row-height, grid, or layout interpolation work to swiftui-layout-components.

Transaction for Selective Animation Override

Override animation for specific subtrees without affecting siblings.

// Disable animation on one child while parent animates
ChildView()
    .transaction { $0.animation = nil }

Profile with Instruments

Use the Core Animation instrument in Xcode Instruments to verify:

  • The chosen sustainable target frame rate has no avoidable dropped frames; refresh rates are system-managed hints, not guarantees.
  • No offscreen rendering passes.
  • GPU utilization stays reasonable during animations.

skills

.mcp.json

README.md

tile.json