My take on Nested UINavigation

Apr 12, 2020

🎇🎇🎆 One day I just needed to push a UINavigation from a UINavigation, well, easy. Nested Navigation

@IBAction func push_nav(_ sender: Any) {
  let navVC = UINavigationController(rootViewController: ViewController())  
  navigationController?.pushViewController(navVC, animated: true)
}

big red crash reason: ‘Pushing a navigation controller is not supported’

Let’s start over and from the anatomy of how this project will work. (We will not use Storyboard’s Segue, it’s here just to paint the picture) Or download the finish code.

Navigation Blueprint

We still use UINavigationController as the parent but our children are more complex. Let’s focus on the brown ViewController.

  • BrownVC (or NestedViewController) has an embed ViewController with UINavigationController as the embed VC.
  • UINavigationController will present a VC with button “1”
  • Button “1” will create another NestedViewController, blue one, and push BlueVC on to the Root UINavigation.
  • We can repeat button “1” action multiple times.

We start by creating a new UINavigationController. I named it NavViewController

  class NavViewController: UINavigationController {
    override func viewDidLoad() {
    super.viewDidLoad()  
    // we’ll work here in a bit  
  }

Create another subclass of UIViewController.

import UIKit  class NestedViewController: UIViewController {
  var rootVC: UIViewController  
  weak var rootNavigation: UINavigationController?  
  // 1  
  init(rootNavigation: UINavigationController) {  
    guard let vc = rootNavigation.viewControllers.first else { 
      fatalError(“root has not been initialized”)  
    }  
    self.rootVC = vc  
    self.rootNavigation = rootNavigation  
    super.init(nibName: nil, bundle: nil)  
  }  
  required init?(coder: NSCoder) {  
    fatalError(“init(coder:) has not been implemented”)  
  }  
  override func viewDidLoad() {  
    super.viewDidLoad()  
    // 2  
    let childNavigation = rootNavigation ?? UINavigationController(rootViewController: rootVC)  
    childNavigation.willMove(toParent: self)  
    addChild(childNavigation)  
    childNavigation.view.frame = view.frame  
    view.addSubview(childNavigation.view)  
    childNavigation.didMove(toParent: self)  
    childNavigation.navigationBar.isTranslucent = false  
    // 3  rootVC.navigationItem.leftBarButtonItem = 
    UIBarButtonItem(barButtonSystemItem: .close, target: nil, action: #selector(back))  
  }  
  @IBAction func back() {
    navigationController?.popViewController(animated: true)  
  }  
}
  1. We create a new init method, taking in an UINavigationController and assign the UINavigationController to variable.

  2. In viewDidLoad() we insert UINavigationController’s view to NestedViewController.

  3. Add a back button (optional)

open ViewController.swift and insert following method

@IBAction func new_nav(_ sender: Any) {
  let cvc = UIStoryboard(name: “Main”, bundle: nil).instantiateViewController(identifier: “ViewController”)  
  let nestedVC = NestedViewController(rootNavigation: UINavigationController(rootViewController: cvc))  
  var nav = navigationController  
  while ((nav?.navigationController) != nil) {  
    nav = nav?.navigationController  
  }  
  DispatchQueue.main.async {  
    nestedVC.rootVC.navigationController?.navigationBar.barTintColor = UIColor.random()  
  }  
  nav?.pushViewController(nestedVC, animated: true)  
}

Open Main.storyboard and drag in a UINavigationController. Make the UINavigationController the initial ViewController and set the root child controller to ViewController

Storyboard1

Drag in a new button to ViewController and assign touch up inside to new_nav.

Storyboard2

Run and enjoy.

For now, our app will not accept slide back gesture. We will need to configure interactivePopGestureRecognizer.

Open NavViewController.swift and add the follow code to bottom of the file

final class AlwaysPoppableDelegate: NSObject, UIGestureRecognizerDelegate {
  weak var navigationController: UINavigationController?  
  weak var originalDelegate: UIGestureRecognizerDelegate?  
  override func responds(to aSelector: Selector!) -> Bool {  
    if aSelector == #selector(gestureRecognizer(_:shouldReceive:)) { 
      return true 
    } else if let responds = originalDelegate?.responds(to: aSelector) {  
      return responds  
    } else {  
      return false  
    }  
  }  
  override func forwardingTarget(for aSelector: Selector!) -> Any? {  
    return originalDelegate  
  }  
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {  
    return  true  
  }  
}  

protocol Nested {  
  var canNestedSwipeBack: Bool {get set}  
}

Declare a new variable for NavViewController

private let alwaysPoppableDelegate = AlwaysPoppableDelegate()

Inside NavViewController’s viewDidLoad() add these code

setNavigationBarHidden(true, animated: false)  
self.navigationBar.isOpaque = true  
alwaysPoppableDelegate.navigationController = self  
interactivePopGestureRecognizer?.delegate = alwaysPoppableDelegate

Here we assign a new interactivePopGestureRecognizer. Build and run.

The swipe back’s working now but if you’re viewing the completed code you’ll notice another block inside AlwaysPoppableDelegate. Go back to AlwaysPoppableDelegate’s gestureRecognizer and change the inside to.

func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {  
  if let nav = navigationController, nav.isNavigationBarHidden, nav.viewControllers.count > 1 {  
    // extra for nested nav viewcontrollers  
    if let nested = (nav.viewControllers.last as? Nested) {  
      return nested.canNestedSwipeBack  
    } else if let nestedVC = (nav.viewControllers.last as? NestedViewController) {  
      return (nestedVC.rootNavigation?.viewControllers.count ?? 0) <= 1  
    }  return true  
  } else if let result = originalDelegate?.gestureRecognizer?(gestureRecognizer, shouldReceive: touch) {  
    return result  
  } else {  
    return false  
  }  
}

gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) is fired when we are performing the swipe back gesture. We override the method to check for nested child array. To see this for yourself, add another button to ViewController and assign it to this method:

@IBAction func same_nav(_ sender: Any) {  
  let cvc = UIStoryboard(name: “Main”, bundle: nil).instantiateViewController(identifier: “ViewController”)  
  navigationController?.pushViewController(cvc, animated: true)  
}

We are presenting another ViewController inside the nested UINavigationController but if we swipe back, there’s a chance that the whole stack will be removed. Our new gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) checks for this and cancel the touch that will remove a whole stack.

Build and run and ask any questions.

Confusians

© 2015 - 2021