animations-transitions

SwiftUI Animations and Transitions

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "animations-transitions" with this command: npx skills add bluewaves-creations/bluewaves-skills/bluewaves-creations-bluewaves-skills-animations-transitions

SwiftUI Animations and Transitions

Comprehensive guide to SwiftUI animations, the new @Animatable macro (iOS 26), transitions, and motion design best practices for modern iOS development.

Prerequisites

  • iOS 17+ for PhaseAnimator/KeyframeAnimator

  • iOS 26+ for @Animatable macro

  • Xcode 26+

@Animatable Macro (iOS 26 - NEW!)

The Revolution in Custom Animations

iOS 26 introduces the @Animatable macro, eliminating the tedious boilerplate previously required for animating custom shapes and views.

Before iOS 26 (Manual Approach)

// OLD WAY - Lots of boilerplate struct PieSlice: Shape { var startAngle: Angle var endAngle: Angle

// Manual animatableData implementation required
var animatableData: AnimatablePair<Double, Double> {
    get {
        AnimatablePair(startAngle.radians, endAngle.radians)
    }
    set {
        startAngle = Angle(radians: newValue.first)
        endAngle = Angle(radians: newValue.second)
    }
}

func path(in rect: CGRect) -> Path {
    var path = Path()
    let center = CGPoint(x: rect.midX, y: rect.midY)
    let radius = min(rect.width, rect.height) / 2
    path.move(to: center)
    path.addArc(center: center, radius: radius,
                startAngle: startAngle, endAngle: endAngle,
                clockwise: false)
    path.closeSubpath()
    return path
}

}

After iOS 26 (With @Animatable)

// NEW WAY - Just add @Animatable @Animatable struct PieSlice: Shape { var startAngle: Angle var endAngle: Angle

func path(in rect: CGRect) -> Path {
    var path = Path()
    let center = CGPoint(x: rect.midX, y: rect.midY)
    let radius = min(rect.width, rect.height) / 2
    path.move(to: center)
    path.addArc(center: center, radius: radius,
                startAngle: startAngle, endAngle: endAngle,
                clockwise: false)
    path.closeSubpath()
    return path
}

}

@AnimatableIgnored

Exclude properties from animation:

@Animatable struct CustomShape: Shape { var animatedValue: CGFloat @AnimatableIgnored var staticConfiguration: Bool // Not animated

func path(in rect: CGRect) -> Path {
    // Use both values, but only animatedValue will animate
}

}

Supported Types for Animation

The @Animatable macro automatically handles:

  • CGFloat , Double , Float

  • Angle

  • CGSize , CGPoint , CGRect

  • UnitPoint

  • Color (component interpolation)

  • Custom types conforming to VectorArithmetic

Complex Example with Multiple Properties

@Animatable struct MorphingShape: Shape { var cornerRadius: CGFloat var insetAmount: CGFloat var rotation: Angle @AnimatableIgnored var fillColor: Color

func path(in rect: CGRect) -> Path {
    let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount)
    var path = Path(roundedRect: insetRect, cornerRadius: cornerRadius)

    let transform = CGAffineTransform(rotationAngle: rotation.radians)
    return path.applying(transform)
}

}

// Usage struct MorphingView: View { @State private var isExpanded = false

var body: some View {
    MorphingShape(
        cornerRadius: isExpanded ? 50 : 10,
        insetAmount: isExpanded ? 20 : 50,
        rotation: isExpanded ? .degrees(45) : .zero,
        fillColor: .blue
    )
    .fill(.blue)
    .frame(width: 200, height: 200)
    .onTapGesture {
        withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
            isExpanded.toggle()
        }
    }
}

}

withAnimation

Basic Usage

struct ContentView: View { @State private var isExpanded = false

var body: some View {
    VStack {
        Rectangle()
            .frame(width: isExpanded ? 200 : 100,
                   height: isExpanded ? 200 : 100)

        Button("Toggle") {
            withAnimation {
                isExpanded.toggle()
            }
        }
    }
}

}

With Animation Type

withAnimation(.spring(duration: 0.5, bounce: 0.3)) { isExpanded.toggle() }

withAnimation(.easeInOut(duration: 0.3)) { opacity = 1.0 }

withAnimation(.linear(duration: 1.0)) { progress = 1.0 }

Completion Handler (iOS 17+)

withAnimation(.easeInOut(duration: 0.5)) { showDetails = true } completion: { // Called when animation completes print("Animation finished") fetchMoreData() }

Nested Animations with Different Timings

Button("Animate") { withAnimation(.spring(duration: 0.4)) { isExpanded = true }

withAnimation(.easeOut(duration: 0.6).delay(0.2)) {
    opacity = 1.0
}

}

Animation Types

Built-in Animations

// Linear - constant speed .linear(duration: 0.3)

// Ease variants - acceleration/deceleration .easeIn(duration: 0.3) // Slow start .easeOut(duration: 0.3) // Slow end .easeInOut(duration: 0.3) // Slow both

// Default (ease in/out) .default

Spring Animations (Modern)

// Modern spring with duration and bounce .spring(duration: 0.5, bounce: 0.3)

// Bounce values: // 0.0 = no bounce (critically damped) // 0.5 = medium bounce // 1.0 = maximum bounce (never settles)

// Extra bounce parameter for overshoot .spring(duration: 0.5, bounce: 0.4, blendDuration: 0.2)

Spring Presets

// Bouncy - playful, energetic .bouncy .bouncy(duration: 0.4, extraBounce: 0.1)

// Snappy - quick, responsive .snappy .snappy(duration: 0.3, extraBounce: 0.05)

// Smooth - gentle, elegant .smooth .smooth(duration: 0.5, extraBounce: 0.0)

Interactive Spring

// For gesture-driven animations .interactiveSpring() .interactiveSpring(response: 0.3, dampingFraction: 0.7)

// Best for drag gestures .interactiveSpring(response: 0.15, dampingFraction: 0.86, blendDuration: 0.25)

Custom Timing Curves

// Bezier curve timing .timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.5)

// Parameters: (x1, y1, x2, y2) // Start: (0, 0), End: (1, 1) // Control points define the curve shape

Animation Modifiers

// Delay before starting .spring().delay(0.2)

// Speed multiplier .spring().speed(2.0) // 2x faster

// Repeat .linear(duration: 1.0).repeatCount(3) .linear(duration: 1.0).repeatForever()

// Autoreverse .easeInOut(duration: 0.5).repeatForever(autoreverses: true)

Explicit Animation Modifier

Preferred Approach

struct ContentView: View { @State private var scale = 1.0

var body: some View {
    Circle()
        .scaleEffect(scale)
        // Explicit: animate only when scale changes
        .animation(.spring, value: scale)
        .onTapGesture {
            scale = scale == 1.0 ? 1.5 : 1.0
        }
}

}

Why Explicit is Better

// AVOID: Implicit animation (animates everything) Circle() .animation(.spring) // Deprecated warning in newer iOS

// PREFER: Explicit animation (precise control) Circle() .animation(.spring, value: specificValue)

Multiple Explicit Animations

struct MultiAnimatedView: View { @State private var scale = 1.0 @State private var opacity = 1.0 @State private var rotation = 0.0

var body: some View {
    Rectangle()
        .scaleEffect(scale)
        .opacity(opacity)
        .rotationEffect(.degrees(rotation))
        // Different animations for different properties
        .animation(.bouncy, value: scale)
        .animation(.easeOut(duration: 0.2), value: opacity)
        .animation(.spring(duration: 1.0), value: rotation)
}

}

Transitions

Basic Transitions

struct ContentView: View { @State private var showDetail = false

var body: some View {
    VStack {
        if showDetail {
            DetailView()
                .transition(.slide)
        }

        Button("Toggle") {
            withAnimation {
                showDetail.toggle()
            }
        }
    }
}

}

Built-in Transitions

.transition(.opacity) // Fade in/out .transition(.scale) // Scale from center .transition(.scale(scale: 0.5)) // Scale from 50% .transition(.slide) // Slide from leading edge .transition(.move(edge: .top)) // Move from specific edge .transition(.push(from: .bottom)) // Push with replacement .transition(.offset(x: 100, y: 0)) // Custom offset

Combined Transitions

// Combine multiple transitions .transition(.scale.combined(with: .opacity))

// Chain combinations .transition( .scale(scale: 0.8) .combined(with: .opacity) .combined(with: .offset(y: 20)) )

Asymmetric Transitions

// Different transitions for insert vs removal .transition(.asymmetric( insertion: .scale.combined(with: .opacity), removal: .slide ))

// Common pattern: slide in from one side, out the other .transition(.asymmetric( insertion: .push(from: .trailing), removal: .push(from: .leading) ))

Custom Transitions

extension AnyTransition { static var flipFromBottom: AnyTransition { .modifier( active: FlipModifier(angle: -90), identity: FlipModifier(angle: 0) ) } }

struct FlipModifier: ViewModifier { let angle: Double

func body(content: Content) -> some View {
    content
        .rotation3DEffect(
            .degrees(angle),
            axis: (x: 1, y: 0, z: 0)
        )
        .opacity(angle == 0 ? 1 : 0)
}

}

// Usage DetailView() .transition(.flipFromBottom)

Phase Animator (iOS 17+)

Basic Usage

enum AnimationPhase: CaseIterable { case initial case middle case final

var scale: CGFloat {
    switch self {
    case .initial: return 1.0
    case .middle: return 1.2
    case .final: return 1.0
    }
}

var opacity: Double {
    switch self {
    case .initial: return 1.0
    case .middle: return 0.5
    case .final: return 1.0
    }
}

}

struct PulsingView: View { var body: some View { PhaseAnimator(AnimationPhase.allCases) { phase in Circle() .fill(.blue) .scaleEffect(phase.scale) .opacity(phase.opacity) } } }

Triggered Animation

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

var body: some View {
    VStack {
        PhaseAnimator(
            AnimationPhase.allCases,
            trigger: trigger
        ) { phase in
            Star()
                .scaleEffect(phase.scale)
                .rotationEffect(.degrees(phase.rotation))
        }

        Button("Animate") {
            trigger.toggle()
        }
    }
}

}

Custom Animation Per Phase

PhaseAnimator(AnimationPhase.allCases) { phase in ContentView(phase: phase) } animation: { phase in switch phase { case .initial: .spring(duration: 0.3) case .middle: .easeOut(duration: 0.2) case .final: .bouncy } }

Keyframe Animator (iOS 17+)

Basic Keyframe Animation

struct AnimationValues { var scale = 1.0 var rotation = 0.0 var yOffset = 0.0 }

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

var body: some View {
    Circle()
        .fill(.blue)
        .frame(width: 100, height: 100)
        .keyframeAnimator(
            initialValue: AnimationValues(),
            trigger: trigger
        ) { content, value in
            content
                .scaleEffect(value.scale)
                .rotationEffect(.degrees(value.rotation))
                .offset(y: value.yOffset)
        } keyframes: { _ in
            KeyframeTrack(\.scale) {
                SpringKeyframe(1.2, duration: 0.2)
                SpringKeyframe(0.9, duration: 0.15)
                SpringKeyframe(1.0, duration: 0.15)
            }

            KeyframeTrack(\.rotation) {
                LinearKeyframe(0, duration: 0.1)
                SpringKeyframe(10, duration: 0.15)
                SpringKeyframe(-10, duration: 0.15)
                SpringKeyframe(0, duration: 0.1)
            }

            KeyframeTrack(\.yOffset) {
                SpringKeyframe(-30, duration: 0.2)
                SpringKeyframe(0, duration: 0.3)
            }
        }
        .onTapGesture {
            trigger.toggle()
        }
}

}

Keyframe Types

KeyframeTrack(.value) { // Linear interpolation LinearKeyframe(targetValue, duration: 0.3)

// Spring-based interpolation
SpringKeyframe(targetValue, duration: 0.3)
SpringKeyframe(targetValue, duration: 0.3, spring: .bouncy)

// Cubic bezier interpolation
CubicKeyframe(targetValue, duration: 0.3)

// Move without animation
MoveKeyframe(targetValue)

}

Complex Multi-Track Animation

struct ComplexAnimationValues { var xOffset = 0.0 var yOffset = 0.0 var scale = 1.0 var opacity = 1.0 var blur = 0.0 }

struct ComplexAnimation: View { @State private var animating = false

var body: some View {
    Image(systemName: "star.fill")
        .font(.system(size: 50))
        .keyframeAnimator(
            initialValue: ComplexAnimationValues(),
            repeating: animating
        ) { content, value in
            content
                .offset(x: value.xOffset, y: value.yOffset)
                .scaleEffect(value.scale)
                .opacity(value.opacity)
                .blur(radius: value.blur)
        } keyframes: { _ in
            KeyframeTrack(\.xOffset) {
                LinearKeyframe(0, duration: 0.25)
                LinearKeyframe(100, duration: 0.5)
                LinearKeyframe(100, duration: 0.25)
                LinearKeyframe(0, duration: 0.5)
            }

            KeyframeTrack(\.yOffset) {
                SpringKeyframe(-50, duration: 0.5)
                SpringKeyframe(0, duration: 0.5)
            }

            KeyframeTrack(\.scale) {
                SpringKeyframe(1.5, duration: 0.25)
                SpringKeyframe(1.0, duration: 0.25)
                SpringKeyframe(1.2, duration: 0.25)
                SpringKeyframe(1.0, duration: 0.25)
            }
        }
        .onAppear {
            animating = true
        }
}

}

Matched Geometry Effect

Hero Transitions

struct HeroTransition: View { @Namespace private var animation @State private var isExpanded = false

var body: some View {
    VStack {
        if isExpanded {
            // Expanded state
            RoundedRectangle(cornerRadius: 20)
                .fill(.blue)
                .matchedGeometryEffect(id: "card", in: animation)
                .frame(width: 300, height: 400)
        } else {
            // Collapsed state
            RoundedRectangle(cornerRadius: 10)
                .fill(.blue)
                .matchedGeometryEffect(id: "card", in: animation)
                .frame(width: 100, height: 100)
        }
    }
    .onTapGesture {
        withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
            isExpanded.toggle()
        }
    }
}

}

Tab Bar Selection Indicator

struct TabBar: View { @Namespace private var animation @State private var selectedTab = 0 let tabs = ["Home", "Search", "Profile"]

var body: some View {
    HStack {
        ForEach(Array(tabs.enumerated()), id: \.offset) { index, title in
            Button(title) {
                withAnimation(.spring(duration: 0.3)) {
                    selectedTab = index
                }
            }
            .padding()
            .background {
                if selectedTab == index {
                    Capsule()
                        .fill(.blue.opacity(0.2))
                        .matchedGeometryEffect(id: "background", in: animation)
                }
            }
        }
    }
}

}

Properties for Matched Geometry

.matchedGeometryEffect( id: "identifier", in: namespace, properties: .frame, // What to match: .frame, .position, .size anchor: .center, // Anchor point for matching isSource: true // Whether this is the source of truth )

Content Transition

Text Morphing

struct CounterView: View { @State private var count = 0

var body: some View {
    Text("\(count)")
        .font(.largeTitle)
        .contentTransition(.numericText())
        .onTapGesture {
            withAnimation {
                count += 1
            }
        }
}

}

Available Content Transitions

// Numeric text morphing .contentTransition(.numericText()) .contentTransition(.numericText(value: count)) .contentTransition(.numericText(countsDown: true))

// Interpolate between text .contentTransition(.interpolate)

// Identity (no transition) .contentTransition(.identity)

// Opacity crossfade .contentTransition(.opacity)

// Symbol effect .contentTransition(.symbolEffect(.replace))

Symbol Effects

SF Symbol Animations

struct SymbolEffectsView: View { @State private var isActive = false

var body: some View {
    VStack(spacing: 30) {
        // Bounce effect
        Image(systemName: "bell.fill")
            .symbolEffect(.bounce, value: isActive)

        // Pulse effect
        Image(systemName: "heart.fill")
            .symbolEffect(.pulse)

        // Variable color
        Image(systemName: "wifi")
            .symbolEffect(.variableColor.iterative)

        // Scale effect
        Image(systemName: "star.fill")
            .symbolEffect(.scale.up, isActive: isActive)

        // Replace effect
        Image(systemName: isActive ? "checkmark.circle" : "circle")
            .contentTransition(.symbolEffect(.replace))

        Button("Toggle") {
            withAnimation {
                isActive.toggle()
            }
        }
    }
    .font(.largeTitle)
}

}

Symbol Effect Options

// Bounce variations .symbolEffect(.bounce) .symbolEffect(.bounce.up) .symbolEffect(.bounce.down) .symbolEffect(.bounce.byLayer) .symbolEffect(.bounce.wholeSymbol)

// Variable color .symbolEffect(.variableColor) .symbolEffect(.variableColor.iterative) .symbolEffect(.variableColor.reversing) .symbolEffect(.variableColor.cumulative)

// Scale .symbolEffect(.scale.up) .symbolEffect(.scale.down)

// Pulse .symbolEffect(.pulse) .symbolEffect(.pulse.byLayer)

Interactive Animations

Drag Gesture Animation

struct DraggableCard: View { @State private var offset = CGSize.zero @State private var isDragging = false

var body: some View {
    RoundedRectangle(cornerRadius: 20)
        .fill(.blue)
        .frame(width: 200, height: 300)
        .offset(offset)
        .scaleEffect(isDragging ? 1.05 : 1.0)
        .animation(.interactiveSpring(response: 0.3), value: isDragging)
        .gesture(
            DragGesture()
                .onChanged { value in
                    offset = value.translation
                    isDragging = true
                }
                .onEnded { value in
                    isDragging = false
                    withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
                        offset = .zero
                    }
                }
        )
}

}

Gesture State for Smooth Tracking

struct SmoothDrag: View { @GestureState private var dragOffset = CGSize.zero @State private var position = CGSize.zero

var body: some View {
    Circle()
        .fill(.blue)
        .frame(width: 100, height: 100)
        .offset(
            x: position.width + dragOffset.width,
            y: position.height + dragOffset.height
        )
        .animation(.interactiveSpring(), value: dragOffset)
        .gesture(
            DragGesture()
                .updating($dragOffset) { value, state, _ in
                    state = value.translation
                }
                .onEnded { value in
                    position.width += value.translation.width
                    position.height += value.translation.height
                }
        )
}

}

Velocity-Based Animation

struct VelocityDrag: View { @State private var offset = CGSize.zero

var body: some View {
    RoundedRectangle(cornerRadius: 20)
        .fill(.blue)
        .frame(width: 200, height: 300)
        .offset(offset)
        .gesture(
            DragGesture()
                .onChanged { value in
                    offset = value.translation
                }
                .onEnded { value in
                    // Use velocity for natural-feeling spring back
                    let velocity = CGSize(
                        width: value.predictedEndTranslation.width - value.translation.width,
                        height: value.predictedEndTranslation.height - value.translation.height
                    )

                    withAnimation(.spring(
                        response: 0.5,
                        dampingFraction: 0.7,
                        blendDuration: 0
                    )) {
                        offset = .zero
                    }
                }
        )
}

}

Scroll Animations

Scroll Transition (iOS 17+)

struct ScrollTransitionView: View { var body: some View { ScrollView { LazyVStack(spacing: 20) { ForEach(0..<20) { index in RoundedRectangle(cornerRadius: 12) .fill(.blue.gradient) .frame(height: 100) .scrollTransition { content, phase in content .opacity(phase.isIdentity ? 1 : 0.5) .scaleEffect(phase.isIdentity ? 1 : 0.9) .blur(radius: phase.isIdentity ? 0 : 2) } } } .padding() } } }

Visual Effect Modifier (iOS 17+)

struct ParallaxScroll: View { var body: some View { ScrollView { LazyVStack(spacing: 0) { ForEach(0..<10) { index in Image("photo(index)") .resizable() .aspectRatio(contentMode: .fill) .frame(height: 300) .clipped() .visualEffect { content, proxy in content .offset(y: parallaxOffset(proxy)) } } } } }

func parallaxOffset(_ proxy: GeometryProxy) -> CGFloat {
    let frame = proxy.frame(in: .scrollView)
    return -frame.minY * 0.3
}

}

Performance Best Practices

  1. Use Explicit Animations

// GOOD: Explicit animation tied to specific value .animation(.spring, value: isExpanded)

// AVOID: Implicit animation (deprecated, less performant) .animation(.spring)

  1. Animate Efficiently

// GOOD: Animate transforms (GPU-accelerated) .scaleEffect(scale) .rotationEffect(.degrees(rotation)) .offset(x: offsetX, y: offsetY) .opacity(opacity)

// AVOID: Animating layout-affecting properties when possible // (These trigger re-layout each frame) .frame(width: animatedWidth) .padding(animatedPadding)

  1. Limit Animation Scope

// GOOD: Animation on specific view ChildView() .animation(.spring, value: childState)

// AVOID: Animation on parent affecting all children ParentView() .animation(.spring, value: anyChange) // All children animate

  1. Use drawingGroup for Complex Graphics

// For complex composited views ComplexAnimatedShape() .drawingGroup() // Renders to offscreen buffer

  1. Keep Durations Short

// RECOMMENDED: Under 0.4 seconds for UI feedback .spring(duration: 0.3, bounce: 0.2)

// Reserve longer animations for: // - Onboarding flows // - Celebrations // - State transitions

  1. Test on Device

// Simulator timing is NOT accurate // Always test animations on physical device // Different devices have different performance characteristics

Common Patterns

Loading Spinner

struct LoadingSpinner: View { @State private var isAnimating = false

var body: some View {
    Circle()
        .trim(from: 0, to: 0.7)
        .stroke(.blue, lineWidth: 4)
        .frame(width: 40, height: 40)
        .rotationEffect(.degrees(isAnimating ? 360 : 0))
        .animation(
            .linear(duration: 1.0).repeatForever(autoreverses: false),
            value: isAnimating
        )
        .onAppear {
            isAnimating = true
        }
}

}

Pulsing Indicator

struct PulsingDot: View { @State private var isPulsing = false

var body: some View {
    Circle()
        .fill(.green)
        .frame(width: 12, height: 12)
        .scaleEffect(isPulsing ? 1.2 : 1.0)
        .opacity(isPulsing ? 0.6 : 1.0)
        .animation(
            .easeInOut(duration: 0.8).repeatForever(autoreverses: true),
            value: isPulsing
        )
        .onAppear {
            isPulsing = true
        }
}

}

Shake Effect

struct ShakeEffect: GeometryEffect { var amount: CGFloat = 10 var shakesPerUnit = 3 var animatableData: CGFloat

func effectValue(size: CGSize) -> ProjectionTransform {
    ProjectionTransform(CGAffineTransform(translationX:
        amount * sin(animatableData * .pi * CGFloat(shakesPerUnit)),
        y: 0))
}

}

// Usage TextField("Email", text: $email) .modifier(ShakeEffect(animatableData: shakeAmount)) .onChange(of: hasError) { withAnimation(.default) { shakeAmount = hasError ? 1 : 0 } }

Official Resources

  • SwiftUI Animations Documentation

  • Animating Views and Transitions

  • PhaseAnimator Documentation

  • KeyframeAnimator Documentation

  • WWDC23: Wind your way through advanced animations

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

photographer-testino

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lindbergh

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lachapelle

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-vonunwerth

No summary provided by upstream source.

Repository SourceNeeds Review