Synchronising CALayer and UIKit Animations

26 November 2019

When building custom views on iOS with advanced and complex animations, we often have to delve in to the lower-level CALayer and CAAnimation Core Animation APIs in order to gain full control over the animations that are produced. However, the drawback of this approach is that it means our animations exist outside of the bounds of UIKit's animation API, meaning our custom views cannot be animated using UIKit's simpler block-based animation API. Instead, we have to provide separate functions on our views to allow the calling code to trigger the animations that we have provided. This adds an extra layer of complexity to our custom view's API, and makes it difficult to synchronise animations between different views.

But is it possible to have our cake and eat it? Can we build animations using powerful Core Animation APIs and still participate in the simplified UIKit animation system? Yes we can! And the solution is simple enough to make its implementation relatively trivial for any kind of animation achieved using CAPropertyAnimation and its various subclasses.

An Example

The holy grail of Core Animation and UIKit symbiosis can be easily demonstrated with a simple example. Today we will build a custom view using a CAShapeLayer that shows an arrow that can point either up or down. We will animate the arrow's change in direction between up and down, as well as change its colour at the same time. Finally, we will perform the necessary alchemy to have our custom animations synchronise with UIKit animations. The full code for this example is available on GitHub.

The custom arrow view that we will be building. One is shown pointing up, and the other pointing down.
The custom arrow view that we will be building. One is shown pointing up, and the other pointing down.

First of all we need to model the two directions that the arrow can point. We do this with an enum called Direction:

extension ArrowView {
    /// The different directions that the arrow can point.
    enum Direction {
        case up
        case down
    }
}

Our view's direction can then be stored in a simple property:

var direction: Direction = .up

We will modify this property later to supply animations when it is changed.

Next we need to create the shape layer that will draw the arrow. This is pretty simple to configure:

private lazy var arrowShapeLayer: CAShapeLayer = {
    let arrowShapeLayer = CAShapeLayer()
    arrowShapeLayer.strokeColor = direction.arrowColour.cgColor
    arrowShapeLayer.lineWidth = ArrowView.arrowLineWidth
    arrowShapeLayer.lineCap = .round
    arrowShapeLayer.fillColor = UIColor.clear.cgColor
    return arrowShapeLayer
}()

Once we have added this layer to our view's layer, we need to lay it out so that its content can be displayed:

override public func layoutSublayers(of layer: CALayer) {
    super.layoutSublayers(of: layer)
    arrowShapeLayer.frame = bounds
    arrowShapeLayer.path = direction.arrowPath(in: bounds).cgPath
}

We will also want to change the shape layer's path when our view's direction is changed. We can do this by adding a property observer on direction:

var direction: Direction = .up {
    didSet {
        guard oldValue != direction else { return }
        arrowShapeLayer.path = direction.arrowPath(in: bounds).cgPath
        arrowShapeLayer.strokeColor = direction.arrowColour.cgColor
    }
}

You may have noticed in these code snippets that our arrow view's direction property is being queried for paths and colours. Going over the precise details of this is not relevant to the topic of this article, but you can view the code for this on the GitHub repo.


Right now we have a view that can draw an arrow pointing up or down in two different colours. But the point of this article is animation - so let's get to that now.

Animating Direction Changes

Our arrow is displayed by setting the path and strokeColor properties on our view's child CAShapeLayer. Both of these properties are animatable using Core Animation's CABasicAnimation class. We can update our direction property observer to create animations when the direction is changed:

var direction: Direction = .up {
    didSet {
        guard oldValue != direction else { return }
        let pathAnimation = CABasicAnimation(keyPath: "path")
        pathAnimation.fromValue = arrowShapeLayer.presentation()?.path
        pathAnimation.duration = 0.5
        arrowShapeLayer.add(pathAnimation, forKey: "pathAnimation")
        
        let strokeColourAnimation = CABasicAnimation(keyPath: "strokeColor")
        strokeColourAnimation.fromValue = arrowShapeLayer.presentation()?.strokeColor
        strokeColourAnimation.duration = 0.5
        arrowShapeLayer.add(strokeColourAnimation, forKey: "strokeColourAnimation")
        
        arrowShapeLayer.path = direction.arrowPath(in: bounds).cgPath
        arrowShapeLayer.strokeColor = direction.arrowColour.cgColor
    }
}

This is fairly standard Core Animation code. We create basic animations for the path and stroke colour properties of our shape layer, making sure to set the fromValue to the value in the shape layer's presentation() layer. This is important as if we need to change the direction of the arrow while a previous direction change animation is taking place, this will cause the new animation to begin from the state that is currently displayed on screen, rather than jumping to the end of the previous animation and starting from there.

With this code, our arrow view now animates between up and down directions, smoothly animating between the up and down paths as well as the colours associated with each direction.

However, this animation is baked in to the view. It cannot be customised or modified by calling code, which is precisely what we want to be able to do using the UIKit block-based animation APIs. Fortunately we only need a small change to the code we've just written to achieve our desired outcome.

Integrating with UIKit Animations

In order to have our Core Animation code synchronise with animations configured by UIKit, we need a way to find out the animation properties that are set up on our view when it is animated using functions such as +[UIView animateWithDuration:animations:]. This can be done very easily by requesting our view's CAAction for the backgroundColor property. Each time a view is animated using UIKit APIs, implicit animations are created for various UIView properties, even if their values don't change. By getting ahold of this implicit animation, we can use it to create animations ourselves with the same animation characteristics as those configured by UIKit.

To do this, we modify our direction property observer to look like this:

var direction: Direction = .up {
    didSet {
        guard oldValue != direction else { return }
        if let backgroundColourAnimation = action(for: layer, forKey: "backgroundColor") as? CABasicAnimation {
            let pathAnimation = backgroundColourAnimation.copy(forKeyPath: "path")
            pathAnimation.fromValue = arrowShapeLayer.presentation()?.path
            arrowShapeLayer.add(pathAnimation, forKey: "pathAnimation")
            
            let strokeColourAnimation = backgroundColourAnimation.copy(forKeyPath: "strokeColor")
            strokeColourAnimation.fromValue = arrowShapeLayer.presentation()?.strokeColor
            arrowShapeLayer.add(strokeColourAnimation, forKey: "strokeColourAnimation")
        }
        
        arrowShapeLayer.path = direction.arrowPath(in: bounds).cgPath
        arrowShapeLayer.strokeColor = direction.arrowColour.cgColor
    }
}

We are getting ahold of the implicit background colour animation, making a copy of it, and setting the fromValue to the values required for our animation. This means that our animation objects have exactly the same animation configuration as the animations set up by UIKit.

The -[CAPropertyAnimation copyForKeyPath:] function that we are using is not part of the Core Animation API, but is instead achieved with an extension on CAPropertyAnimation:

extension CAPropertyAnimation {
    
    /// Creates a copy of the receiver with the `keyPath` set to the given
    /// value.
    /// - Parameter keyPath: The value of the returned animation's `keyPath`.
    /// - Returns An animation identical to the receiver with the given
    /// `keyPath`.
    func copy(forKeyPath keyPath: String) -> Self {
        let copy = self.copy() as! Self
        copy.keyPath = keyPath
        return copy
    }
}

Our view now has an animatable direction property whose animation is configured using the UIKit block-based animation APIs, and whose animations are implemented using Core Animation. Animating our view is now incredibly simple, and makes the API feel like a first-class citizen on iOS.

Putting It All Together

Now that our view is animatable, let's animate it! To make it simple, let's add a helper to our Direction enum:

mutating func flip() {
    switch self {
    case .up:
        self = .down
    case .down:
        self = .up
    }
}

This allows us to easily switch the direction of our view's arrow between up and down. Next, let's write the code to animate the changing of the direction of our arrow:

UIView.animate(withDuration: 0.3) {
    self.arrowView.direction.flip()
}

That's it! Calling the flip() function on our arrow view's direction causes the value to invert. This triggers the direction property observer which in turn picks up the implicit animations created by our call to the +[UIView animateWithDuration:animations:] function, and we get an animated arrow change:

The custom arrow view animating between directions.
The custom arrow view animating between directions.

Conclusion

As we have seen with our arrow view example, bridging the parallel worlds of Core Animation and UIKit needn't be daunting, and is certainly not impossible. In doing so, we simplify the APIs of our custom animatable views, and integrate in to the UIKit ecosystem effectively.

If you would like to play with an example, the GitHub repo contains a sample application utilising the code detailed above, which allows you to animate arrows to your heart's content. The next time you're working on a custom animation for your app, consider whether you could use the techniques described in this article to make your view's API a developer's delight.