DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Animation Transactions Internals (Advanced)

SwiftUI animations feel simple on the surface:

withAnimation {
    state.toggle()
}
Enter fullscreen mode Exit fullscreen mode

But under the hood, a lot more is happening.

SwiftUI doesn’t “animate views” — it propagates animation intent through a transaction system that controls:

  • which changes animate
  • which changes don’t
  • how multiple animations merge
  • how interruptions work
  • how layout, rendering, and state updates stay in sync

If you’ve ever wondered:

  • why some animations don’t run
  • why others unexpectedly animate
  • why animations cancel or jump
  • why nested animations behave strangely

…the answer is almost always transactions.

This post explains how SwiftUI animation transactions really work, and how to control them intentionally.


🧠 What Is a Transaction?

A Transaction is a context object SwiftUI uses to describe how state changes should be applied.

It contains things like:

  • animation (or no animation)
  • animation timing
  • whether animations are disabled
  • how updates propagate

Every SwiftUI update runs inside a transaction — even when you don’t specify one.


🔄 The Core Update Flow

When state changes:

State Mutation
   ↓
Transaction Created
   ↓
Transaction Propagates Down View Tree
   ↓
Views Read Transaction
   ↓
Layout + Render + Animate
Enter fullscreen mode Exit fullscreen mode

Animations are not “attached to views” —
they are instructions carried by the transaction.


🎬 withAnimation Is Just a Transaction Wrapper

This:

withAnimation(.spring()) {
    isExpanded.toggle()
}
Enter fullscreen mode Exit fullscreen mode

Is conceptually equivalent to:

var transaction = Transaction(animation: .spring())
transaction.perform {
    isExpanded.toggle()
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI:

  • creates a transaction
  • attaches the animation
  • applies state changes
  • propagates that animation to all affected views

🧩 Implicit vs Explicit Animations

Implicit animation

Text("Hello")
    .opacity(isVisible ? 1 : 0)
    .animation(.easeInOut, value: isVisible)
Enter fullscreen mode Exit fullscreen mode

This attaches an animation to that view subtree only.

Explicit animation

withAnimation {
    isVisible.toggle()
}
Enter fullscreen mode Exit fullscreen mode

This applies the animation globally to all state changes inside the block.

Rule of thumb:

  • withAnimation → broad, global intent
  • .animation(_, value:) → scoped, local intent

🧱 How Transactions Propagate

Transactions flow top-down.

If a parent view has an animation transaction:

  • all children inherit it
  • unless they override or disable it

Example:

withAnimation(.easeInOut) {
    state.toggle()
}
Enter fullscreen mode Exit fullscreen mode

Every affected view animates — opacity, position, size, layout — unless explicitly stopped.


⛔ Disabling Animations with Transactions

Sometimes you want state updates without animation, even inside an animated context.

Transaction(animation: nil).perform {
    state = newValue
}
Enter fullscreen mode Exit fullscreen mode

Or in a view:

.transaction { transaction in
    transaction.animation = nil
}
Enter fullscreen mode Exit fullscreen mode

This cuts animation propagation at that point in the tree.

Extremely useful for:

  • list updates
  • pagination inserts
  • layout recalculations
  • accessibility updates

🧵 Nested Animations: Who Wins?

withAnimation(.spring()) {
    stateA.toggle()

    withAnimation(.linear) {
        stateB.toggle()
    }
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI resolves this by transaction priority:

  • inner transactions override outer ones
  • but only for the state they mutate

Result:

  • stateA → spring
  • stateB → linear

Transactions are merged, not replaced.


⚠️ Why Some Animations “Don’t Run”

Common causes:

1. No animatable difference

SwiftUI only animates changes between animatable values.

if isOn {
    ViewA()
} else {
    ViewB()
}
Enter fullscreen mode Exit fullscreen mode

Without transitions, this is a replacement, not an animation.


2. Identity changed

If a view’s identity changes:

  • SwiftUI treats it as a new view
  • animation has nothing to interpolate

This is why .id() often breaks animations.


3. Transaction was disabled upstream

A parent may have:

.transaction { $0.animation = nil }
Enter fullscreen mode Exit fullscreen mode

Which cancels animations downstream.


🔁 Animation Interruptions

SwiftUI animations are interruptible by design.

If state changes again:

  • the current animation is interrupted
  • SwiftUI interpolates from the current presentation value
  • not the original value

This is why SwiftUI animations feel smooth even under rapid updates.


🎛️ Transactions vs animation(_:)

Key difference:

  • .animation() → attaches behavior to a view
  • Transaction → controls the update pipeline

Prefer transactions when:

  • coordinating multiple updates
  • disabling animations selectively
  • working with lists
  • handling pagination
  • managing accessibility motion

♿ Reduce Motion & Transactions

When “Reduce Motion” is enabled:

SwiftUI automatically modifies transactions:

  • reduces or removes animations
  • switches to opacity changes
  • shortens durations

If you hard-code animations without respecting transactions,
you can accidentally break accessibility.


🧠 Mental Model Cheat Sheet

  • State changes create transactions
  • Transactions carry animation intent
  • Views read transactions, not animation modifiers
  • Identity determines whether animation can occur
  • Nested transactions merge intelligently
  • Disabling animations is a transaction concern
  • Interruptions are expected and correct

🚀 Final Thoughts

SwiftUI animation is not magic — it’s transaction-driven state propagation.

Once you understand transactions:

  • animations become predictable
  • bugs become explainable
  • performance improves
  • interruptions feel intentional
  • advanced UI becomes manageable

Top comments (0)