Categories
iOS Development

How to animate UIBezierPath using CABasicAnimation

Currently, I am working on an app that features a table view with custom cells representing single items or grouped items. You can expand the group cells (to see their content) by selecting them. There are multiple ways to achieve this, I decided to use a custom UIViewController transition in that I animate a UIBezierPath with CABasicAnimation. During the custom transition, the layer drawn by the animated UIBezierPath is layed on top of the item. This makes it look as if the actual cell is expanding. After finally getting this animation done, I decided to write a post about it. The final view can be reused for a lot of different animations or can be at least a good starting point. So, in this post I will show you how to animate UIBezierPath using CABasicAnimation because even easy animations can be tricky.

The animation we want to create.

If you are not sure what a bézier curve actually is you can check out wikipedia for a more graphical reference how this works (https://en.wikipedia.org/wiki/B%C3%A9zier_curve) or have a look at Apple’s documentation (https://developer.apple.com/documentation/uikit/uibezierpath). However, there might be a chance that you already have used bézier curves in some graphical tool (e.g. Photoshop, Sketch, etc). I think it’s easiest to understand what happens if you already dragged around these curves in some tool.

As easy as creating two paths

Unfortunately, CABasicAnimation is not as easy as using UIView.animate(). However, if you master this more sophisticated way of animating you can do pretty cool stuff that you cannot do with just using UIView.animate(). Because of the API being not so straight forward I use this animation presented here as a kind of template in different places. I reused it and just changed initialPath and finalPath to create this one.

Another animation based on mostly the same code.

For me there there is one really important rule when it comes to animating UIBezierPath:

The starting UIBezierPath and the final UIBezierPath should have the same number of control points. If not, your animation will most probably not look as you would expect.

I will show you later why this is important. First let me briefly describe what we want to do:

  • There is an initial UIBezierPath that will hold the control points to draw the content of the first frame of our animation.
  • A final UIBezierPath will hold the control points for the ending frame of the animation.
  • We also want to reverse the animation somehow or play it from the final to the inital path
  • There should be a completion handler for the forward and the reversed animation so that we can easily chain other actions.

The Code

Alright, let’s write the code! There are so many pitfalls when using CABasicAnimation, so I think it is best to fiddle around with the animation in a fresh new .playground file.

(You can download the complete code at the end of this post).

The starting file will look like this:

import UIKit import PlaygroundSupport /// A view with an animated layer, that expands a colored layer with the view's bounds to a given `CGRect`. class AnimatedCardView: UIView { /// The colored card shape. private let shapeLayer = CAShapeLayer() /// The alpha mask, needed so that the animation does not clip. private let maskLayer = CAShapeLayer() /// The initial path at the animation's beginning private var initialPath = UIBezierPath() /// The final path at the animation's end. private var finalPath = UIBezierPath() /// The layer's background color. private var layerBackgroundColor: UIColor /// The initializer. /// - Parameters: /// - frame: The view's frame. /// - cornerRadius: The layer's corner radius. /// - backgroundColor: The layer's solid color. init(frame: CGRect, cornerRadius: CGFloat, backgroundColor: UIColor) { self.layerBackgroundColor = backgroundColor super.init(frame: .init(x: 0, y: 0, width: frame.width, height: frame.height)) self.backgroundColor = .clear initialPath = UIBezierPath(roundedRect: frame, cornerRadius: cornerRadius) } /// Animates the view's layer to the size of a given frame. /// - Parameters: /// - frame: The final frame of the layer. /// - duration: The animation's duration. /// - completed: The animation's completion handler. func animate(to frame: CGRect, duration: CFTimeInterval = 1, completed: (() -> Void)?) { // [...] } /// Animates the view's layer back to its initial bounds. /// - Parameters: /// - frame: The initial frame of the reverted animation. /// - duration: The animation's duration. /// - completed: The animation's completion handler. func reverseAnimation(from frame: CGRect, duration: CFTimeInterval = 1, completed: ((Bool) -> Void)?) { // [...] } // [...] }
Code language: Swift (swift)

I created a new class that subclasses UIView to make this animation easily reusable in some other view or view controller. We also have two methods that are in charge for animating the path to its final or initial state. You could also combine both of these methods into one but for simplicity: Let’s keep them separated for now.

The CGRect passed during initialization will be used as dimensions for the initial path, so we only need to pass in the final path when animating. We could also do this in the initializer but passing it when actually animating felt more flexible.

Setting up all the layers

Core Animation uses layers for rendering content. If you want to add animations you add sublayers with content you want to animate and add the animations to these layers. For our purpose I created shapeLayer on that the visible shape will be animated and an additional maskLayer that will hold information about which parts will be visible. Both layers will contain the same path. First I tried to create the animation without setting mask but that did not work me. Without setting it, the animation always looked clipped.

All of this can be setup in layoutSubviews.

override func layoutSubviews() { super.layoutSubviews() shapeLayer.path = initialPath.cgPath layer.addSublayer(shapeLayer) maskLayer.path = shapeLayer.path maskLayer.position = shapeLayer.position shapeLayer.fillColor = UIColor.red.cgColor. layer.mask = maskLayer }
Code language: Swift (swift)

Setting the final path

Before we write some actual animation code, let me show you how to setup the final path and why it is important that your starting path and your final path have the same number of control points.

I introduced another method called setFinalPath(frame:) that saves the passed final path in a property so that the reverse animation can access it as well.

/// Sets the final path of the animation. /// - Parameter frame: The `CGRect` used to create the path. private func setFinalPath(frame: CGRect) { if let superview = superview { let frame = superview.convert(frame, to: self) finalPath = UIBezierPath(roundedRect: frame, cornerRadius: .leastNonzeroMagnitude) } }
Code language: Swift (swift)

The CGRect that is used to create the final UIBezierPath is converted to the superview’s coordinate system before initializing the path (see line 5). This ensures that the layer will be presented at the right position, independent from the AnimatedCardView‘s dimensions.

Also have a look at the initializer for the path (see line 6). I did create a rounded rectangle with the smallest possible value for cornerRadius so that there are no visible corners. If we would use UIBezierPath(rect:) which also creates a rectangle without any rounded corners we would get a strange looking animation (see following video). This is because both paths have differnt numbers of control points.

Different numbers of control points lead to strange animations.

Forward animation (expand rectangle)

We will have a look now at the first animation. So, without further talking, here is the code:

/// Animates the view's layer to the size of a given frame. /// - Parameters: /// - frame: The final frame of the layer. /// - duration: The animation's duration. /// - completed: The animation's completion handler. func animate(to frame: CGRect, duration: CFTimeInterval = 1, completed: ((Bool) -> Void)?) { setFinalPath(frame: frame) CATransaction.begin() CATransaction.setCompletionBlock { completed?(true) } // Do animation stuff CATransaction.commit() }
Code language: Swift (swift)

When using Core Animation, you create a CABasicAnimation for that you define which values are changed and what timing function to use while animating. You add this animation after configuring it to a layer. If you want to group multiple animations you can use CAAnimationGroup. You can read more about this here: https://developer.apple.com/documentation/quartzcore/caanimationgroup.

This list of calls to configure the animation will be nested between CATransaction.begin and CATransation.commit (see line 8 and 15). If you want to add a completion block, you can do that by adding CATransation.setCompletionBlock { }. Core Animation calls the content of this completion block after finishing the animation.

Let’s have a look now at the actual animation code:

let animation = CABasicAnimation(keyPath: "path") animation.duration = duration animation.fromValue = initialPath.cgPath animation.toValue = finalPath.cgPath animation.timingFunction = CAMediaTimingFunction(name: .default) animation.fillMode = .forwards animation.isRemovedOnCompletion = false shapeLayer.add(animation, forKey: "animateCard") maskLayer.add(animation, forKey: "animateCard")
Code language: Swift (swift)

First you have to initialize an instance of CABasicAnimation with a named key path. After that, you configure your animation. The most important settings are the animation’s duration, its initial value (fromValue) and its final value (toValue). Furthermore, you set which timing function to use (e.g. start fast and end slow or a linear function) and which state of the animation should be visible at the end (fillMode). We do not want to remove the animation after finishing. Therefore we set isRemovedOnCompletion to false.

The reversed animation (collapse rectangle)

The method for running the reversed animation is pretty similar. Therefore, I just start with the complete listing here:

/// Animates the view's layer back to its initial bounds. /// - Parameters: /// - frame: The initial frame of the reverted animation. /// - duration: The animation's duration. /// - completed: The animation's completion handler. func reverseAnimation(from frame: CGRect, duration: CFTimeInterval = 1, completed: ((Bool) -> Void)?) { setFinalPath(frame: frame) CATransaction.begin() CATransaction.setCompletionBlock { completed?(true) } let reverseAnimation = CABasicAnimation(keyPath: "path") reverseAnimation.duration = duration reverseAnimation.fromValue = finalPath.cgPath reverseAnimation.toValue = initialPath.cgPath reverseAnimation.timingFunction = CAMediaTimingFunction(name: .default) reverseAnimation.isRemovedOnCompletion = false reverseAnimation.fillMode = .backwards shapeLayer.removeAnimation(forKey: "animateCard") maskLayer.removeAnimation(forKey: "animateCard") shapeLayer.add(reverseAnimation, forKey: "animateCard") maskLayer.add(reverseAnimation, forKey: "animateCard") CATransaction.commit() }
Code language: Swift (swift)

As you can see, the code is almost the same. So, it might be a good candidate for refactoring, e.g. add a parameter direction or something like that. However, for simplicity reasons, we just have two separate methods for now.

The main differences between both methods are that fromValue and toValue are flipped and we also set the fillMode to .backwards. To not conflict with the forward animation, we remove it and add the reversed animation at the end. We use the same key for both directions because we never want to have both animations at the same time.

Conclusion

In my opinion Core Animation code does not read very difficult but writing it can sometimes lead to headaches. However, if you stick to the rule of having the same amount of control points at the beginning and end of the animation, you can create pretty cool stuff in a short amount of time.

If you want to download the playground file, you can get it here:

Leave a Reply

Your email address will not be published. Required fields are marked *