Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 048 / 100

Animations in SwiftUI: a Swift & iOS guide for developers

Motion that feels native.

Expona Admin9 min read

Animations in SwiftUI: a Swift & iOS guide for developers

Motion in iOS apps separates polished experiences from clunky ones. When a button press triggers a visual response that feels instantaneous yet natural, users stay engaged. This guide covers animations in SwiftUI—from simple state-driven implicit animations to complex multi-step keyframe sequences—and shows you how to make motion feel native rather than forced.

Why animations matter in production apps

We've all felt the difference: an app that responds to interaction with smooth, predictable motion versus one that jumps between states. In e-commerce apps, a smooth add-to-cart animation builds confidence. In ride-hailing, map animations during pickup feel intentional. In SaaS dashboards, transitioning between data views maintains context.

SwiftUI's animation system is built around two core ideas: state drives motion, and declarative timing replaces manual frame-by-frame work. Unlike UIKit's animation blocks, SwiftUI animations track what changed and animate those differences over time. This means fewer bugs and more consistent behavior across your app.

Implicit animations — withAnimation around state changes

The simplest way to animate in SwiftUI is implicit animation: you change state, and SwiftUI automatically animates any property changes that visual modifier depends on.

swift
@State private var isExpanded = false

var body: some View {
    VStack {
        if isExpanded {
            Text("Expanded content")
                .transition(.opacity)
        }
        
        Button("Toggle") {
            withAnimation(.easeInOut(duration: 0.3)) {
                isExpanded.toggle()
            }
        }
    }
}

Here, withAnimation wraps the state change. Any view property change inside that closure gets animated. Without withAnimation, the view toggle happens instantly. The animation duration and curve (`.easeInOut`) apply to all animated properties within that scope.

In a real e-commerce flow, you might animate a price update when applying a discount code:

swift
@State private var discountApplied = false
@State private var price: Double = 99.99

var body: some View {
    VStack(spacing: 16) {
        Text("$\(String(format: "%.2f", price))")
            .font(.title2)
            .foregroundColor(discountApplied ? .green : .primary)
        
        Button("Apply 20% Discount") {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                price = 79.99
                discountApplied = true
            }
        }
    }
}

The text color and value both animate. Users see the price drop smoothly, reinforcing the discount's value. The spring curve adds a subtle bounce that feels responsive without being cartoonish.

Key gotcha: withAnimation only animates changes to properties that SwiftUI tracks. Changing the contents of a filtered array won't animate—you're changing the array itself, not a property that drives a visual modifier. For those cases, use explicit animations or transitions.

Transitions — Animate views appearing and disappearing

Transitions define how views enter and exit the view hierarchy. They're essential for list deletions, conditional visibility, and navigation flows.

swift
@State private var showAlert = false

var body: some View {
    VStack {
        if showAlert {
            VStack {
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                    Text("Order confirmed")
                }
                .padding()
                .background(Color.green.opacity(0.1))
                .cornerRadius(8)
            }
            .transition(.scale.combined(with: .opacity))
        }
        
        Button("Confirm Order") {
            withAnimation(.easeOut(duration: 0.4)) {
                showAlert = true
            }
        }
    }
}

The `.scale.combined(with: .opacity)` transition means the view scales up and fades in simultaneously when it appears, then scales down and fades out when it disappears.

For list item removal in a task management app, sliding and opacity work well together:

swift
@State private var tasks: [String] = ["Task 1", "Task 2", "Task 3"]

var body: some View {
    List {
        ForEach(tasks, id: \.self) { task in
            Text(task)
                .transition(.asymmetric(
                    insertion: .opacity.combined(with: .scale),
                    removal: .slide.combined(with: .opacity)
                ))
        }
        .onDelete { indexSet in
            withAnimation(.easeInOut(duration: 0.3)) {
                tasks.remove(atOffsets: indexSet)
            }
        }
    }
}

Notice .asymmetric: tasks fade and scale in when added, but slide and fade out when deleted. Asymmetric transitions feel more natural because adding and removing are conceptually different operations.

Custom transitions let you build more complex effects. Here's a transition that rotates while fading:

swift
extension AnyTransition {
    static var rotateAndFade: AnyTransition {
        AnyTransition.asymmetric(
            insertion: .scale.combined(with: .opacity),
            removal: .rotation3D(
                Rotation3D(angle: .degrees(90), axis: (x: 0, y: 1, z: 0))
            ).combined(with: .opacity)
        )
    }
}

matchedGeometryEffect — Hero-style transitions between views

Hero transitions (or shared-element transitions in Android terminology) animate an element from one position to another across different states or screens. matchedGeometryEffect makes this straightforward.

Imagine a product grid where tapping an item expands it into a detail view. Instead of the detail view appearing separately, it animates from the grid position:

swift
@Namespace private var animation
@State private var selectedProduct: String?

var body: some View {
    VStack {
        if selectedProduct == nil {
            // Grid view
            LazyVGrid(columns: [GridItem(), GridItem()], spacing: 16) {
                ForEach(["Product A", "Product B", "Product C", "Product D"], id: \.self) { product in
                    VStack {
                        RoundedRectangle(cornerRadius: 8)
                            .fill(Color.blue.opacity(0.3))
                            .frame(height: 120)
                            .matchedGeometryEffect(id: product, in: animation)
                        
                        Text(product)
                    }
                    .onTapGesture {
                        withAnimation(.easeInOut(duration: 0.4)) {
                            selectedProduct = product
                        }
                    }
                }
            }
        } else if let product = selectedProduct {
            // Detail view
            VStack {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.blue.opacity(0.3))
                    .frame(height: 300)
                    .matchedGeometryEffect(id: product, in: animation)
                
                VStack(alignment: .leading, spacing: 12) {
                    Text(product)
                        .font(.title)
                    Text("$99.99")
                    Text("A great product")
                }
                .padding()
                
                Spacer()
                
                Button("Back") {
                    withAnimation(.easeInOut(duration: 0.4)) {
                        selectedProduct = nil
                    }
                }
                .padding()
            }
        }
    }
}

The magic here: the rectangle in the grid and the rectangle in the detail view share the same `matchedGeometryEffect(id:in:)`. When the state changes, SwiftUI animates the rectangle's position and size automatically. No manual frame calculations needed.

The `@Namespace` creates an animation namespace. Multiple matched geometry effects can share the same namespace, so you can coordinate multiple hero transitions at once:

swift
// In a chat app: animate both the avatar and message bubble
Image("avatar")
    .matchedGeometryEffect(id: "avatar_\(chatId)", in: animation)

VStack {
    Text(message)
}
.matchedGeometryEffect(id: "bubble_\(chatId)", in: animation)

Both the avatar and bubble animate from their positions in the list to their positions in the conversation detail, all in one coordinated motion.

Phase and keyframe animators — Multi-step animations in iOS 17+

For complex animations requiring multiple stages, keyframe animators (iOS 17+) let you sequence animations declaratively. This is particularly useful in trading dashboards where you want to animate price ticks, or in SaaS onboarding flows with multi-step visual feedback.

swift
@State private var animationTrigger = false

var body: some View {
    VStack(spacing: 20) {
        RoundedRectangle(cornerRadius: 8)
            .fill(Color.blue)
            .frame(height: 80)
            .keyframeAnimator(
                initialValue: CGPoint(x: 0, y: 0),
                trigger: animationTrigger
            ) { content, value in
                content.offset(x: value.x, y: value.y)
            } keyframes: { _ in
                KeyframeTrack(\.x) {
                    LinearKeyframe(0, duration: 0)
                    LinearKeyframe(100, duration: 0.3)
                    LinearKeyframe(100, duration: 0.2)
                    LinearKeyframe(0, duration: 0.3)
                }
                KeyframeTrack(\.y) {
                    LinearKeyframe(0, duration: 0)
                    LinearKeyframe(0, duration: 0.3)
                    LinearKeyframe(50, duration: 0.2)
                    LinearKeyframe(0, duration: 0.3)
                }
            }
        
        Button("Run Animation") {
            animationTrigger.toggle()
        }
    }
}

This rectangle moves right, then down, then back to center—each keyframe specified independently. The X-axis moves for 0.3s, stays put for 0.2s, then returns in 0.3s. The Y-axis follows its own timeline. Precise control like this is hard to achieve with implicit animations alone.

For a CRM app where you want to highlight a newly added contact with a pulsing glow, keyframe animators shine:

swift
@State private var pulse = false

var body: some View {
    VStack {
        RoundedRectangle(cornerRadius: 12)
            .fill(Color.green)
            .frame(width: 100, height: 100)
            .keyframeAnimator(
                initialValue: 0.0,
                trigger: pulse
            ) { content, value in
                content
                    .scaleEffect(1 + value * 0.2)
                    .opacity(1 - value * 0.3)
            } keyframes: { _ in
                KeyframeTrack(\.self) {
                    CubicKeyframe(0, duration: 0)
                    CubicKeyframe(1, duration: 0.6)
                    CubicKeyframe(0, duration: 0.6)
                }
            }
        
        Button("Highlight New Contact") {
            pulse.toggle()
        }
    }
}

The scale and opacity animate in tandem over 1.2 seconds: grow and fade for 0.6s, then shrink and solidify for 0.6s. It repeats cleanly, drawing attention without being intrusive.

Common mistakes

Animating after view hierarchy changes: If you remove a view from the hierarchy and then try to animate it, the animation won't run because the view is already gone. Always use transitions paired with conditional visibility and withAnimation.

Nesting withAnimation calls: Nested withAnimation blocks can cause unexpected behavior. The inner block overrides the outer one. Keep animation scopes flat and clear.

Ignoring performance: Animating complex view hierarchies (lots of subviews) causes frame drops. Use .drawingGroup() to flatten expensive hierarchies into a single render pass, or simplify the animated view structure.

swift
// Heavy animation? Flatten it.
VStack {
    // ... complex content
}
.drawingGroup()
.scaleEffect(scale)

Over-using matchedGeometryEffect: Each matched geometry effect has a memory cost. If you're coordinating 20+ elements, consider a simpler transition or a manual animation approach.

Forgetting to update state inside withAnimation: Changes outside withAnimation won't animate. Always keep state updates inside the closure.

Key takeaways

  • Implicit animations with withAnimation are your primary tool for simple, state-driven motion. They're predictable and minimize boilerplate.
  • Transitions control view lifecycle animations. Use asymmetric transitions when insertion and removal feel different conceptually.
  • matchedGeometryEffect enables hero transitions without manual frame calculations. One namespace can coordinate multiple elements.
  • Keyframe animators (iOS 17+) provide fine-grained control for multi-stage animations that implicit animations can't express easily.
  • Performance matters. Use .drawingGroup() for expensive animations and keep your animated view hierarchies lean.
  • Animations should serve context, not distract from it. Stick to durations between 0.2–0.6 seconds for most interactions; faster feels snappy, slower feels sluggish.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles