Stop using modals as gates

January 14th, 2017
#navigation #ui

Every app has at least one flow - a sequence of screens that allow the user to complete a given task, i.e. read a news article, post a status update, favourite a photo, etc. Most apps have multiple flows. In iOS, we typically represent different flows by placing each flow in a new, self-contained navigation stack, e.g. in Twitter posting a tweet means leaving the feed and opening a model screen where we can compose our tweet. Most apps have a primary flow that the user keeps returning to after they are finished with secondary flows, e.g. after composing your tweet (secondary flow) you are returned to the feed (primary flow). However some flows don't fit neatly into this primary and secondary paradigm, some flows are temporary primary flows, e.g. sign-up/sign-in, activation walls, device searching, etc. These temporary primary flows act as gates to the proper primary flow; they are designed to hold the user until some action has been completed.

The temptation is to treat these temporary primary flows as secondary flows and use the same tools to present them, such as modals. This is a misuse secondary navigation flows and if we aren't careful can lead to some users getting through a gate when they should be held at it - we need to be especially careful when using modals to present these temporary primary flows. In this post, I want to explore how we can avoid using modals as gates for these flows and instead manipulate the navigation stack to allow us to switch out any temporary primary flow with the real deal while retaining any gate like functionality.

Photos showing the horizon

Switching from one flow to another

A common temporary primary flow is onboarding. A typical onboarding flow compromises: introduction, sign-in or sign-up and a welcome screen. The onboarding flow is meant only for unknown users - once they tell us who they are (by either signing-up or signing-in), they are allowed access to the primary flow. Ideally, when a user moves from unknown to known, we don't want that user to be able to navigate back from the primary flow to the onboarding flow. Onboarding is a one-way gate.

You may be thinking:

Well, this is a perfect example of where to use a vertical/modal transition - have a holding viewcontroller that presents the primary flow if the user is known or the onboarding flow if the user is unknown.

That approach will work most of the time. But there is one deceptively, tricky issue that can come up when using this approach. When transitioning between modal states (i.e. dismissing the onboarding modal and presenting the primary flow modal) it possible that we encounter the following error:

Screenshot showing modal transition error message where one navigation transition has been cancelled due to crashing with a different modal transition

This error is shown when the app attempts to perform a navigation transition when another one is already underway. When this happens, the second transition is cancelled. Having the chance that presenting the primary flow of our apps cancelled is pretty far from ideal. Especially if that cancellation leaves our users trapped in a completed flow that they can't leave - requiring them to force quit the app and relaunch it.

It's one of those bugs that is very hard to reproduce because it's based on a race condition.

You may now be thinking:

Well, just have a delegate call from onboarding to the holding viewcontroller to present the primary flow viewcontroller once onboarding is complete.

When dismissing the onboarding flow, the user would see this holding viewcontroller's view before the primary flow's viewcontroller can be presented (remember we need to wait for any animation to finish - even if we set the animation parameter to false when dismissing the modal). So now we need to decide what to show every user that signs-up or signs-in for that brief second they see the holding viewcontroller's view - first impression count and having a weird transition can give the user the wrong impression about the quality of our app. A much better approach is to take the user directly from onboarding to the primary flow screens with any intermediate (implementation-detail) screen being shown.

We can avoid both the potential error, UI awkwardness and the need for a holding viewcontroller by not presenting the onboarding then presenting the primary flow. Instead, we can manipulate the navigation stack to push the actual primary flow screens without giving the user the ability to go back to onboarding - that way we treat both of them as primary flows, just dependent on the user's state.

The below example is a simple multi-path (sign-up and sign-in) onboarding flow that ends with the user seeing the real primary flow (starting from the tab-bar controller):

Diagram of the storyboard flow showing onboarding and main sections of the app

We can think of each primary flow as having a head viewcontroller which needs to be the root viewcontroller. When a head viewcontroller is pushed onto the navigation stack, we reset that stack by remove all the viewcontrollers expect for that head viewcontroller.

This example has two head viewcontrollers: RootTabBarController and TutorialViewController.

Let's look at a custom UINavigationController subclass that has this reset functionality:

class RootNavigationController: UINavigationController {

    // MARK: - ViewLifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.delegate = self
    }

    // MARK: - Reset

    func canBeMadeHeadViewController(viewController: UIViewController) -> Bool {
        return viewController.isKind(of: RootTabBarController.self) || viewController.isKind(of: TutorialViewController.self)
    }

    func resetNavigationStackWithLatestViewControllerAsHead() {
        if viewControllers.count > 1 {
            viewControllers.removeFirst((viewControllers.count - 1))
        }
    }
}

extension RootNavigationController: UINavigationControllerDelegate {

    // MARK: - UINavigationControllerDelegate

    func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
        if canBeMadeHeadViewController(viewController: viewController) {
            viewController.navigationItem.setHidesBackButton(true, animated: false)
        }
    }

    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        //Delete existing view controllers on navigation stack if view controller can be made the head of the stack.
        if canBeMadeHeadViewController(viewController: viewController) {
            resetNavigationStackWithLatestViewControllerAsHead()
        }
    }
}

Let's break the above code snippet down into smaller pieces.

We set RootNavigationController as it's own delegate so that we get callbacks when changes are made to the navigation stack:

func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
    //Delete existing view controllers on navigation stack if view controller can be made the head of the stack.
    if canBeMadeHeadViewController(viewController: viewController) {
        resetNavigationStackWithLatestViewControllerAsHead()
    }
}

In this method we check if the viewcontroller that was added onto the stack can be a head viewcontroller, if that viewcontroller is a head viewcontroller we then trigger a reset of the navigation stack:

func resetNavigationStackWithLatestViewControllerAsHead() {
    if viewControllers.count > 1 {
        viewControllers.removeFirst((viewControllers.count - 1))
    }
}

Here we reset the navigation stack by removing all viewcontrollers apart the last one added.

func canBeMadeHeadViewController(viewController: UIViewController) -> Bool {
    return viewController.isKind(of: RootTabBarController.self) || viewController.isKind(of: TutorialViewController.self)
}

The above method determines if a viewcontroller can be treated as a head viewcontroller. This check can easily be extended by adding more viewcontroller subclasses as OR conditions.

For housing keeping we also take the chance to reset the back button of the new head viewcontroller (this will happen once the navigation stack is reduced to only having 1 viewcontroller, but here we can do it before the user spots the back button in the UI):

func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
    if canBeMadeHeadViewController(viewController: viewController) {
        viewController.navigationItem.setHidesBackButton(true, animated: false)
    }
}

Gates that work 🏰

That's pretty much all there is to this post - we have an app with two mutually exclusive, primary flows. When the head of one primary flow is pushed onto the navigation stack, that stack is reset thus preventing the user from going back to the now dead, other primary flow. This pattern can easily be extended to handle more cases and even conditional heads. So with this approach, we reduce the risk of modal animation collisions in our apps while continuing to have mutually exclusive primary flows depending on the user's state 👯.

To see the above code snippets together in a working example, head over to the repo and clone the project.

What do you think? Let me know by getting in touch on Twitter - @wibosco