I’d like to preface this blog by pointing you to this page which really inspired me and a lot of the calculations are taken directly from that page, but explained in more detail in this post.
I wanted to create a nice animation similar to the Headspace app’s play button which kind of resembles an animating, warped and imperfect blob shape. There were so many ways of doing something like this that it sent me way down the rabbit hole of Core Animation so I thought I would write about it.
My solution involves using CADisplayLink and CoreAnimation to achieve the animated blob.
CADisplayLink
CADisplayLink is a way of animating things in a super-smooth fashion. Every time you run an app it aims to refresh itself 60 times per second to achieve a nice, smooth experience. However, it is highly likely that there will be small fluctuations between each render, sometimes tiny, tiny parts of a second. This however creates a stuttering effect to the user if you don’t take the fluctuations into account and the solution is this handy API (which I thought was deprecated) to keep track of those gaps.
Delta time is the name given to the gap between the last frame that was rendered and is measured in seconds. If we keep track of this everything will look awesome in our app.
A DisplayLink essentially runs a method (selector) on a specific thread right before each frame is rendered. That’s it! Let’s make an animator class and see how it’s going to work:
import UIKit // This will enable us to view our animation live in the Playground import PlaygroundSupport // Neaten up the readability with a completion typealias // All it will take is our updated TimeInterval and return nothing typealias AnimationBlock = (TimeInterval) -> Void class Animator { // MARK: - Properties - // Our animation block to run private var animationBlock: AnimationBlock // Let's start the delta time at 0 private var totalTime: TimeInterval = 0 // We'll need a display link that we'll run on the main thread which will essentially run a selector at every frame private var displayLink: CADisplayLink { return CADisplayLink(target: self, selector: #selector(updateDeltaTime)) } // MARK: - Initialiser - // We initialise with a block to run and make sure the link is added to the main runloop init(animationBlock: @escaping AnimationBlock) { self.animationBlock = animationBlock displayLink.add(to: RunLoop.main, forMode: .common) } // MARK: - Private Methods - // The selector updates the time since the last frame was rendered and passes it to the completion block to handle @objc private func updateDeltaTime(link: CADisplayLink) { totalTime += link.duration animationBlock(totalTime) } // Always invalidate this at the end deinit { displayLink.invalidate() } }
As you can see we simply create a display link and specify that it run on the main run loop which is run on the main thread. Note: Never specify other threads that you are not on else things get nasty…
Next we’ll grab some code to create a bezier path to draw a rough shape of our blob.
func createShape () -> UIBezierPath { let ovalPath = UIBezierPath() ovalPath.move(to: CGPoint(x: 13.71, y: -29.07)) ovalPath.addCurve(to: CGPoint(x: 27.92, y: -14), controlPoint1: CGPoint(x: 20.64, y: -25.95), controlPoint2: CGPoint(x: 24.57, y: -20.72)) ovalPath.addCurve(to: CGPoint(x: 33, y: 0.5), controlPoint1: CGPoint(x: 30.08, y: -9.68), controlPoint2: CGPoint(x: 33, y: -4.64)) ovalPath.addCurve(to: CGPoint(x: 20.82, y: 26), controlPoint1: CGPoint(x: 33, y: 10.93), controlPoint2: CGPoint(x: 27.47, y: 17.84)) ovalPath.addCurve(to: CGPoint(x: 0, y: 33), controlPoint1: CGPoint(x: 16.02, y: 31.88), controlPoint2: CGPoint(x: 7.63, y: 33)) ovalPath.addCurve(to: CGPoint(x: -16.72, y: 28.33), controlPoint1: CGPoint(x: -6.21, y: 33), controlPoint2: CGPoint(x: -11.89, y: 31.29)) ovalPath.addCurve(to: CGPoint(x: -23.86, y: 22), controlPoint1: CGPoint(x: -19.59, y: 26.57), controlPoint2: CGPoint(x: -22.22, y: 24.28)) ovalPath.addCurve(to: CGPoint(x: -28, y: 17), controlPoint1: CGPoint(x: -25.19, y: 20.16), controlPoint2: CGPoint(x: -26.74, y: 19.46)) ovalPath.addCurve(to: CGPoint(x: -33, y: 0.5), controlPoint1: CGPoint(x: -30.24, y: 12.61), controlPoint2: CGPoint(x: -33, y: 5.74)) ovalPath.addCurve(to: CGPoint(x: -23.86, y: -23), controlPoint1: CGPoint(x: -33, y: -9.63), controlPoint2: CGPoint(x: -31.23, y: -17.04)) ovalPath.addCurve(to: CGPoint(x: -4.57, y: -33), controlPoint1: CGPoint(x: -18.17, y: -27.6), controlPoint2: CGPoint(x: -12.51, y: -33)) ovalPath.addCurve(to: CGPoint(x: 13.71, y: -29.07), controlPoint1: CGPoint(x: 0.32, y: -33), controlPoint2: CGPoint(x: 9.53, y: -30.95)) ovalPath.close() return ovalPath }
We need this path to draw our shape layer with that we’ll do next:
// First we need to create a shape layer let blob = CAShapeLayer() // Next we create our path and assign the shape's path let blobLayer = createShape() blob.path = blobLayer.cgPath blob.frame = blobLayer.bounds blob.anchorPoint = CGPoint(x: 0, y: 0) blob.fillColor = UIColor.orange.cgColor // Let's create a view and position everything nicely let viewRect = CGRect(x: 0, y: 0, width: 640, height: 480) var view = UIView(frame: viewRect) view.backgroundColor = UIColor.gray.withAlphaComponent(0.2) blob.position = view.center view.layer.addSublayer(blob)
Next, we’ll make sure things render in our Playground:
// Playground setup for easy displaying and debugging PlaygroundPage.current.liveView = view PlaygroundPage.current.needsIndefiniteExecution = true
Finally let’s create our Animator class and see things in action:
let animation = Animator { // The block is a trailing closure here so we don't need to explicitly use parenthesis like normal constructors let skewBaseTime = $0 * 0.1 // First transform to rotate the entire shape let rotation = CATransform3DMakeRotation(CGFloat(acos(cos(skewBaseTime))), 0, 0, 1) // cos takes an angle and gives you the ration between the two sides of a triangle, acos inverts this - we're turning it into an angle in radians here essentially let upscale = 5.0 let scaleAdjustment = 0.1 // Second transform which takes a positive value which will always be between 0 and 1 and multiplies it by the fraction above let scale = CATransform3DMakeScale(CGFloat(upscale + abs(sin(skewBaseTime) * scaleAdjustment)), CGFloat(upscale + abs(cos(skewBaseTime) * scaleAdjustment)), 1) // Third transform let skewTransform = CGAffineTransform(a: 1.0, b: 0.0, c: CGFloat(cos(skewBaseTime + .pi / 2) * 0.1), d: 1.0, tx: 0.0, ty: 0.0) // Transactions are a way of batching core animation properties simultaneously - very handy CATransaction.begin() CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions) // Concatenate the affine matrix with the current transformation matrix view.layer.transform = CATransform3DConcat(scale, CATransform3DMakeAffineTransform(skewTransform)) blob.transform = rotation CATransaction.commit() }
A few notes on some of the math above: all it’s really doing is rotating and skewing the rendered image. This is done using matrixes and transforms. These are quite deep in themselves but matrix multiplication can help with lots of things and are really handy to understand if you are doing work using ARKit natively as well.
Awesome! Now we have a super-cool Headspace-like blob for our play button. Note that this doesn’t have the play icon image itself but deals with how you could achieve something similar with the effect. There were also many ways we could have done this such as animating the keypaths of the shape itself, however I just wanted to document what I learned using display link.
Feel free to play around with the calculations to get a different effect.