Closing the Gate when the User Walks Through
There are few things more disturbing than watching a user actually use our apps. One area that I always find surprising is how a user navigates through an app - moving forwards and backwards in unexpected ways. While this is often fine, sometimes moving backwards can lead to error and confusion when that backwards step takes the user back to a one-way action, such as registering for an account. The user finds themselves trapped in front of a gate that they just went through, as the app doesn't know how to react. We should be preventing the user from being able to return to completed flows, but UINavigationController, while great at moving a user forwards, is not so great at preventing that same user from moving backwards.

This post will explore creating a simple UINavigationController subclass that will allow us to reset the navigation stack when a gate is passed.
This post has an accompanying example project that you can clone to see the final version.
Looking at the Alternatives
Resetting the Window's Navigation Stack
One way to prevent the user from returning to a completed flow is to throw away the current navigation stack entirely, setting the rootViewController property on the main UIWindow instance to the new flow's navigation stack. While this is clean, transitioning gracefully between flows using this approach is far from straightforward, especially if you want to duplicate any of the standard navigation animations (as these animations aren't available outside of a navigation controller).
Using Modals
Another way to prevent the user from returning to a completed flow is to start a new navigation stack using modals.
This is a misuse of modal navigation. Modals are intended for temporary flows where the user completes a task and then returns. By using modals for flows that never return, we break the implicit contract for how a modal should be used - making the app harder to understand and maintain.
Using modals to present that new flow also keeps the completed flow alive in memory, allowing those now inaccessible view controllers to continue responding to system events (which could lead to hard-to-reproduce bugs).
Worse still, when transitioning between modal states - dismissing one view controller while presenting another - we can run into a transition race-condition:

This error appears when the app attempts a navigation transition while one is already underway. When this happens, the second transition is cancelled - leaving the user trapped in a completed flow that they now can't leave.
Manipulating the navigation stack sidesteps these issues entirely while requiring less code.
Building Our Gates
We can think of these one-way transitions as gates - points in the app where the previous state is no longer valid. Once a user passes through a gate, returning to the earlier flow would lead to confusion or error.
When a gate view controller is pushed, we reset the navigation stack by removing all previous view controllers, leaving only the gate itself.
To achieve this, we could introduce new methods on UINavigationController to push gate view controllers, but it would only take one new path that didn't use those methods to break our gating. It's better to keep using the existing push API and let gate view controllers identify themselves through the type system:
protocol NavigationGate { }
NavigationGate is a protocol that has no property or method requirements - its sole purpose is to identify which view controllers should act as gates when pushed on the navigation stack. Using a protocol here means each view controller declares itself as a gate rather than a UINavigationController having to keep a record of all the gate view controllers. This separation of what a gate is from how a gate affects the navigation stack allows us to add and remove gates from individual view controllers as needed, without having to touch code that the rest of the app depends on.
Using the type system works well when a view controller is always a gate or always not a gate. If you need the same view controller to sometimes be a gate, then you need to move the gate checking to the runtime. I'd recommend building a Coordinator as shown here. But keep reading this post, as the view stack manipulation will still be required.
With this ability to identify gates, we can create a UINavigationController subclass that trims the view controller stack when a gate view controller is pushed:
// 1
class GateNavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
// 2
delegate = self
}
}
extension GateNavigationController: UINavigationControllerDelegate {
// 3
func navigationController(_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool) {
if viewController is NavigationGate {
viewController.navigationItem.setHidesBackButton(true,
animated: false)
}
}
// 4
func navigationController(_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool) {
if viewController is NavigationGate {
viewControllers = [viewController]
}
}
}
GateNavigationControlleris aUINavigationControllersubclass that can manage the transition between gated flows.- Setting itself as its own
delegate,GateNavigationControllercan monitor changes to the navigation stack. navigationController(_:willShow:animated:)is called just before a view controller is shown on the navigation stack. If the incoming view controller conforms toNavigationGate, the back button is hidden before it can appear in the UI - this avoids the back button flash appearing on the navigation bar before it is removed due to theviewControllersarray being set to just the gate view controller.navigationController(_:didShow:animated:)is called after a view controller has been shown on the navigation stack. If the shown view controller conforms toNavigationGate, the navigation stack is reset by settingviewControllersto an array containing only the gate view controller. From the user's perspective, the transition looks like any other push - they have no idea the stack was just wiped behind them.
As backwards navigation is also possible by swiping from the edge of the screen, removing the back button alone isn't enough - that is why we also need to alter the navigation stack itself.
GateNavigationController setting itself as its own delegate while valid feels fragile - all it would take is for anything else to set itself as delegate for the app to lose its ability to respond to a gate view controller being pushed onto the stack. Bugs like this are especially painful because the cause and the symptom appear in completely different parts of the app - good luck discovering that connection quickly. A better approach is to proxy the delegate - letting external objects set themselves as delegate while GateNavigationController continues to be its own delegate internally:
class GateNavigationController: UINavigationController {
// 1
private weak var externalDelegate: UINavigationControllerDelegate?
// 2
override var delegate: UINavigationControllerDelegate? {
get {
return externalDelegate
}
set {
if newValue is GateNavigationController {
super.delegate = newValue
} else {
externalDelegate = newValue
}
}
}
// Omitted unchanged functionality
}
extension GateNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool) {
// Omitted unchanged functionality
// 3
externalDelegate?.navigationController?(navigationController,
willShow: viewController,
animated: animated)
}
func navigationController(_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool) {
// Omitted unchanged functionality
// 4
externalDelegate?.navigationController?(navigationController,
didShow: viewController,
animated: animated)
}
}
externalDelegateholds a weak reference to any delegate set from outsideGateNavigationController.- The
delegateproperty is overridden so thatGateNavigationControlleralways remains the actual delegate of the navigation controller. Any external delegate is redirected toexternalDelegate, and the getter returnsexternalDelegateso that callers see the delegate they set rather thanGateNavigationControlleritself. - After performing the gating logic, the
navigationController(_:willShow:animated:)callback is forwarded to the external delegate. - After performing the gating logic, the
navigationController(_:didShow:animated:)callback is forwarded to the external delegate.
UINavigationControllerDelegatecontains other methods, I've only included the methods that are relevant to this post, but to implement true proxying, you would need to fully implement all methods inUINavigationControllerDelegateand forward those calls on.
All that is left to do is to create a gate view controller:
class GateViewController: UIViewController, NavigationGate {
// Omitted unrelated functionality
}
And that's everything 👯.
In this post, I haven't covered unit tests, but the accompanying example project has tests if you are interested.
Gates That Work
With NavigationGate and GateNavigationController in place, the gate closes behind the user automatically - turning one-way flows into a natural part of navigation rather than something enforced with fragile modal transitions. The next time we watch a user actually use our app, we can allow ourselves to be disturbed by the other ways they use our app.
To see the above code snippets together in a working example, head over to the repository and clone the project.