CtrlK
BlogDocsLog inGet started
Tessl Logo

thiennc-tesoglobal/ios-skills

Community-maintained Agent Skills for complete Swift and Apple-platform app delivery.

72

Quality

90%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Medium

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))
)

skills

.mcp.json

README.md

tile.json