Delightful Interactive Animations
June 15, 2020
#swift
#ui
Animations are an essential part of modern mobile applications. They give users feedback on their actions and notify about what’s going on in the app. On top of that animations give a higher quality look and feel to your app.
In this article, I’ll show how to build interactive animations that are quite similar to the ones you’ve seen in the Spotify iOS application.
Initial setup
Let’s start with creating UI elements we’d like to animate. We’ll be using the next classes in this article:
PlayerViewController
- defines the player’s UI.TabBarController
- contains a tab bar view and hasPlayerViewController
added as a child.TransitionCoordinator
- handles user gestures and runs animations for thePlayerViewController
andTabBarController
views.
The view controllers classes are quite self-explanatory and TransitionCoordinator
could be defined as follows:
class TransitionCoordinator {
enum State: Equatable {
case open
case closed
static prefix func !(_ state: State) -> State {
return state == .open ? .closed : .open
}
}
private weak var tabBarViewController: TabBarController!
private weak var playerViewController: PlayerViewController!
private lazy var panGestureRecognizer = createPanGestureRecognizer()
private var state: State = .closed // ➊
private var runningAnimators = [UIViewPropertyAnimator]() // ➋
}
➊ Defines the player’s state that could be either open or closed.
➋ The collection of running property animators. We’ll add a property animator to the collection when we create one and remove when the animation is finished.
Interactive animations with UIViewPropertyAnimator
There are different APIs you can use to run an animation on iOS depending on the level of control and customization you want. In iOS 10 Apple introduced UIViewPropertyAnimator
that offers more capabilities and a greater degree of control than UIView.animate
. It works nicely in conjunction with UIPanGestureRecognizer
and could be easily used to create interactive animations.
Let’s add a pan gesture recognizer handler so that we can interactively pull the player’s view up or down.
@objc private func didPanPlayer(recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
startInteractiveTransition(for: !state) // ➊
case .changed:
let translation = recognizer.translation(in: recognizer.view!)
updateInteractiveTransition(distanceTraveled: translation.y) // ➋
case .ended:
let velocity = recognizer.velocity(in: recognizer.view!).y
let isCancelled = isGestureCancelled(with: velocity)
continueInteractiveTransition(cancel: isCancelled)
case .cancelled, .failed:
continueInteractiveTransition(cancel: true) // ➌
default:
break
}
}
As you can see above, there are three functions that drive the view controller’s transition. Let’s dive into details and analyze them.
The startInteractiveTransition
function is called when the pan gesture begins. It creates all of the animators and pauses ones immediately.
private func startInteractiveTransition(for state: State) {
state = newState
runningAnimators = createTransitionAnimators(with: TransitionCoordinator.animationDuration)
runningAnimators.pauseAnimations()
}
While the user is dragging the player’s view we call updateInteractiveTransition
function that sets the completion percentage of the animation via fractionComplete
property:
private func updateInteractiveTransition(distanceTraveled: CGFloat) {
var fraction = distanceTraveled / totalAnimationDistance
if state == .open { fraction *= -1 }
runningAnimators.fractionComplete = fraction
}
When the user’s finger is lifted off we continue or reverse animations based on the gesture’s direction:
private func continueInteractiveTransition(cancel: Bool) {
if cancel {
runningAnimators.reverse()
state = !state
}
runningAnimators.continueAnimations()
}
Transition animations
As you might notice, we are using createTransitionAnimators
function to create animators when the pan gesture begins:
private func createTransitionAnimators(with duration: TimeInterval) -> [UIViewPropertyAnimator] {
switch state {
case .open:
return [
openPlayerAnimator(with: duration),
fadeInPlayerAnimator(with: duration),
fadeOutMiniPlayerAnimator(with: duration)
]
case .closed:
return [
closePlayerAnimator(with: duration),
fadeOutPlayerAnimator(with: duration),
fadeInMiniPlayerAnimator(with: duration)
]
}
}
Now let’s have a look at the animations we run on transition between closed
and open
states.
Frame animation
First, let’s define frame animation for the player and tab bar views. This is a spring animation that doesn’t oscillate or overshoot its target value. In this case we should create the UIViewPropertyAnimator
instance with 1.0 dampingRatio value:
private func openPlayerAnimator(with duration: TimeInterval) -> UIViewPropertyAnimator {
let animator = UIViewPropertyAnimator(duration: duration, dampingRatio: 1.0)
addAnimation(to: animator, animations: {
self.updatePlayerContainer(with: self.state)
self.updateTabBar(with: self.state)
})
return animator
}
It is important to note that scrubsLinearly
property of the UIViewPropertyAnimator
is true by default. This causes the animator to use a linear timing function during scrubbing.
Content animations
Next, let’s animate the content of our views with fade in and fade out animations. These animations take less time to finish compared to frame animation. In order to create this effect, we perform a keyframe animation inside of the property animator with relative start time and duration:
private func fadeInPlayerAnimator(with duration: TimeInterval) -> UIViewPropertyAnimator {
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeIn)
addKeyframeAnimation(to: animator, withRelativeStartTime: 0.0, relativeDuration: 0.5) {
self.updatePlayer(with: self.state)
}
animator.scrubsLinearly = false
return animator
}
private func fadeOutMiniPlayerAnimator(with duration: TimeInterval) -> UIViewPropertyAnimator {
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeOut)
addKeyframeAnimation(to: animator, withRelativeStartTime: 0.0, relativeDuration: 0.5) {
self.updateMiniPlayer(with: self.state)
}
animator.scrubsLinearly = false
return animator
}
As you can see, we set scrubsLinearly
to false, which causes the animator to use the specified timing curve and maintain its pacing when it’s being driven interactively.
A similar approach could be used to create animations for transition between open
and closed
states. Just like that, we’ve created the player screen that could be animated interactively on pan gesture.
Conclusion
Animation is a great way to notify a user about what’s going on and add a polished look and feel to your application. In this article we’ve learned how to build interactive animations using UIViewPropertyAnimator
.
You can find the project’s source code on Github. Feel free to play around and reach me out on Twitter if you have any questions, suggestions or feedback.
Thanks for reading!