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: UIViewPropertyAnimatoranimator = 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.animations 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.0position.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 = trueroundView.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.
https://github.com/jullianm/LoadingControllerWithCustomLoader
— 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 = truedynamicBehavior.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.
https://github.com/jullianm/Sliding-Effect-UIKitDynamics
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.