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.
