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.
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.
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:
Let’s look at the implementation of each of them.
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.
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<CommonCoordinator>
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 })
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.
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-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.
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
}
}
}
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()
}
}
}
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)
}
}
}
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!