SwiftUI Navigation and Routing

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 another ViewController.
  • 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 construct ViewB along with dependencies.
  • ViewA is bound to only ever navigate to ViewB and cannot be reused in another context.
  • ViewB cannot be mocked in View 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 from ViewA.
  • Inject a routing object into ViewA that can provide a View 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 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.

  • ViewA no longer needs to know how to construct ViewB or any of it’s dependencies.
  • ViewA is not bound to only ever presenting ViewB.
  • The Router is generic therefore ViewA can be initialised with different Router implementations.
  • ViewA can be used in other contexts and present other Views on completion of doSomethingAsync.

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 navigations
  • ViewModel: Drive flow state by publishing an active navigation property
  • Router: 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.

Tags:
,
adrian.stoyles@shinesolutions.com
No Comments

Leave a Reply