DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI Animations Internals: Transactions, Timing & Identity

SwiftUI animations look effortless — until they don’t.

That’s when you see:

  • animations restarting randomly
  • views snapping instead of animating
  • animations not firing at all
  • conflicting animations fighting each other
  • performance dropping during transitions

The reason is simple:

SwiftUI animations are state-driven, transactional, and identity-sensitive.

This post explains how SwiftUI animations actually work under the hood so you can design animations that are smooth, predictable, and performant.


🧠 The Core Rule of SwiftUI Animation

SwiftUI does not animate views.

It animates state changes.

State changes

SwiftUI computes differences

If an animation is attached → interpolate values

If state doesn’t change → nothing animates.


🔄 Implicit vs Explicit Animations

Implicit Animation

Attached directly to a view:

.opacity(show ? 1 : 0)
.animation(.easeInOut, value: show)
Enter fullscreen mode Exit fullscreen mode
  • Animates when show changes
  • Clean and declarative
  • Preferred for most UI

Explicit Animation

Wrap state changes:

withAnimation(.spring()) {
    show.toggle()
}
Enter fullscreen mode Exit fullscreen mode
  • Animates all animatable changes inside the block
  • Powerful but broader in scope
  • Use carefully

🧾 Transactions: The Hidden Animation Container

Every SwiftUI update runs inside a Transaction.

A transaction defines:

  • animation
  • animation speed
  • whether animations are disabled

You can access it:

.transaction { txn in
    txn.animation = .easeOut
}
Enter fullscreen mode Exit fullscreen mode

Or disable animations:

.transaction { txn in
    txn.disablesAnimations = true
}
Enter fullscreen mode Exit fullscreen mode

This is how SwiftUI decides what animates together.


⚠️ Why Animations Sometimes Don’t Run

Common causes:

  • state changes outside the animation’s value
  • view identity changed
  • id() reset
  • view removed & reinserted
  • conditional rendering
  • animation attached to the wrong level

Example bug:

if show {
    MyView()
}
Enter fullscreen mode Exit fullscreen mode

When show becomes false:

  • view is removed
  • no animation possible

Better:

MyView()
    .opacity(show ? 1 : 0)
Enter fullscreen mode Exit fullscreen mode

🆔 Identity & Animation Reset

Animations reset when identity changes.

This forces a fresh view:

MyView()
    .id(UUID()) // ❌ resets animation every update
Enter fullscreen mode Exit fullscreen mode

Even subtle identity changes (like unstable list IDs) will:

  • restart animations
  • break transitions
  • cause flickering

📌 Stable identity = stable animations


🔀 Animatable Properties (What Can Animate?)

SwiftUI animates values that conform to VectorArithmetic:

  • Double
  • CGFloat
  • Color
  • CGSize
  • CGPoint
  • Angle

These animate smoothly:

.offset(x: dragX)
.scaleEffect(scale)
.rotationEffect(angle)
Enter fullscreen mode Exit fullscreen mode

Non-animatable changes will jump instantly.


🧩 Custom Animations with Animatable

For advanced control:

struct MorphView: View, Animatable {
    var progress: CGFloat

    var animatableData: CGFloat {
        get { progress }
        set { progress = newValue }
    }

    var body: some View {
        Rectangle()
            .scaleEffect(1 + progress)
    }
}
Enter fullscreen mode Exit fullscreen mode

SwiftUI interpolates animatableData automatically.

This is how:

  • shape morphing
  • waveform animations
  • custom loaders
  • progress indicators

are built.


🧵 Animation Timing & Interruptions

SwiftUI animations are interruptible by default.

If state changes mid-animation:

  • SwiftUI retargets the animation
  • no snapping
  • no restart

Example:

withAnimation(.spring()) {
    offset = newValue
}
Enter fullscreen mode Exit fullscreen mode

Changing offset again smoothly redirects the animation.

This is why SwiftUI animations feel “alive”.


🔁 Multiple Animations in One View

Bad pattern:

.animation(.easeIn, value: a)
.animation(.easeOut, value: b)
Enter fullscreen mode Exit fullscreen mode

Only the last animation wins.

Correct pattern:

.animation(.easeIn, value: a)
.animation(.spring(), value: b)
Enter fullscreen mode Exit fullscreen mode

Attached at the correct view level, not stacked blindly.


📦 Animations & Lists

In lists:

  • identity matters even more
  • unstable IDs break transitions
  • insertions/deletions animate by default

Use:

withAnimation {
    items.append(newItem)
}
Enter fullscreen mode Exit fullscreen mode

And ensure:

ForEach(items, id: \.id)
Enter fullscreen mode Exit fullscreen mode

Avoid heavy animations inside rows.


⚡ Performance Rules for Animations

✔ Animate transforms (opacity, scale, offset)
✔ Avoid animating layout where possible
✔ Keep animations short
✔ Avoid deep animated hierarchies
✔ Avoid animating large lists
✔ Prefer implicit animations

Animations are cheap — layout recalculations are not.


🧠 Debugging Animation Bugs

Ask:

  1. Did state change?
  2. Is the animation attached to the right value?
  3. Did identity change?
  4. Is the view conditionally removed?
  5. Is a parent invalidating identity?
  6. Is layout fighting animation?

99% of animation bugs fall into these.


🧠 Mental Model Cheat Sheet

State change
   ↓
Transaction created
   ↓
SwiftUI diffs values
   ↓
Animatable values interpolate
   ↓
Layout + render

Enter fullscreen mode Exit fullscreen mode

Control state → control animation.


🚀 Final Thoughts

SwiftUI animations are not magic.

They are:

  • state-driven
  • identity-sensitive
  • transactional
  • interruptible
  • predictable

Once you understand the internals:

  • animations stop breaking
  • transitions feel intentional
  • performance stays smooth
  • your UI feels truly native

Top comments (0)