on
iOS - Animations
Animations enable us to enhance the user experience by creating a wide range of visual effects in an app. Animations are quite sensitive because of the thin line lying between an overkill one, which will affect the user experience, and a well-balanced one.
Hopefully, UIKit includes a set of APIs to easily implement these animations on different levels and use the right one according to our needs.
UIView.Animations /// (discouraged by Apple)
UIViewPropertyAnimator
UIKitDynamics
CAAnimation /// Core Animation
— UIView.Animations
UIView.Animations
acts on the view level using type methods on the UIView
itself.
These class methods provide basic animations.
class func animate(
withDuration duration: TimeInterval,
animations: @escaping () -> Void
)
The animate()
class method takes a duration argument — the length of the animation — and an escaping closure where the animation is actually performed.
Translating a view on the X-axis with a duration of 2 seconds results in the following implementation.
UIView.animate(withDuration: 2) {
self.roundView.center.x += 200.0
}
Delay, options plus a completion handler could be added to improve the animation.
class func animate(
withDuration duration: TimeInterval,
delay: TimeInterval,
options: UIView.AnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
withDuration
: The duration of the animation.
delay
: The amount of seconds UIKit will wait before it starts the animation.
options
: Lets you customize a number of aspects about your animation. An empty array [] stands for ‘no options‘.
animations
: The closure expression to provide your animations.
completion
: A code closure to execute when the animation completes. This parameter often comes in handy when you want to perform some final cleanup tasks or chain animations one after the other.
UIView.animate(
withDuration: 2,
delay: 1,
options: [.repeat, .autoreverse], animations: {
self.roundView.center.x += self.view.bounds.width
},completion: nil)
The latter will translate our roundView
object just like before except the animation will begin with a delay of 1 seconds playing it forward then in reverse, forever.
Let’s take a look at some of the other options.
.curveEaseIn
: This option applies acceleration to the start of your animation.
.curveEaseOut
: This option applies deceleration to the end of your animation.
.curveEaseInOut
: This option applies acceleration to the start of your animation and applies deceleration to the end of your animation.
.curveLinear
: This option applies no acceleration or deceleration to the animation.
A UIView
has several animatable properties which could be used simultaneously to create a fancy animation with just a few lines of code.
bounds
: Animate this property to reposition the view’s content within the view’s frame.
frame
: Animate this property to move and/or scale the view.
center
: Animate this property when you want to move the view to a new location on screen
backgroundColor
: Change this property of a view to have UIKit gradually change the background color over time.
alpha
: Change this property to create fade-in and fade-out effects.
transform
: Modify this property within an animation block to animate the rotation, scale, and/or position of a view
Note: Layout constraints are also animatable.
The springWithDamping
class method allows us to apply a spring effect on a view.
Combining the duration, the dampingRatio
and the initial velocity values will have a direct impact on the spring strength.
class func animate(
withDuration duration: TimeInterval,
delay: TimeInterval,
usingSpringWithDamping dampingRatio: CGFloat,
initialSpringVelocity velocity: CGFloat,
options: UIView.AnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
UIView.animate(
withDuration: 2,
delay: 0,
usingSpringWithDamping: 0.5,
initialSpringVelocity: 0.0,
options: [], animations: {
self.roundView.center.x += (self.view.bounds.width/2 —
self.roundView.frame.width/2)
})
Since animate
is a method type from UIView
, we could easily create the animation from a UIView
extension and apply it directly to the view.
extension UIView {
func animate(
withDuration duration: Double = 2,
delay: Double = 0,
damping: CGFloat = 0.5,
velocity: CGFloat = 0.0,
options: UIView.AnimationOptions = []
) {
UIView.animate(
withDuration: duration,
delay: delay,
usingSpringWithDamping: damping,
initialSpringVelocity: velocity,
options: options
animations: {
self.center.x += 100
},
completion: nil
)
}
}
roundView.animate()
It provides a concise yet readable way to implement the animation.
Chaining animations
While it is quite tempting to chain the animations using the animations completion blocks, Apple actually provides an animateKeyFrames
type method to do so in a much proper way.
class func animateKeyframes(
withDuration duration: TimeInterval,
delay: TimeInterval,
options: UIView.KeyframeAnimationOptions = [],
animations: @escaping () -> Void,
completion: ((Bool) -> Void)? = nil
)
The animations
block parameter will contain the animations key frames using yet another type method called addKeyFrame
.
class func addKeyframe(
withRelativeStartTime frameStartTime: Double,
relativeDuration frameDuration: Double,
animations: @escaping () -> Void
)
Each key frame will be added inside the block using relative Double
values from 0 to 1 where 0 and 1 represents respectively the beginning and the end of the animation.
Moving our roundView
on the X-axis then on the Y-axis becomes quite easy using the animateKeyFrames
type method.
UIView.animateKeyframes(
withDuration: 2.0,
delay: 0.0,
options: [],
animations: {
UIView.addKeyframe(
withRelativeStartTime: 0,
relativeDuration: 0.5
) {
self.roundView.center.x += (self.view.bounds.width/2 —
self.roundView.frame.width/2)
}
UIView.addKeyframe(
withRelativeStartTime: 0.5,
relativeDuration: 0.5
) {
self.roundView.center.y -= (self.view.bounds.height/2
— self.roundView.frame.height/2)
}
}, completion: nil)
Our view will move right then up for 1 second each time. Let’s make our view shines by changing its background color.
We want the color to gradually change as soon as the animation begins thus we specify a relative start time of 0 and a relative duration of 1 which will make our background fully yellow by the time the animation finishes.
UIView.addKeyframe(
withRelativeStartTime: 0,
relativeDuration: 1
) {
self.roundView.backgroundColor = .yellow
}
While these APIs are widely popular, Apple is discouraging their use as mentioned in the UIView
documentation:
Use of these methods is discouraged. Use the UIViewPropertyAnimator class to perform animations instead.
— UIViewPropertyAnimator
A class that animates changes to views and allows the dynamic modification of those animations.
class UIViewPropertyAnimator : NSObject
The view property animator is very powerful as it enables us to keep a reference to the object handling our animations. Using the animator property, animations can be added, started, paused, finished at any time.
It is composed of several convenient init() methods where initial settings can be set such as a duration (which cannot be changed once defined), a damping ratio for the ‘spring‘ effect, or a curve.
An initial animation may also be declared using the animations block.
convenience init(
duration: TimeInterval,
curve: UIView.AnimationCurve,
animations: (() -> Void)? = nil
)
convenience init(
duration: TimeInterval,
dampingRatio ratio: CGFloat,
animations: (() -> Void)? = nil
)
let animator: UIViewPropertyAnimator
animator = UIViewPropertyAnimator(
duration: 2.0,
curve: .easeInOut,
animations: nil
)
animator.addAnimations {
self.roundView.center.x += (self.view.bounds.width/2
self.roundView.frame.width/2)
}
animator.addAnimations {
self.roundView.backgroundColor = .yellow
}
animator.addCompletion { position in
guard position == .end else {
return
}
print(“animation completed”)
}
The startAnimation
method must be called to trigger the animation.
animator.startAnimation()
The pauseAnimation
method will pause the animation at any time.
animator.pauseAnimation()
The continueAnimation
method will unpause the animation using the newly provided timing curve and duration.
animator.continueAnimation(
withTimingParameters: nil,
durationFactor: 2.0
)
The type method runningPropertyAnimator
comes in handy when you don’t actually need to keep hold of the animations state.
Removing all the boilerplate code, it acts just like the good old UIView.animate()
class method except it will return an animator instance, reusable later on.
UIViewPropertyAnimator.runningPropertyAnimator(
withDuration: 2.0,
delay: 0.0,
options: [],
animations: {
self.roundView.center.x += (self.view.bounds.width/2
self.roundView.frame.width/2)
}, completion: nil
)
Animators are really convenient in that they provide much more flexibility than their UIView.animation
counterpart.
UIView.Animations
and UIViewPropertyAnimators
provide a convenient level of abstraction enabling us to easily implement different sorts of animations.
However, under the hood , the actual animation is performed by Core Animation
on the view’s layer.
Whenever your animation is too complex to be managed on the view’s level, you might consider using Core Animation
.
— Core animation
A UIView
is backed with a CALayer
.
A view is really just a wrapper around a layer enabling us to handle touch events and user interactions.
We can access a view’s layer using the layerClass
type property as follows:
override class var layerClass: AnyClass {
return CALayer.self
}
By default, all views are backed with a CALayer
however we can change it using a different layer’s type.
override class var layerClass: AnyClass {
return CAGradientLayer.self
}
Different types of layers
CALayer
CAGradientLayer
CAShapeLayer
CAGradientLayer
CAReplicatorLayer
CAEmitterLayer
These layers offer much more animatable properties than UIView does which opens up a lot of possibilities.
Different types of animations
Some of the following core animation classes are used to animate the layers.
CABasicAnimation
CAKeyframeAnimation
CASpringAnimation
CAAnimationGroup
Let’s translate our roundView on the X-axis using the CABasicAnimation
class.
Creating a CABasicAnimation
instance using a keyPath
, representing the animatable property.
let translationX = CABasicAnimation(keyPath: "position.x")
translationX.fromValue = roundView.layer.position.x
translationX.toValue = roundView.center.x += (view.bounds.width/2 — roundView.frame.width/2)
translationX.duration = 0.5
A CABasicAnimation
object is just a data model, which is not bound to any particular layer.
The instance will be copied to the view’s layer and the animation will then be run using the add(:, forKey:)
method.
roundView.layer.add(translationX, forKey: nil)
Instead of nil, we could set a specific key — which will be tied to a specific animation — to easily retrieve it, later on, from our roundView’s layer.
Presentation layer
Whenever a layer is animated, it is not the layer itself displayed on screen but a cached version of it known as the presentation layer, accessible through the presentation()
method.
roundView.layer.presentation()
The presentation()
method returns a copy of the presentation layer object that represents the state of the layer as it currently appears onscreen. The keyword here is represents
.
The presentation layer is not the original layer, just a temporary ‘ghost‘ layer. Thus, the animated UI component may not remain at its final position when an animation finishes.
The fillMode
and isRemovedOnCompletion
properties address this issue however you should use it only when your visual effect is not possible otherwise as this is not the actual layer but a representation of your layer with no interactivity and it doesn’t reflect the actual state of your screen.
Setting the fillMode
property to .backwards
will display the first frame of the animation before it begins while .forwards
will display the last frame after it completes.
Let’s see how that works using a corner radius animation.
let roundedCorners = CABasicAnimation(
keyPath: #keyPath(CALayer.cornerRadius)
)
roundedCorners.fromValue = 0.0
roundedCorners.toValue = 10.0
roundedCorners.duration = 1
roundedCorners.fillMode = .forwards
roundedCorners.isRemovedOnCompletion = false
// We want the corner radius effect to remain on screen after the animation completes.
roundView.layer.add(roundedCorners, forKey: nil)
Note: if we didn’t specify the fillMode
and isRemovedOnCompletion
properties, the presentation layer would disappear at the end of the animation leaving us with the original layer (with no corner radius).
Whenever you need to animate a struct value (immutable) such as CGRect
or CGPoint
, it needs to be wrapped around as an object using NSValue
.
let position = CABasicAnimation(
keyPath: #keyPath(CALayer.position)
)
position.duration = 1.0
position.fromValue = NSValue(cgPoint: CGPoint(x: self.roundView.layer.position.x, y: self.roundView.layer.position.y))
position.toValue = NSValue(cgPoint: CGPoint(x: self.roundView.layer.position.x + 100.0, y: self.roundView.layer.position.y — 100.0))
self.roundView.layer.add(position, forKey: nil)
The CAKeyFrameAnimation
class works similarly as its UIView.animateKeyFrames
counterpart however the syntax looks much cleaner.
let translateX = CAKeyframeAnimation()
translateX.keyPath = "position.x"
translateX.values = [
0,
(self.view.bounds.width/2 — self.roundView.frame.width/2),
0
]
translateX.keyTimes = [0, 0.5, 1]
translateX.duration = 2
translateX.isAdditive = true
roundView.layer.add(translateX, forKey: nil)
In these multiple examples, we created animations on the default CALayer
class.
It is possible to add as many layers as needed to the default views’s layer using the addSublayer()
method or simply set another default layer to the view overriding the layerClass
type property.
The layer, once set, becomes animatable using its underlying properties.
In addition to its own properties, the following CALayer
subclasses will inherit from the CALayer
animatable properties.
CAGradientLayer
colors
: Animate the gradient’s colors to give it a tint.
locations
: Animate the color milestone locations to make the colors movearound inside the gradient.
startPoint
and endPoint
: Animate the extents of the layout of the gradient.
CAShaperLayer
path
: Morph the layer’s shape into a different shape.
fillColor
: Change the fill tint of shape to a different color.
lineDashPhase
: Create a marquee or “marching ants” effect around your shape.
lineWidth
: Grow or shrink the size of the stroke line of your shape.
CAReplicatorLayer
instanceDelay
: Animate the amount of delay between instances
instanceTransform
: Change the transform between replications on the fly
instanceRedOffset
, instanceGreenOffset
, instanceBlueOffset
: Apply a delta to apply to each instance color component
instanceAlphaOffset
: Change the opacity delta applied to each instance
The CAReplicatorLayer
class enables us to create rather complex animations with just a few lines of code.
Each instance layer created will be copied using the specified configuration.
Here’s an implementation of a custom activity indicator using the CAReplicatorLayer
class.
— UIKit Dynamics
UIKit Dynamics enables us to apply physics-based animations to our views. which means UI components will behave just like if they were in the real world. It is particularly well-suited for handling user interactions. Let’s implement some ‘real-world‘ animations using the following classes.
UIDynamicAnimator
An object that provides physics-related capabilities and animations for its dynamic items, and provides the context for those animations.
UIDynamicBehavior
An object that confers a behavioral configuration on one or more dynamic items, for their participation in 2D animation
UICollisionBehavior
An object that confers to a specified array of dynamic items the ability to engage in collisions with each other and with the behavior’s specified boundaries.
UIGravityBehavior
An object that applies a gravity-like force to all of its associated dynamic items.
UISnapBehavior
A spring-like behavior whose initial motion is damped over time so that the object settles at a specific point.
var animator: UIDynamicAnimator!
var dynamicBehavior: UIDynamicBehavior!
var snapBehavior: UISnapBehavior!
override func viewDidLoad() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.animate()
}
}
private func animate() {
animator = UIDynamicAnimator(referenceView: view)
dynamicBehavior = UIDynamicBehavior()
snapBehavior = UISnapBehavior(
item: roundView,
snapTo: .init(
x: roundView.center.x + 100,
y: roundView.center.y + 100
)
)
dynamicBehavior.addChildBehavior(snapBehavior)
animator.addBehavior(dynamicBehavior)
}
The UIDynamicAnimator
class takes a referenceView which serves as the coordinate system for the animator’s behaviors and items.
animator = UIDynamicAnimator(referenceView: view)
Our animator instance will receive later on dynamic behaviors providing a context for our animations.
The UIDynamicBehavior
class enables us to group our dynamic behaviors using the addChildBehavior()
method.
dynamicBehavior = UIDynamicBehavior()
dynamicBehavior.addChildBehavior(snapBehavior)
The UISnapBehavior
class will snap our roundView
item to a specified CGPoint
.
snapBehavior = UISnapBehavior(
item: roundView,
snapTo: .init(
x: roundView.center.x + 100,
y: roundView.center.y + 100
)
)
Finally, we need to add our dynamic behavior — which only contains our snap behavior so far — to the animator to trigger the animation.
animator.addBehavior(dynamicBehavior)
The UISnapBehavior
is used to handle user gestures — e.g a drag gesture —
Now, let’s remove our snap behavior and replace it by some gravity.
var gravityBehavior: UIGravityBehavior!
gravityBehavior = UIGravityBehavior(items: [roundView])
dynamicBehavior.addChildBehavior(gravityBehavior)
The UIGravityBehavior
takes an array of UIDynamicItem
which is a protocol implemented by UIView
and UICollectionViewLayoutAttributes
.
All the items inside the array will be affected by gravity.
Add the following line animator.addBehavior(dynamicBehavior)
to see our roundView
fall down and go out of the screen.
Surely enough our view should collide when it hits the bottom of screen.
var collisionBehavior: UICollisionBehavior!
collisionBehavior = UICollisionBehavior(items: [roundView])
collisionBehavior.translatesReferenceBoundsIntoBoundary = true
dynamicBehavior.addChildBehavior(collisionBehavior)
The translatesReferenceBoundsIntoBoundary
property will automatically create boundaries based on the referenceView
that is our view controller’s view.
animator.addBehavior(dynamicBehavior)
Now, the roundView
will fall down and stop with a little bouncing effect when hitting the bottom of the screen.
Whenever multiple objects should collide with each other, you should use the addBoundary
method to set it manually.
The UIDynamicItemBehavior
comes in handy when you need to define special configuration on the items affected by behaviors.
Using multiple behaviors might end up in great visual effects. For instance, combining collision, gravity, snap behaviors with a drag gesture will result in a nice sliding effect. Here’s a concrete implementation.
Conclusion
UIKit provides different level of abstractions to implement animations in an app such as the animate
type methods or a property animator
, recommended by Apple, and based upon the Core Animation framework.
Depending on the complexity of the animations, Core Animation might be a better choice as it enables us to work directly on the layer level.
UIKit Dynamics uses physics-based behaviors which result in a more realistic animation providing also a better user experience.