SwiftUI Navigation: Part 2 - Navigation Types

The complete guide for app navigation using routers architecture.

Itay Amzaleg
Fiverr Tech
9 min readMay 23, 2024

--

Having established the essential elements of our router setup in part 1, we’ve set a solid foundation for our app’s navigation system. In this part, we’ll expand into the different navigation types in our architecture.

Feel free to skip to the sections that interest you most or meet your specific needs:

Part 1:

Part 2:

Part 3:

Basic router navigation

Using Tab A as our first example. We begin by defining two destinations within a new TabARouter class for View 1 and View 2:

@Observable class TabARouter: BaseRouter {
enum TabADestination: String, RouterDestination, CaseIterable {
case viewOne
case viewTwo

var title: String {
return switch self {
case .viewOne:
"View One"
case .viewTwo:
"View Two"
}
}
}

@ObservationIgnored override var routerDestinationTypes: [any RouterDestination.Type] {
return [TabADestination.self]
}

//MARK: - Public
func navigate(to destination: TabADestination) {
path.append(destination)
}
}

To simplify the navigation process, child routers include a specific navigate(to:) method for their unique destinations, reducing the need for external components to directly modify the path. This method ensures routers manage only navigation relevant to their domain. Yet, the path will continue to be public to support its binding with the navigation stack.

Next, we create two simple views corresponding to these destinations:

struct ViewOne: View {
var body: some View {
Image(systemName: "1.circle.fill")
.font(.extraLargeTitle)
}
}
struct ViewTwo: View {
var body: some View {
Image(systemName: "2.circle.fill")
.font(.extraLargeTitle)
}
}

Finally, we can connect everything within our Tab A view, setting up navigation and integrating our router:

struct TabA: View {
typealias Destination = TabARouter.TabADestination

private let navigationTitle = ContentView.Tab.a.title
@Environment(TabARouter.self) private var router

// MARK: - Views
var body: some View {
@Bindable var router = router

NavigationStack(path: $router.path) {
listView
.routerDestination(router: router,
navigationBackTitle: navigationTitle,
destination: navigationDestination)
.navigationTitle(navigationTitle)
}
}

@ViewBuilder private func navigationDestination(_ destination: Destination) -> some View {
switch destination {
case .viewOne:
ViewOne()
case .viewTwo:
ViewTwo()
}
}

private var listView: some View {
List(Destination.allCases) { destination in
Text(destination.title)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
router.navigate(to: destination)
}
}
}
}

This setup demonstrates a very basic approach to our router-based navigation. By defining specific destinations within our TabARouter and creating corresponding views, we’ve outlined a clear path for navigating between different subviews of Tab A.

Tab A

Associated values

Often in real-world scenarios, views need to be instantiated with some necessary initial data. This requirement leads us into the realm of associated values within our destination enums, enabling data transfer between views.

@Observable class TabBRouter: BaseRouter {
typealias TransportationType = TransportationView.TransportationType

enum Destination: RouterDestination {
case transportation(type: TransportationType)

var description: String {
return switch self {
case .transportation(let type):
"transportation(type: \(type))"
}
}
}

@ObservationIgnored override var routerDestinationTypes: [any RouterDestination.Type] {
return [Destination.self]
}

//MARK: - Public
func navigate(to destination: Destination) {
path.append(destination)
}
}

In our router for Tab B, we introduce associated values to our destination enum. This requires us to manually describe each destination since the automatic generation from a string raw value is no longer feasible.

For the reusable view, we’ll create a TransportationView that takes a TransportationType as an initial parameter.

struct TransportationView: View {
enum TransportationType: String, CaseIterable, Identifiable, Codable {
case airplane
case car
case bus
case tram
case cablecar
case ferry
case bicycle
case scooter
case sailboat

var id: String {
return rawValue
}
}

private let type: TransportationType

// MARK: - Lifecycle
init(type: TransportationType) {
self.type = type
}

// MARK: - Views
var body: some View {
Image(systemName: type.rawValue)
.font(.extraLargeTitle)
}
}

Finally, in our Tab B view, we populate a list with different transportation types. Tapping on an item navigates to a TransportationView initialized with the selected transportation type.

struct TabB: View {
typealias Destination = TabBRouter.Destination
typealias TransportationType = TransportationView.TransportationType

private let navigationTitle = ContentView.Tab.b.title
@Environment(TabBRouter.self) private var router

// MARK: - Views
var body: some View {
@Bindable var router = router

NavigationStack(path: $router.path) {
listView
.routerDestination(router: router,
navigationBackTitle: navigationTitle,
destination: navigationDestination)
.navigationTitle(navigationTitle)
}
}

@ViewBuilder private func navigationDestination(_ destination: Destination) -> some View {
switch destination {
case .transportation(let type):
TransportationView(type: type)
}
}

private var listView: some View {
List(TransportationType.allCases) { type in
Text(type.rawValue.capitalized)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
router.navigate(to: .transportation(type: type))
}
}
}
}

This example, while quite basic, can scale up to fit any type that suits your application’s needs. You can pass any type as an associated value, provided it conforms to Codable.

Tab B

Nested destinations

Moving on to nested destinations, we’ll examine handling subviews within subviews for Tab C.

Let’s begin by setting up the router:

@Observable class TabBRouter: BaseRouter {
enum TabBDestination: String, RouterDestination {
case inbox

var title: String {
rawValue.capitalized
}
}

enum InboxDestination: String, RouterDestination {
case chat

var title: String {
rawValue.capitalized
}
}

//Nested views
@ObservationIgnored override var routerDestinationTypes: [any RouterDestination.Type] {
return [TabBDestination.self, InboxDestination.self]
}

//MARK: - Public
func navigate(to destination: TabBDestination) {
path.append(destination)
}

func navigate(to destination: InboxDestination) {
path.append(destination)
}
}

This router introduces two types of destinations, one for the main tab view and another for the nested inbox view.

Now, let’s define the corresponding subviews:

struct InboxView: View {
typealias Destination = TabBRouter.InboxDestination

@Environment(TabCRouter.self) private var router

// MARK: - Views
var body: some View {
VStack {
Button {
router.navigate(to: .chat)
} label: {
VStack {
Image(systemName: "tray")
.font(.extraLargeTitle)

Text("Go to chat")
.font(.body)
}
.foregroundStyle(Color.primary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.systemGroupedBackground)
.routerDestination(router: router,
destination: navigationDestination)
}

@ViewBuilder private func navigationDestination(_ destination: Destination) -> some View {
switch destination {
case .chat:
ChatView()
}
}
}
struct ChatView: View {
var body: some View {
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.extraLargeTitle)
}
}

And to populate Tab C:

struct TabC: View {
typealias Destination = TabCRouter.Destination

private let navigationTitle = ContentView.Tab.c.title
@Environment(TabCRouter.self) private var router

// MARK: - Views
var body: some View {
@Bindable var router = router

NavigationStack(path: $router.path) {
VStack {
Button {
router.navigate(to: .inbox)
} label: {
VStack {
Image(systemName: "envelope")
.font(.extraLargeTitle)

Text("Go to inbox")
.font(.body)
}
.foregroundStyle(Color.primary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.systemGroupedBackground)
.routerDestination(router: router,
navigationBackTitle: navigationTitle,
destination: navigationDestination)
.navigationTitle(navigationTitle)
}
}

@ViewBuilder private func navigationDestination(_ destination: Destination) -> some View {
switch destination {
case .inbox:
InboxView()
.environment(\.inboxRouter, router)
}
}
}

This illustrates the power of the router based system in handling both top-level and nested navigation seamlessly. In this setup, each view is responsible for managing navigation for its own subviews, yet all are coordinated through the same router path.

Reusability

As we continue onto our last tab, the focus shifts toward the concept of reusability.

Imagine a scenario where we aim to present our chat screen from various locations within our app.

The challenge here lies in preserving the capability to create navigation destinations specific to Tab D while reusing existing destinations and ensuring they remain accessible and functional from any other context in the app.

First, we’ll take InboxDestination previously introduced in TabCRouter and move it into a new file as a new InboxNavigationProtocol.

protocol InboxNavigationProtocol: BaseRouter, Observable {
func navigate(to destination: InboxDestination)
}

enum InboxDestination: String, RouterDestination {
case chat

var title: String {
rawValue.capitalized
}
}

Protocols like this will be responsible for enabling other routers to navigate to destinations other than their own. So, for example, our TabBRouter should now look like this:


@Observable class TabBRouter: BaseRouter {
typealias TransportationType = TransportationView.TransportationType

enum Destination: RouterDestination {
case transportation(type: TransportationType)

var description: String {
return switch self {
case .transportation(let type):
"transportation(type: \(type))"
}
}
}

@ObservationIgnored override var routerDestinationTypes: [any RouterDestination.Type] {
return [Destination.self]
}

//MARK: - Public
func navigate(to destination: Destination) {
path.append(destination)
}
}

By conforming to InboxNavigationProtocol and adding InboxDestination to our current router destination types array, TabBRouter can now navigate to inbox destinations.

Notice that each router should now have only one destination, while any nested destinations will be handled via protocols.

Now Tab D router can also conform to the same protocol and present the same destinations.

@Observable class TabDRouter: BaseRouter {
enum Destination: String, RouterDestination {
case subview = "Tab d subview"
case inbox
}

//Protocols
@ObservationIgnored override var routerDestinationTypes: [any RouterDestination.Type] {
return [Destination.self, InboxDestination.self]
}

//MARK: - Public
func navigate(to destination: Destination) {
path.append(destination)
}
}
//MARK: - InboxRouterProtocol
extension TabDRouter: InboxNavigationProtocol {
func navigate(to destination: InboxDestination) {
path.append(destination)
}
}

But there is one issue that arises from transitioning nested destinations to being managed through a protocol, such as InboxNavigationProtocol. The InboxView no longer has access to a router of a concrete type but instead, must work with any type conforming to InboxNavigationProtocol.

To address this challenge, we’ll introduce custom EnvironmentValues and a corresponding EnvironmentKey for InboxRouter.

This approach enables any router that conforms to InboxNavigationProtocol to be published into the environment.

struct InboxRouterKey: EnvironmentKey {
static let defaultValue: any InboxNavigationProtocol = TabCRouter()
}

extension EnvironmentValues {
var inboxRouter: any InboxNavigationProtocol {
get { self[InboxRouterKey.self] }
set { self[InboxRouterKey.self] = newValue }
}
}

With this setup, publishing a router into the environment at the call site becomes straightforward:

InboxView()
.environment(\.inboxRouter, router)

And within InboxView, the router can then be accessed simply as:

@Environment(\.inboxRouter) private var router

This solution allows views to interact with their routers without needing to know their concrete router type, maintaining flexibility and reusability in our navigation system.

It is now possible for us to integrate the inbox and its nested destinations as part of Tab D’s navigation destinations, alongside its other designated routes.

struct TabD: View {
typealias Destination = TabDRouter.Destination

enum NavigationItem: String, CaseIterable, Identifiable {
case subview = "Tab d subview"
case inbox

var id: String {
return rawValue
}
}

private let navigationTitle = ContentView.Tab.d.title
@Environment(TabDRouter.self) private var router

// MARK: - Views
var body: some View {
@Bindable var router = router

NavigationStack(path: $router.path) {
listView
.navigationTitle(navigationTitle)
.routerDestination(router: router,
navigationBackTitle: navigationTitle,
destination: navigationDestination)
}
}

@ViewBuilder private func navigationDestination(_ destination: Destination) -> some View {
switch destination {
case .subview:
Image(systemName: "d.circle")
.font(.extraLargeTitle)
case .inbox:
InboxView()
.environment(\.inboxRouter, router)
}
}

private var listView: some View {
List {
Section("Navigation items") {
ForEach(NavigationItem.allCases, id: \.self) { item in
listRow(title: item.rawValue.capitalized)
.onTapGesture {
didSelectNavigationItem(item)
}
}
}
}
}

private func listRow(title: String) -> some View {
Text(title)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle())
}

// MARK: - Private
private func didSelectNavigationItem(_ item: NavigationItem) {
switch item {
case .subview:
router.navigate(to: .subview)
case .inbox:
router.navigate(to: .inbox)
}
}
}

This not only aids in achieving a more organized and maintainable codebase but also paves the way for a more dynamic and flexible application architecture, where key functionalities like chatting can be available across different parts of the app without compromising on the integrity of navigation flows specific to each view or router.

Tab D

Conclusion

Having explored the four navigation strategies, we’ve laid a comprehensive foundation for building complex and flexible navigation systems.

As we wrap up part 2 of our series, we’ve seen how these navigation patterns can be used together to create a robust and maintainable navigation structure that can support the development of large-scale SwiftUI applications.

Looking ahead to part 3, we’ll advance into the nuances of managing the navigation state globally, ensuring that our app can respond to changes in the navigation state from anywhere. Additionally, we’ll explore the integration of deep links, allowing our app to handle external triggers and navigate directly to specific views or states. Stay tuned.

If you’re curious to see the source code to explore in detail before moving on to the last part, check out the RoutersDemo project in our GitHub repository.

--

--