Skip to content

EpoxyNavigationController

Tyler Hedrick edited this page Jan 14, 2021 · 2 revisions

NavigationController brings an easy-to-use declarative API to UINavigationController making it clear what the current state of the navigation stack is, while also making it easy to update.

Basic Usage

Imagine you have a flow of view controllers that you'd like to coordinate. Maybe you are working on a form or onboarding flow with multiple steps. In an imperative world, you would need to manually push and pop view controllers as needed when actions occur. This can quickly get hairy and lead to unexpected states and errors. NavigationController solves this by having a central source of truth for the current state of the navigation stack.

As an example, let's assume we are building a form with 3 steps. We can keep track of which steps are on the navigation stack through a shared State object, and simply return a NavigationModel if it should be shown, or nil if it should be hidden. Here's the code:

final class FormViewController: NavigationController {

  override func viewDidLoad() {
    super.viewDidLoad()
    setStack(stack, animated: false)
  }

  // MARK: Private

  private struct State {
    // we don't need a showStep1 flag because it will always be on the stack
    var showStep2 = false
    var showStep3 = false
  }

  private enum DataIDs {
    case step1
    case step2
    case step3
  }

  private var state = State() {
    didSet {
      setStack(stack, animated: true)
    }
  }

  private var stack: [NavigationModel?] {
    // Note that when this view loads, only step1 is non-nil which will have the result
    // of our navigation stack only having one UIViewController.
    // Order is important here - the order you return these in 
    // will determine the order they are pushed onto the stack
    [
      step1,
      step2,
      step3
    ]
  }

  private var step1: NavigationModel {
    // we use the `root` `NavigationModel` here because `step1` will always be present in the stack
    // acting as our UINavigationController's rootViewController
    .root(dataID: DataIDs.step1) { [weak self] in
      let viewController = Step1ViewController()
      viewController.didTapNext = {
        // setting this property on our state will automatically update the `state` instance variable
        // which will cause the stack to be re-created and reset. This will have the effect of pushing on Step2ViewController.
        self?.state.showStep2 = true
      }
    }
  }

  private var step2: NavigationModel? {
    guard state.showStep2 else { return nil }
    return NavigationModel(
      dataID: DataIDs.step2,
      makeViewController: { [weak self] in
        let vc = Step2ViewController()
        vc.didTapNext = {
          self?.showStep3 = true
        }
        return vc
      },
      // if the user taps back (or we programmatically pop this VC) this closure will be called so we can
      // keep our navigation stack state up-to-date
      remove: { [weak self] in
        self?.state.showStep2 = false
      })
  }

  private var step3: NavigationModel? {
    guard state.showStep3 else { return nil }
    return NavigationModel(
      dataID: DataIDs.step3,
      makeViewController: { [weak self] in
        let vc = Step3ViewController()
        vc.didTapNext = {
          // Handle dismissal of this flow, or navigate somewhere else
        }
        return vc
      },
      remove: { [weak self] in
        self?.state.showStep3 = false
      })
  }
}

Nested UINavigationControllers

One issue with UINavigationController as it is today is that you cannot nest them. If you try to push a UINavigationController onto another UINavigationController's navigation stack, your app will crash. Epoxy's NavigationController solves this by allowing you to initialize it with an optional wrapNavigation closure which returns a standard UIViewController with the expectation that the provided UINavigationController is a child of that UINavigationController.

final class ComplexFormViewController: NavigationController {

  init() {
    super.init(wrapNavigation: { navigationController in 
      // wrap the navigationController in a `UIViewController` and return that view controller
    })
  }

}

Note that it's important to ensure that nested UINavigationControllers hide their navigationBar. You can use EpoxyBars TopBarInstaller to create custom navigation bars that live outside of the UINavigationController as a substitute.

Navigation callbacks

NavigationModel has a few helpful callbacks you can set to respond to navigation lifecycle events. For example, if you wanted to log an event whenever a particular view controller becomes visible in the stack, you could do that like this:

private var step2: NavigationModel? {
  guard state.showStep2 else { return nil }
  return NavigationModel(
    dataID: DataIDs.step2,
    makeViewController: { ... },
    remove: { ... })
  .didShow { [weak self] viewController in
    self?.logDidShowEvents(for: viewController)
  }
}

There are 4 available callbacks you can utilize:

Callback Discussion
didShow Invoked when the view controller becomes the top view controller on the navigation stack (the currently visible view controller)
didHide Invoked when the view controller is no longer the top view controller on the navigation stack
didAdd Invoked when the view controller is added to the navigation stack
didRemove Invoked when the view controller is removed from the navigation stack