- Published on
How Your Views Actually Move
- Authors

- Name
- Omar Elsayed
Last week, I was deep in the trenches updating the swiftui-expert-skill with Antoine, specifically working on adding comprehensive animation references. You know that feeling when you start documenting something and realize you don't understand it as deeply as you thought? Yeah, that happened.
I found myself going down rabbit hole after rabbit hole—phase animations, keyframe timing, transaction propagation, the Animatable protocol. What started as "let me add some animation best practices" turned into a full-blown deep dive into SwiftUI's animation system.
And honestly? I'm glad it did. Because now I actually understand why this works:
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
While this doesn't:
if showDetail {
DetailView()
.animation(.spring, value: showDetail) // Why no animation on removal? 🤔
}
Underneath that innocent .animation() modifier lies a sophisticated system that's interpolating values at 60fps, managing view identity, and deciding whether to animate properties or trigger transitions. Once you understand the mechanism, those mysterious animation bugs suddenly make perfect sense.
So today, we're exploring the complicated mechanism behind SwiftUI animations—from the fundamentals to the shiny new iOS 17 APIs. And next week? We'll put everything we learn here into practice with a real-world example that brings it all together.
Let's dive in.
- How SwiftUI Animations Actually Work
- Implicit vs Explicit Animations
- Transitions
- Transactions: The Secret Sauce
- Performance considerations
- Conclusion
How SwiftUI Animations Actually Work
Here's the thing most tutorials skip: state changes are the only way to trigger view updates in SwiftUI. No state change? No animation. It doesn't matter how many .animation() modifiers you throw at your view.
When you trigger an animation, here's what happens under the hood:
- State change triggers view tree re-evaluation
- SwiftUI compares the new view tree to the current render tree
- Animatable properties are identified (width changed from 100 to 200? Got it.)
- The timing curve generates progress values from 0 to 1
- These values interpolate the property smoothly (~60 fps)
Animation Progress Timeline:
┌─────────────────────────────────────────┐
│ 0% 25% 50% 75% 100% │
│ ├──────┼──────┼──────┼──────┤ │
│ 100 125 150 175 200 (width)│
└─────────────────────────────────────────┘
The key insight: Animations are additive and cancelable. They always start from the current render tree state—not the previous target.
This means if you interrupt an animation mid-flight, the new animation starts smoothly from wherever the view currently is. No jarring jumps. SwiftUI just figures it out.
For me this is a very interesting fact to be honest. Most animation systems require you to manage this yourself. SwiftUI? It just works. 😅
Implicit vs Explicit Animations
SwiftUI gives you two primary ways to animate: implicit and explicit. Choose wrong, and you'll spend hours debugging why your animation isn't working.
Implicit Animations
Use .animation(_:value:) when you want animations tied to specific value changes:
// GOOD - uses value parameter for precise control
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
.onTapGesture { isExpanded.toggle() }
// BAD - deprecated, animates EVERYTHING unexpectedly
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring) // Don't do this!
That second version without the value parameter? Deprecated. And for good reason—it would animate on any state change, including ones you didn't intend. Orientation change? Animated. Parent view update? Animated. It was chaos.
Explicit Animations
Use withAnimation when you want to animate state changes triggered by events:
Button("Toggle") {
withAnimation(.spring) {
isExpanded.toggle()
}
}
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
No .animation() modifier needed on the view. The animation context flows from the state change. When to Use Which?
Implicit animations → When you want animations tied to specific value changes with precise view tree scope.
Explicit animations → When you want event-driven animations (button taps, gestures) and don't need fine-grained control over which views animate.
But here's the plot twist: implicit animations override explicit ones. The modifier executes later while evaluating the view tree, so it wins:
Button("Tap") {
withAnimation(.linear) { flag.toggle() }
}
.animation(.bouncy, value: flag) // .bouncy wins!
Animation Placement Matters (A Lot)
This trips up so many developers. The placement of your animation modifier determines what gets animated:
// GOOD - animation after properties
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.foregroundStyle(isExpanded ? .blue : .red)
.animation(.default, value: isExpanded) // Animates BOTH frame and color
// BAD - animation before properties
Rectangle()
.animation(.default, value: isExpanded) // Too early!
.frame(width: isExpanded ? 200 : 100, height: 50)
.foregroundStyle(isExpanded ? .blue : .red)
The animation modifier only affects what comes before it in the modifier chain.
So for example, if we want to animate the size but not the color? Stack your animation modifiers:
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded) // Animate size
.foregroundStyle(isExpanded ? .blue : .red) // Color doesn't animate
// In some iOS versions, you might need to use a separate animation modifier to prevent color from animating
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded) // Animate size
.foregroundStyle(isExpanded ? .blue : .red)
.animation(nil, value: isExpanded) // Animate color
iOS 17 made this even cleaner with scoped animations:
Rectangle()
.foregroundStyle(isExpanded ? .blue : .red) // Not animated
.animation(.spring) {
$0.frame(width: isExpanded ? 200 : 100, height: 50) // Only this is animated
}
The Animatable Protocol: Custom Animation Magic
SwiftUI's built-in animations are great, but what if you want something custom—like a shake effect that returns to its starting position?
The goal of this protocol is to animate custom properties of the view and with that we can create custom animations like the shake effect.
Meet the Animatable protocol:
protocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}
The animatableData property tells SwiftUI what value to animate. Here's a shake modifier:
struct ShakeModifier: ViewModifier, Animatable {
var shakeCount: Double
var animatableData: Double {
get { shakeCount }
set { shakeCount = newValue }
}
func body(content: Content) -> some View {
content
.offset(x: sin(shakeCount * .pi * 2) * 10)
}
}
extension View {
func shake(count: Int) -> some View {
modifier(ShakeModifier(shakeCount: Double(count)))
}
}
Usage:
@State private var shakeCount = 0
Button("Shake!") { shakeCount += 3 }
.shake(count: shakeCount)
.animation(.default, value: shakeCount)
When shakeCount goes from 0 to 3, SwiftUI interpolates through all the intermediate values (0.1, 0.2, 0.3...). The sine function converts these into a smooth shake motion: left → right → left → right → center.
Multiple Animated Properties
If you want to animate multiple properties at the same time? Use AnimatablePair:
struct ComplexModifier: ViewModifier, Animatable {
var scale: CGFloat
var rotation: Double
var animatableData: AnimatablePair {
get { AnimatablePair(scale, rotation) }
set {
scale = newValue.first
rotation = newValue.second
}
}
func body(content: Content) -> some View {
content
.scaleEffect(scale)
.rotationEffect(.degrees(rotation))
}
}
Three properties? Nest them:
var animatableData: AnimatablePair<AnimatablePair, Double> {
get { AnimatablePair(AnimatablePair(x, y), rotation) }
set {
x = newValue.first.first
y = newValue.first.second
rotation = newValue.second
}
}
Yeah, it's a bit verbose. But it works beautifully.
The Silent Failure Trap
Here's a nasty gotcha: if you forget to implement animatableData, SwiftUI uses the default EmptyAnimatableData—and your animation just jumps to the final value with no interpolation.
No compiler error. No runtime warning. Just... broken animation.
// BAD - missing animatableData (silent failure!)
struct BadShakeModifier: ViewModifier {
var shakeCount: Double
// Missing animatableData! Uses EmptyAnimatableData
func body(content: Content) -> some View {
content.offset(x: sin(shakeCount * .pi * 2) * 10)
}
}
// Animation jumps to final value instead of interpolating
Always explicitly implement animatableData. Future you will thank present you.
That is all for animation 🤣, let's move on to transitions and what the difference animation and transition.
Transitions
Transition animations are triggered when views are inserted or removed from the view hierarchy, they are used to animate the appearance or disappearance of views.
Property animations interpolate values on views that exist before AND after the state change. Same view, different properties.
Transitions animate views being inserted or removed from the render tree. Different views entirely.
// Property animation - same Rectangle, different size
Rectangle()
.frame(width: isExpanded ? 200 : 100, height: 50)
.animation(.spring, value: isExpanded)
// Transition - view inserted/removed
Group {
if showDetail {
DetailView()
.transition(.scale)
}
}
.animation(.spring, value: showDetail)
The Critical Transition Gotcha
Here's what kept me up at night when I first learned SwiftUI:
// BAD - animation inside conditional (REMOVED with the view!)
if showDetail {
DetailView()
.transition(.slide)
.animation(.spring, value: showDetail) // Won't work on removal!
}
When showDetail becomes false, the entire view—including its animation modifier—gets removed.
No animation context exists for the removal and the removal isn't animated.
The fix: Put the animation outside the conditional:
// GOOD - animation outside conditional
VStack {
Button("Toggle") { showDetail.toggle() }
if showDetail {
DetailView()
.transition(.slide)
}
}
.animation(.spring, value: showDetail) // This survives removal
// ALSO GOOD - explicit animation
Button("Toggle") {
withAnimation(.spring) {
showDetail.toggle()
}
}
if showDetail {
DetailView()
.transition(.scale.combined(with: .opacity))
}
Built-in Transitions
SwiftUI gives you several out of the box:
| Transition | Effect |
|---|---|
.opacity | Fade in/out (default) |
.scale | Scale up/down |
.slide | Slide from leading edge |
.move(edge:) | Move from specific edge |
.offset(x:y:) | Move by offset amount |
Combine them for richer effects:
.transition(.scale.combined(with: .opacity))
Need different animations for insertion vs removal? Use asymmetric:
.transition(
.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .move(edge: .bottom).combined(with: .opacity)
)
)
Transactions: The Secret Sauce
Every animation in SwiftUI is powered by transaction. The withAnimation function? Just syntactic sugar for withTransaction:
// These are equivalent
withAnimation(.default) { flag.toggle() }
var transaction = Transaction(animation: .default)
withTransaction(transaction) { flag.toggle() }
Transactions is like an object that carry the animation information down through the view tree.
You can intercept and modify them:
.transaction { t in
t.animation = .bouncy // Override animation
}
.transaction { t in
t.disablesAnimations = true // Kill all animations
}
.transaction { $0.animation = nil } // Remove animation
This is how you disable animations for specific views even when a parent has animation applied.
Animation Completion Handlers (iOS 17+)
Finally, we can know when animations finish:
Button("Animate") {
withAnimation(.spring) {
isExpanded.toggle()
} completion: {
showNextStep = true // Runs when animation completes
}
}
Since transaction holds on to the animation information, we can also add completion closure for the animation throught it.
But watch out for this gotcha with .transaction:
// BAD - completion only fires ONCE
.transaction { transaction in
transaction.addAnimationCompletion {
completionCount += 1 // Only fires once, ever
}
}
// GOOD - add value parameter for reexecution
.transaction(value: bounceCount) { transaction in
transaction.animation = .spring
transaction.addAnimationCompletion {
message = "Animation complete" // Fires every time
}
}
Without the value parameter, SwiftUI's dependency tracking doesn't reexecute the closure.
Classic SwiftUI trap 🪤.
Performance considerations
Before I let you go, one critical performance tip:
// GOOD - GPU accelerated transforms (fast)
Rectangle()
.frame(width: 100, height: 100)
.scaleEffect(isActive ? 1.5 : 1.0)
.offset(x: isActive ? 50 : 0)
.rotationEffect(.degrees(isActive ? 45 : 0))
// BAD - layout changes (expensive)
Rectangle()
.frame(width: isActive ? 150 : 100, height: isActive ? 150 : 100)
.padding(isActive ? 50 : 0)
Transforms (scaleEffect, offset, rotationEffect) are GPU-accelerated and don't trigger layout passes.
Layout changes (frame, padding) force SwiftUI to recalculate the entire layout hierarchy.
When possible, fake size changes with scaleEffect instead of actually changing the frame. Your users' devices will thank you for that 🤣.
To be honest, the performance win for a single view isn't massive—you probably won't notice it.
But here's the thing: in complex view hierarchies with nested animations, these small optimizations compound.
A list with 50 animating cells? A dashboard with multiple animated widgets? That's when transform-based animations shine, keeping your UI buttery smooth while layout-based animations start to stutter.
Conclusion
The next time you add an animation to your SwiftUI view, take a moment to appreciate what's happening under the hood and think clearly about what type of animation you need.
You're not just toggling a boolean—you're triggering a sophisticated system that evaluates view trees, identifies animatable properties, manages transactions, and interpolates values at 60fps while gracefully handling interruptions.
SwiftUI's animation system is genuinely impressive. We finally have the tools to create those polished, complex animations without fighting the framework.
Quick reference:
- Use
.animation(_:value:)for implicit animations (always include the value!) - Use
withAnimationfor event-driven animations - Put animation modifiers outside conditionals for transitions
- Prefer transforms over layout changes for performance
Now go make something beautiful 🤩. And stay tuned for next week when we'll build a real-world example putting all of this into practice.
subscribe for more like this and if you have any questions, feel free to reach out!