Building Better iOS App Animations
Creating a popup menu with UIViewPropertyAnimator
Animations are key to a quality user experience. They serve a wide variety of purposes, including directing user attention and connecting user actions to results on screen.
Animations make the experience of your app unique — they can enable a level of responsiveness and interactivity not possible in other mediums. To build better animations, they need to convey an improved sense of direct connection between user interaction and visual changes. One way to accomplish this is to make animations fully interactive.
Why Create Interactive Animations?
Interactive animations have been around since the introduction of the iPhone. The world’s first look at the original iPhone was the classic “slide to unlock” screen, where the user directly moved the slider to unlock the device. This interactive animation was immediately intuitive for those who had never used a multi-touch device before.
Interactive animations give the user more control over the user interface. Direct manipulation is a natural interaction model, especially on mobile devices. It connects their actions to on-screen animations and gives them full control over the completion or cancellation of their actions.
They also look great. Users often associate how an app looks with how well it works, so if it looks good, they are more likely to forgive other shortcomings.
In this tutorial, we will be building an interactive popup animation in Swift with UIViewPropertyAnimator
.
Introduction to UIViewPropertyAnimator
UIViewPropertyAnimator
was added to UIKit in iOS 10, and improved slightly in iOS 11. It provides a UIView-level object-oriented API to create animations.
Here is a simple example:
Using traditional UIView
animations, you might write something like this:
UIView.animate(withDuration: 1, delay: 0, options: [.curveEaseOut], animations: {
self.myView.transform = CGAffineTransform(translationX: 50, y: 0)
self.myView.alpha = 0.5
}, completion: nil)
Using the new UIViewPropertyAnimator
, you can write this instead:
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut, animations: {
self.myView.transform = CGAffineTransform(translationX: 50, y: 0)
self.myView.alpha = 0.5 })
animator.startAnimations()
The code is very similar. With UIViewPropertyAnimator
, you first create an animator object and then call startAnimation()
instead of calling a static method on the UIView
class.
UIViewPropertyAnimator
becomes more useful as the animation increases in complexity. Let’s take a look at a more complex animation.
This animation begins when the view is panned, can be scrubbed in either direction, and animates to its final position once the pan is finished.
Before looking at the code, it’s important to understand the state machine backing UIViewPropertyAnimator
.
An animator can be in one of three possible states: inactive, active, and stopped. An animator is initialized in an inactive state but moves to the active state when started or paused. When the animation is completed, it moves back to the inactive state. If an animation has been started, and is paused, it remains in the active state and does not undergo a state transition.
Let’s see how to use a UIPanGestureRecognizer
alongside a UIViewPropertyAnimator
to create the animation above.
var animator = UIViewPropertyAnimator()private func handlePan(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
animator = UIViewPropertyAnimator(duration: 3, curve: .easeOut, animations: {
myView.transform = CGAffineTransform(translationX: 275, y: 0) myView.alpha = 0 })
animator.startAnimation()
animator.pauseAnimation()
case .changed:
animator.fractionComplete = recognizer.translation(in: myView).x / 275
case .ended:
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
default:
()
}
}
Note that pauseAnimation()
is called immediately after startAnimation()
. Because our animation begins on a pan gesture, the user is most likely to scrub the animation first before releasing their tap. When the animation is paused, set the fractionComplete
property to move the view along with the user’s touch.
If we tried to do this with standard UIView
animations, we would need a lot more logic than what is listed in the example above. UIView
animations don’t provide an easy way to directly control the completion percentage of the animation, or allow us to easily pause and continue the animation to completion.
Let’s Build a Popup Menu!
We are going to build a fully interactive, interruptible, scrubbable, and reversible popup menu in 10 steps. (If you prefer to work backward from the final code instead, a link to the final repository is available at the end of this post.)
For simplicity, all views will be created and modified in code (not in storyboard, although this code would work just as well with views created in the storyboard). Also, all code will be placed in the ViewController.swift
file.
Step #1: Tap to open and close.
First, let’s make our popup view animate between its open and closed state. No fancy tricks here, just the basics of UIViewPropertyAnimator
we learned previously.
private enum State {
case closed
case open
}extension State {
var opposite: State {
switch self {
case .open:
return .closed
case .closed:
return .open
}
}
}class ViewController: UIViewController { private lazy var popupView: UIView = {
let view = UIView()
view.backgroundColor = .gray
return view
}() override func viewDidLoad() {
super.viewDidLoad()
layout()
popupView.addGestureRecognizer(tapRecognizer)
} private var bottomConstraint = NSLayoutConstraint() private func layout() {
popupView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(popupView)
popupView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
popupView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
bottomConstraint = popupView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 440) bottomConstraint.isActive = true
popupView.heightAnchor.constraint(equalToConstant: 500).isActive = true
} private var currentState: State = .closed private lazy var tapRecognizer: UITapGestureRecognizer = {
let recognizer = UITapGestureRecognizer()
recognizer.addTarget(self, action: #selector(popupViewTapped(recognizer:)))
return recognizer
}() @objc private func popupViewTapped(recognizer: UITapGestureRecognizer) {
let state = currentState.opposite
let transitionAnimator = UIViewPropertyAnimator(duration: 1, dampingRatio: 1, animations: {
switch state {
case .open:
self.bottomConstraint.constant = 0
case .closed:
self.bottomConstraint.constant = 440
}
self.view.layoutIfNeeded()
})
transitionAnimator.addCompletion { position in
switch position {
case .start:
self.currentState = state.opposite
case .end:
self.currentState = state
case .current:
()
}
switch self.currentState {
case .open:
self.bottomConstraint.constant = 0
case .closed:
self.bottomConstraint.constant = 440
}
}
transitionAnimator.startAnimation()
}
}
The relevant animation code is in the popupViewTapped
function, which is called when the view is tapped. We simply create an animator, set its animations to modify the value of a constraint, and start the animator.
We introduce a State
enum to indicate whether the popup is open or closed. It also has a computed opposite
property, which returns the opposite of the current state. We could have implemented this with a boolean flag instead, but this is easier to reason about, especially once our animation code gets more complex.
One thing to point out — we are manually updating the value of the constraint when the animation is complete. This should be handled automatically by the animator, but explicitly setting them fixes some edge-case bugs.
Step #2: Add a pan gesture.
To make our animation interactive, we are going to introduce a second gesture recognizer, a pan gesture recognizer. This will allow the user to start and interrupt the animation by swiping on the popup view.
@objc private func popupViewPanned(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
animateTransitionIfNeeded(to: currentState.opposite, duration: 1.5)
transitionAnimator.pauseAnimation()
case .changed:
let translation = recognizer.translation(in: popupView)
var fraction = -translation.y / popupOffset
if currentState == .open { fraction *= -1 }
transitionAnimator.fractionComplete = fraction
case .ended:
transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
default:
()
}
}
This code is very similar to the previous example, except that the animation can be interrupted. We have refactored our animation code into a function named animateTransitionIfNeeded
, which runs all of the code that was previously inside our popupViewTapped
function.
Step #3: Record the animation progress to fix the interruption offset.
One problem: when the animation is interrupted, it is offset from the user’s touch. This is due to the pan handler not considering the current progress of the animation. To fix this, we need to record the fractionComplete
of the animator, and use this as our baseline when calculating a pan offset.
We will need a property to store the current progress of the animation:
private var animationProgress: CGFloat = 0
When the pan gesture is in its began
state, we record the current progress of the animation:
animationProgress = transitionAnimator.fractionComplete
In the pan gesture’s changed
state, we add the animation progress to the calculated fraction:
transitionAnimator.fractionComplete = fraction + animationProgress
Now the pan gesture works as expected, and tracks the users finger more naturally.
Step #4: Introduce a custom instant pan gesture.
The interruption behavior works, but is awkward. In order for the pan to be recognized, the user must tap on the screen and then move their finger in any direction. We would prefer the behavior to act like a scroll view, which allows the user to “catch” the view with only a touch down. Currently, the tap gesture and pan gesture are only fired on touch up and touches moved, respectively. In order to fire an event on touch down, we can create our own custom gesture recognizer.
class InstantPanGestureRecognizer: UIPanGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
if (self.state == UIGestureRecognizerState.began) { return }
super.touchesBegan(touches, with: event)
self.state = UIGestureRecognizerState.began
}
}
This pan gesture subclass enters the began state on touch down. It allows us to replace both of our previous gesture recognizers. The “tap” is now an “instant pan” that ends right after it begins. By using this custom gesture recognizer, we can improve the behavior of our previous tap/pan solution as well as simplify our logic.
Note: In order to subclass a UIGestureRecognizer
, you’ll need to include this import at the top of the file:
import UIKit.UIGestureRecognizerSubclass
Step #5: Use the pan velocity to reverse animations.
One remaining problem is that the popup doesn’t respect which way the view is “thrown”. If we tap on the closed popup, catch it mid-animation, and swipe back down, it will continue to animate open.
To solve this, we can conditionally reverse the animator. This will be based on a few factors: the current state of our popup, whether the animator is currently reversed, and the velocity of the pan gesture.
The ended
case of the pan gesture handler now looks like this:
let yVelocity = recognizer.velocity(in: popupView).y
let shouldClose = yVelocity > 0if yVelocity == 0 {
transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
break
}switch currentState {
case .open:
if !shouldClose && !transitionAnimator.isReversed {
transitionAnimator.isReversed = !transitionAnimator.isReversed
}
if shouldClose && transitionAnimator.isReversed {
transitionAnimator.isReversed = !transitionAnimator.isReversed
}
case .closed:
if shouldClose && !transitionAnimator.isReversed {
transitionAnimator.isReversed = !transitionAnimator.isReversed
}
if !shouldClose && transitionAnimator.isReversed {
transitionAnimator.isReversed = !transitionAnimator.isReversed
}
}transitionAnimator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
This logic may seem complex at first, but it can be derived by considering all the possible cases.
In the changed case of the pan gesture handler, we need to respect the isReversed
property of the animator:
let translation = recognizer.translation(in: popupView)
var fraction = -translation.y / popupOffset
if currentState == .open { fraction *= -1 }
if transitionAnimator.isReversed { fraction *= -1 }
transitionAnimator.fractionComplete = fraction + animationProgress
Now our animation can be reversed! If the user wants to close the popup mid-animation, it’s easy and intuitive for them to do so.
Step #6: Animate the corner radius.
In iOS 11, a CALayer
's corner radius is animatable without the need for a CABasicAnimation
. This means we can update a view’s corner radius in an animation block, and it will just work!
self.popupView.layer.cornerRadius = 20
We can also specify which corners to round. In this case, we only want the top left and top right corners to be rounded.
view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
Now the top two corners are animated alongside our original animation.
Step #7: Make it prettier!
Our gray popup view works well, but could use some visual improvements. Let’s add a background image, an overlay view, a title label, a subtle shadow, and some sample reviews.
This tutorial is not going to cover the implementation of these additional views. If you would like to see how they are created, check out the full source code at the bottom of the post.
Much nicer. 😎
Step #8: Animate the label.
The “Reviews” label looks great when the popup is closed, but when opened, it fails to stand out from the rest of the content. We would like to give the label a larger font size and darker color when the popup is open. This label transition needs to be animated since our popup is fully interactive and scrubbable.
There is no built-in way to animate a label’s color or font style. Our solution is a simple workaround: cross-fade the labels.
To smooth the animation, we need to animate the scale and translation of each label so they overlap perfectly during the entire length of their animation.
Inside of our animation block, we modify the label’s alpha
and transform
:
switch state {
case .open:
// other animations here ...
self.closedTitleLabel.transform = CGAffineTransform(scaleX: 1.6, y: 1.6).concatenating(CGAffineTransform(translationX: 0, y: 15))
self.openTitleLabel.transform = .identity
self.openTitleLabel.alpha = 1
self.closedTitleLabel.alpha = 0
case .closed:
// other animations here ...
self.closedTitleLabel.transform = .identity
self.openTitleLabel.transform = CGAffineTransform(scaleX: 0.65, y: 0.65).concatenating(CGAffineTransform(translationX: 0, y: -15))
self.openTitleLabel.alpha = 0
self.closedTitleLabel.alpha = 1
}
Now the labels appear as if they are morphing into each other. With the correct alignment, it looks like there is only a single label.
Step #9: Refactor for multiple animators.
The label animation works well, but the timing could be improved to smooth the transition further. In order to modify the timing curve of the label animations, we need additional animators. A UIViewPropertyAnimator
can only have one timing curve, so in order to use multiple timing curves, we need to coordinate multiple animators.
We need to refactor our code a bit to support any number of animators. To do this we will create an array of animators.
private var runningAnimators = [UIViewPropertyAnimator]()
Whenever we create a new animator, we add it to the array of running animators.
runningAnimators.append(transitionAnimator)
Whenever an animation finishes, we will remove it from the array. To make the rest of the code work with multiple animators, anything applied to the transitionAnimator
is applied to the entire array.
Step #10: Add new animators for the label alpha.
With our new infrastructure, we can create two new animators: one to animate the new label in, and another to animate the old label out. The benefit of using multiple animators is that each can have their own timing curve.
let inTitleAnimator = UIViewPropertyAnimator(duration: duration, curve: .easeIn, animations: {
switch state {
case .open:
self.openTitleLabel.alpha = 1
case .closed:
self.closedTitleLabel.alpha = 1
}
})
inTitleAnimator.scrubsLinearly = false
We set the animator’s scrubsLinearly
property to false so that the fractionComplete
of the animation gets mapped to the ease-in timing curve, instead of a linear timing curve. Generally animations that follow the user’s finger should follow a linear timing curve, which is why this property is true by default.
(Note: scrubsLinearly
is only available on iOS 11+)
The difference is subtle but will allow the animation to be customized further in the future. Getting this transition exactly right is important when the user has full control over the animation and can scrub it to any point.
Here is our final animation! The user can start the animation with a tap or swipe, can interrupt the animation, and can reverse the animation. Pretty cool for a relatively small amount of code.
When should I use UIViewPropertyAnimator?
With so many animation API’s available in iOS, when is it best to use UIViewPropertyAnimator
?
The differentiator is interruptibility. If you want to “catch” a view mid-flight, scrub it, and continue or reverse the animation, UIViewPropertyAnimator
is the best option. Some other animation tactics are interruptible, such as animating views alongside a scroll view’s contentOffset
property, but these animations are restricted to the timing curve of the scroll view.
Sometimes it doesn’t make sense for an animation to be interruptible. Only make an animation interruptible when the user can tap on the view during its animation. The animation should have a long enough duration that the user could react quickly enough to tap it, and the animated view should have a large tap target. The popup animation above is a perfect example of meeting these requirements.
Conclusion
Hopefully, you learned something new about interactive animations! The full source code for the popup animation is linked below.
When designing and building apps in the future, consider how interactive animations can improve the user experience. Instead of designing every screen to remain static and only respond to taps, think about ways to make the user interface animated and interactive.
Links and Resources
Full source code is available on GitHub:
https://github.com/nathangitter/interactive-animations
Great WWDC 2017 Presentation about interactive animations:
https://developer.apple.com/videos/play/wwdc2017/230/
Official UIViewPropertyAnimator Documentation:
https://developer.apple.com/documentation/uikit/uiviewpropertyanimator
About SwiftKick Mobile
SwiftKick Mobile is a mobile application design and development agency in Austin, TX. Need help with your next app? Reach out to hello@swiftkickmobile.com
Originally published at www.swiftkickmobile.com on September 11, 2017.