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:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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:


1
2
3
// 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:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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()
}

Headspace Play Button iOS Swift

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.