Core Animation (CA) is the underlying library UIKit and SwiftUI use to animate and manipulate content in their UIViews. While higher level APIs are often easier to use and recommended for doing iOS animations, understanding how Core Animation works not only gives you insight, it is sometimes necessary to get the desired effects you want.
The power of Core Animation is it’s speed. It works off the main run loop. It leverages the GPU hardware to animate things very quickly. Which is why it is able to animate and update your screen roughly 60 times per second.
Now just to be clear. There are higher level APIs for doing animations in iOS.
UIKit has a host of property animations, view controller transitions, and physics based animation libraries to help us animate views in UIKit.
The reason we are spending time down here in the underlying library that powers all this is insight and understanding. Sometimes we can’t do the type of animation we want in UIKit, and it is only by dropping down into Core Animation directly that we can get the effect we want.
But just as importantly is understanding. Regardless of whether you are working in UIKit or SwiftUI, all animations eventually get rendered through this framework. And by understanding how it works, it will make working with these higher level APIs easier.
Core Animation works off of layers. Every UIView
has an underlying CALayer
that you use to animate and change properties of your view.
You basically specify a start and stop position. Add the animation to the layer, and Core Animation takes care of the rest interpolating the images between the two states and animating them on screen.
Using Core Animation we can move shapes.
let animation = CABasicAnimation()
animation.keyPath = "position.x"
animation.fromValue = 20 + 140/2
animation.toValue = 300
animation.duration = 1
redView.layer.add(animation, forKey: "basic")
redView.layer.position = CGPoint(x: 300, y: 100 + 100/2) // update to final position
Scale things up.
let animation = CABasicAnimation()
animation.keyPath = "transform.scale"
animation.fromValue = 1
animation.toValue = 2
animation.duration = 0.4
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeScale(2, 2, 1) // update
Rotate them.
let animation = CABasicAnimation()
animation.keyPath = "transform.rotation.z" // Note: z-axis
animation.fromValue = 0
animation.toValue = CGFloat.pi / 4
animation.duration = 1
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 4, 0, 0, 1)
Even shake things up.
let animation = CAKeyframeAnimation()
animation.keyPath = "position.x"
animation.values = [0, 10, -10, 10, 0]
animation.keyTimes = [0, 0.16, 0.5, 0.83, 1]
animation.duration = 0.4
animation.isAdditive = true
textField.layer.add(animation, forKey: "shake")
In Core Animation everything animates around the views position
or anchor point
. To animate a rectangle from left-to-right along it's x-axis we first need to create a CABasicAnimation
.
let animation = CABasicAnimation()
We then need to tell this animation what exactly we would like to animate. We do this by setting its keyPath
.
animation.keyPath = "position.x"
Keypaths are how we tell Core Animation what we would like to animate. You can find a list of different here.
Just note you need to add the extension to the keyPath
when reading the documention. For example transform.rotation.z
instead of just rotation.z
.
Then we need to define the start and end states of our animation like this.
animation.fromValue = 20 + 140/2
animation.toValue = 300
animation.duration = 1
The tricky thing to understand is Core Animation (CA) works of a shapes position
or anchor point
. The position is the shapes middle. So to move the rectangle left-to-right we first need to calculate the x
point value of the rectangle middle, and then final x
position of the rectangle middle and make those our fromValue
and toValue
.
Then we add the animation to our view's layer.
redView.layer.add(animation, forKey: "basic")
And then update the final animated position.
redView.layer.position = CGPoint(x: 300, y: 100 + 100/2) // update to final position
This last step is critical. As mentioned earlier, CA keeps two layers - the model layer (current state) and the presentation layer (animation state). And the end of our animation we need to update our model state to reflect the final presentation state. That is what this line does here.
Full source.
MoveViewController.swift
import UIKit
class MoveViewController: UIViewController {
let redView = UIView(frame: CGRect(x: 20, y: 100, width: 140, height: 100))
let button = makeButton(withText: "Animate")
override func viewDidLoad() {
super.viewDidLoad()
redView.backgroundColor = .systemRed
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .primaryActionTriggered)
view.addSubview(redView)
view.addSubview(button)
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: button.bottomAnchor, multiplier: 2),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
func animate() {
let animation = CABasicAnimation()
animation.keyPath = "position.x"
animation.fromValue = 20 + 140/2
animation.toValue = 300
animation.duration = 1
redView.layer.add(animation, forKey: "basic")
redView.layer.position = CGPoint(x: 300, y: 100 + 100/2) // update to final position
}
@objc func buttonTapped(_ sender: UIButton) {
animate()
}
}
// MARK: - Factories
func makeButton(withText text: String) -> UIButton {
let button = UIButton()
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitle(text, for: .normal)
button.titleLabel?.adjustsFontSizeToFitWidth = true
button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)
button.backgroundColor = .systemBlue
button.layer.cornerRadius = 8
return button
}
Using these same principles we can scale shapes too.
let animation = CABasicAnimation()
animation.keyPath = "transform.scale"
animation.fromValue = 1
animation.toValue = 2
animation.duration = 0.4
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeScale(2, 2, 1) // update
Here we set the keyPath
to transform.scale
. Specify how big we would like our shape to scale (x2). And then update our final scaled size using CATransform3DMakeScale
.
The one difference with this example is note how we didn't set the rectangle's size until the viewDidAppear
.
I did this to avoid hard coding the size, so you could see how to dynamically set a views size based on the size of the phone screen.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
redView.frame = CGRect(x: view.bounds.midX - _width/2,
y: view.bounds.midY - _height/2,
width: _width, height: _height)
}
Full source.
ScaleViewController.swift
import UIKit
class ScaleViewController: UIViewController {
let redView = UIView()
let _width: CGFloat = 140
let _height: CGFloat = 100
let button = makeButton(withText: "Animate")
override func viewDidLoad() {
super.viewDidLoad()
redView.backgroundColor = .systemRed
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .primaryActionTriggered)
view.addSubview(redView)
view.addSubview(button)
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: button.bottomAnchor, multiplier: 2),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
redView.frame = CGRect(x: view.bounds.midX - _width/2,
y: view.bounds.midY - _height/2,
width: _width, height: _height)
}
func animate() {
let animation = CABasicAnimation()
animation.keyPath = "transform.scale"
animation.fromValue = 1
animation.toValue = 2
animation.duration = 0.4
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeScale(2, 2, 1) // update
}
@objc func buttonTapped(_ sender: UIButton) {
animate()
}
}
Rotating is the same as the others. Only here note that we rotate around the z-axis and that angles are in radians
.
let animation = CABasicAnimation()
animation.keyPath = "transform.rotation.z" // Note: z-axis
animation.fromValue = 0
animation.toValue = CGFloat.pi / 4
animation.duration = 1
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 4, 0, 0, 1)
This example rotates the rectangle 45 degrees (pi/4) and the z-axis is the one coming out of the page.
Full source.
RotateViewController.swift
import UIKit
class RotateViewController: UIViewController {
let redView = UIView()
let _width: CGFloat = 140
let _height: CGFloat = 100
let button = makeButton(withText: "Animate")
override func viewDidLoad() {
super.viewDidLoad()
redView.backgroundColor = .systemRed
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .primaryActionTriggered)
view.addSubview(redView)
view.addSubview(button)
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: button.bottomAnchor, multiplier: 2),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
redView.frame = CGRect(x: view.bounds.midX - _width/2,
y: view.bounds.midY - _height/2,
width: _width, height: _height)
}
func animate() {
let animation = CABasicAnimation()
animation.keyPath = "transform.rotation.z" // Note: z-axis
animation.fromValue = 0
animation.toValue = CGFloat.pi / 4
animation.duration = 1
redView.layer.add(animation, forKey: "basic")
redView.layer.transform = CATransform3DMakeRotation(CGFloat.pi / 4, 0, 0, 1)
}
@objc func buttonTapped(_ sender: UIButton) {
animate()
}
}
CA enables you to very specifically control how your animations works via keyframes
.
let animation = CAKeyframeAnimation()
animation.keyPath = "position.x"
animation.values = [0, 10, -10, 10, 0]
animation.keyTimes = [0, 0.16, 0.5, 0.83, 1]
animation.duration = 0.4
animation.isAdditive = true
textField.layer.add(animation, forKey: "shake")
Keyframes are specific frames in your animation that CA will animate between. For exanple to get this shake effect, we specifiy which position.x
positions we would like our textField
to oscilate between for specifiy seconds.
animation.values = [0, 10, -10, 10, 0]
animation.keyTimes = [0, 0.16, 0.5, 0.83, 1]
When this animation is applied and added, that's what gives the textField that shake.
Full source.
ShakeViewController.swift
import UIKit
class ShakeViewController: UIViewController {
let textField = UITextField()
let shakeButton = makeButton(withText: "Shake")
override func viewDidLoad() {
super.viewDidLoad()
setup()
layout()
animate()
}
}
// MARK: - Setup
extension ShakeViewController {
func setup() {
textField.setIcon(UIImage(systemName: "lock")!)
textField.translatesAutoresizingMaskIntoConstraints = false
textField.backgroundColor = .systemGray5
textField.font = UIFont.preferredFont(forTextStyle: .title1)
textField.layer.cornerRadius = 6
textField.placeholder = " ••••• "
shakeButton.addTarget(self, action: #selector(shakeTapped(_:)), for: .primaryActionTriggered)
view.addSubview(textField)
view.addSubview(shakeButton)
}
func layout() {
NSLayoutConstraint.activate([
textField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 60),
textField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
shakeButton.topAnchor.constraint(equalToSystemSpacingBelow: textField.bottomAnchor, multiplier: 2),
shakeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
@objc func shakeTapped(_ sender: UIButton) {
animate()
}
}
// MARK: - Animations
extension ShakeViewController {
func animate() {
let animation = CAKeyframeAnimation()
animation.keyPath = "position.x"
animation.values = [0, 10, -10, 10, 0]
animation.keyTimes = [0, 0.16, 0.5, 0.83, 1]
animation.duration = 0.4
animation.isAdditive = true
textField.layer.add(animation, forKey: "shake")
}
}
extension UITextField {
func setIcon(_ image: UIImage) {
let iconView = UIImageView(frame: CGRect(x: 10, y: 5, width: 20, height: 20))
iconView.image = image
let iconContainerView: UIView = UIView(frame: CGRect(x: 20, y: 0, width: 30, height: 30))
iconContainerView.addSubview(iconView)
leftView = iconContainerView
leftViewMode = .always
}
}
To get good at Core Animation it's important to understand the relative natural of the coordinate system and how things connect together.
Take this red square for example. Say we would like to make it circle the middle of the screen following that red path.
If we naively added the rectangle not centered perfectly to the circle, we would get an animation that looks like this.
redView.frame = CGRect(x: view.bounds.midX,
y: view.bounds.midY,
width: _width, height: _height)
This happens a lot when working in CA. You often don't get things lined quite up right.
If we center our square relative to the circle however (by adjusting its position by half its width and height).
redView.frame = CGRect(x: view.bounds.midX - _width/2,
y: view.bounds.midY - _height/2,
width: _width, height: _height)
Everything is centered and it all works out. This was the most confusing thing for me when first starting out. But once you understand the coordinate system, and how shapes relate to one another, it start making sense.
import UIKit
class CirclingViewController: UIViewController {
let redView = UIView()
let _width: CGFloat = 40
let _height: CGFloat = 40
let redCircle = UIImageView()
let _diameter: CGFloat = 300
let button = makeButton(withText: "Animate")
override func viewDidLoad() {
super.viewDidLoad()
redView.backgroundColor = .systemRed
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(buttonTapped(_:)), for: .primaryActionTriggered)
view.addSubview(redView)
view.addSubview(redCircle)
view.addSubview(button)
NSLayoutConstraint.activate([
view.safeAreaLayoutGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: button.bottomAnchor, multiplier: 2),
button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
])
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// draw box
redView.frame = CGRect(x: view.bounds.midX - _width/2,
y: view.bounds.midY - _height/2,
width: _width, height: _height)
// Demo purposes
// redView.frame = CGRect(x: view.bounds.midX,
// y: view.bounds.midY,
// width: _width, height: _height)
// draw circle
redCircle.frame = CGRect(x: view.bounds.midX - _diameter/2,
y: view.bounds.midY - _diameter/2,
width: _diameter, height: _diameter)
let renderer = UIGraphicsImageRenderer(size: CGSize(width: _diameter, height: _diameter))
let img = renderer.image { ctx in
let rectangle = CGRect(x: 0, y: 0, width: _diameter, height: _diameter)
ctx.cgContext.setStrokeColor(UIColor.red.cgColor)
ctx.cgContext.setFillColor(UIColor.clear.cgColor)
ctx.cgContext.setLineWidth(1)
ctx.cgContext.addEllipse(in: rectangle)
ctx.cgContext.drawPath(using: .fillStroke)
}
redCircle.image = img
}
func animate() {
let boundingRect = CGRect(x: -_diameter/2, y: -_diameter/2, width: _diameter, height: _diameter)
let orbit = CAKeyframeAnimation()
orbit.keyPath = "position"
orbit.path = CGPath(ellipseIn: boundingRect, transform: nil)
orbit.duration = 2
orbit.isAdditive = true
// orbit.repeatCount = HUGE
orbit.calculationMode = CAAnimationCalculationMode.paced;
orbit.rotationMode = CAAnimationRotationMode.rotateAuto;
redView.layer.add(orbit, forKey: "redbox")
}
@objc func buttonTapped(_ sender: UIButton) {
animate()
}
}
extension NotificationBadgeView {
@objc func imageViewTapped(_ recognizer: UITapGestureRecognizer) {
shake()
}
private func shake() {
let dur = 0.1667
UIView.animateKeyframes(withDuration: 1, delay: 0, options: [],
animations: {
UIView.addKeyframe(withRelativeStartTime: 0.0,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform(rotationAngle: -.pi/8)
}
UIView.addKeyframe(withRelativeStartTime: dur,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform(rotationAngle: +.pi/8)
}
UIView.addKeyframe(withRelativeStartTime: dur*2,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform(rotationAngle: -.pi/8)
}
UIView.addKeyframe(withRelativeStartTime: dur*3,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform(rotationAngle: +.pi/8)
}
UIView.addKeyframe(withRelativeStartTime: dur*4,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform(rotationAngle: -.pi/8)
}
UIView.addKeyframe(withRelativeStartTime: dur*5,
relativeDuration: dur) {
self.imageView.transform = CGAffineTransform.identity
}
},
completion: nil
)
}
}