withAnimation completion callback with animatable modifiers

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.

  1. The animatable modifier is instantiated when the view is initialized
  2. 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
  3. When the animation starts, our animatable modifier will be instantiated with the outcome value. In our case, this is 1.
  4. Both animatableData and targetValue are set to 1 at this point but thedidSet callback is not triggered by init methods. In other words, our notifyCompletionIfFinished is not yet called.
  5. 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 and notifyCompletionIfFinished method which will result in a negative match.
  6. 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!