Navigation in Swift

Every app that doesn't only consist of one single UI needs some form of navigation - to enable users to move between different screens and to display information or react to events.

Whether you use navigation controllers, modal view controllers or some form of custom paradigm - having a nice way to perform navigation, in a way that doesn't paint you into corners, can be really tricky. It's easy for view controllers to become tightly coupled and to have to spread dependencies out across your entire app.

This week, let's take a look at a few different options for dealing with navigation in Swift apps, focusing on iOS this time.

The pushing problem

One of the standard ways to perform navigation on iOS is to use a UINavigationController that each view controller can either pop or push other view controllers onto, like this:

class ImageListViewController: UITableViewController {
    override func tableView(_ tableView: UITableView,
                            didSelectRowAt indexPath: IndexPath) {
        let image = images[indexPath.row]
        let detailVC = ImageDetailViewController(image: image)
        navigationController?.pushViewController(detailVC, animated: true)
    }
}

While doing the above works totally fine (especially for simpler apps), it can become quite tricky to deal with as an app grows - in case you want to be able to navigate to the same view controller from multiple places, or if you want to implement something like deep linking from outside your app.

A case for Coordinators

One way to make navigation a bit more flexible (and to avoid requiring view controllers to know about each other) is to use the coordinator pattern. The idea is that you introduce an intermediate/parent object that coordinates multiple view controllers.

For example, let's say that we're building an onboarding flow, where the user gets introduced to some of the key concepts of our app through a series of screens. Instead of having each view controller push the next one onto its navigationController, we can use a coordinator that takes care of that.

We'll start by introducing a delegate protocol that our onboarding view controllers can use to notify their owner when a button that should take the user to the next screen was tapped:

protocol OnboardingViewControllerDelegate: AnyObject {
    func onboardingViewControllerNextButtonTapped(
        _ viewController: OnboardingViewController
    )
}

class OnboardingViewController: UIViewController {
    weak var delegate: OnboardingViewControllerDelegate?

    private func handleNextButtonTap() {
        delegate?.onboardingViewControllerNextButtonTapped(self)
    }
}

We can then introduce a coordinator class that acts as the delegate for all onboarding view controllers and manages the navigation between them using a navigation controller:

class OnboardingCoordinator: OnboardingViewControllerDelegate {
    weak var delegate: OnboardingCoordinatorDelegate?

    private let navigationController: UINavigationController
    private var nextPageIndex = 0

    // MARK: - Initializer

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    // MARK: - API

    func activate() {
        goToNextPageOrFinish()
    }

    // MARK: - OnboardingViewControllerDelegate

    func onboardingViewControllerNextButtonTapped(
        _ viewController: OnboardingViewController) {
        goToNextPageOrFinish()
    }

    // MARK: - Private

    private func goToNextPageOrFinish() {
        // We use an enum to store all content for a given onboarding page
        guard let page = OnboardingPage(rawValue: nextPageIndex) else {
            delegate?.onboardingCoordinatorDidFinish(self)
            return
        }

        let nextVC = OnboardingViewController(page: page)
        nextVC.delegate = self
        navigationController.pushViewController(nextVC, animated: true)

        nextPageIndex += 1
    }
}

One big benefit of using coordinators is that the navigation logic can be completely moved out of the view controllers themselves, giving you a lot more flexibility and enabling the view controllers to focus on what they do best - managing views 👍.

One thing to note above is that OnboardingCoordinator has a delegate of its own. We could either use this to have our AppDelegate manage the coordinator by retaining it and becoming its delegate, or we could compose multiple levels of coordinators to construct most of our app's navigation flow. We could, for instance, have an AppCoordinator that in turn manages an OnboardingCoordinator and any other coordinators that are on the same level in the navigation hierarchy. Pretty powerful stuff!

Where to, Navigator?

Another approach that can be really useful (especially when building apps that have a large amount of screens and destinations that can be reached from multiple places) is to introduce dedicated navigator types.

To do that, we can start by creating a Navigator protocol that in turn has an associated type for what kind of Destination that it can navigate to:

protocol Navigator {
    associatedtype Destination

    func navigate(to destination: Destination)
}

Using the above protocol, we can then implement multiple navigators that each perform the navigation within a given scope of our app - like for when before the user has logged in:

class LoginNavigator: Navigator {
    // Here we define a set of supported destinations using an
    // enum, and we can also use associated values to add support
    // for passing arguments from one screen to another.
    enum Destination {
        case loginCompleted(user: User)
        case forgotPassword
        case signup
    }

    // In most cases it's totally safe to make this a strong
    // reference, but in some situations it could end up
    // causing a retain cycle, so better be safe than sorry :)
    private weak var navigationController: UINavigationController?

    // MARK: - Initializer

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    // MARK: - Navigator

    func navigate(to destination: Destination) {
        let viewController = makeViewController(for: destination)
        navigationController?.pushViewController(viewController, animated: true)
    }

    // MARK: - Private

    private func makeViewController(for destination: Destination) -> UIViewController {
        switch destination {
        case .loginCompleted(let user):
            return WelcomeViewController(user: user)
        case .forgotPassword:
            return PasswordResetViewController()
        case .signup:
            return SignUpViewController()
        }
    }
}

Using navigators, navigating to a new view controller becomes as simple as calling navigator.navigate(to: destination) and we don't need multiple levels of delegation to make it happen. All we need is for each view controller to keep a reference to a navigator that supports all desired destinations, like this:

class LoginViewController: UIViewController {
    private let navigator: LoginNavigator

    init(navigator: LoginNavigator) {
        self.navigator = navigator
        super.init(nibName: nil, bundle: nil)
    }

    private func handleLoginButtonTap() {
        performLogin { [weak self] result in
            switch result {
            case .success(let user):
                self?.navigator.navigate(to: .loginCompleted(user: user))
            case .failure(let error):
                self?.show(error)
            }
        }
    }

    private func handleForgotPasswordButtonTap() {
        navigator.navigate(to: .forgotPassword)
    }

    private func handleSignUpButtonTap() {
        navigator.navigate(to: .signup)
    }
}

We can also take this a step further, and combine navigators with the factory pattern (like we took a look at in "Dependency injection using factories in Swift"), to be able to easily move the creation of view controllers out from the navigators themselves, leaving us with an even more decoupled setup:

class LoginNavigator: Navigator {
    private weak var navigationController: UINavigationController?
    private let viewControllerFactory: LoginViewControllerFactory

    init(navigationController: UINavigationController,
         viewControllerFactory: LoginViewControllerFactory) {
        self.navigationController = navigationController
        self.viewControllerFactory = viewControllerFactory
    }

    func navigate(to destination: Destination) {
        let viewController = makeViewController(for: destination)
        navigationController?.pushViewController(viewController, animated: true)
    }

    private func makeViewController(for destination: Destination) -> UIViewController {
        switch destination {
        case .loginCompleted(let user):
            return viewControllerFactory.makeWelcomeViewController(forUser: user)
        case .forgotPassword:
            return viewControllerFactory.makePasswordResetViewController()
        case .signup:
            return viewControllerFactory.makeSignUpViewController()
        }
    }
}

When using something like the above, we also have an excellent opportunity to inject other types of navigators into our view controllers without making them aware of each other. For example, we might want to inject a WelcomeNavigator into WelcomeViewController, which can be done in the factory instead of requiring LoginNavigator to be aware of it 👍.

URLs and deep linking

For many kinds of apps we not only want to make it easy to navigate within our own app, but also to enable other apps & websites to deep link into ours. A common way to do this on iOS is to define a URL scheme that other apps can then use to link directly into a specific screen or feature of our app.

Using either (or both!) coordinator and navigator objects, implementing URL and deep linking support becomes a lot easier, since we have dedicated places for navigation in which we can inject our URL handling logic. We'll take a closer look at URL-based navigation in an upcoming blog post.

Conclusion

Moving navigation logic out of view controllers and into dedicated objects like coordinators or navigators can make moving between multiple view controllers a lot simpler. What I like about both the coordinator & navigator approaches is that they are highly composable, making it possible to easily split out our navigation logic into multiple scopes & objects, rather than relying on some form of central routing.

Another nice benefit is that both of these techniques let us remove non-optional optionals like when relying on a view controller's navigationController reference for our navigation logic. Usually, that leads to more predictable and future-proof code.

What do you think? Do you use some of these navigation techniques already, or do you have another favorite way of performing navigation? Let me know, along with any questions, comments or feedback that you might have on Twitter @johnsundell.

Thanks for reading! 🚀

Code encapsulation in Swift

Unit testing asynchronous Swift code