Decoupling Navigation in SwiftUI

How to move your Navigation Flow away from your View Code

SwiftUI is a great way to build modern, interactive UIs. While it lacks some features that still need to be wrapped via UIKit, it is mature enough to be used in production for a good while now. But one thing that is still cumbersome to do is navigation — explicitly making the View irresponsible for navigation. This article will show a simple way to decouple your navigation from view logic. No UIKit required.

Ideal flow. Views only have loose coupling to each other.


NavigationLinks are the primary source of navigation in SwiftUI. While they offer a simple yet elegant way to handle navigation in our apps, they can become quite limiting when navigation flow gets more complex.

Let’s look at the most simple NavigationLink for now.

A simple NavigationLink.

When the user taps on the content passed via label the link is triggered, and the NavigationView pushes destionation onto the View Stack. While this is fine for some cases, it already shows one big drawback: We cannot control when we want to navigate; the link is triggered immediately on tap. Another disadvantage is that we can only pop the new View when interacting with the default back action provided by iOS. That can become a problem. The only way for us to handle navigational events is onDisappear and onAppear.

Now with States

To solve the first drawback, we can instead rely on State and trigger NavigationLinks via bindings. NavigationLink has another init which allows us to pass an additional parameter, isAcvtive. When it changes State, the NavigationLink pushes or pops the View given via destionation. That is especially helpful when we need to issue commands, like a database request, before navigation.

NavigationLink is now triggered on State changes.

We could also wait for a backend call to finish or not toggle the State at all if a check or response fails. States are an excellent way for managing NavigationLinks, but one drawback stays the same. While we can now trigger navigations when needed, we can only pop the newly pushed View via the default iOS action. What if we wanted to pop the View after an action finished?

Enter EnvironmentObject

Instead of relying on the State of the View, we can inject an Object into the environment. Injecting such an object allows Views to share a common source of truth for navigation. A NavigationObject can be as simple or complex as you need it. It could be a number of @Published vars each mapping to one View, or it could contain a fully-fledged custom navigation stack. What is important is that it is shared between all Views across the app.

The first approach is identical to the @State method for each View, except that every following View will have access to that State; thus, they can change it. The second approach is more complex as it involves writing the navigation from scratch, providing more freedom and functionality. Let’s have a look at our View code with a NavigationObject.

The View no longer triggers navigation.

As you can see, the code is mostly identical to our previous method. But we are injecting the NavigationObject, which makes it visible to all following Views. isActive is now a constant that holds the current value of the NavigationObject. This might not be ideal for a production code, but it does get the job done. It works because each time the View reappears, the constant is re-evaluated with the new value. The NavigationObject is implemented in the following way:

A simple example of a NavigationObject.

Notice the navigationStack made from enum values. Whenever a view is pushed or popped, currentPage gets changed based on the Stack. The next View can call popView() and it will be removed from the Stack. Now we can push and pop Views whenever and how we want. One thing that remains defective is the native back action. It does not affect the NavigationStack.

For that, we can write a custom back action that calls popView()on our object. Alternatively, if no action besides the native back action is required in a view, we can call popView() when the View goes out of scope. SwiftUI provides the modifier onDisappear for this behaviour. We will use the latter in our example as it requires less work. Here is our second View code:

Dismissing the current View is simple now.

Just a heads up: We cannot rely on onDisappear when we also have custom code that should pop the view, because popView() already pops the current View. If we were to call popView() somewhere in the View, it would get called twice as our onDisappear modifier triggers when the View is popped. If you need custom popping + popping on back action, you need to implement a custom back action instead. We will not cover custom back actions in this article as they are more complex.

Where to Go From Here

EnvironmentObjects provide a great way for navigation in SwiftUI. They follow SwiftUI’s concept of States and Bindings and are easily shared among Views.

In a fully-featured app, we could define helper methods that show modals right away and much more. We could return EmptyView, or our View with a modal, based on NavigaionObject properties. There are even more ways, and EnvironmentObjects provide a great way to explore them.

Thank you for reading.



Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store