> ## Content Index
> Fetch the complete content index at: https://williamboles.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Closing the Gate when the User Walks Through
- URL: https://williamboles.com/closing-the-gate-when-the-user-walks-through/
- Published: 2017-01-14T18:46:05.000Z
- Updated: 2026-03-31T21:30:01.000Z
- Description: Modals are often used as one-way gates, e.g., preventing a user returning to the registration screens after registering. However, this misuse of modals leads to bugs when the user inevitably jumps the gate. Here, we look at how to build proper one-way gates by tweaking the navigation stack.
- Author: William Boles
- Tags: Navigation, UIKit

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](https://developer.apple.com/documentation/uikit/uinavigationcontroller?ref=williamboles.com), while great at moving a user forwards, is not so great at preventing that same user from moving backwards.

![A photo of a user walking through a gate](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2026/03/user-walking-through-a-gate-min.jpg)

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](https://github.com/wibosco/AvoidingModal-Example?ref=williamboles.com) 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](https://developer.apple.com/documentation/uikit/uiwindow?ref=williamboles.com) 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](https://developer.apple.com/design/human-interface-guidelines/modality?ref=williamboles.com).

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](https://en.wikipedia.org/wiki/Race%5Fcondition?ref=williamboles.com):

![Screenshot showing modal transition error message where one navigation transition has been cancelled due to crashing with a different modal transition](https://storage.ghost.io/c/b9/b4/b9b42214-5933-4584-8e56-9ee8ca21d23b/content/images/2017/01/Screen-Shot-2017-01-10-at-23.42.54.png)

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](https://khanlou.com/2015/01/the-coordinator/?ref=williamboles.com). 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]
        }
    }
}
```

Here's what we did above:

1. `GateNavigationController` is a `UINavigationController` subclass that can manage the transition between gated flows.
2. Setting itself as its own [delegate](https://developer.apple.com/documentation/uikit/uinavigationcontrollerdelegate?ref=williamboles.com), `GateNavigationController` can monitor changes to the navigation stack.
3. `navigationController(_:willShow:animated:)` is called just before a view controller is shown on the navigation stack. If the incoming view controller conforms to `NavigationGate`, 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 the `viewControllers` array being set to just the gate view controller.
4. `navigationController(_:didShow:animated:)` is called after a view controller has been shown on the navigation stack. If the shown view controller conforms to `NavigationGate`, the navigation stack is reset by setting `viewControllers` to 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](https://en.wikipedia.org/wiki/Proxy%5Fpattern?ref=williamboles.com) 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)
    }
}
```

1. `externalDelegate` holds a weak reference to any delegate set from outside `GateNavigationController`.
2. The `delegate` property is overridden so that `GateNavigationController` always remains the actual delegate of the navigation controller. Any external delegate is redirected to `externalDelegate`, and the getter returns `externalDelegate` so that callers see the delegate they set rather than `GateNavigationController` itself.
3. After performing the gating logic, the `navigationController(_:willShow:animated:)` callback is forwarded to the external delegate.
4. After performing the gating logic, the `navigationController(_:didShow:animated:)` callback is forwarded to the external delegate.

> `UINavigationControllerDelegate` contains 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 in `UINavigationControllerDelegate` and 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](https://github.com/wibosco/AvoidingModal-Example?ref=williamboles.com) 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](https://github.com/wibosco/AvoidingModal-Example?ref=williamboles.com) and clone the project.