paint-brush
Proper Navigation in SwiftUI With Coordinators: A Guideby@ivkuznetsov
330 reads
330 reads

Proper Navigation in SwiftUI With Coordinators: A Guide

by Ilia KuznetsovNovember 7th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Discover how the Coordinator pattern simplifies SwiftUI navigation. Centralize navigation logic and dependencies for cleaner, modular, and scalable code.
featured image - Proper Navigation in SwiftUI With Coordinators: A Guide
Ilia Kuznetsov HackerNoon profile picture


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<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  })


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!