axiom-swiftui-animation-ref

Use when implementing SwiftUI animations, understanding VectorArithmetic, using @Animatable macro, zoom transitions, UIKit/AppKit animation bridging, choosing between spring and timing curve animations, or debugging animation behavior - comprehensive animation reference from iOS 13 through iOS 26

charleswiltgen/axiom224 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: axiom-swiftui-animation-ref
description: Use when implementing SwiftUI animations, understanding VectorArithmetic, using @Animatable macro, zoom transitions, UIKit/AppKit animation bridging, choosing between spring and timing curve animations, or debugging animation behavior - comprehensive animation reference from iOS 13 through iOS 26
license: MIT
---

# SwiftUI Animation

## Overview

Comprehensive guide to SwiftUI's animation system, from foundational concepts to advanced techniques. This skill covers the Animatable protocol, the iOS 26 @Animatable macro, animation types, and the Transaction system.

**Core principle** Animation in SwiftUI is mathematical interpolation over time, powered by the VectorArithmetic protocol. Understanding this foundation unlocks the full power of SwiftUI's declarative animation system.

## System Requirements

- iOS 13+: Animatable protocol, timing/spring animations
- iOS 17+: Default spring animations, scoped animations, PhaseAnimator, KeyframeAnimator
- iOS 18+: Zoom transitions, UIKit/AppKit animation bridging
- iOS 26+: @Animatable macro

---

## Part 1: Understanding Animation

### What Is Interpolation

Animation is the process of generating intermediate values between a start and end state.

#### Example: Opacity animation

```swift
.opacity(0) → .opacity(1)
```

While this animation runs, SwiftUI computes intermediate values:

```
0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0
```

**How values are distributed**
- Determined by the animation's timing curve or velocity function
- Spring animations use physics simulation
- Timing curves use bezier curves
- Each animation type calculates values differently

### VectorArithmetic Protocol

SwiftUI requires animated data to conform to `VectorArithmetic` — providing subtraction, scaling, addition, and a zero value. This enables SwiftUI to interpolate between any two values.

**Built-in conforming types**: `CGFloat`, `Double`, `Float`, `Angle` (1D), `CGPoint`, `CGSize` (2D), `CGRect` (4D).

**Key insight** Vector arithmetic abstracts over dimensionality. SwiftUI animates all these types with a single generic implementation.

### Why Int Can't Be Animated

`Int` doesn't conform to VectorArithmetic — no fractional intermediates exist between 3 and 4. SwiftUI simply snaps the value.

**Solution**: Use `Float`/`Double` and display as `Int`:

```swift
@State private var count: Float = 0
// ...
Text("\(Int(count))")
    .animation(.spring, value: count)
```

### Model vs Presentation Values

Animatable attributes conceptually have two values:

#### Model Value
- The target value set by your code
- Updated immediately when state changes
- What you write in your view's body

#### Presentation Value
- The current interpolated value being rendered
- Updates frame-by-frame during animation
- What the user actually sees

**Example**

```swift
.scaleEffect(selected ? 1.5 : 1.0)
```

When `selected` becomes `true`:
- **Model value**: Immediately becomes `1.5`
- **Presentation value**: Interpolates `1.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5` over time

---

## Part 2: Animatable Protocol

### Overview

The `Animatable` protocol allows views to animate their properties by defining which data should be interpolated.

```swift
protocol Animatable {
    associatedtype AnimatableData: VectorArithmetic

    var animatableData: AnimatableData { get set }
}
```

SwiftUI builds an animatable attribute for any view conforming to this protocol.

### Built-in Animatable Views

Many SwiftUI modifiers conform to Animatable:

#### Visual Effects
- `.scaleEffect()` — Animates scale transform
- `.rotationEffect()` — Animates rotation
- `.offset()` — Animates position offset
- `.opacity()` — Animates transparency
- `.blur()` — Animates blur radius
- `.shadow()` — Animates shadow properties

#### All Shape types
- `Circle`, `Rectangle`, `RoundedRectangle`
- `Capsule`, `Ellipse`, `Path`
- Custom `Shape` implementations

### AnimatablePair for Multi-Dimensional Data

When animating multiple properties, use `AnimatablePair` to combine vectors. For example, `scaleEffect` combines `CGSize` (2D) and `UnitPoint` (2D) into a 4D vector via `AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData>`. Access components via `.first` and `.second`. The `@Animatable` macro (iOS 26+) eliminates this boilerplate entirely.

### Custom Animatable Conformance

#### When to use
- Animating custom layout (like RadialLayout)
- Animating custom drawing code
- Animating properties that affect shape paths

#### Example: Animated number view

```swift
struct AnimatableNumberView: View, Animatable {
    var number: Double

    var animatableData: Double {
        get { number }
        set { number = newValue }
    }

    var body: some View {
        Text("\(Int(number))")
            .font(.largeTitle)
    }
}

// Usage
AnimatableNumberView(number: value)
    .animation(.spring, value: value)
```

**How it works**
1. `number` changes from 0 to 100
2. SwiftUI calls `body` for every frame of the animation
3. Each frame gets a new `number` value: 0 → 5 → 15 → 30 → 55 → 80 → 100
4. Text updates to show the interpolated integer

### Performance Warning

**Custom Animatable conformance is expensive** — SwiftUI calls `body` for every frame on the main thread. Built-in effects (`.scaleEffect()`, `.opacity()`) run off-main-thread and don't call `body`. Use custom conformance only when built-in modifiers can't achieve the effect (e.g., animating a custom `Layout` that repositions subviews per-frame).

---

## Part 3: @Animatable Macro (iOS 26+)

### Overview

The `@Animatable` macro eliminates the boilerplate of manually conforming to the Animatable protocol.

**Before iOS 26**, you had to:
1. Manually conform to `Animatable`
2. Write `animatableData` getter and setter
3. Use `AnimatablePair` for multiple properties
4. Exclude non-animatable properties manually

**iOS 26+**, you just add `@Animatable`:

```swift
@MainActor
@Animatable
struct MyView: View {
    var scale: CGFloat
    var opacity: Double

    var body: some View {
        // ...
    }
}
```

The macro automatically:
- Generates `Animatable` conformance
- Inspects all stored properties
- Creates `animatableData` from VectorArithmetic-conforming properties
- Handles multi-dimensional data with `AnimatablePair`

### Before/After Comparison

#### Before @Animatable macro

```swift
struct HikingRouteShape: Shape {
    var startPoint: CGPoint
    var endPoint: CGPoint
    var elevation: Double
    var drawingDirection: Bool // Don't want to animate this

    // Tedious manual animatableData declaration
    var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
                        AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
        get {
            AnimatablePair(
                AnimatablePair(startPoint.x, startPoint.y),
                AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
            )
        }
        set {
            startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
            elevation = newValue.second.first
            endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
        }
    }

    func path(in rect: CGRect) -> Path {
        // Drawing code
    }
}
```

#### After @Animatable macro

```swift
@Animatable
struct HikingRouteShape: Shape {
    var startPoint: CGPoint
    var endPoint: CGPoint
    var elevation: Double

    @AnimatableIgnored
    var drawingDirection: Bool // Excluded from animation

    func path(in rect: CGRect) -> Path {
        // Drawing code
    }
}
```

**Lines of code**: 20 → 12 (40% reduction)

### @AnimatableIgnored

Use `@AnimatableIgnored` to exclude properties from animation.

#### When to use
- **Debug values** — Flags for development only
- **IDs** — Identifiers that shouldn't animate
- **Timestamps** — When the view was created/updated
- **Internal state** — Non-visual bookkeeping
- **Non-VectorArithmetic types** — Colors, strings, booleans

#### Example

```swift
@MainActor
@Animatable
struct ProgressView: View {
    var progress: Double // Animated
    var totalItems: Int // Animated (if Float, not if Int)

    @AnimatableIgnored
    var title: String // Not animated

    @AnimatableIgnored
    var startTime: Date // Not animated

    @AnimatableIgnored
    var debugEnabled: Bool // Not animated

    var body: some View {
        VStack {
            Text(title)
            ProgressBar(value: progress)
            if debugEnabled {
                Text("Started: \(startTime.formatted())")
            }
        }
    }
}
```

### Real-World Use Case

@Animatable works for any numeric display — stock prices, heart rate, scores, timers, progress bars:

```swift
@MainActor
@Animatable
struct AnimatedValueView: View {
    var value: Double
    var changePercent: Double

    @AnimatableIgnored
    var label: String

    var body: some View {
        VStack(alignment: .trailing) {
            Text("\(value, format: .number.precision(.fractionLength(2)))")
                .font(.title)
            Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
                .foregroundStyle(changePercent > 0 ? .green : .red)
        }
    }
}

// Usage
AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price")
    .animation(.spring(duration: 0.8), value: currentPrice)
```

---

## Part 4: Animation Types

### Timing Curve Animations

Timing curve animations use bezier curves to control the speed of animation over time.

#### Built-in presets

```swift
.animation(.linear)          // Constant speed
.animation(.easeIn)          // Starts slow, ends fast
.animation(.easeOut)         // Starts fast, ends slow
.animation(.easeInOut)       // Slow start and end, fast middle
```

#### Custom timing curves

```swift
let customCurve = UnitCurve(
    startControlPoint: CGPoint(x: 0.2, y: 0),
    endControlPoint: CGPoint(x: 0.8, y: 1)
)

.animation(.timingCurve(customCurve, duration: 0.5))
```

#### Duration

All timing curve animations accept an optional duration:

```swift
.animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0))
```

**Default**: 0.35 seconds

### Spring Animations

Spring animations use physics simulation to create natural, organic motion.

#### Built-in presets

```swift
.animation(.smooth)     // No bounce (default since iOS 17)
.animation(.snappy)     // Small amount of bounce
.animation(.bouncy)     // Larger amount of bounce
```

#### Custom springs

```swift
.animation(.spring(duration: 0.6, bounce: 0.3))
```

**Parameters**
- `duration` — Perceived animation duration
- `bounce` — Amount of bounce (0 = no bounce, 1 = very bouncy)

**Much more intuitive** than traditional spring parameters (mass, stiffness, damping).

### Higher-Order Animations

Modify base animations to create complex effects.

#### Delay

```swift
.animation(.spring.delay(0.5))
```

Waits 0.5 seconds before starting the animation.

#### Repeat

```swift
.animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false))
```

Repeats the animation multiple times or infinitely.

#### Speed

```swift
.animation(.spring.speed(2.0))  // 2x faster
.animation(.spring.speed(0.5))  // 2x slower
```

Multiplies the animation speed.

### Default Animation Changes (iOS 17+)

**Before iOS 17**
```swift
withAnimation {
    // Used timing curve by default
}
```

**iOS 17+**
```swift
withAnimation {
    // Uses .smooth spring by default
}
```

**Why the change**: Spring animations feel more natural and preserve velocity when interrupted.

**Recommendation**: Embrace springs. They make your UI feel more responsive and polished.

---

## Part 5: Transaction System

### withAnimation

The most common way to trigger an animation.

```swift
Button("Scale Up") {
    withAnimation(.spring) {
        scale = 1.5
    }
}
```

**How it works**
1. `withAnimation` opens a transaction
2. Sets the animation in the transaction dictionary
3. Executes the closure (state changes)
4. Transaction propagates down the view hierarchy
5. Animatable attributes check for animation and interpolate

#### Explicit animation

```swift
withAnimation(.spring(duration: 0.6, bounce: 0.4)) {
    isExpanded.toggle()
}
```

#### No animation

```swift
withAnimation(nil) {
    // Changes happen immediately, no animation
    resetState()
}
```

### animation() View Modifier

Apply animations to specific values within a view.

#### Basic usage

```swift
Circle()
    .fill(isActive ? .blue : .gray)
    .animation(.spring, value: isActive)
```

**How it works**: Animation only applies when `isActive` changes. Other state changes won't trigger this animation.

#### Multiple animations on same view

```swift
Circle()
    .scaleEffect(scale)
    .animation(.bouncy, value: scale)
    .opacity(opacity)
    .animation(.easeInOut, value: opacity)
```

Different animations for different properties.

### Scoped Animations (iOS 17+)

Narrowly scope animations to specific animatable attributes.

#### Problem with old approach

```swift
struct AvatarView: View {
    var selected: Bool

    var body: some View {
        Image("avatar")
            .scaleEffect(selected ? 1.5 : 1.0)
            .animation(.spring, value: selected)
            // ⚠️ If image also changes when selected changes,
            //    image transition gets animated too (accidental)
    }
}
```

#### Solution: Scoped animation

```swift
struct AvatarView: View {
    var selected: Bool

    var body: some View {
        Image("avatar")
            .animation(.spring, value: selected) {
                $0.scaleEffect(selected ? 1.5 : 1.0)
            }
            // ✅ Only scaleEffect animates, image transition doesn't
    }
}
```

**How it works**
- Animation only applies to attributes in the closure
- Other attributes are unaffected
- Prevents accidental animations

### Custom Transaction Keys

Define custom `TransactionKey` types to propagate context through the transaction system. Use `withTransaction` to set values and `.transaction` modifier to read them. This enables applying different animations based on how a state change was triggered (tap vs programmatic).

---

## Part 6: Advanced Topics

### CustomAnimation Protocol

Implement your own animation algorithms.

```swift
protocol CustomAnimation {
    // Calculate current value
    func animate<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V?

    // Optional: Should this animation merge with previous?
    func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool

    // Optional: Current velocity
    func velocity<V: VectorArithmetic>(
        value: V,
        time: TimeInterval,
        context: AnimationContext<V>
    ) -> V?
}
```

#### Example: Linear timing curve

```swift
struct LinearAnimation: CustomAnimation {
    let duration: TimeInterval

    func animate<V: VectorArithmetic>(
        value: V,              // Delta vector: target - current
        time: TimeInterval,
        context: inout AnimationContext<V>
    ) -> V? {
        if time >= duration { return nil }
        return value.scaled(by: time / duration)
    }
}
```

**Critical understanding**: `value` is the **delta vector** (target - current), not the target. Return `nil` when done. SwiftUI adds the scaled delta to the current value automatically.

### Animation Merging Behavior

What happens when a new animation starts before the previous one finishes?

#### Timing curve animations (default: don't merge)

```swift
func shouldMerge(...) -> Bool {
    return false // Default implementation
}
```

**Behavior**: Both animations run together, results are combined additively.

**Example**
- First tap: animate 1.0 → 1.5 (running)
- Second tap (before finish): animate 1.5 → 1.0
- Result: Both animations run, values combine

#### Spring animations (merge and retarget)

```swift
func shouldMerge(...) -> Bool {
    return true // Springs override this
}
```

**Behavior**: New animation incorporates state of previous animation, preserving velocity.

**Example**
- First tap: animate 1.0 → 1.5 with velocity V
- Second tap (before finish): retarget to 1.0, preserving current velocity V
- Result: Smooth transition, no sudden velocity change

**Why springs feel more natural**: They preserve momentum when interrupted.

---

## Part 7: Multi-Step Animations (iOS 17+)

### PhaseAnimator

Cycles through a sequence of phases, applying different modifiers at each phase. Each phase transition is independently animated.

```swift
PhaseAnimator([false, true]) { phase in
    Image(systemName: "star.fill")
        .scaleEffect(phase ? 1.5 : 1.0)
        .opacity(phase ? 1.0 : 0.5)
        .rotationEffect(.degrees(phase ? 360 : 0))
} animation: { phase in
    phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4)
}
```

**How it works**: Begins at first phase, animates to second, then loops. The `animation` closure returns the animation used to transition INTO that phase. Phases can be any `Equatable` type — use an enum for complex multi-step sequences:

```swift
enum PulsePhase: CaseIterable { case idle, expand, contract }

PhaseAnimator(PulsePhase.allCases) { phase in
    Circle()
        .scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0)
}
```

**Trigger**: Add a `trigger` parameter to run the animation only when a value changes (instead of looping continuously).

### KeyframeAnimator

Provides per-property keyframe tracks for precise, timeline-based animations. More control than PhaseAnimator.

```swift
struct AnimationValues {
    var scale: Double = 1.0
    var rotation: Angle = .zero
    var yOffset: Double = 0
}

KeyframeAnimator(initialValue: AnimationValues()) { values in
    Image(systemName: "heart.fill")
        .scaleEffect(values.scale)
        .rotationEffect(values.rotation)
        .offset(y: values.yOffset)
} keyframes: { _ in
    KeyframeTrack(\.scale) {
        SpringKeyframe(1.5, duration: 0.3)
        SpringKeyframe(1.0, duration: 0.3)
    }
    KeyframeTrack(\.rotation) {
        LinearKeyframe(.degrees(15), duration: 0.15)
        LinearKeyframe(.degrees(-15), duration: 0.3)
        LinearKeyframe(.zero, duration: 0.15)
    }
    KeyframeTrack(\.yOffset) {
        CubicKeyframe(-20, duration: 0.3)
        CubicKeyframe(0, duration: 0.3)
    }
}
```

**Keyframe types**: `LinearKeyframe` (constant velocity), `SpringKeyframe` (spring physics), `CubicKeyframe` (bezier curves), `MoveKeyframe` (instant jump, no interpolation).

**vs PhaseAnimator**: Use PhaseAnimator for simple state cycling. Use KeyframeAnimator when different properties need independent timing.

### .transition()

Defines how a view animates when inserted/removed from the view hierarchy.

```swift
if showDetail {
    DetailView()
        .transition(.slide)                          // Slide in/out
        .transition(.scale.combined(with: .opacity)) // Combine transitions
        .transition(.move(edge: .bottom))            // Move from edge
        .transition(.asymmetric(                     // Different in/out
            insertion: .scale.combined(with: .opacity),
            removal: .opacity
        ))
}
```

**Requires animation context** — wrap the state change in `withAnimation` or use `.animation()` modifier. Without animation, the view appears/disappears instantly.

### matchedGeometryEffect

Smoothly animate a view's frame between two positions in the hierarchy. Commonly used for hero transitions and shared element animations.

```swift
@Namespace private var animation

// Source
if !isExpanded {
    RoundedRectangle(cornerRadius: 10)
        .matchedGeometryEffect(id: "card", in: animation)
        .frame(width: 100, height: 100)
}

// Destination
if isExpanded {
    RoundedRectangle(cornerRadius: 20)
        .matchedGeometryEffect(id: "card", in: animation)
        .frame(width: 300, height: 400)
}
```

**Key rules**: Same `id` + same `Namespace` = matched pair. Only one view with a given ID should be `isSource: true` (default) at a time. Wrap state change in `withAnimation` for smooth interpolation.

### contentTransition

Animates changes to text and symbol content within a view (iOS 16+).

```swift
Text(value, format: .number)
    .contentTransition(.numericText(countsDown: value < previous))

Image(systemName: isFavorite ? "heart.fill" : "heart")
    .contentTransition(.symbolEffect(.replace))
```

---

## Part 8: Zoom Transitions (iOS 18+)

### Overview

iOS 18 introduces the zoom transition, where a tapped cell morphs into the incoming view. This transition is continuously interactive—users can grab and drag the view during or after the transition begins.

**Key benefit** In parts of your app where you transition from a large cell, zoom transitions increase visual continuity by keeping the same UI elements on screen across the transition.

### SwiftUI Implementation

Two steps to adopt zoom transitions:

#### Step 1: Declare the transition style on the destination

```swift
NavigationLink {
    BraceletEditor(bracelet)
        .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
    BraceletPreview(bracelet)
}
```

#### Step 2: Mark the source view

```swift
BraceletPreview(bracelet)
    .matchedTransitionSource(id: bracelet.id, in: namespace)
```

#### Complete example

```swift
struct BraceletListView: View {
    @Namespace private var braceletList
    let bracelets: [Bracelet]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
                    ForEach(bracelets) { bracelet in
                        NavigationLink {
                            BraceletEditor(bracelet: bracelet)
                                .navigationTransition(
                                    .zoom(sourceID: bracelet.id, in: braceletList)
                                )
                        } label: {
                            BraceletPreview(bracelet: bracelet)
                        }
                        .matchedTransitionSource(id: bracelet.id, in: braceletList)
                    }
                }
            }
        }
    }
}
```

### UIKit Implementation

Set `preferredTransition = .zoom { context in ... }` on the pushed view controller. The closure returns the source view and is called on both zoom in and zoom out — capture a stable identifier (model object), not a view directly.

### Presentations

Zoom transitions also work with `fullScreenCover` and `sheet`:

```swift
.fullScreenCover(item: $selectedBracelet) { bracelet in
    BraceletEditor(bracelet: bracelet)
        .navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}
```

### Styling the Source View

```swift
.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
    source.cornerRadius(8.0).shadow(radius: 4)
}
```

### Fluid Transition Lifecycle

Push transitions cannot be cancelled — when interrupted, they convert to pop transitions. The view controller always reaches the Appeared state. Don't guard against overlapping transitions; let the system handle them.

---

## Part 9: UIKit/AppKit Animation Bridging (iOS 18+)

### Overview

iOS 18 enables using SwiftUI `Animation` types to animate UIKit and AppKit views. This provides access to the full suite of SwiftUI animations, including custom animations.

### Basic Usage

```swift
// Old way
UIView.animate(withDuration: 0.5, delay: 0,
               usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
    bead.center = endOfBracelet
}

// New way: Use SwiftUI Animation type
UIView.animate(.spring(duration: 0.5)) {
    bead.center = endOfBracelet
}
```

All SwiftUI animations work: `.linear`, `.easeIn/Out`, `.spring`, `.smooth`, `.snappy`, `.bouncy`, `.repeatForever()`, and custom animations.

**Architecture note**: Unlike old UIKit APIs, no `CAAnimation` is generated — presentation values are animated directly.

---

## Part 10: UIViewRepresentable Animation Bridging (iOS 18+)

### The Problem

When wrapping UIKit views in SwiftUI, animations don't automatically bridge:

```swift
struct BeadBoxWrapper: UIViewRepresentable {
    @Binding var isOpen: Bool

    func updateUIView(_ box: BeadBox, context: Context) {
        // ❌ Animation on binding doesn't affect UIKit
        box.lid.center.y = isOpen ? -100 : 100
    }
}

// Usage
BeadBoxWrapper(isOpen: $isOpen)
    .animation(.spring, value: isOpen)  // No effect on UIKit view
```

### The Solution: context.animate()

Use `context.animate()` to bridge SwiftUI animations:

```swift
struct BeadBoxWrapper: UIViewRepresentable {
    @Binding var isOpen: Bool

    func makeUIView(context: Context) -> BeadBox {
        BeadBox()
    }

    func updateUIView(_ box: BeadBox, context: Context) {
        // ✅ Bridges animation from Transaction to UIKit
        context.animate {
            box.lid.center.y = isOpen ? -100 : 100
        }
    }
}
```

### How It Works

1. SwiftUI stores animation info in the current `Transaction`
2. `context.animate()` reads the Transaction's animation
3. Applies that animation to UIView changes in the closure
4. If no animation in Transaction, changes happen immediately (no animation)

### Key Behavior

```swift
context.animate {
    // Changes here
} completion: {
    // Called when animation completes
    // If not animated, called immediately inline
}
```

**Works whether animated or not** — safe to always use this pattern.

### Perfect Synchronization

A single animation running across SwiftUI Views and UIViews runs **perfectly in sync**. This enables seamless mixed hierarchies.

---

## Part 11: Gesture-Driven Animations (iOS 18+)

### Automatic Velocity Preservation

SwiftUI animations automatically preserve velocity through animation merging — no manual velocity calculation needed:

```swift
// UIKit with SwiftUI animations
func handlePan(_ gesture: UIPanGestureRecognizer) {
    switch gesture.state {
    case .changed:
        UIView.animate(.interactiveSpring) {
            bead.center = gesture.location(in: view)
        }
    case .ended:
        UIView.animate(.spring) {  // Inherits velocity automatically
            bead.center = endOfBracelet
        }
    default: break
    }
}

// Pure SwiftUI equivalent
DragGesture()
    .onChanged { value in
        withAnimation(.interactiveSpring) { position = value.location }
    }
    .onEnded { _ in
        withAnimation(.spring) { position = targetPosition }
    }
```

Each `.interactiveSpring` retargets the previous animation, and the final `.spring` inherits the accumulated velocity for smooth deceleration.

---

---

## Troubleshooting

### Property Not Animating

Check in order:
1. **Type conforms to VectorArithmetic?** — `Int` can't animate; use `Double`/`Float`
2. **Animation modifier present?** — Need `.animation(.spring, value: x)` or `withAnimation`
3. **Correct value tracked?** — `.animation(.spring, value: progress)` not `.animation(.spring, value: title)`
4. **View conforms to Animatable?** — Custom views need `@Animatable` (iOS 26+) or manual `animatableData`

### Animation Stuttering

Custom `Animatable` conformance calls `body` every frame on main thread. Use built-in effects (`.opacity()`, `.scaleEffect()`) when possible — they run off-main-thread. Profile with Instruments for complex cases.

### Unexpected Animation Merging

Spring animations merge by default, preserving velocity. Use timing curve animations (`.easeInOut`) if you don't want merging behavior. See **Animation Merging Behavior** section above.

---

## Resources

**WWDC**: 2023-10156, 2023-10157, 2023-10158, 2024-10145, 2025-256

**Docs**: /swiftui/animatable, /swiftui/animation, /swiftui/vectorarithmetic, /swiftui/transaction, /swiftui/view/navigationtransition(_:), /swiftui/view/matchedtransitionsource(id:in:configuration:), /uikit/uiview/animate(_:changes:completion:)

**Skills**: axiom-swiftui-26-ref, axiom-swiftui-nav-ref, axiom-swiftui-performance, axiom-swiftui-debugging, axiom-sf-symbols-ref

More Debugging skills

← All Debugging skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY