Skip to content

Instantly share code, notes, and snippets.

@thexande
Created December 5, 2020 03:33
Show Gist options
  • Save thexande/827960e5e1e5050cbb6e9528efbeec3a to your computer and use it in GitHub Desktop.
Save thexande/827960e5e1e5050cbb6e9528efbeec3a to your computer and use it in GitHub Desktop.
A writeup on interactive transitions in UIKit.

Leveraging Interactive Custom View Controller Transitions

At Ibotta, we're constantly striving to provide our savers with the best possible user experience. As an iOS engineer, there are a number of super powerful APIs at my disposal to provide a top noch mobile UX.

One such API is the custom view controller transitioning capabilities contained within UIKit. These protocols provide an interface to control how one view controller is presented over another, and level of customization available to the programmer is significant.

Let's consider the basic use case of a card modal presentation. When a user selects an item on one view, another detail view is presented over the top. The presented detail view can then be dismissed by swiping down. This downward swipe is generally interactive, meaning the view follows the movement of your thumb as you swipe to dismiss.

This pattern is quite common across iOS applications, especially apps which have payment capabilities. With Ibotta's entrance into the payments space, it seems only fitting to provide our users with a highly polished, native implementation of this well established interaction.

/// First gif here

Object Breakdown

First, we're going to need a reference type class object which will coordinate our state during the users interaction. This object's state will be modified when the user begins an interactive presentation, and when our transition should complete. These mutations will occur from within a UIPanGestureRecognizer target, but we will get to that eventually.

final class CardPresentationCoordinator: UIPercentDrivenInteractiveTransition {
    var hasStartedInteraction = false
    var shouldFinishTransition = false
}

This object's properties will be returned by our presenting view controller's UIViewControllerTransitioningDelegate implementation. Let's look at that implementation now.

We begin with a simple UIViewController sub class containing a single button to present our modal. This will be the main root view in our example application.

final class RootViewController: UIViewController {

    private let button = UIButton()
    private let presentationCoordinator = CardPresentationCoordinator()

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Payments"
        view.backgroundColor = .systemBackground
        view.addSubview(button)

        button.backgroundColor = .systemBlue
        button.setTitle("Present Modal", for: .normal)
        button.layer.cornerRadius = 12
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(presentModal), for: .touchUpInside)

        NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|-36-[button]-36-|",
                                                                   options: [],
                                                                   metrics: nil,
                                                                   views: ["button":button]))
        NSLayoutConstraint.activate([.init(item: button,
                                           attribute: .centerY,
                                           relatedBy: .equal,
                                           toItem: view,
                                           attribute: .centerY, multiplier: 1, constant: 0),
                                     .init(item: button,
                                           attribute: .height,
                                           relatedBy: .equal,
                                           toItem: nil,
                                           attribute: .notAnAttribute,
                                           multiplier: 1,
                                           constant: 60)])
    }

    @objc private func presentModal() {
        let modal = ModalViewController()
        modal.presentationCoordinator = presentationCoordinator
        modal.modalPresentationStyle = .currentContext
        modal.transitioningDelegate = self
        present(modal, animated: true)
    }
}

Next, we extend our UIViewController sub class to conform to UIViewControllerTransitioningDelegate. This implementation will manage the modal's presentation configuration, and will allow us to set our RootViewController to the ModalViewController's transitioningDelegate. This allows UIKit to call these delegate methods over the life cycle of the presentation and the interactive dismissal.

You will notice that we're returning two separate configuration objects for our presentation controller and dismissal controller. Each of these objects subclass NSObject and conform to UIViewControllerAnimatedTransitioning, which we will get into in the next section.

extension RootViewController: UIViewControllerTransitioningDelegate {
    public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CardModalDismissAnimator()
    }

    public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return presentationCoordinator.hasStartedInteraction ? presentationCoordinator : nil
    }

    public func animationController(forPresented presented: UIViewController,
                                    presenting: UIViewController,
                                    source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CardModalPresentAnimator()
    }
}

Custom Presentation

In order for our custom presentation to function correctly, we need to configure a few things at the time of presentation. Let's exampine our RootViewController's presentModal a bit closer.

@objc private func presentModal() {
    let modal = ModalViewController()
    modal.presentationCoordinator = presentationCoordinator
    modal.modalPresentationStyle = .currentContext
    modal.transitioningDelegate = self
    present(modal, animated: true)
}

First, we initialize or modal presentation view controller, and set it's presentationCoordinator to the RootViewController's instance property. This way, when we mutate the state of this object within the ModalViewController's pan gesture delegate target method during user interaction, our UIViewControllerTransitioningDelegate implementation can access the same properties.

Next, we set the modalPresentationStyle to currentContext. If you forget this, you will be surprised to find your presenting view controller disappearing upon dismissal completion!

Finally, we set the ModalViewController's transitioningDelegate to the RootViewController. This ensures that the presentation life cycle for the ModalViewController calls our RootViewController's implementation of UIViewControllerTransitioningDelegate.

Our ModalViewController implementation

The modal we're presenting in our demo app is a semi screen modal containing a "qr" code, in this case a black square. Our ModalViewController configures its subviews to partially cover it's view's contents, thus leaving a gap at the top of the view. This will be our "window" for viewing the context from which this modal was presented, in our case the RootViewController.

Additionally, a pan gesture is configured. This gesture is connected to a target which drives the interactive transition via a percentage. In order to determine this completion precentage, we need to do some quick math to translate the user's gesture into a completion percentage. Should the gesture be interrupted, or not fully complete, the transition returns to it's presented state, and the modal does not dismiss.

final class ModalViewController: UIViewController {

    var presentationCoordinator: CardPresentationCoordinator?
    private let content = UIView()
    private let qr = UIView()


    override func viewDidLoad() {
        super.viewDidLoad()
        view.addGestureRecognizer(UIPanGestureRecognizer(target: self,
                                                         action: #selector(handleGesture(_:))))

        // subview configuration and constraint code
    }

    @objc private func handleGesture(_ sender: UIPanGestureRecognizer) {
        let percentThreshold: CGFloat = 0.2
        let translation = sender.translation(in: view)
        let verticalMovement = translation.y / view.bounds.height
        let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
        let downwardMovementPercent = fminf(downwardMovement, 1.0)
        let progress = CGFloat(downwardMovementPercent)

        guard let presentationCoordinator = presentationCoordinator else { return }

        switch sender.state {
        case .began:
            presentationCoordinator.hasStartedInteraction = true
            dismiss(animated: true, completion: nil)
        case .changed:
            presentationCoordinator.shouldFinishTransition = progress > percentThreshold
            presentationCoordinator.update(progress)
        case .cancelled:
            presentationCoordinator.hasStartedInteraction = false
            presentationCoordinator.cancel()
        case .ended:
            presentationCoordinator.hasStartedInteraction = false
            if presentationCoordinator.shouldFinishTransition {
                presentationCoordinator.finish()
            }
            else {
                presentationCoordinator.cancel()
            }
        default:
            break
        }
    }
}

The Card Modal's Presentation Animator

As discussed earlier, the methods contained within UIViewControllerTransitioningDelegate are called to request configuration objects to handle a view controller's custom presentation and dismissal. Let's examine the presentation configuration now.

final class CardModalPresentAnimator: NSObject, UIViewControllerAnimatedTransitioning {

    private let dimmingOverlayView = UIView()

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // outlined below
    }
}

The first method defines a TimeInterval (measured in seconds) for the presentation to elapse.

The implementation of func animateTransition(using transitionContext: UIViewControllerContextTransitioning) is far more involved, so let's break down what is actually happening.

  1. We unwrap the origin and the destination view controller, in this case the RootViewController is the origin view controller and the ModalViewController is the destination view controller.
guard
    let originViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
    let destinationViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
    else { return }
  1. We use the transitionContext: UIViewControllerContextTransitioning's container view to insert the destination controller's view over the origin view controller's view before offsetting the destination view's frame one screen length below were it should be. Effectively, this places the destination view controller directly at the bottom of the device, ready to be animated up from the bottom.
let containerView = transitionContext.containerView
containerView.insertSubview(destinationViewController.view, aboveSubview: originViewController.view)
destinationViewController.view.center.y += UIScreen.main.bounds.height
  1. We take a snapshot of the origin view controller. This gives the appearance to the user that the previous context is still present, and our partial screen presentation is relient on this.
guard let snapshot = originViewController.view.snapshotView(afterScreenUpdates: false) else { return }
containerView.insertSubview(snapshot, belowSubview: destinationViewController.view)
  1. In order to have an interactive dimming animation when the user pans, we insert a dimmingView instance property, which is just a UIView subclass, to change the opacity later. This view is inserted directly over the snapshot of the origin view, but below the destination view, resulting in a background dimming effect.
containerView.insertSubview(dimmingOverlayView, belowSubview: destinationViewController.view)
dimmingOverlayView.backgroundColor = .clear
dimmingOverlayView.translatesAutoresizingMaskIntoConstraints = false

let views = ["dimmingOverlay": dimmingOverlayView]
dimmingOverlayView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingOverlay]|",
                                                            options: [],
                                                            metrics: nil,
                                                            views: views))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingOverlay]|",
                                                            options: [],
                                                            metrics: nil,
                                                            views: views))
  1. Now that we have configured all aspects of our view stack, we use UIView.animate to actually execute the animation.
UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
    destinationViewController.view.center.y = UIScreen.main.bounds.height / 2
    self.dimmingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.6)
}, completion: { _ in
    transitionContext.completeTransition(transitionContext.transitionWasCancelled == false)
})

The Card Modal's Dismiss Animator

As you can imagine, the dismiss animation is remarkably similar to the presentation animation, only executed in reverse. Lets take a look at the implementation now.

final class CardModalDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {

    private let dimmingOverlayView = UIView()

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.4
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard
            let originViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
            let destinationViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
            else { return }

        transitionContext.containerView.insertSubview(destinationViewController.view, belowSubview: originViewController.view)

        let containerView = transitionContext.containerView
        let screenSize = UIScreen.main.bounds.size
        let bottomLeftCorner = CGPoint(x: 0, y: screenSize.height)
        let finalFrame = CGRect(origin: bottomLeftCorner, size: screenSize)
        dimmingOverlayView.backgroundColor = .clear

        containerView.insertSubview(dimmingOverlayView, belowSubview: originViewController.view)

        let views = ["dimmingOverlay": dimmingOverlayView]
        dimmingOverlayView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingOverlay]|",
                                                                   options: [],
                                                                   metrics: nil,
                                                                   views: views))
        NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingOverlay]|",
                                                                   options: [],
                                                                   metrics: nil,
                                                                   views: views))

        dimmingOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.6)

        UIView.animate(withDuration: transitionDuration(using: transitionContext),
                       animations: {
                        originViewController.view.frame = finalFrame
                        self.dimmingOverlayView.backgroundColor = .clear
        }, completion: { _ in
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }
}

The main difference to consider here is the UIView.animate's duration. You will notice it's using transitionDuration(using: transitionContext), and not a raw time value. This is because the transition, and as a result the animation, is tied to an interactive gesture which does not have a predictable time frame. The user may pan slow or fast, either way we need to tie the animation's duration to the interaction's completion percentage.

Conclusion

You may be wondering if that was worth all of the work just for a custom modal transition. The APIs for these interactive transitions certainly have a steep learning curve, but once you grasp the main concepts, you could quickly implement many other types of transitions. Consider that with a few lines of code, one could convert the above transition to an interactive slide out menu like the one in Gmail as an example. In conclusion, interactive transitions can be a powerful UX improvement for your application.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment