SwiftUI Navigation: Part 3 - Global Navigation

The complete guide for app navigation using routers architecture.

Itay Amzaleg
Fiverr Tech
5 min readMay 23, 2024

--

In the earlier segments of this series, we established the foundation for a tab bar application and introduced a router architecture to facilitate navigation within each tab’s destinations.

Now, we’ll focus on strategies for managing our navigation flows on a global scale.

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

Part 1:

Part 2:

Part 3:

Modal presentations

Modality in Apple’s user interface design refers to creating focused environments within the app that temporarily restrict user interaction to a specific task or set of options. It’s used to capture the user’s full attention on important content or decisions.

In this section, we will review modals that can be presented globally from any part of the app, like authentication screens, forms, banners, or alerts. We’ll focus on sheets for our examples.

First, we’ll create an enum named PresentedSheet to define a set of available sheets:

enum PresentedSheet: Identifiable {
case viewOne
case transportation(type: TransportationView.TransportationType)

var id: String {
switch self {
case .viewOne:
return "View one"
case .transportation:
return "Transportation"
}
}
}

For demonstration purposes, we’ll repurpose screens already in use within the app, like ViewOne and TransportationView. Next, we’ll add an observed value presentedSheet in our AppRouter to track the currently presented sheet:

var presentedSheet: PresentedSheet?

To enable sheet presentations from any view within our environment, we’ll create EnvironmentValues and a corresponding EnvironmentKey for presenting sheets:

struct PresentedSheetKey: EnvironmentKey {
static var defaultValue: Binding<PresentedSheet?> = .constant(nil)
}
extension EnvironmentValues {
var presentedSheet: Binding<PresentedSheet?> {
get { self[PresentedSheetKey.self] }
set { self[PresentedSheetKey.self] = newValue }
}
}

Then, we’ll integrate everything into our ContentView, enabling any view in our app to control and present sheets:

struct ContentView: View {
@Environment(\.appRouter) private var appRouter

var body: some View {
TabView(selection: $appRouter.selectedTab) {
// Tab views setup
}
.sheet(item: $appRouter.presentedSheet) { presentedSheet in
view(for: presentedSheet)
}
.environment(\.presentedSheet, $appRouter.presentedSheet)
}

@ViewBuilder private func view(for presentedSheet: PresentedSheet) -> some View {
switch presentedSheet {
case .viewOne:
ViewOne()
case .transportation(let type):
TransportationView(type: type)
}
}
}

With this setup, sheets can be presented from anywhere in the app.

To illustrate that let’s extend Tab D by introducing a new section titled “Sheet items”.

struct TabD: View {
...

enum SheetItem: String, CaseIterable, Identifiable {
case viewOne = "View one"
case bicycle

var id: String {
return rawValue
}
}

...
@Environment(\.presentedSheet) var presentedSheet

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

Section("Sheet items") {
ForEach(SheetItem.allCases, id: \.self) { item in
listRow(title: item.rawValue.capitalized)
.onTapGesture {
didSelectSheetItem(item)
}
}
}
}
}

...
private func didSelectSheetItem(_ item: SheetItem) {
switch item {
case .viewOne:
presentedSheet.wrappedValue = .viewOne
case .bicycle:
presentedSheet.wrappedValue = .transportation(type: .bicycle)
}
}
}

Now, we can effectively handle global presentations from any view while keeping the current presentation state tracked within our AppRouter.

Tab D

Finalizing app router

With every component now in place, we can take a moment to review the final structure of our AppRouter. The central piece that orchestrates the navigation within our application, managing both global states like modal presentations and tab selections, as well as individual tab’s inner destinations navigation. Here’s how it looks in its completed form:

@Observable class AppRouter {
//MARK: - App states
var presentedSheet: PresentedSheet?
var selectedTab: ContentView.Tab = .a

//MARK: - Routers
var tabARouter = TabARouter()
var tabBRouter = TabBRouter()
var tabCRouter = TabCRouter()
var tabDRouter = TabDRouter()
}

Globally accessible through the ContentView`s environment, we possess the ability to show or dismiss any currently presented modals, navigate to the desired tab, and establish a navigation path within a specific router.

Deeplinks

Before our series reaches its conclusion, we’ll discuss the concept of deep linking and how we can send users directly to any destination within our app. For example’s sake we’ll avoid integrating new app capabilities such as associated domains or push notifications, we’ll operate under the assumption that we already have all the necessary data to determine the desired app state.

Start by introducing the DeeplinkManager, a shared instance that should interface with the AppDelegate or SceneDelegate user activity or push notifications delegate callbacks. It’s tasked with parsing deep link data and signaling our app about incoming navigational requirements:

class DeeplinkManager {
enum DeeplinkType {
case chat
case transportation(type: TransportationView.TransportationType)
}

static let shared = DeeplinkManager()

let userActivityPublisher = PassthroughSubject<DeeplinkType, Never>()

// MARK: - Lifecycle
private init() {}
}

Now, let’s adjust our app to respond to these deep link triggers:

@main
struct RoutersDemoApp: App {
@Bindable private var appRouter = AppRouter()

var body: some Scene {
WindowGroup {
ContentView()
.environment(appRouter)
.onReceive(DeeplinkManager.shared.userActivityPublisher, perform: handleDeeplink)
}
}

// MARK - Deeplinks
func handleDeeplink(_ type: DeeplinkManager.DeeplinkType) {
switch type {
case .chat:
appRouter.presentedSheet = nil
appRouter.selectedTab = .c
appRouter.tabCRouter.navigate(to: .inbox)
appRouter.tabCRouter.navigate(to: .chat)
case .transportation(let type):
appRouter.presentedSheet = .transportation(type: type)
}
}
}

We could certainly enhance our routers with convenience functions that navigate to more complex paths, rather than calling navigate(to:) multiple times. However, for this demonstration, we want to illustrate the entire process step by step.

To test this system, we can add tasks to ContentView to trigger DeeplinkManager’s user activity publisher a few seconds after app launch.

.task {
try? await Task.sleep(for: .seconds(3))
DeeplinkManager.shared.userActivityPublisher.send(.chat)
}
.task { 
try? await Task.sleep(for: .seconds(3))
DeeplinkManager.shared.userActivityPublisher.send(.transportation(type: .bus))
}

This demonstrates our app’s capability to create a detailed navigation state tailored to specific requirements. Whether it’s dismissing any presented modals, transitioning to a particular tab, navigating through a specified router path, or unveiling a targeted modal screen, it showcases the versatility of our navigation architecture in handling complex deep link scenarios.

Conclusion

As we draw this series to a close, let’s recap how we navigated the intricacies of implementing a dynamic and robust navigation system within SwiftUI using router architecture. From establishing a foundational tab bar application to managing global navigation, we’ve covered a comprehensive spectrum of navigation strategies to enhance the scalable development of SwiftUI apps.

In this final installment, we focused on the power of managing our navigation states globally, showcasing how our AppRouter can seamlessly manage states and direct users to precise destinations within our app.

This series aims to equip you with the knowledge and tools to architect sophisticated navigation systems that can scale with your app’s growth, handle various navigation patterns, and respond to external triggers with ease. As you incorporate these principles into your projects, remember that SwiftUI is continually evolving, and the developer community is still establishing standardized approaches for architectures and work methodologies.

For those eager to dive deeper into the code and explore the nuances of each navigation technique, the RoutersDemo project on our GitHub repository awaits you.

Thank you for joining us on this journey through SwiftUI navigation. As the scene of app development continues to evolve, we look forward to discovering and sharing new approaches and innovations that make building complex, user-friendly applications more accessible and enjoyable.

--

--