
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
ViewController
can internally present or push anotherViewController
. - An object external to a
ViewController
can 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:
ViewA
needs to constructViewB
along with dependencies.ViewA
is bound to only ever navigate toViewB
and cannot be reused in another context.ViewB
cannot be mocked inView
testing.ViewA
is 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
ViewB
construction and associated dependencies fromViewA
. - Inject a routing object into
ViewA
that can provide aView
when 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 Router
s 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.
ViewA
no longer needs to know how to constructViewB
or any of it’s dependencies.ViewA
is not bound to only ever presentingViewB
.- The
Router
is generic thereforeViewA
can be initialised with differentRouter
implementations. ViewA
can be used in other contexts and present otherView
s 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 Router
s 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 NavigationLink
s for new cases. Further, if the Route is CaseIterable
it is possible to create multiple NavigationLink
s 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 ViewModel
s 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.