Avoiding modal transitions by thinking horizontally

Most of the apps that we create will have self-contained subsections that break from the main navigation stack. For these self-contained sections we often use modal transitions as these transitions represent a vertical rather horizontal navigation change. These self-contained sections are used for: Settings, Image Pickers, Terms & Conditions etc where the user is expected to complete a task and return to the main flow of the app. However self-contained sections are also often used for mutually exclusive sections from which the user isn't expected to return. In this post I want to explore how we can avoid using vertical navigation changes for moving from one mutually exclusive section to another but rather to use horizontal navigation changes.

Onboarding ⚓

Let's look at a common example of a mutually exclusive section: Onboarding a new user. Now Onboarding brings up images of ships and departing for an adventure but we must cast all that aside and instead focus on UIViewControllers and UINavigationControllers, as we are developers (the greatest of all non-sea based onboarders). The onboarding section of our apps normally compromises of: introduction, sign-in or sign-up. It offers an alternative starting point for our apps from the main section of our apps. If a user is unknown we show them the onboarding screens or if is known (i.e. the user is logged into an account) we show them the main screens. Ideally for a user that moves from unknown to known we don't want that user to be able to navigate back from main to onboarding.

You may be thinking:

Well, this is a perfect example of where to use a vertical/modal transition

First of all, well done on such concise thinking in using a duel name for the transition when talking to yourself.

However if we use a modal transition here we would forever (or at least until the user or iOS kills the app) have the onboarding screens underneath the main screens and so be consuming our app's limited resources. But the real danger is when transitioning between modal states:

Modal transition error message

This error is shown when the app attempts to perform one modal transition when another one is already underway. When this happens the second transition is cancelled which isn't great if that transition was the one that moved your user into a mutually exclusive section and now the user finds that they are trapped in a completed part of your flow. It's also one of those bugs that are very hard to reproduce and won't show up on your crash reports (as the app doesn't crash) so you will never be entirely sure how many users are affected by it.

Let's look at how we can avoid this error by pushing (horizontal) the viewcontroller onto the navigation stack rather than presenting it (vertical). The below example is a simple multipath (sign-up and sign-in) onboarding process that ends with the user seeing the main section of the app as a tabbar.

Storyboard flow

We can think of each mutually exclusive section in our apps as being a reset point, where we take what was in the navigation stack and remove the previous viewcontrollers to leave the navigationcontroller with only one viewcontroller. 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)
    }
}

Now you can call

popToRootViewController(animated: true)

At any time in your app without the need to check if were the user started their session.

Let's look at some alternatives

The beauty of programming is that there are always alternative ways to solve the same problem so let's look at some of them:

Present main section from one of the mutually exclusive sections

With this approach we start with the onboarding section and present any subsequent mutually exclusive sections modally/vertically until we get to the main section. As we spoke about this approach above, I won't detail it too much but I believe it has one significant disadvantage:

  1. Wasted Resources
Present mutually exclusive sections from the main section

With this approach we would have the first main viewcontroller(s) be responsible for presenting other mutually exclusive sections. I believe this has a number of issues:

  1. Breaks single responsibility principle as the main viewcontroller(s) now needs to be aware of the apps state and effectively act as a gate keeper. In our example that would result in the either the tabbar controller or it's primary viewcontroller being aware of whether to show onboarding, tutorial or itself.
  2. Transitioning between mutually exclusive sections will result in showing briefly the main section as you wait for modal animation to finish. Briefly showing the main section leaves us open to the more nimble of users interacting with the main UI before their account is not properly setup.
  3. Our API calls now need to be aware of if the user is logged in so either we add if statements to our main viewcontroller's calls for data or we add it into our network layer, either way we end up with a more nested, more complicated networking stack.
Present mutually exclusive sections from the main section but have the appDelegate control the flow

With this approach we would have the appDelegate act as the main navigation controller for moving between mutually exclusive sections. It has the same issues as point 1 but also some of it's own:

  1. The appDelegate tends to grow the more functionality an app has due to iOS channeling a lot of system calls through it e.g. App Lifecycle, Push Notifications, Spotlight, etc. Unless we are diligent it's easy to end up with the appDelegate becoming a "big ball of mud" by doing too much. So for the sake of avoiding this we want to move as much functionality into dedicated classes that only have on job (SRP again).
  2. We need our classes to either be directly accessing the appDelegate to call methods, set values, etc or we use notifications to pass state around - either way we end with a more tightly coupled code base.
  3. Our storyboards no longer reflect the flow of the app as we have navigation choices being made else where. This increases the cost of every new developer who joins the project and will also be the source of frustrations as changes made in the storyboard will potentially be overridden in code.
Exiting stage right

That's pretty much all there is to this post - we have an app with two heads (three if you count the original one) where the navigation stack is reset upon an instance of those heads being pushed onto it. 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 sections 👯.

You can find the completed project by heading over to https://github.com/wibosco/AvoidingModal-Example