20 Aug 2021 SwiftUI Navigation and Routing
At WWDC 2019, Apple announced SwiftUI to the Apple development community. SwiftUI sees the future of app development on Apple platforms move away from the imperative stylings of UIKit to the increasingly popular data-driven declarative style. In this post we will discuss and explore methods of navigation flow and view construction in SwiftUI. In particular, we will explore a method for abstracting both of these activities out of the view layer.
Before we begin
Before we dive in, let’s review a few differences between UIKit and SwiftUI in regards to navigation.
UIKit, an imperative UI framework, offers multiple methods for managing navigation. To list a few:
- A
ViewControllercan internally present or push anotherViewController. - An object external to a
ViewControllercan request it to navigate. - A
NavigationController‘s stack can be mutated in a variety of ways (popToRoot,setViewControllers, etc) - Storyboard Segues
With this level of freedom, it is not unusual to find mixed practices of navigation throughout a single codebase. Various architectures and frameworks (Coordinator, VIPER, RIBs) address this problem by consolidating navigation and flow control into a dedicated component such as a router or coordinator. Abstracting navigation out of the ViewController layer improves the reusability of a ViewController and provides greater testability for a codebase as a whole.
SwiftUI, on the other hand, is a declarative UI framework where navigation and flow control is strictly confined to the view layer. While this approach does instil a higher level of predictably to app navigation there are some shortcomings in terms of View reusability and testability. Apple’s examples of navigation tend to couple a presenting View to the presented View.
An example
Below is a common example of programmatic navigation in SwiftUI where ViewA pushes ViewB onto the navigation stack:
struct ViewA: View {
@State var navigateToViewB: Bool = false
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.navigateToViewB = true
}
}
NavigationLink(
destination: ViewB(
viewModel: .init(
userRepo: .init()
)
),
isActive: $navigateToViewB,
label: {
EmptyView()
}
)
}
}
}
}
Immediately, we can see a number of issues with this approach:
ViewAneeds to constructViewBalong with dependencies.ViewAis bound to only ever navigate toViewBand cannot be reused in another context.ViewBcannot be mocked inViewtesting.ViewAis managing navigation destination state.
There are frameworks already that work on top of SwiftUI’s navigation system to provide an approach that is similar to UIKit (for example, SwiftUIRouter‘s path-based routing). What we’d like to achieve in this article is to create a pattern that, whilst retaining SwiftUI’s declarative approach to navigation in a type- and null-safe manner, also achieves the following:
- Remove
ViewBconstruction and associated dependencies fromViewA. - Inject a routing object into
ViewAthat can provide aViewwhen navigation is triggered. - Allow the routing object to scope down from the app/global domain to a local domain to enable modularity.
- Enable business logic layer to dictate navigation flow.
For the sake of general familiarity we’ll look at a solution using an MVVM architecture. However, these principals could also well be adapted to other patterns.
View Construction
The first objective is to remove the responsibility of constructing ViewB from ViewA and delegate this task to another object. We’ll call this object a Router. To remove specific implementation of the Router from the View we’ll create a protocol to which it will conform. We’ll also add an associated type called Route so we can create other Routers over other types:
protocol Routing {
associatedtype Route
associatedtype View: SwiftUI.View
@ViewBuilder func view(for route: Route) -> Self.View
}
In this case Route will be an enum with our two screens as cases viewA and viewB:
enum AppRoute {
case viewA
case viewB
}
In the concrete implementation, called AppRouter, we need to provide an Environment object. This contains the dependencies required to build a View. Also note, we’ll need to update our View to require a Router property:
struct AppRouter: Routing {
let environment: Environment
func view(for route: AppRoute) -> some View {
switch route {
case .viewA:
ViewA(router: self)
case .viewB:
ViewB(
router: self,
viewModel: .init(
userRepo: environment.userRepo
)
)
}
}
}
Now that our Router is created we can inject it into ViewA and delegate the construction of ViewB. Given that Routing contains associated types we’ll need to make our view generic over the protocol type and constrain the Route type to AppRoute.
struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
let router: Router
@State var navigateToViewB: Bool = false
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.navigateToViewB = true
}
}
NavigationLink(
destination: router.view(for: .viewB),
isActive: $navigateToViewB,
label: {
EmptyView()
}
)
}
}
}
}
Although the changes are minor to the ViewA struct, we have achieved a number of things here.
ViewAno longer needs to know how to constructViewBor any of it’s dependencies.ViewAis not bound to only ever presentingViewB.- The
Routeris generic thereforeViewAcan be initialised with differentRouterimplementations. ViewAcan be used in other contexts and present otherViews on completion ofdoSomethingAsync.
Creating and Distributing the Router
As the above example demonstrates, the Router is passed down through the view hierarchy from view to view. In the context of an application, the Router could be initialised on the @main View and passed down view hierarchy from that point. Depending on the size of the app, it may be beneficial to break Routers down into more modular components. Not every view needs to know about every Route. For example you may have a router specifically for an authentication module:
enum AuthRoute {
case signIn
case signUp
case forgotPassword
}
struct AuthRouter: Routing {
let environment: AuthEnvironment
func view(for route: AuthRoute) -> some View {
switch route {
case .signIn:
SignInView(
viewModel: AuthViewModel(
userRepo: environment.userRepo
),
router: self
)
case .signUp:
SignUpView(
viewModel: AuthViewModel(
userRepo: environment.userRepo),
router: self
)
case .forgotPassword:
ForgotPasswordView(
viewModel: AuthViewModel(
userRepo: environment.userRepo),
router: self
)
}
}
}
Navigation Flow
The example so far only accounts for a single navigation scenario from ViewA to ViewB. If we were to add another NavigationLink to ViewA, we should avoid adding further @State Bool variables to indicate navigation state. For instance, only one navigation should be active at a single point in time.
What would happen if we somehow ended up in a state where more than one navigation is active? Thankfully there is another NavigationLink initialiser that we can use. It requires a binding to any Optional Hashable type. When the value of the binding is set to the tag value, the navigation is activated:
public init<V>(
destination: Destination,
tag: V,
selection: Binding<V?>,
@ViewBuilder label: () -> Label
) where V : Hashable
Using this method of activation, navigateToViewB can be replaced with an optional AppRoute and renamed to the more general activeNavigation. In this case adaptation was simple since our enum AppRoute was automatically Hashable. Note that enums containing cases with associated types will require further work to conform the type to Hashable:
struct ViewA<Router: Routing>: View where Router.Route == AppRoute {
let router: Router
@State var activeNavigation: AppRoute?
var body: some View {
NavigationView {
VStack {
Button("Go to ViewB") {
doSomethingAsync() {
self.activeNavigation = .viewB
}
}
NavigationLink(
destination: router.view(for: .viewB),
tag: .viewB,
selection: $activeNavigation,
label: { EmptyView() }
)
}
}
}
}
Now that ViewA can specify an active navigation of the AppRoute type, it is simple to add further NavigationLinks for new cases. Further, if the Route is CaseIterable it is possible to create multiple NavigationLinks within a ForEach View. Say, if you’re using a single Router for the entirety of the app:
ForEach(
Array(Router.Route.allCases),
id: \.self
) {
NavigationLink(
destination: router.view(for: $0),
tag: $0,
selection: $activeNavigation,
label: { EmptyView() }
)
}
This may not be ideal in a lot of cases however.
Next, we can look at moving the activeNavigation off the View layer and into the ViewModel.
To do this, we define a base ViewModel protocol:
protocol ViewModel: ObservableObject {
associatedtype Route
var activeNavigation: Route? { get set }
}
In a ViewModel implementation, the activeNavigation may be set to one of any Route type. This is useful when a flow needs to deviate, perhaps due to a particular response from a service request or some other state.
Tying it all together
Now that we have defined the roles and responsibilities of each object in our pattern we can specify some rules and protocols to enforce a consistent navigation pattern for an app:
View: Register and perform navigationsViewModel: Drive flow state by publishing an active navigation propertyRouter: Construct views with dependencies for the given context
Routing is already defined as:
protocol Routing {
associatedtype Route: RouteType
associatedtype Body: View
@ViewBuilder func view(for route: Route) -> Self.Body
}
We constrain the Route to the following conformances:
typealias RouteType = Hashable & Identifiable & CaseIterable
Next we can define a protocol for our ViewModels to follow. Note that for implementations of ViewModel, the navigationRoute will need to wrapped in the @Published property wrapper:
protocol ViewModel: ObservableObject {
associatedtype Route: RouteType
var navigationRoute: Route? { get set }
}
Lastly, we define a protocol for our Views. This brings all of our pieces together and enforces that the ViewModel, View and Router are all generic over the same Route type:
protocol AppView: View {
associatedtype VM: ViewModel
associatedtype Router: Routing where Router.Route == VM.Route
var viewModel: VM { get }
var router: Router { get }
}
Conclusion
In this post I’ve presented a pattern for have a consistent approach to navigation throughout a SwiftUI app. The pattern requires dependency injection and is testable. It also supports downscoping into smaller domains, meaning that features can be modularized and don’t need to be bound to the app domain.
That said, there are, as always, further considerations. These examples are isolated, simple and don’t address complicated flows where state external to a View and ViewModel may affect flow. We’ll continue to explore more advanced use-cases and report-back on how it goes. However, so far found this pattern to be an encouraging step in the right direction.
imyrvold
Posted at 20:58h, 11 SeptemberCan I use this pattern to make a router capable of routing from ViewA to ViewB to ViewC etc?
Josh Kneedler
Posted at 11:02h, 20 NovemberThere are some really solid well thought out ideas here. I’m seeing a few compiler complaints. Can you post the github source? Many thanks.
Michael
Posted at 07:20h, 03 FebruaryThis has been very informative and helpful. Thanks for putting it out there. It beats the AnyView approach I see elsewhere.
buahahahahha
Posted at 02:35h, 08 Marchcould you present final usage of this architecture?
AbuTalha
Posted at 18:16h, 23 AugustGreat article. Can you please share if you have repo, would be very useful.