Building a declarative animation framework in Swift - Part 2

This week, let's finish building the animation framework that we started in last week's post! To recap, the goal is to build a declarative API that enables us to clearly express animations through a series of operations that we define up-front (rather than inline in nested animation closures).

Our API, as it stands from last week, is already very capable when it comes to running animations on a single view. While that may cover many of our use cases - one thing that is missing is the ability to coordinate animations performed on multiple views.

Let's say we want to animate a sequence where first a label fades in, then moves slightly upwards, and then finally a button is also faded in, like this:

label.animate([
    .fadeIn(),
    .move(byX: 0, y: -50)
])

// We want this animation to run *after* the label animation
button.animate([
    .fadeIn()
])

If we run the above code using our current animation API, both of those animations will be performed simultaneously, which is not what we want. To solve this, we're going to need to introduce another layer which can sequence animations, not just on an individual view level.

Adding a top level function

One way of solving the above problem would be to add the option to pass a completion handler when starting an animation, and then performing the second one in that handler, like this:

label.animate([
    .fadeIn(),
    .move(byX: 0, y: -50)
], completionHandler: {
    button.animate([
        .fadeIn()
    ])
})

The above works, and is kind of the go-to solution for most iOS developers, since it's a very common pattern. However, if we go for that solution, we loose a bit of the declarative characteristics of our animation framework. Nested closures is exactly what we set out to avoid, so it would be nice if we could still fulfil that goal, even when adding this new capability.

So what we're going to do, is to add a top level function that can be used to wrap multiple calls to animate() on various views, enabling us to write code like this:

animate([
    label.animate([
        .fadeIn(),
        .move(byX: 0, y: -50)
    ]),
    button.animate([
        .fadeIn()
    ])
])

One challenge is to make sure that the button's animation doesn't start until after the label's animation is finished. We could solve this by simply introducing a new API, called waitThenAnimate() or something, but that doesn't seem very elegant, and would require us to keep changing which API we call depending on in which context we want to animate - not very nice.

Tokens

Instead, we're going to solve this problem using a token-based technique, where each call to animate() produces an AnimationToken, which will be used to either execute the animation directly, or after any pending animations. We can do this by triggering the animation once the token gets deallocated, like this:

public final class AnimationToken {
    deinit {
        // Perform animation
    }
}

That way, we can make our top-level animate() function hold onto each token until it's time to perform its animation, and when an animation is performed without the top-level function, the token will be immediately deallocated, resulting in the animation being performed without any delay.

Let's implement AnimationToken, which will hold a reference to the UIView and the [Animations] that it is for. We'll also add the ability to internally pass a completion handler when performing an animation, which we will use a bit later for the actual sequencing.

// We add an enum to describe in which mode we want to animate
internal enum AnimationMode {
    case inSequence
    case inParallel
}

public final class AnimationToken {
    private let view: UIView
    private let animations: [Animation]
    private let mode: AnimationMode
    private var isValid = true

    // We don't want the API user to think that they should create tokens
    // themselves, so we make the initializer internal to the framework
    internal init(view: UIView, animations: [Animation], mode: AnimationMode) {
        self.view = view
        self.animations = animations
        self.mode = mode
    }

    deinit {
        // Automatically perform the animations when the token gets deallocated
        perform {}
    }

    internal func perform(completionHandler: @escaping () -> Void) {
        // To prevent the animation from being executed twice, we invalidate
        // the token once its animation has been performed
        guard isValid else {
            return
        }

        isValid = false

        switch mode {
        case .inSequence:
            view.performAnimations(animations,
                                   completionHandler: completionHandler)
        case .inParallel:
            view.performAnimationsInParallel(animations, 
                                             completionHandler: completionHandler)
        }
    }
}

As you can see above, we are calling two new methods on UIView; performAnimations() and performAnimationsInParallel(). The reason we are going to introduce these is because the animation methods that we added last week will now instead return an AnimationToken, instead of actually performing the animation.

Let's start by refactoring our previous methods:

public extension UIView {
    @discardableResult func animate(_ animations: [Animation]) -> AnimationToken {
        return AnimationToken(
            view: self,
            animations: animations,
            mode: .inSequence
        )
    }

    @discardableResult func animate(inParallel animations: [Animation]) -> AnimationToken {
        return AnimationToken(
            view: self,
            animations: animations,
            mode: .inParallel
        )
    }
}

As you can see above, the @discardableResult attribute has been added to our animate() methods. This is so that the API user doesn't get a warning when calling these outside of our new top-level function, which will cause the token to be discarded immediately.

Next, let's move the implementation of the previous methods into the new ones (that will perform the animations), and add support for the completionHandler argument:

internal extension UIView {
    func performAnimations(_ animations: [Animation], completionHandler: @escaping () -> Void) {
        // This implementation is exactly the same as before, only now we call
        // the completion handler when our exit condition is hit
        guard !animations.isEmpty else {
            return completionHandler()
        }

        var animations = animations
        let animation = animations.removeFirst()

        UIView.animate(withDuration: animation.duration, animations: {
            animation.closure(self)
        }, completion: { _ in
            self.performAnimations(animations, completionHandler: completionHandler)
        })
    }

    func performAnimationsInParallel(_ animations: [Animation], completionHandler: @escaping () -> Void) {
        // If we have no animations, we can exit early
        guard !animations.isEmpty else {
            return completionHandler()
        }

        // In order to call the completion handler once all animations
        // have finished, we need to keep track of these counts
        let animationCount = animations.count
        var completionCount = 0

        let animationCompletionHandler = {
            completionCount += 1

            // Once all animations have finished, we call the completion handler
            if completionCount == animationCount {
                completionHandler()
            }
        }

        // Same as before, only with the call to the animation
        // completion handler added
        for animation in animations {
            UIView.animate(withDuration: animation.duration, animations: {
                animation.closure(self)
            }, completion: { _ in
                animationCompletionHandler()
            })
        }
    }
}

Putting all the pieces together

We have now laid all the groundwork required to implement our new top-level animate() function, which is actually quite simple. Like when we perform a sequence of animations, we just need to define an exit condition (using a guard statement), and recursively call the function until all animations have been performed:

public func animate(_ tokens: [AnimationToken]) {
    guard !tokens.isEmpty else {
        return
    }

    var tokens = tokens
    let token = tokens.removeFirst()

    token.perform {
        animate(tokens)
    }
}

Taking our new feature for a spin

Alright - let's try out our new feature of coordinating multiple view animations, all without any nested closures! πŸŽ‰

Last week, we used red rectangles to try out our API. While that did the job, it's a bit boring, so let's add some proper views to animate instead:

let label = UILabel()
label.text = "Let's animate..."
label.sizeToFit()
label.center = view.center
label.alpha = 0
view.addSubview(label)

let button = UIButton(type: .system)
button.setTitle("...multiple views!", for: .normal)
button.sizeToFit()
button.center.x = view.center.x
button.center.y = label.frame.maxY + 50
button.alpha = 0
view.addSubview(button)

And let's perform the animation that we initially set out to make possible! πŸš€

animate([
    label.animate([
        .fadeIn(duration: 3),
        .move(byX: 0, y: -50, duration: 3)
    ]),
    button.animate([
        .fadeIn(duration: 3)
    ])
])

I set the animation duration for each step to 3 seconds, to be able to easily see what's going on in the playground.

Adding a spoonful of sugar

OK, now for the bonus round! One thing you might notice when we call our new top-level animate() function above, is that we have to add quite a lot of syntax, especially with all the array literals ([]).

One way we can reduce that, is to provide APIs that take a variable number of arguments, instead of an array. That way, the compiler can automatically construct an array from the arguments, leaving us with a much cleaner syntax:

animate(
    label.animate(
        .fadeIn(duration: 3),
        .move(byX: 0, y: -50, duration: 3)
    ),
    button.animate(.fadeIn(duration: 3))
)

To make this happen, we simply add overloads that forward into our array-accepting implementations from before:

public func animate(_ tokens: AnimationToken...) {
    animate(tokens)
}

public extension UIView {
    @discardableResult func animate(_ animations: Animation...) -> AnimationToken {
        return animate(animations)
    }

    @discardableResult func animate(inParallel animations: Animation...) -> AnimationToken {
        return animate(inParallel: animations)
    }
}

Conclusion

We now have a declarative animation framework, capable of performing animations both in sequence and in parallel, for both a single view and multiple ones πŸŽ‰

There are of course many more features that we can add to this framework to make it more capable, and to handle even more use cases (like being able to nest multiple top-level animate() calls with both sequences and parallelized animations), but I think that we're off to a very good start.

You can find all the code from this post (combined with the content from last post) on this branch of the Animate repo. I'll also continue working on this framework on master, so feel free to either contribute to it with pull requests, or create your own frameworks and tools based on the ideas from these two blog posts.

If you have any questions, feedback or comments on this post, or any of my other ones - feel free to either leave a comment below or contact me on Twitter @johnsundell.

Thanks for reading πŸš€

Reducing flakiness in Swift tests

Building a declarative animation framework in Swift - Part 1