Building Better iOS App Animations

Creating a popup menu with UIViewPropertyAnimator

The final animation we are going to build.

Why Create Interactive Animations?

Introduction to UIViewPropertyAnimator

UIView.animate(withDuration: 1, delay: 0, options: [.curveEaseOut], animations: {
    self.myView.transform = CGAffineTransform(translationX: 50, y: 0)
    self.myView.alpha = 0.5
}, completion: nil)
let animator = UIViewPropertyAnimator(duration: 1, curve: .easeOut, animations: {
    self.myView.transform = CGAffineTransform(translationX: 50, y: 0)
    self.myView.alpha = 0.5 })
animator.startAnimations()
Showing the animation is srubbable.
State diagram for UIViewPropertyAnimator.
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:
        ()
    }
}

Let’s Build a Popup Menu!

Step #1: Tap to open and close.

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()
    }
}
Tap to open and close.

Step #2: Add a pan gesture.

@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:
        ()
    }
}
The view can be dragged, but still has some issues.

Step #3: Record the animation progress to fix the interruption offset.

private var animationProgress: CGFloat = 0
animationProgress = transitionAnimator.fractionComplete
transitionAnimator.fractionComplete = fraction + animationProgress
Interrupting the animation works more like users expect.

Step #4: Introduce a custom instant pan gesture.

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
    }
}
Interruption occurs immediately, similar to a scroll view.
import UIKit.UIGestureRecognizerSubclass

Step #5: Use the pan velocity to reverse animations.

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)
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
The animation is reversible.

Step #6: Animate the corner radius.

self.popupView.layer.cornerRadius = 20
view.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
The top left and right corners animate alongside the other animations.

Step #7: Make it prettier!

Now our animation looks more like it would in a real app.

Step #8: Animate the label.

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
}
The “Reviews” label smoothly transitions between states.

Step #9: Refactor for multiple animators.

private var runningAnimators = [UIViewPropertyAnimator]()
runningAnimators.append(transitionAnimator)

Step #10: Add new animators for the label alpha.

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
The label animation is subtly different, and now everything is complete!

When should I use UIViewPropertyAnimator?

Conclusion

Links and Resources

About SwiftKick Mobile


SwiftKick Mobile

SwiftKick Mobile Blog

1.1K

1.1K claps
Nathan Gitter

Written by

designer + engineer, iOS app maker, writing about the intersection of art and tech

SwiftKick Mobile

SwiftKick Mobile Blog