SwiftUI is great when it comes down to animations as it does a lot for you with methods like withAnimation
and animation(...)
. You can simply pass in the things you’d like it to animate and SwiftUI will make sure your views move smoothly from one state to another.
Sometimes, however, you’d like to have the same functionality as you’re used to from UIKit that allows you to update the state after an animation completes. When you’re looking for ways to get a callback once an animation completes you’ll realize that it’s not that simple. In fact, there’s no built-in API available for animation completion handlers.
A custom AnimatableModifier
implementation allows us to get a callback once an animation of a specific property completes. This is in most cases enough to build the desired implementations.
An example implementation
To explain to you how it works we will start with a simple example implementation in which we animate the opacity of a simple text label. You could imagine this being your introduction animation after which you’d like to trigger a navigation to a different view.
struct IntroductionView: View {
@State private var introTextOpacity = 0.0
var body: some View {
VStack {
Text("Welcome to SwiftLee")
.opacity(introTextOpacity)
.onAnimationCompleted(for: introTextOpacity) {
print("Intro text animated in!")
}
}.onAppear(perform: {
withAnimation(.easeIn(duration: 1.0)) {
introTextOpacity = 1.0
}
})
}
}
The view contains our introduction label for which the animation is triggered in the onAppear
callback. We use a introTextOpacity
value to change the opacity from 0 to 1 with ease in animation.
The code also contains our final implementation to get a callback for when the introTextOpacity
property animation completes. The callback is called whenever an animation of this property finishes. This is made possible through a custom implementation of the AnimatableModifier
protocol.
Triggering a withAnimation completion using an animatable modifier
To get a callback when a withAnimation
triggered animation completes we have to implement a custom implementation of the AnimatableModifier
protocol.
The AnimatableModifier
protocol requires implementing both the ViewModifier
and Animatable
protocols and makes it possible to adjust how views are animated. It also requires to return data for the animation through the animatableData
property which we will use for validating whether the animation completes.
To understand how this works I’ll share you the final code implementation of the animatable modifiers which I’ll explain after.
/// An animatable modifier that is used for observing animations for a given animatable value.
struct AnimationCompletionObserverModifier<Value>: AnimatableModifier where Value: VectorArithmetic {
/// While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
var animatableData: Value {
didSet {
notifyCompletionIfFinished()
}
}
/// The target value for which we're observing. This value is directly set once the animation starts. During animation, `animatableData` will hold the oldValue and is only updated to the target value once the animation completes.
private var targetValue: Value
/// The completion callback which is called once the animation completes.
private var completion: () -> Void
init(observedValue: Value, completion: @escaping () -> Void) {
self.completion = completion
self.animatableData = observedValue
targetValue = observedValue
}
/// Verifies whether the current animation is finished and calls the completion callback if true.
private func notifyCompletionIfFinished() {
guard animatableData == targetValue else { return }
/// Dispatching is needed to take the next runloop for the completion callback.
/// This prevents errors like "Modifying state during view update, this will cause undefined behavior."
DispatchQueue.main.async {
self.completion()
}
}
func body(content: Content) -> some View {
/// We're not really modifying the view so we can directly return the original input value.
return content
}
}
The AnimationCompletionObserverModifier
takes a generic animatable property that conforms to the VectorArithmetic
protocol. This type is required to make sure the input is actually animatable and we’re not observing something that is never going to be animated.
Our body is simply returning the original view. This is because we’re not really animating something but observing the animation of a property instead.
This might be complicated to understand so let’s break it down step by step.
- The animatable modifier is instantiated when the view is initialized
- It takes our animatable property as input which at first is set to its initial value. In our example, this will be the opacity value of 0
- When the animation starts, our animatable modifier will be instantiated with the outcome value. In our case, this is 1.
- Both
animatableData
andtargetValue
are set to 1 at this point but thedidSet
callback is not triggered byinit
methods. In other words, ournotifyCompletionIfFinished
is not yet called. - The animatable data property is updated to the old value once an animation occurs. In our example, this means that it’s updated to 0. This will also trigger the
didSet
andnotifyCompletionIfFinished
method which will result in a negative match. - The
notifyCompletionIfFinished
method keeps on validating whether the input value matches our expected outcome and if so, triggers the completion callback
All the magic happens inside the animatableData
property for which the property description explains best what it does:
While animating, SwiftUI changes the old input value to the new target value using this property. This value is set to the old value until the animation completes.
In other words, once the animation is done it will match our expected outcome. This is exactly how we know that an animation completes.
Create a view extension to access the completion callback
It’s recommended to write your own view extension methods for custom modifiers as it simply improves the readability of custom modifiers. In our case, this means we need to write a method that allows us to set up the animation monitor:
extension View {
/// Calls the completion handler whenever an animation on the given value completes.
/// - Parameters:
/// - value: The value to observe for animations.
/// - completion: The completion callback to call once the animation completes.
/// - Returns: A modified `View` instance with the observer attached.
func onAnimationCompleted<Value: VectorArithmetic>(for value: Value, completion: @escaping () -> Void) -> ModifiedContent<Self, AnimationCompletionObserverModifier<Value>> {
return modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))
}
}
The method takes the property that’s going to be animated as input and requires to pass in the completion callback.
An implementation example of this method looks as follows:
Text("Welcome to SwiftLee")
.opacity(introTextOpacity)
.onAnimationCompleted(for: introTextOpacity) {
print("Intro text animated in!")
}
Conclusion
Animations in SwiftUI are triggered through methods like withAnimation
and animation(...)
. By default, it’s not possible to get callbacks when an animations completes. A custom animatable modifier allows us to built a custom solution which triggers a callback once an animation of a certain property completes.
If you like to improve your SwiftUI knowledge, even more, check out the SwiftUI category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.
Thanks!