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.
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.
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
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.
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?
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.
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:
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:
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.