Animations are cool. They are an important part of iOS Human Interface Guidelines. Animations help you to draw user’s attention to important things or just add some fun to your app.
There are few ways to implement animations in iOS. Probably, the most popular one is UIView.animate(withDuration:animations:)
. You can animate view’s layer with CABasicAnimation
. Also, UIKit lets you customize animation of view controller’s presentation with UIViewControllerTransitioningDelegate
.
In this tutorial, I want to discuss with you another exciting way to animate your views – UIViewPropertyAnimator
. This class gives you a lot more control than its predecessor UIView.animate
. With it, you can build custom timing, interactive, and interruptible animations. Furthermore, you can change the animator on the fly.
Sound confusing? No worries. You’ll understand in a while.
Starting with UIViewPropertyAnimator
UIViewPropertyAnimator
was introduced in iOS 10. It allows you to create animations in object-oriented way. Let’s have at a sample animation created using UIViewPropertyAnimator
.
This is how you will do it with UIView.
UIView.animate(withDuration: 0.3) {
view.frame = view.frame.offsetBy(dx: 100, dy: 0)
}
And this is how to do it with UIViewPropertyAnimator
:
let animator = UIViewPropertyAnimator(duration:0.3, curve: .linear) {
view.frame = view.frame.offsetBy(dx:100, dy:0)
}
animator.startAnimation()
If you want to test the animation, simply create a Playground project and run the code like below. Both code snippet will result the same animation.
You may think, that there is not a big difference. So, what is the point to introduce a new way for creating animations? UIViewPropertyAnimator
becomes more useful, when you want to create interactive animations.
Interactive and interruptible animations
Do you remember the classic “Slide to Unlock” gesture? Or the “Swipe from bottom” gesture for opening Control Center? These are perfect examples of interactive and interruptible animations. You can start moving a view with your finger, then release it and the view will go back to its original position. Alternatively, you can catch the view during the animation and continue dragging it with your finger.
UIView animations, however, don’t provide an easy way to control the completion percentage of an animation. You can’t pause an animation in the middle of a cycle and continue to execute it after the interruption.
This is the power of UIViewPropertyAnimator
. You’ll see how we can build a fully interactive, interruptible, scrubbable, and reversible animation in a few steps.
Preparing the Starter Project
First, please download the starter project to get started. Once unzip the archive, you will find the CityGuide application, which helps users plan their vacations. User can swipe through the list of cities and then open a detailed card with a detailed information of the city user likes more.
Let’s explore the project code a little bit before creating the animations.
Here is what you can find in the Xcode project:
ViewController.swift
: The main view controller of our application with aUICollectionView
to display an array ofCity
objects.CityCollectionViewCell.swift
: The cell for displayingCity
. In fact, most of the changes will be applied to this class in this tutorial. You may notice that we havedescriptionLabel
andcloseButton
defined in the class. However, if you run the app, you will not see both objects. No worries. We will make them visible later. In the class, we also havecollectionView
andindex
properties. Similarly, we will use them later for animations.CityCollectionViewFlowLayout.swift
: This class is responsible for fancy horizontal scroll. We will not touch it at all.City.swift
: The main model of our application. Also, we have a factory method here, which we used inViewController
.Main.storyboard
: You can find UI forViewController
andCityCollectionViewCell
there.
Try to build it and execute the sample app. This is what you should see.
Implementing the Expand and Collapse Animations
After launching the app, it shows a list of cities. But the user can’t interact with the cells. Now we want to show information for each city when a user taps one of the cell. Take a look at the final deliver. This is what we want to build:
The animation looks good, right? But nothing fancy here, it’s just some basic UIViewPropertyAnimator
logic. Let’s see how to implement this type of animation. Create the collectionView(_:didSelectItemAt)
method by inserting the following code snippet to the end of the ViewController
file:
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let selectedCell = collectionView.cellForItem(at: indexPath)! as! CityCollectionViewCell
selectedCell.toggle()
}
Now we need implement the toggle
method. Let’s switch over to CityCollectionViewCell.swift
and implement the method.
First, add State
enum to the top of the file, right before the class declaration of CityCollectionViewCell
. This enum allows us to keep track of the state of the cell:
private enum State {
case expanded
case collapsed
var change: State {
switch self {
case .expanded: return .collapsed
case .collapsed: return .expanded
}
}
}
Next, let’s add a few properties for controlling animation to the CityCollectionViewCell
class:
private var initialFrame: CGRect?
private var state: State = .collapsed
private lazy var animator: UIViewPropertyAnimator = {
return UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut)
}()
The initialFrame
variable is used to store the frame of cell before animation. state
is used to track if the cell is expanded or collapsed. And, the animator
variable is used to drive and control the animation.
Now add the toggle
method and invoke it from the close
action method like this:
@IBAction func close(_ sender: Any) {
toggle()
}
func toggle() {
switch state {
case .expanded:
collapse()
case .collapsed:
expand()
}
}
Here we added two more methods: expand()
and collapse()
in the code. Let’s continue to implement them. First, we start with the expansion d()
method:
private func expand() {
guard let collectionView = self.collectionView, let index = self.index else { return }
animator.addAnimations {
self.initialFrame = self.frame
self.descriptionLabel.alpha = 1
self.closeButton.alpha = 1
self.layer.cornerRadius = 0
self.frame = CGRect(x: collectionView.contentOffset.x, y:0 , width: collectionView.frame.width, height: collectionView.frame.height)
if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
leftCell.center.x -= 50
}
if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
rightCell.center.x += 50
}
self.layoutIfNeeded()
}
animator.addCompletion { position in
switch position {
case .end:
self.state = self.state.change
collectionView.isScrollEnabled = false
collectionView.allowsSelection = false
default:
()
}
}
animator.startAnimation()
}
Wow, that’s a lot of code. Let me explain to you step by step:
- First, we check if
collectionView
andindex
are not nil. Otherwise, we will not be able to run animation. - Next, we start to create the animation by calling
animator.addAnimations
. - Next, we store the current frame, which is used to restore it on the collapse animation.
- We then set the alpha value of both
descriptionLabel
andcloseButton
to make the visible. - Next, we remove the rounded corner and set a new frame for the cell. The cell will be shown in full-screen.
- Next, we move the neighbor cells.
- Lastly, we call the
animator.addComplete()
method to disable interaction of the collection view. This prevents users from scrolling it while the cell is expanding. We also change the current state of the cell. It’s important that we only change the cell’s state when the animation completes.
Now we are going to add collapse animation. In brief, we just restore the cell to its previous state:
private func collapse() {
guard let collectionView = self.collectionView, let index = self.index else { return }
animator.addAnimations {
self.descriptionLabel.alpha = 0
self.closeButton.alpha = 0
self.layer.cornerRadius = self.cornerRadius
self.frame = self.initialFrame!
if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) {
leftCell.center.x += 50
}
if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) {
rightCell.center.x -= 50
}
self.layoutIfNeeded()
}
animator.addCompletion { position in
switch position {
case .end:
self.state = self.state.change
collectionView.isScrollEnabled = true
collectionView.allowsSelection = true
default:
()
}
}
animator.startAnimation()
}
Now, it’s time to build our application. Try to tap on the cell and you should see the animation. To close the view, tap on the cross icon at the upper right corner.
Adding a pan gesture
You may argue we can achieve the same result by using UIView.animate
. What’s the point of using UIViewPropertyAnimator
?
Okay, it’s time to make the animation interactive. We will add a UIPanGestureRecognizer
and a new property named popupOffset
to track how much we can pan the cell. Let’s declare these variables in the CityCollectionViewCell
class:
private let popupOffset: CGFloat = (UIScreen.main.bounds.height - cellSize.height)/2.0
private lazy var panRecognizer: UIPanGestureRecognizer = {
let recognizer = UIPanGestureRecognizer()
recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:)))
return recognizer
}()
Next, add the following method to register the pan recognizer:
override func awakeFromNib() {
self.addGestureRecognizer(panRecognizer)
}
Now, we need to add the popupViewPanned
method to track the pan gesture. Insert the following code in CityCollectionViewCell
:
@objc func popupViewPanned(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
toggle()
animator.pauseAnimation()
case .changed:
let translation = recognizer.translation(in: collectionView)
var fraction = -translation.y / popupOffset
if state == .expanded { fraction *= -1 }
animator.fractionComplete = fraction
case .ended:
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
default:
()
}
}
We have three states here. In the beginning of the gesture, we initialize the animator with the toggle
method and immediately pause it. While the user is dragging the cell, we update the animation by setting the fractionComplete
property of animator
. This is the core magic of the animator that allows us to control the animator. Lastly, when the user releases his/her finger, we call the continueAnimation
method of the animator to continue the execution of the animation. The cell will then go to the target position.
If you run the app, you can drag the cell up to expand it. And then drag the expanded cell down to collapse it.
Now that the animation looks pretty good, but you can’t interrupt the animation in the middle. Therefore, to make the animation fully interactive we have to add one more feature – interruption. The user can initiate the expand/collapse animation as usual, but the animation should be paused immediately once the user taps on the cell during the animation cycle.
To achieve it, we have to store the progress of the animation and then take this value into account to calculate the completion percentage of the animation.
First, let’s declare a new property to CityCollectionViewCell
:
private var animationProgress: CGFloat = 0
Next, update the .began
case of the popupViewPanned
method with the following line of code to remember the progress:
animationProgress = animator.fractionComplete
For the .changed
case, you will need to update the following line of code to correctly compute the completion percentage:
animator.fractionComplete = fraction + animationProgress
Now you’re ready to test the app. Run the project and see what you get. If you follow me correctly, the animation should look this:
Reversing the animation
You may find a drawback for the current implementation. When you drag the cell up a little bit and then revert it to the original position, the cell will still continue to expand when you release the finger. Let’s fix this issue to make the interactive animation even better.
Update the .end
case of the popupViewPanned
method like this:
let velocity = recognizer.velocity(in: self)
let shouldComplete = velocity.y > 0
if velocity.y == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
break
}
switch state {
case .expanded:
if !shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
if shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
case .collapsed:
if shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed }
if !shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed }
}
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
Now we take the velocity of the pan gesture into account to determine if the animation should be reversed.
Lastly, insert one more line of code in the .changed
case. Put it right the computation of animator.fractionComplete
.
if animator.isReversed { fraction *= -1 }
Let’s build app again. Now everything should work smoothly.
Fixing the pan gesture
One more thing! Well, we completed the implementation of the animation with UIViewPropertyAnimator
. However, there is one nasty bug. Probably, you may have experienced it while testing the app. The problem is that we can’t scroll horizontally over the cells. Try to swipe left/right over the cells and you’ll experience the issue.
The root cause is due to UIPanGestureRecognizer
we created. It also catches the swipe gesture that conflicts with the built-in gesture recogniser of the UICollectionView
.
Though the user can still swipe the upper/lower part of the cells or the space between cell to scroll through the cities, I don’t like such bad user experience. Let’s fix it.
To resolve the conflicts, we need to implement a delegate method called gestureRecognizerShouldBegin(_:)
. This method controls whether the gesture recognizer should proceed with interpreting touches. If you return false
in the method, the gesture recognizer will then ignore the touches. So what we’re going to do is to instruct our own pan recognizer to ignore horizontal swipes.
To do that, let’s set the delegate
of our pan recognizer. Insert the following line of code in the initialization of panRecognizer
(you can put the code right before return recognizer
:
recognizer.delegate = self
Next, implement the gestureRecognizerShouldBegin(_:)
method like this:
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return abs((panRecognizer.velocity(in: panRecognizer.view)).y) > abs((panRecognizer.velocity(in: panRecognizer.view)).x)
}
We will start opening/closing pan gesture if its vertical velocity is greater than its horizontal counterpart.
Cool! Let’s test the app again. You should now be able to navigate through the city records by swiping left/right the cells.
Bonus: Custom timing functions
Before we end this tutorial, let’s talk something about custom time functions. Do you still remember the last time when a designer asked you to implement custom timing function for your animation?
Usually you have to change UIView.animation
to CABasicAnimation
or wrap it in CATransaction
. With UIViewPropertyAnimator
, you can easily implement custom timing function.
Replace the animator initialization with this custom timing function (try to draw your own cubic bezier curve) like this:
private lazy var animator: UIViewPropertyAnimator = {
let cubicTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.17, y: 0.67), controlPoint2: CGPoint(x: 0.76, y: 1.0))
return UIViewPropertyAnimator(duration: 0.3, timingParameters: cubicTiming)
}()
Alternatively, instead of using cubic timing parameters, you can also use spring timing like this:
let springTiming = UISpringTimingParameters(mass: 1.0, stiffness: 2.0, damping: 0.2, initialVelocity: .zero)
Try to run the project again and see what you get.
Conclusion
With UIViewPropertyAnimator
, you can improve static screens and enhance user experience with interactive animations.
I know you can’t wait to implement what you learned into your own project. If you apply the technique in your project, it would be great if you can let me know by leaving a comment below.
For reference, you can download the final project here.
Further References
- Advanced Animations with UIKit – https://developer.apple.com/videos/play/wwdc2017/230/
- UIViewPropertyAnimator Apple Developer Documentation – https://developer.apple.com/documentation/uikit/uiviewpropertyanimator