Navigator pattern with nonlinear navigation in iOS

Recently I started to use the “Navigation Coordinator” pattern in my projects.

This pattern helps to significantly reduce coupling. It takes responsibility for choosing and presenting the right view controller from another view controller.

I borrowed the idea from a course on Lynda.com. I made changes that allow choosing where you want to navigate from the current View controller.

Another place where this concept is well explained is a Swift Talk episode Connecting View Controllers I’m not completely satisfied with this approach and working on the update. I described the following update in my next post about coordinators.

Below is an example of a traditional navigation pattern often used in code examples and simple applications:

class MainViewController: UIViewController {

  ....

  func showScreenA() {
    let viewController = ScreenAViewController() // 1
    self.navigationController?.pushViewController(viewController, animated: true) // 2
  }

  func showScreenB() {
    let dependency: SomeClassBDependency()  
    let viewController = ScreenBViewController(dependency: dependency)
    self.navigationController?.pushViewController(viewController, animated: true) // 3
  }
}

Here we have two functions that can be called to present another ViewControllers. In case of ScreenBViewController, we also inject a dependency.

  1. MainViewController should know how to create the ScreenAViewController.
  2. It also knows where to present (self.navigationController)
  3. And how to present ( pushViewController(viewController, animated: true) )

With this pattern, the MainViewController besides to its main function also should know how and where to present the ScreenAViewController and ScreenBViewController.

It bloats the code and violates the Single Responsibility Principle. To deal with it we should remove all mentions of other ViewControllers from the MainViewController. The Navigation Coordinator should make decisions about the controller it should present next.

##Refactoring for Navigation Coordinator

To simplify the example I will create the new controller inside the Navigation Coordinator. In a real project we should move this responsibility to some object factory. Also I will not use protocols.

As a starting point, I used a simple method that allows linear navigation between two View Controllers.

Let’s create a NavigationCoordinator class and pass it to our MainViewController from a common parent class.

// 1
enum NavigationState {
  case atMainView, atScreenA, atScreenB
}

class NavigationCoordinator {
  private var navigationState: NavigationState = .atMainView
  private var navigationController: UINavigationController!

  // 2
  init(with navigationController: UINavigationController) {
    self.navigationController = navigationController
  }

  // 3
  func next(arguments: [String: Any]?) { 
    switch navigationState {
      case .atMainView:
        showSecondaryView(arguments: arguments)
      default:
        break
    }
  }

  private func showSecondaryView(arguments: [String: Any]?) {
    guard let selectedScreen = arguments["screen"] as? MainViewNavigation else { // 4
      return 
    }
    switch selectedScreen {
      case .screenA:
      showScreenA(arguments: arguments)
      case .screenB:
      showScrenB(arguments: arguments)
    }
  }

  private func showScreenA(arguments: [String: Any]?) {
    let viewController = ScreenAViewController()
    navigationController.pushViewController(viewController, animated: true)
    navigationState = .atScreenA
  }

  private func showScreenB(arguments: [String: Any]?) {
    let dependency = arguments["screenBDependency"]
    let viewController = ScreenBViewController(arguments: dependency)
    navigationController.pushViewController(viewController, animated: true)
    navigationState = .atScreenB
  }
}

In the NavigatoinCoordinator above we made a few steps that will allow to create and present the screen we need.

  1. We use the enum to store possible navigation states.
  2. In the initializer, we inject the navigation controller that will present the child view controllers.
  3. The function next(arguments: [String: Any]?) checks the current navigation state to find the ViewContrtoller from which it is called. Then it calls the corresponding navigation method.
  4. In the navigation function, we use a passed argument to find what screen should be created.

Let’s check the new MainViewController

// 1
enum MainViewNavigation: Int {
  case screenA, screenB
}

class MainViewController: UIViewController {
  weak var navigationCoordinator: NavigationCoordinator? // 2

  init(with navCoordinator: NavigatonCoordinator) { // 2
    self.navigationCoordinator = navCoordinator
  }

  func showScreenA() {
    let arguments = ["screen": MainViewNavigation.screenA]
    self.navigationCoordinator.next(arguments: arguments) // 3
  }

  func showScreenB() {
    let dependency: SomeClassBDependency()
    self.navigationCoordinator.next(arguments: ["screen": MainViewNavigation.screenB,
                                                "screenBDependency": dependency,  // 4
                                              //"someOtherDependency": dependency // 5
                                                ]) 
  }
}
  1. We use the enum MainViewNavigation to store the codes of all possible screens we need to navigate to.
  2. The NavigationCoordinator is passed from the common parent and stored as a weak variable. It is very similar to the Delegate pattern.
  3. Now, instead of instantiating a new ViewController we call the method next of the navigation coordinator letting it know what kind of screen we want to present (or what event happened, if you prefer)
  4. If we need to inject a dependency, we can do it by passing an additional argument.
  5. It’s possible to add all kinds of additional arguments to the call.

The updated MainViewController knows nothing about screens that will be presented. It also doesn’t know where or how these controllers will be presented.

The only external method it has access to is navigationCoordinator.next(arguments:). By passing the “screen” and “dependency” arguments it allows NavigationCoordinator to decide what to do next.

It looks like this approach adds a lot of code, but it gives much more benefits in return.

Please leave a comment if you know a better way to achieve this functionality or have a question.