Routing in SwiftUI With NavigationStack
using only one router per a navigation layer
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.