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
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()
}
}
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
.
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
}
}
}
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.
- We unwrap the origin and the destination view controller, in this case the
RootViewController
is the origin view controller and theModalViewController
is the destination view controller.
guard
let originViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from),
let destinationViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
else { return }
- 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
- 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)
- In order to have an interactive dimming animation when the user pans, we insert a
dimmingView
instance property, which is just aUIView
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))
- 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)
})
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.
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.