Routing in SwiftUI With NavigationStack

using only one router per a navigation layer

Ihor Vovk
Better Programming

--

Photo by matthaeus on Unsplash

This solution is an evolution of the routing approach with type-erased modifiers described in my article Routing in SwiftUI. Now it uses the new NavigationStack functionality available from iOS 16.0.

In that approach, the routing mechanism consisted of two components:

  • Router subclass that defined navigation state and available navigation actions
  • View modifiers sheet, navigation, fullScreenCover that translated router state to actual SwiftUI navigation

And we had to create a separate router instance for every view that performed navigation.

The new approach has a similar Router class implementation. But the translation of router state to SwiftUI navigation is different. We will use RoutingView instead of modifiers now. And we will need only one pair of Router + RoutingView per navigation layer.

Here, a navigation layer is either all views from a single NavigationStack, or just one view, if it does not participate in push navigation. If we present a sheet or a full-screen cover, it will be a new navigation layer with its own router and a navigation stack.

A known issue with the previous approach was that it did not allow us to navigate more than one view back with a single atomic animation. With NavigationStack, it is not an issue anymore.

Implementation

Router state

In the previous approach, we stored destination views as type erased AnyView in the router state. But we cannot store AnyView instances in NavigationStack path because the latter requires conformance to the Hashable protocol. Instead, we can introduce a specific model type to identify destination views — ViewSpec. The router state will look this way:

struct State {
var navigationPath: [ViewSpec] = []
var presentingSheet: ViewSpec? = nil
var presentingFullScreen: ViewSpec? = nil
var presentingModal: ViewSpec? = nil
var isPresented: Binding<ViewSpec?>

var isPresenting: Bool {
presentingSheet != nil || presentingFullScreen != nil || presentingModal != nil
}
}

Here, navigationPath represents push navigation over a stack view, presentingSheet — presenting a system sheet, presentingFullScreen — a system full-screen cover, presentingModal — a custom modal view.

isPresented is binding to either presentingSheet or presentingFullScreen state of the parent router. Router uses this binding to dismiss the corresponding navigation layer.

ViewSpec

ViewSpec is an enum that identifies a view.

This enum should contain all possible views we can present in our application. It can be flat, or it can include nested enums.

We can also specify the parameters required for the corresponding view creation.

Here is a simple flat enum for our sample app:

enum ViewSpec: Equatable, Hashable {

case main
case list
case detail(String)
case alert
}

extension ViewSpec: Identifiable {

var id: Self { self }
}

RoutingView

RoutingView listens to changes in the corresponding router state and performs actual navigation.

It delegates the creation of a destination view to the router by passing two parameters: ViewSpec and Route — a type of navigation.

struct RoutingView<Content: View>: View {

@StateObject var router: Router
private let content: Content

init(router: Router, @ViewBuilder content: @escaping () -> Content) {
_router = StateObject(wrappedValue: router)
self.content = content()
}

var body: some View {
NavigationStack(path: router.navigationPath) {
content
.navigationDestination(for: ViewSpec.self) { spec in
router.view(spec: spec, route: .navigation)
}
}.sheet(item: router.presentingSheet) { spec in
router.view(spec: spec, route: .sheet)
}.fullScreenCover(item: router.presentingFullScreen) { spec in
router.view(spec: spec, route: .fullScreenCover)
}.modal(item: router.presentingModal) { spec in
router.view(spec: spec, route: .modal)
}
}
}

enum Route {
case navigation
case sheet
case fullScreenCover
case modal
}

RoutingView is a NavigationStack that can also present a sheet, fullScreenCover, and custom types of modal views. RoutingView should reside in the root of a navigation layer.

In the implementation of RoutingView we use bindings to the router state.

Let’s define them in Router extension:

extension Router {

var navigationPath: Binding<[ViewSpec]> {
binding(keyPath: \.navigationPath)
}

var presentingSheet: Binding<ViewSpec?> {
binding(keyPath: \.presentingSheet)
}

var presentingFullScreen: Binding<ViewSpec?> {
binding(keyPath: \.presentingFullScreen)
}

var presentingModal: Binding<ViewSpec?> {
binding(keyPath: \.presentingModal)
}

var isPresented: Binding<ViewSpec?> {
state.isPresented
}
}

private extension Router {

func binding<T>(keyPath: WritableKeyPath<State, T>) -> Binding<T> {
Binding(
get: { self.state[keyPath: keyPath] },
set: { self.state[keyPath: keyPath] = $0 }
)
}
}

Custom modal presentation

modal is an example of a custom modal view. We can use it for presenting custom alerts or simple dialogues.

Implemented as a function on View, it has a similar interface to sheet and fullScreenCover:

extension View {

func modal<Item, Content>(item: Binding<Item?>, onDismiss: (() -> Void)? = nil, content: @escaping (Item) -> Content) -> some View where Item: Identifiable, Content: View {
ZStack(alignment: .center) {
self
if let wrappedItem = item.wrappedValue {
Color.black.opacity(0.5)
.ignoresSafeArea()
VStack(spacing: 0) {
HStack {
Button {
item.wrappedValue = nil
} label: {
Image(systemName: "xmark.circle")
}

Spacer()
}.frame(height: 50)
.padding(.horizontal, 12)

Divider()
content(wrappedItem)
}.clipShape(RoundedRectangle(cornerRadius: 8))
.background(
RoundedRectangle(cornerRadius: 8)
.foregroundColor(.white)
).padding(.horizontal, 16)
}
}
}
}

Base Router navigation interface

The base Router class provides navigation commands that can be called from more specific functions of subclasses or directly from views.

Those commands modify the router state resulting in actual navigation performed by the corresponding RoutingView.

extension Router {

func navigateTo(_ viewSpec: ViewSpec) {
state.navigationPath.append(viewSpec)
}

func navigateBack() {
state.navigationPath.removeLast()
}

func replaceNavigationStack(path: [ViewSpec]) {
state.navigationPath = path
}

func presentSheet(_ viewSpec: ViewSpec) {
state.presentingSheet = viewSpec
}

func presentFullScreen(_ viewSpec: ViewSpec) {
state.presentingFullScreen = viewSpec
}

func presentModal(_ viewSpec: ViewSpec) {
state.presentingModal = viewSpec
}

func dismiss() {
if state.presentingSheet != nil {
state.presentingSheet = nil
} else if state.presentingFullScreen != nil {
state.presentingFullScreen = nil
} else if state.presentingModal != nil {
state.presentingModal = nil
} else if navigationPath.count > 1 {
state.navigationPath.removeLast()
} else {
state.isPresented.wrappedValue = nil
}
}
}

MainRouter

Let’s consider a specific MainRouter subclass. It provides three methods for presenting other screens.

These methods encapsulate the actual type of presentation: as a sheet, full-screen cover, push navigation, etc.

class MainRouter: Router {

func presentList() {
presentSheet(.list)
}

func presentDetail(description: String) {
navigateTo(.detail(description))
}

func presentAlert() {
presentModal(.alert)
}

override func view(spec: ViewSpec, route: Route) -> AnyView {
AnyView(buildView(spec: spec, route: route))
}
}

For convenience, the view factory method calls a @ViewBuilder function inside. It allows us to avoid many return calls and erase the resulting view type to AnyView only once.

private extension MainRouter {

@ViewBuilder
func buildView(spec: ViewSpec, route: Route) -> some View {
switch spec {
case .list:
ListView(router: router(route: route))
case .detail(let description):
DetailView(description: description, router: router(route: route))
case .alert:
AlertView()
default:
EmptyView()
}
}

func router(route: Route) -> MainRouter {
switch route {
case .navigation:
return self
case .sheet:
return MainRouter(isPresented: presentingSheet)
case .fullScreenCover:
return MainRouter(isPresented: presentingFullScreen)
case .modal:
return self
}
}
}

MainView

MainView is the first view presented by our app. So, we wrap its content in RoutingView to allow navigation from it to other views.

struct MainView: View {

@StateObject private var router: MainRouter

init(router: MainRouter) {
_router = StateObject(wrappedValue: router)
}

var body: some View {
RoutingView(router: router) {
VStack {
Spacer()
Button("Present List") {
router.presentList()
}

Spacer()
}
}
}
}

Here is how we create and pass MainRouter to MainView, initialising the app:

@main
struct RouterWithNavigationStackApp: App {

@StateObject private var router = MainRouter(isPresented: .constant(.main))

var body: some Scene {

WindowGroup {
MainView(router: router)
}
}
}

ListView

ListView is intended to be presented modally as a sheet/fullScreenCover and supports push navigation.

So, we also wrap its content in RoutingView. Every view presented modally as sheet or fullScreenCover becomes the root view of a new navigation layer and should be wrapped in RoutingView to enable navigation in this layer.

Unless it does not support push navigation itself.

struct ListView: View {

@StateObject private var router: MainRouter

init(router: MainRouter) {
_router = StateObject(wrappedValue: router)
}

var body: some View {
RoutingView(router: router) {
VStack(spacing: 20) {
ForEach(1..<4) {
cell(index: $0)
}
}.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
router.dismiss()
} label: {
Image(systemName: "xmark.circle")
}
}
}
}
}
}

private extension ListView {

func cell(index: Int) -> some View {
Button("Item \(index)") {
router.presentDetail(description: "Description \(index)")
}
}
}

In this example, ListView reuses the same MainRouter type. Alternatively, we could create a separate router type for this navigation layer.

DetailView

We will not present DetailView modally and perform push navigation from it. So, we don’t wrap it in RoutingView.

We only pass the router responsible for navigation in this layer to the initializer.

struct DetailView: View {

private let description: String
@StateObject private var router: MainRouter

init(description: String, router: MainRouter) {
self.description = description
_router = StateObject(wrappedValue: router)
}

var body: some View {
VStack(spacing: 20) {
Text(description)
Button("Present Alert") {
router.presentAlert()
}
}
}
}

AlertView

AlertView is used only for presentation. It does not support nested navigation.

So, its implementation is straightforward, without the usage of router and RoutingView:

struct AlertView: View {

var body: some View {
Text("Alert")
.padding(.vertical, 20)
}
}

You can see above that we call router.presentAlert() from DetailView, a nested view in the navigation layer where the root view is ListView.

But the RoutingView embedded in the root ListView performs the actual presentation.

Conclusion

  • The introduction of NavigationStack allows us to use only one router per a navigation layer
  • We got rid of AnyView in router state, making code less tricky
  • We replace view modifiers with RoutingView, which is easier to use and less error-prone
  • We get some known issues solved

Resources

You can find the source code used for this article in my GitHub repository.

--

--