Search code examples
swiftuinavigationcontrollerunwind-seguestack-unwinding

How do you conditionally modify a UINavigationController's back-stack to skip previous entries regardless of where you came from?


We're trying to figure out how to conditionally exclude certain screens in our back-stack when navigating backwards. For instance, take the following screens:

  1. Main Dashboard (You can go to 2 or 3 from here)
  2. Order Management (you can go to 3 or 6 from here)
  3. Enter Order
  4. Review Order
  5. Order Confirmation (if you make it here, back should skip 3 & 4)
  6. Order Status (if you make it here, back should skip 5 (it already skips 3 & 4))

Screens 3-5 represent entering an order so if you've made it to the confirmation page, we don't want you going back to the review or edit pages.

For the same reason, if you've navigated to 'Order Status' from the confirmation page, we don't want you navigating back to the 'Order Confirmation', but rather back to 'Order Management' or 'Main Dashboard' (depending on how you got there)

Note: We're aware of popToViewController and how it lets you skip over all controllers between where you are and the one you specify. The issue is I don't know what that view controller is. I only know which I want to skip over (if they are present.) More importantly, the back button reflects what's in the stack, not what I'm popping to. That's why I'm hoping to tell the system 'Just ignore these in the stack when going back'. Hope that makes sense.

My crude attempt is to modify the back stack like so...

func showConfirmation() {

    guard navigationController = navigationController else {
        return
    }

    navigationController.pushViewController(orderConfirmation, animated:true)

    if let orderEntryIndex  = navigationController.viewControllers.index(of:orderEntry),
       let orderReviewIndex = navigationController.viewControllers.index(of:orderReview){

        navigationController.viewControllers.removeSubrange(orderEntryIndex...orderReviewIndex)
    }
}

func showStatus() {

    guard navigationController = navigationController else {
        return
    }

    navigationController.pushViewController(orderStatus, animated:true)

    if let orderConfirmationIndex = navigationController.viewControllers.index(of:orderConfirmation){

        navigationController.viewControllers.remove(at: orderConfirmationIndex)
    }
}

Note: This is simplified code. We don't actually hang onto instances of the view controllers. We find the indexes by looking for their type in the back-stack. This is for simple illustrative purposes only.

While the navigation works, during the animation, the back button shows the original back location, then after the animation, the label's value 'snaps' to show the new back location. This of course proves this isn't the right approach.

I know there are unwind segues, but that worries about where you're going. We just want to remove items from the backlog after navigating away from them, but otherwise leave the back-stack intact.

So how does one achieve this behavior of ignoring prior steps?


Solution

  • Ok, I figured out how to do it, and have added an extension on UINavigationController to help with this in the future.

    The secret is to use the setViewControllers method to replace the entire set of UIViewController objects, removing the ones you want to skip. To facilitate that, I pass in an array of UIViewController.Types which I use to filter out the existing controllers which I don't want.

    extension UINavigationController {
    
        func pushViewController(_ viewController:UIViewController, removingBackItemsOfType backItemTypesToRemove:[UIViewController.Type], animated:Bool) {
    
            var viewControllers = self.viewControllers
                .filter{ vc in !backItemTypesToRemove.contains(where:{ vcType in vcType == type(of:vc) } ) }
    
            viewControllers.append(viewController)
    
            setViewControllers(viewControllers, animated: animated)
        }
    }
    

    So now, when I'm on 'Review Order', I use this method to push to 'Order Confirmation' passing in the items I want to remove from the back-stack, like so...

    let orderConfirmation = OrderConfirmation()
    
    let typesToRemove:[UIViewController.Type] = [
        EnterOrder.self,
        ReviewOrder.self
    ]
    
    navigationController?.pushViewController(orderConfirmation, removingBackItemsOfType:typesToRemove, animated:true)
    

    The result is it pushes to the new Order Confirmation view controller, but pressing back brings me to whatever screen I was at before Enter Order, regardless of where I came from.

    I then do the same when pushing from Order Confirmation to Order Status, this time removing [OrderConfirmation.self]. Same thing... back now brings me to the screen that launched me into Order Entry.

    In short, this lets you set up the scenarios I described above without having to worry about what came before. Just what you want to skip.

    Hope this helps!