Elegant Custom UIViewController Transitions

Elegant Custom UIViewController Transitions

If you’ve ever implemented a custom view controller transition by adopting UIViewControllerTransitioningDelegate and UIViewControllerContextTransitioning in your view controller, you may have found that the result added unwelcome clutter to your view controller and wasn’t very reusable. This tutorial will show you how to build a reusable, standalone view controller transition by subclassing UIStoryboardSegue. You’ll see how the segue conveniently integrates with Interface Builder as a custom segue type and can be used in any UIKit app — no storyboard required.

IOS
IOS ANIMATIONS
SWIFTMESSAGES
UISTORYBOARDSEGUE
UIVIEWCONTROLLERANIMATEDTRANSITIONING
UIVIEWCONTROLLERTRANSITIONINGDELEGATE

The image above shows this technique in action in the SwiftMessages demo app. The transitions you see where done using a custom segue that I introduced in SwiftMessages 5 to utilize SwiftMessages layouts, animations, and more for modal view controller presentation and dismissal.

For this tutorial, we’re going to reproduce the bottom card-style transition. To keep it simple, some of the non-essential functionality, like background dimming and dismissal gestures, will be left to you as an exercise.

The full source of the final project can be found on our CustomSegueDemo GitHub repo. Let’s get started.

Step 1: Create a custom segue.

Start by creating a subclass of UIStoryboardSegue called BottomCardSegue.

class BottomCardSegue: UIStoryboardSegue {
    override func perform() {
        source.present(destination, animated: true, completion: nil)
    }
}

The segue’s action happens inperform(), which we override and do a modal presentation.

source.present(destination, animated: true, completion: nil)

At this stage, BottomCardSegue is already a fully-functional custom segue. It just doesn’t do anything interesting yet.

Step 2: Set up the storyboard.

Taking a look at main.storyboard, the initial view controller consists of a “Show” and “Show Programmatically” buttons. The destination view controller is a simple navigation stack (copied directly from the SwiftMessages demo app). We define our storyboard segue (highlighted blue) using the standard procedure:

  1. Control-drag from “Show” to the navigation controller.
  2. Select “bottom card” from the segue type prompt.

Interface Builder has conveniently included BottomCardSegue in the segue type prompt using the autogenerated (and oddly lower-cased) name “bottom card”.

Step 3: Prepare for segue.

Next, we add a “Done” button to the root view controller. I would normally recommend having the “Done” button trigger an unwind segue. However, in order to point out that a custom segue experiences the same lifecycle as a built-in segue, we’ll add the button and configure its action in prepare(for:sender:).

class ViewController: UIViewController {
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        let navigationVC = segue.destination as! UINavigationController
        let rootVC = navigationVC.viewControllers.first!
        let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(hide))
        rootVC.navigationItem.rightBarButtonItem = doneButton
    }

     @objc private func hide() {
        dismiss(animated: true, completion: nil)
    }
}

The dismissal action is done in the hide() function by calling dismiss(animated:completion:).

dismiss(animated: true, completion: nil)

You can now run the app to verify that “Show” and “Done” are working. What you’ll see is the default full-screen modal transition — the one that slides up and down from the bottom.

Configuration Options

Suppose that we added an option to BottomCardSegue to specify whether or not to dim the background. In an ideal world, Interface Builder would support @IBInspectable on UIStoryboardSegue, allowing us to set the dim option directly on the segue’s Attribute Inspector panel. Alas, this is not the world we live in, so I’ll suggest a couple of for configuring segues.

  1. Subclass BottomCardSegue and configure options in init(identifier:source:destination:). For example, we could define BottomCardDimmedSegue with the hypothetical background dimming option enabled. Interface Builder would add a “bottom card dimmed” option to the segue selection prompt.
  2. Downcast segue in prepare(for:sender:) to BottomCardSegue and configure options there.

While you’re considering which path to take, ask Apple to support @IBInspectable on UIStoryboardSegue!

Step 4: Add the custom transition.

We’re ready to add the custom transition by having BottomCardSegue adopt UIViewControllerTransitioningDelegate and UIViewControllerContextTransitioning. We need to add a few more steps to perform() before calling present().

class BottomCardSegue: UIStoryboardSegue {

    private var selfRetainer: BottomCardSegue? = nil

    override func perform() {
        destination.transitioningDelegate = self
        selfRetainer = self
        destination.modalPresentationStyle = .overCurrentContext
        source.present(destination, animated: true, completion: nil)
    }
}

First, set the destination view controller’s transitioning delegate to self to gain control over the transition.

destination.transitioningDelegate = self

UIKit doesn’t automatically retain instances of UIStoryboardSegue. Therefore, in order to stay around long enough to perform the dismissal transition, we need to create a strong reference to self. We’ll set it to nil after dismissal to avoid leaking memory.

private var selfRetainer: BottomCardSegue? = nil

override func perform() {
    ...
    selfRetainer = self
    ...
}

Finally, we set the modal presentation style to .overCurrentContext to allow the presenting view controller to remain visible under the presented content.

destination.modalPresentationStyle = .overCurrentContext

Our job as transitioning delegate is to vend the instances of UIViewControllerAnimatedTransitioning that perform the actual presentation and dismissal animations. In a moment, we’ll introduce nested classes Presenter and Dismisser and have each of them adopt UIViewControllerAnimatedTransitioning. But first, let’s adopt UIViewControllerTransitioningDelegate on BottomCardSegue by returning Presenter and Dismisser instances when asked.

extension BottomCardSegue: UIViewControllerTransitioningDelegate {
    public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return Presenter()
    }

    public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        selfRetainer = nil
        return Dismisser()
    }
}

The segue’s final task is to vend the dismissal component. So we are free to release the strong self reference just before returning Dismisser().

selfRetainer = nil

The Presenter and Dismisser classes adopt UIViewControllerTransitioningDelegate as follows.

private class Presenter: NSObject, UIViewControllerAnimatedTransitioning {

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

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let container = transitionContext.containerView
        let toView = transitionContext.view(forKey: .to)!
        let toViewController = transitionContext.viewController(forKey: .to)!
        // Configure the layout
        do {
            toView.translatesAutoresizingMaskIntoConstraints = false
            container.addSubview(toView)
            // Specify a minimum 20pt bottom margin
            let bottom = max(20 - toView.safeAreaInsets.bottom, 0)
            container.safeAreaLayoutGuide.bottomAnchor.constraint(equalTo: toView.bottomAnchor, constant: bottom).isActive = true
            container.safeAreaLayoutGuide.leadingAnchor.constraint(equalTo: toView.leadingAnchor, constant: -20).isActive = true
            container.safeAreaLayoutGuide.trailingAnchor.constraint(equalTo: toView.trailingAnchor, constant: 20).isActive = true
            // Respect `toViewController.preferredContentSize.height` if non-zero.
            if toViewController.preferredContentSize.height > 0 {
                toView.heightAnchor.constraint(equalToConstant: toViewController.preferredContentSize.height).isActive = true
            }
        }
        // Apply some styling
        do {
            toView.layer.masksToBounds = true
            toView.layer.cornerRadius = 20
        }
        // Perform the animation
        do {
            container.layoutIfNeeded()
            let originalOriginY = toView.frame.origin.y
            toView.frame.origin.y += container.frame.height - toView.frame.minY
            UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0, options: [], animations: {
                toView.frame.origin.y = originalOriginY
            }) { (completed) in
                transitionContext.completeTransition(completed)
            }
        }
    }
}

private class Dismisser: NSObject, UIViewControllerAnimatedTransitioning {

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

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let container = transitionContext.containerView
        let fromView = transitionContext.view(forKey: .from)!
        UIView.animate(withDuration: 0.2, animations: {
            fromView.frame.origin.y += container.frame.height - fromView.frame.minY
        }) { (completed) in
            transitionContext.completeTransition(completed)
        }
    }
}

We won’t step through the above layout and animation code in detail because transition mechanics are not the focus of this tutorial. Many existing resources do a great job covering UIViewControllerAnimatedTransitioning (try Apple’s documentation, AppCoda, or objc.io).

However, it’s worth noting one layout detail found in Presenter. When setting up the Auto Layout constraints on toView, we respect the view controller’s preferredContentSize.height property by adding an explicit height constraint if preferredContentSize.height >= 0.

if toViewController.preferredContentSize.height > 0 {
    toView.heightAnchor.constraint(equalToConstant: toViewController.preferredContentSize.height).isActive = true
}

No height is specified when preferredContentSize.height == 0 (the default). Instead, Auto Layout is left to work out toView‘s height based on its constraints.

A word of caution about UINavigationController. In our testing, the intrinsic height of a navigation controller does not reflect the Auto Layout constraints of its content. It is therefore necessary to specify a value for preferredContentSize.height. Without it, our navigation stack would have zero height. So let’s specify 200pt in the navigation controller’s Attribute Inspector panel in main.storyboard.

With that out of the way, we’re done! We now have a reusable UIStoryboardSegue subclass that utilizes custom view controller transitioning APIs to present a modal view controller using a card-style layout. Try it out.

Programmatic Transitioning

We’ll conclude by showing how apps that don’t use storyboards can use BottomCardSegue. For simplicity, we’re going to use the view controllers in main.storyboard. The important point is that the transition will be performed programmatically without an associated segue in the storyboard itself.

To set this up, we introduce an @IBAction in the main view controller and connect it to the “Show Programmatically” button.

@IBAction func showProgrammatically(_ sender: Any) {
    let childVC = storyboard!.instantiateViewController(withIdentifier: "Child")
    let segue = BottomCardSegue(identifier: nil, source: self, destination: childVC)
    prepare(for: segue, sender: nil)
    segue.perform()
}

Instantiate the destination view controller.

let childVC = storyboard!.instantiateViewController(withIdentifier: "Child")

Use this to initialize BottomCardSegue.

let segue = BottomCardSegue(identifier: nil, source: self, destination: childVC)
prepare(for: segue, sender: nil)

Call prepare(for:sender:) to configure the “Done” button. I don’t advocate calling this in general, but UIKit doesn’t seem to mind.

Finally, perform the segue.

segue.perform()

Cool!

Here’s what the final result looks like.

Custom UIViewController transitions can be a great way make your app unique and enjoyable. We hope this tutorial has inspired you to give it a try. Thanks for reading and happy coding.

Everything you need to grow your business

Crafting exceptional mobile experiences so you stand out in a crowded digital landscape. Trusted by Experian, Vrbo, & Expedia.

Let's do it together