The navigation APIs in SwiftUI are simple to use but they come with a tradeoff: they couple our navigation logic with our views.
To decouple our view code from our navigation code we can employ a well-suited design pattern. In the realm of UIKit, one such pattern we have available is the Coordinator pattern. However, if our apps are built exclusively in SwiftUI, we need a different solution that suits the SwiftUI approach to navigation.
One such pattern that has grown in popularity is the Router pattern. Similar to the Coordinator pattern, its goal is to help us separate our navigation logic as much as possible from our views to improve our apps maintainability and scalability.
In this article we will study what the Router pattern is and how we can implement it. Before we do that, it may be helpful for us to understand why we would use it.
You can view the final completed implementation of the Router pattern I cover in this series here: https://github.com/obvios/Routing
The case for using the Router pattern for navigation
In SwiftUI, if we want to push a view using a navigation stack, we would do it something like this:
I acknowledge that this involves significantly less code compared to the equivalent process in UIKit, but still there a few problems with this code:
ViewA knows that we are using a NavigationStack to display ViewB, because it literally contains it! If we wanted to present ViewB using a sheet instead, we would need to make several updates to ViewA.
On top of knowing how to present ViewB, ViewA needs to know what value is associated with the view. We can see this in the NavigationLink and the navigationDestination view modifier.
Probably the biggest offense in my opinion, ViewA is responsible for building ViewB!
All of this results in our two views being coupled to each other, meaning any updates to ViewB have a high probability of affecting ViewA. For example, if ViewB needs new data to be initialized, ViewA must be updated to provide it. In a different case, if we no longer want to push ViewB from ViewA, again ViewA will need to be modified significantly. Coupling between the two views is what is causes all these updates related to ViewB to affect ViewA
Our view code should only focus on displaying data to users and capturing user input, ideally it should not also be responsible for navigation logic. This is known as the single responsibility principle, and it’s a good principle to follow if we want our code to be clean, maintainable, and scalable.
To ensure our views have only one responsibility, we need to move the navigation logic elsewhere. One way to do this is to introduce a Router.
Using the Router pattern for SwiftUI Navigation
As stated above, our goal is to move all navigation related code out of our views and into a Router. The following image illustrates how we could use a Router:
The Router View will be a SwiftUI view where we place the SwiftUI navigation APIs. For simplicity, we'll only be using a NavigationStack.
The Router object will be responsible for building the views contained in our Router View, managing navigation between the views, and containing any conditional flow logic required.
Our views no longer need to know about each other or how they are displayed, the Router takes care of that. All our views need to know is that they can use the Router object to navigate to a different screen.
Finally, to actually use our Router pattern we wrap our root view with our RouterView:
Final thoughts
This post was intended to merely introduce the concept of a Router at a high level and give you an idea of the why and how we would use it. Our Router could be generalized to support other types of navigation in addition to a navigation stack, like using sheets and full screen presentation. We explore this in the following post which is now available: Router Pattern for SwiftUI Navigation: Sheets and Full Screen Covers.
You can also see the completed implementation of the Router pattern on my GitHub: https://github.com/obvios/Routing