SwiftUI withAnimation сompletion on iOS 13–16


Greetings, traveler!

In iOS 17 Apple added withAnimation(_:completion:), and it feels like the obvious missing piece. On earlier versions, SwiftUI does not expose a completion callback for implicit animations. withAnimation only wraps state changes into an animation transaction. It is not an animation object you can observe, so there is nothing to “finish” from SwiftUI’s point of view.

That gap shows up in real code fast: you animate something out, then you want to remove it, trigger navigation, start the next phase, or unblock input.

A Timer approach

A timer works:

extension View {
    func animate(duration: CGFloat, _ perform: @escaping () -> Void) async {
        await withCheckedContinuation { continuation in
            withAnimation(.linear(duration: duration)) {
                perform()
            }
            
            DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
                continuation.resume()
            }
        }
    }
}

However, there are hidden problems. Reduced Motion, interruptions, and interactive state changes make “duration-based completion” brittle.

The core idea

Instead of trying to “wait for time to pass”, observe the animated value itself.

SwiftUI drives animations by changing an animatableData value over time. If we can watch that value, we can call a closure once it reaches the final target.

This approach stays deterministic. It does not care about easing curves, frame rate, or whether the animation was interrupted and restarted. It simply reacts when the value settles.

Step 1: create an animatable completion observer

This modifier watches a VectorArithmetic value (CGFloat, Double, Angle, etc.). When the animation reaches the target value, it calls completion.

import SwiftUI

struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
    var targetValue: Value
    var completion: () -> Void

    var animatableData: Value {
        didSet { notifyIfFinished() }
    }

    init(observedValue: Value, completion: @escaping () -> Void) {
        self.completion = completion
        self.animatableData = observedValue
        self.targetValue = observedValue
    }

    private func notifyIfFinished() {
        guard animatableData == targetValue else { return }

        // Keep the call on main.
        DispatchQueue.main.async {
            completion()
        }
    }

    func body(content: Content) -> some View {
        content
    }
}

Step 2: add a small view extension

extension View {
    func onAnimationCompleted<Value: VectorArithmetic>(
        for value: Value,
        completion: @escaping () -> Void
    ) -> some View {
        modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
    }
}

Now you have a completion hook that works back to iOS 13, without guessing timing.

Step 3: use it with a real example

Let’s animate a card sliding out. When it finishes, we remove it from the hierarchy.

struct SlideOutCard: View {
    @State private var isVisible = true
    @State private var xOffset: CGFloat = 0

    var body: some View {
        VStack(spacing: 16) {
            if isVisible {
                RoundedRectangle(cornerRadius: 16)
                    .frame(height: 120)
                    .offset(x: xOffset)
                    .onAnimationCompleted(for: xOffset) {
                        // Called when the offset reaches the final target.
                        isVisible = false
                    }
            }

            Button("Dismiss") {
                withAnimation(.easeInOut(duration: 0.35)) {
                    xOffset = 500
                }
            }
        }
        .padding()
    }
}

This is the key difference versus asyncAfter: if the user triggers a different animation midway, the completion follows the actual final value.

How to manage boolean types

A Bool flip often drives an animation, but you cannot observe it with VectorArithmetic. Observe the animatable value that changes because of it: opacity, offset, scale, or a progress value you control.

Multiple animated properties

If you animate two values and want completion after both, observe the one that is guaranteed to settle last, or drive both from a single progress value and derive everything else from it.

When a timer is still fine

Sometimes you really do not care about interruptions, reduced motion, or user input. A quick DispatchQueue.main.asyncAfter can be acceptable for fire-and-forget UI polish. Just keep it out of code paths that affect navigation, state correctness, or business logic.

Conclusion

The pattern above is small, testable, and stays inside SwiftUI’s animation model instead of fighting it. It also fits the “avoid fake depth, stay concrete” style you sent in your writing rules.