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 has PlayerViewController added as a child.
  • TransitionCoordinator - handles user gestures and runs animations for the PlayerViewController and TabBarController 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!