Your resource for web content, online publishing
and the distribution of digital products.
S M T W T F S
 
 
 
 
 
1
 
2
 
3
 
4
 
5
 
6
 
7
 
8
 
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
 
29
 
30
 

Proper Navigation in SwiftUI With Coordinators: A Guide

DATE POSTED:November 6, 2024

\ In this article, I’ll introduce a Coordinators framework for designing navigation in your SwiftUI app using the coordinator pattern. This pattern abstracts navigation logic away from individual views, centralizing it within "coordinator" objects.

\ SwiftUI provides built-in navigation tools like NavigationStack and NavigationLink, but as applications become more complex, managing navigation through a centralized coordinator simplifies dependencies and state management. Coordinators handle navigation, dependency injection, and deep linking, making SwiftUI views lightweight and focused on UI concerns.

\ Complex applications may have multiple coordinators, each responsible for a specific feature or flow, such as user authorization, configuration wizard, settings, etc.

Key Features
  • Views Focused on UI: Views handle their own UI and state but do not manage navigation.

\

  • No Screen-to-Screen Knowledge: Each screen doesn’t know how to construct another screen. The coordinator is solely responsible for creating and displaying new screens, which keeps views reusable and decoupled.

\

  • Dependency Injection: Coordinators inject dependencies into views, making data flow and service access streamlined and helping avoid singletons.

\

  • State Management Across Screens: Coordinators can manage the state across multiple screens, ensuring logical progression across a navigation flow.

\

  • Deep Linking: Coordinators simplify deep linking by determining the correct sequence of screens to present, providing a seamless user experience.
Adding to a Project

The framework is distributed via Swift Package Manager (SPM). To integrate it, add a link to the package in your project’s package dependencies tab.

\ adding an SPM dependency

\

Navigation Types

This framework was designed to keep your code minimal, and basically, it follows the familiar logic we had in UIKit projects.

\ You may notice the similarity between UINavigationController and UIKit presentation logic.

\ In the iOS application, we have three main types of navigation:

  • Navigation Stack
  • Modal Sheet
  • Tabs

\ Let’s look at the implementation of each of them.

Navigation Stack

NavigationStack allows users to navigate through multiple layers of views, maintaining a stack-like structure for managing the view hierarchy. Each screen “pushed” onto the stack represents a deeper level in the navigation sequence. Usually, the navigation of the new screen is accompanied by horizontal animation. Users can go back by “popping” views off the stack using a back button or swiping gesture.

\ navigation stack

\ Start by creating screens you’ll navigate between.

import SwiftUI struct FirstScreen: View { var body: some View { Color.red } } struct SecondScreen: View { var body: some View { Color.blue } } struct ThirdScreen: View { var body: some View { Color.yellow } }

\ Now, we can create a simple navigation coordinator.

import SwiftUI import Coordinators class CommonCoordinator: NavigationCoordinator { // screens available for navigation enum Screen: ScreenProtocol { case first case second case third } // view for each screen func destination(for screen: Screen) -> some View { switch screen { case .first: FirstScreen() case .second: SecondScreen() case .third: ThirdScreen() } } }

\ The NavigationCoordinator protocol requires you to implement an enumeration of screens and a function to construct views for each screen. That’s it.

\ Now, initialize the coordinator at the root level of the app.

@main struct CoordinatorsExampleApp: App { // create an instance of the coordinator @StateObject var coordinator = CommonCoordinator() var body: some Scene { WindowGroup { // present root view of coordinator coordinator.view(for: .first) } } }

\ In the current example, the coordinator is stored as a StateObject, and its initial screen (.first) is presented as the app’s root view.

\ To navigate between screens, modify the first screen to include buttons for navigation.

struct FirstScreen: View { // reference to the coordinator @EnvironmentObject var coordinator: Navigation var body: some View { VStack { Button("Second") { // navigation to the second screen coordinator().present(.second) } Button("Third") { // navigation to the third screen coordinator().present(.third) } } } }

\ Our coordinator is passed to all the children's views as @EnvironmentObject in the Navigation wrapper. Now, you can navigate to the other screens using the function coordinator().present(). This function accepts only screens that this coordinator can present.

\ To go back, you can use well-known dismiss environment value:

@Environment(\.dismiss) var dismiss

\ Or if you need additional options you can use a coordinator reference:

coordinator().pop() coordinator().popTo(.first) coordinator().popToRoot() coordinator().popTo(where: { screen in })

\

Modal Sheet

In modal navigation, a new screen is presented over the current one, covering it partially or entirely. This approach temporarily interrupts the main navigation flow, allowing users to focus on a new task or piece of content, with the expectation that they’ll eventually return to the previous screen.

\ modal navigation

\ To support modal navigation, make your coordinator conform to ModalCoordinator. It follows similar logic to NavigationCoordinator, but we also can present child coordinators.

class CommonCoordinator: NavigationCoordinator, ModalCoordinator { //-- // screens or navigation stacks available to be presented modally enum Modal: ModalProtocol { case firstModal case secondModal case child(ChildNavigationCoordinator = .init()) } // view for each modal screen func destination(for modal: Modal) -> some View { switch modal { case .firstModal: FirstScreen() case .secondModal: SecondScreen() case .child(let coordiantor): coordiantor.view(for: .first) } } }

\ The modal presentation style can also be customized.

enum Modal: ModalProtocol { case first case second case child(ChildNavigationCoordinator = .init()) var style: ModalStyle { switch self { case .first: .cover case .second: .overlay case .child(let childNavigationCoordinator): .sheet } } }

\ To present modal flow, you will use present() function:

Button("Present Modally") { coordinator().present(.child()) }

\ To dismiss it the same as before:

@Environment(\.dismiss) var dismiss

\ Or referencing the coordinator

coordinator().dismiss() coordinator().dismissPresented()

The first one is for dismissing the current coordinator, and the second one is to dismiss the modal screen presented over the current coordinator.

Tab Navigation and Others

Tab-based navigation allows users to switch between different sections of the app by tapping on icons typically located at the bottom of the screen. Each tab represents a distinct area of the app, allowing for easy and quick access to different parts of the app without disrupting the user’s current context.

\ Tabs Navigation

\ For implementing such navigation or any other similar one, you can use protocol CustomCoordinator.

\ You need to implement your own view and pass it to a destination function.

class TabsCoordinator: CustomCoordinator { enum Tabs: Hashable { case tab1 case tab2 case tab3 } @Published var currentTab: Tabs = .tab1 let tab1 = CommonCoordinator() let tab2 = CommonCoordinator() let tab3 = CommonCoordinator() func destination() -> some View { TabsScreen(coordinator: self) } struct TabsScreen: View { @ObservedObject var coordinator: TabsCoordinator var body: some View { TabView(selection: $coordinator.currentTab) { coordinator.tab1.view(for: .first) .tabItem { Label("First", systemImage: "circle") } .tag(Tabs.tab1) coordinator.tab2.view(for: .first) .tabItem { Label("Second", systemImage: "circle") } .tag(Tabs.tab2) coordinator.tab3.view(for: .first) .tabItem { Label("Third", systemImage: "circle") } .tag(Tabs.tab3) } } } }

\ And use a rootView property in the view hierarchy.

@main struct CoordinatorsExampleApp: App { @StateObject var coordinator = TabsCoordinator() var body: some Scene { WindowGroup { coordinator.rootView } } }

\

Dependencies Management

Coordinators simplify dependency injection, passing services to views directly and enabling easier testing with mock services. You don’t need to use singletons or pass services to views deep down through the view hierarchy.

class CommonCoordinator: NavigationCoordinator, ModalCoordinator { let someService: SomeService let anotherService: SomeService enum Screen: ScreenProtocol { case first case second case third } func destination(for screen: Screen) -> some View { switch screen { case .first: FirstScreen(someService: self.someService) case .second: SecondScreen(anotherService: self.anotherService) case .third: ThirdScreen() } } }

\

Deep Linking

It becomes easy to handle deep links, just need to add a URL handler to your root coordinator.

@main struct CoordinatorsExampleApp: App { @StateObject var coordinator = CommonCoordinator() var body: some Scene { WindowGroup { coordinator.view(for: .first).onOpenURL { url in coordinator.handle(url: url) } } } }

\ And present a corresponding screen or even navigation flow.

class CommonCoordinator: NavigationCoordinator, ModalCoordinator { //-- @MainActor func handle(url: URL) { let showModal: Bool // parse an url if showModal { // create a child coordinator presenting some screen if needed let childCoordinator = ChildNavigationCoordinator() childCoordinator.present(.deepLinkScreen) // present child coordinator modally present(.child(childCoordinator), resolve: .replaceCurrent) } } }

\

Conclusion

This article has covered the basics of using the Coordinators framework, but it also offers even more advanced capabilities by accessing the coordinator’s navigation state. You can observe and modify it enabling highly customized and complex workflows.

\ Using the Coordinators framework centralizes navigation and dependency management, leading to cleaner, modular code that’s easier to test, maintain, and scale. By abstracting navigation logic, coordinators keep views lightweight, reusable, and focused purely on UI concerns.

\ With this framework, your SwiftUI apps will be better structured, more maintainable, and ready to handle complex navigation flows—whether through deep linking, dependency injection, or multi-layered navigation stacks.

\ ==Link== to the Coordinators framework on GitHub.

\ Please, put a star on it if you like it.

\ Happy Coding!