Skill: Navigator
Guide for implementing navigation using NavigationCoordinator with SwiftUI NavigationStack, Navigator pattern for decoupling, and Outgoing/Incoming Navigation for cross-feature communication.
When to use this skill
- •Set up navigation in the App
- •Add navigation to ViewModels via Navigator pattern
- •Implement deep link handlers for features
- •Connect features via Outgoing/Incoming Navigation
- •Test navigation behavior
Architecture Overview
code
┌─────────────────────────────────────────────────────────────────────────┐
│ RootContainerView │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ @State private var coordinator = NavigationCoordinator( │ │
│ │ redirector: AppNavigationRedirect() │ │
│ │ ) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ NavigationStack(path: $coordinator.path) { │
│ Features receive coordinator (NavigatorContract) │
│ } │
└─────────────────────────────────────────────────────────────────────────┘
Navigation Flow:
1. HomeNavigator.navigateToCharacters()
2. coordinator.navigate(to: HomeOutgoingNavigation.characters)
3. AppNavigationRedirect.redirect() → CharacterIncomingNavigation.list
4. NavigationStack shows CharacterListView
Navigation Types
| Type | Description | Implementation |
|---|---|---|
| Incoming | Destinations a feature can handle | {Feature}IncomingNavigation enum |
| Outgoing | Destinations a feature wants to navigate to | {Feature}OutgoingNavigation enum |
| Redirect | Connects Outgoing → Incoming | AppNavigationRedirect in App layer |
Why? Features remain decoupled. Feature A doesn't import Feature B. The App layer connects them via redirects.
Core Module Components
NavigatorContract (Protocol)
swift
// Libraries/Core/Sources/Navigation/NavigatorContract.swift
import Foundation
public protocol NavigatorContract {
func navigate(to destination: any NavigationContract)
func goBack()
}
NavigationContract (Protocol)
swift
// Libraries/Core/Sources/Navigation/Navigation.swift
nonisolated public protocol NavigationContract: Hashable, Sendable {}
nonisolated public protocol IncomingNavigationContract: NavigationContract {}
nonisolated public protocol OutgoingNavigationContract: NavigationContract {}
NavigationCoordinator (Implementation)
swift
// Libraries/Core/Sources/Navigation/NavigationCoordinator.swift
import Foundation
import SwiftUI
@Observable
public final class NavigationCoordinator: NavigatorContract {
public var path = NavigationPath()
private let redirector: (any NavigationContractRedirectContract)?
public init(redirector: (any NavigationContractRedirectContract)? = nil) {
self.redirector = redirector
}
public func navigate(to destination: any NavigationContract) {
let resolved = redirector?.redirect(destination) ?? destination
path.append(resolved)
}
public func goBack() {
guard !path.isEmpty else {
return
}
path.removeLast()
}
}
NavigationRedirectContract (Protocol)
swift
// Libraries/Core/Sources/Navigation/NavigationRedirectContract.swift
import Foundation
public protocol NavigationRedirectContract: Sendable {
func redirect(_ navigation: any NavigationContract) -> (any NavigationContract)?
}
FeatureContract Protocol
swift
// Libraries/Core/Sources/Feature/Feature.swift
import SwiftUI
public protocol FeatureContract {
/// The deep link handler for this feature (optional).
var deepLinkHandler: (any DeepLinkHandlerContract)? { get }
/// Creates the main view for this feature.
func makeMainView(navigator: any NavigatorContract) -> AnyView
/// Resolves a navigation destination to a view.
/// Returns nil if this feature doesn't handle the given navigation.
func resolve(_ navigation: any NavigationContract, navigator: any NavigatorContract) -> AnyView?
}
public extension FeatureContract {
var deepLinkHandler: (any DeepLinkHandlerContract)? { nil }
}
Notes:
- •
deepLinkHandleris optional with defaultnilimplementation - •
makeMainView()creates the feature's default entry point view - •
resolve()handles navigation destinations (returnsnilif not handled)
DeepLinkHandlerContract (Protocol)
swift
// Libraries/Core/Sources/Navigation/DeepLinkHandler.swift
import Foundation
public protocol DeepLinkHandlerContract: Sendable {
var scheme: String { get }
var host: String { get }
func resolve(_ url: URL) -> (any NavigationContract)?
}
NavigatorMock (for testing)
swift
// Libraries/Core/Mocks/NavigatorMock.swift
import ChallengeCore
import Foundation
public final class NavigatorMock: NavigatorContract {
public private(set) var navigatedDestinations: [any NavigationContract] = []
public private(set) var goBackCallCount = 0
public init() {}
public func navigate(to destination: any NavigationContract) {
navigatedDestinations.append(destination)
}
public func goBack() {
goBackCallCount += 1
}
}
Feature Navigation
Each feature defines Incoming and optionally Outgoing navigation:
Incoming Navigation (Destinations the feature handles)
swift
// Features/{Feature}/Sources/Presentation/Navigation/{Feature}IncomingNavigation.swift
import ChallengeCore
public enum {Feature}IncomingNavigation: IncomingNavigationContract {
case list
case detail(identifier: Int)
}
Outgoing Navigation (Destinations to other features)
swift
// Features/{Feature}/Sources/Presentation/Navigation/{Feature}OutgoingNavigation.swift
import ChallengeCore
public enum {Feature}OutgoingNavigation: OutgoingNavigationContract {
case characters // Navigates to Character feature
case settings // Navigates to Settings feature
}
Note: Outgoing navigations are public so AppNavigationRedirect can access them.
DeepLinkHandler
swift
// Features/{Feature}/Sources/Presentation/Navigation/{Feature}DeepLinkHandler.swift
import ChallengeCore
import Foundation
struct {Feature}DeepLinkHandler: DeepLinkHandlerContract {
let scheme = "challenge"
let host = "{feature}" // e.g., "character"
func resolve(_ url: URL) -> (any NavigationContract)? {
switch url.path {
case "/list":
return {Feature}IncomingNavigation.list
case "/detail":
guard let id = url.queryParameter("id").flatMap(Int.init) else {
return nil
}
return {Feature}IncomingNavigation.detail(identifier: id)
default:
return nil
}
}
}
URL Format: challenge://{feature}/{path}?param=value
Examples:
- •
challenge://character/list - •
challenge://character/detail?id=42
App Layer: Connecting Features
AppNavigationRedirect
swift
// App/Sources/Navigation/AppNavigationRedirect.swift
import ChallengeCharacter
import ChallengeCore
import ChallengeHome
struct AppNavigationRedirect: NavigationRedirectContract {
func redirect(_ navigation: any NavigationContract) -> (any NavigationContract)? {
switch navigation {
case let outgoing as HomeOutgoingNavigation:
return redirect(outgoing)
default:
return nil
}
}
// MARK: - Private
private func redirect(_ navigation: HomeOutgoingNavigation) -> any NavigationContract {
switch navigation {
case .characters:
return CharacterIncomingNavigation.list
}
}
}
Rules:
- •Centralized place to connect features
- •Maps Outgoing → Incoming navigation
- •Only place that imports multiple features
RootContainerView
swift
// AppKit/Sources/Presentation/Views/RootContainerView.swift
import ChallengeCore
import SwiftUI
public struct RootContainerView: View {
public let appContainer: AppContainer
@State private var navigationCoordinator: NavigationCoordinator
public init(appContainer: AppContainer) {
self.appContainer = appContainer
_navigationCoordinator = State(initialValue: NavigationCoordinator(redirector: AppNavigationRedirect()))
}
public var body: some View {
NavigationStack(path: $navigationCoordinator.path) {
appContainer.makeRootView(navigator: navigationCoordinator)
.navigationDestination(for: AnyNavigation.self) { navigation in
appContainer.resolve(navigation.wrapped, navigator: navigationCoordinator)
}
}
.onOpenURL { url in
appContainer.handle(url: url, navigator: navigationCoordinator)
}
}
}
/*
#Preview {
RootContainerView(appContainer: AppContainer())
}
*/
Key Changes:
- •Uses
AnyNavigationwrapper for type-erased navigation inNavigationPath - •
appContainer.resolve()iterates through features to find the handler - •Located in
AppKitmodule (notApp) for testability
AppContainer (Navigation Resolution)
swift
// AppKit/Sources/AppContainer.swift
/// Resolves any navigation to a view by iterating through features.
/// Falls back to NotFoundView if no feature can handle the navigation.
public func resolve(
_ navigation: any NavigationContract,
navigator: any NavigatorContract
) -> AnyView {
for feature in features {
if let view = feature.resolve(navigation, navigator: navigator) {
return view
}
}
// Fallback to NotFoundView
return systemFeature.makeMainView(navigator: navigator)
}
/// Handles deep links by resolving URLs to navigation.
public func handle(url: URL, navigator: any NavigatorContract) {
for feature in features {
if let navigation = feature.deepLinkHandler?.resolve(url) {
navigator.navigate(to: navigation)
return
}
}
}
Navigator Pattern
ViewModels use Navigators instead of NavigatorContract directly. This:
- •Decouples ViewModels from navigation implementation details
- •Makes testing easier with focused mocks
- •Provides semantic navigation methods
Navigator Contract
swift
// Features/{Feature}/Sources/Presentation/{Screen}/Navigator/{Screen}NavigatorContract.swift
protocol {Screen}NavigatorContract {
func navigateToDetail(id: Int) // Internal navigation
func goBack()
}
Navigator Implementation (Internal Navigation)
swift
// Features/{Feature}/Sources/Presentation/{Screen}/Navigator/{Screen}Navigator.swift
import ChallengeCore
struct {Screen}Navigator: {Screen}NavigatorContract {
private let navigator: NavigatorContract
init(navigator: NavigatorContract) {
self.navigator = navigator
}
func navigateToDetail(id: Int) {
// Uses IncomingNavigation (same feature)
navigator.navigate(to: {Feature}IncomingNavigation.detail(identifier: id))
}
func goBack() {
navigator.goBack()
}
}
Navigator Implementation (External Navigation)
swift
// Features/Home/Sources/Presentation/Home/Navigator/HomeNavigator.swift
import ChallengeCore
struct HomeNavigator: HomeNavigatorContract {
private let navigator: NavigatorContract
init(navigator: NavigatorContract) {
self.navigator = navigator
}
func navigateToCharacters() {
// Uses OutgoingNavigation (different feature)
// AppNavigationRedirect will convert to CharacterIncomingNavigation.list
navigator.navigate(to: HomeOutgoingNavigation.characters)
}
}
Key Difference:
- •Internal: Uses
{Feature}IncomingNavigationdirectly - •External: Uses
{Feature}OutgoingNavigation(redirected by App layer)
Feature Implementation
Feature Struct
swift
// Features/{Feature}/Sources/{Feature}Feature.swift
import ChallengeCore
import ChallengeNetworking
import SwiftUI
public struct {Feature}Feature: FeatureContract {
private let container: {Feature}Container
public init(httpClient: any HTTPClientContract) {
self.container = {Feature}Container(httpClient: httpClient)
}
// MARK: - Feature Protocol
public var deepLinkHandler: (any DeepLinkHandlerContract)? {
{Feature}DeepLinkHandler()
}
public func makeMainView(navigator: any NavigatorContract) -> AnyView {
AnyView({Name}ListView(
viewModel: container.make{Name}ListViewModel(navigator: navigator)
))
}
public func resolve(
_ navigation: any NavigationContract,
navigator: any NavigatorContract
) -> AnyView? {
guard let navigation = navigation as? {Feature}IncomingNavigation else {
return nil
}
switch navigation {
case .list:
return makeMainView(navigator: navigator)
case .detail(let identifier):
return AnyView({Name}DetailView(
viewModel: container.make{Name}DetailViewModel(
identifier: identifier,
navigator: navigator
)
))
}
}
}
ViewModel with Navigator
swift
// Features/{Feature}/Sources/Presentation/ViewModels/{Name}ViewModel.swift
import Foundation
@Observable
final class {Name}ViewModel {
private(set) var state: {Name}ViewState = .idle
private let get{Name}UseCase: Get{Name}UseCaseContract
private let navigator: {Name}NavigatorContract
init(get{Name}UseCase: Get{Name}UseCaseContract, navigator: {Name}NavigatorContract) {
self.get{Name}UseCase = get{Name}UseCase
self.navigator = navigator
}
func didSelectItem(_ item: Item) {
navigator.navigateToDetail(id: item.id)
}
func didTapOnBack() {
navigator.goBack()
}
}
Testing Navigation
Navigator Mock
swift
// Features/{Feature}/Tests/Mocks/{Screen}NavigatorMock.swift
@testable import Challenge{Feature}
final class {Screen}NavigatorMock: {Screen}NavigatorContract {
private(set) var navigateToDetailIds: [Int] = []
private(set) var goBackCallCount = 0
func navigateToDetail(id: Int) {
navigateToDetailIds.append(id)
}
func goBack() {
goBackCallCount += 1
}
}
Navigator Tests
swift
// Features/{Feature}/Tests/Presentation/{Screen}/Navigator/{Screen}NavigatorTests.swift
import ChallengeCoreMocks
import Testing
@testable import Challenge{Feature}
struct {Screen}NavigatorTests {
@Test
func navigateToDetailUsesCorrectNavigation() {
// Given
let navigatorMock = NavigatorMock()
let sut = {Screen}Navigator(navigator: navigatorMock)
// When
sut.navigateToDetail(id: 42)
// Then
let destination = navigatorMock.navigatedDestinations.first as? {Feature}IncomingNavigation
#expect(destination == .detail(identifier: 42))
}
}
AppNavigationRedirect Tests
swift
// App/Tests/Navigation/AppNavigationRedirectTests.swift
import ChallengeCharacter
import ChallengeHome
import Testing
@testable import Challenge
struct AppNavigationRedirectTests {
@Test
func redirectHomeOutgoingCharactersToCharacterList() throws {
// Given
let sut = AppNavigationRedirect()
// When
let result = sut.redirect(HomeOutgoingNavigation.characters)
// Then
let characterNavigation = try #require(result as? CharacterIncomingNavigation)
#expect(characterNavigation == .list)
}
@Test
func redirectUnknownNavigationReturnsNil() {
// Given
let sut = AppNavigationRedirect()
// When
let result = sut.redirect(CharacterIncomingNavigation.list)
// Then
#expect(result == nil)
}
}
DeepLinkHandler Tests
swift
import ChallengeCore
import Foundation
import Testing
@testable import Challenge{Feature}
struct {Feature}DeepLinkHandlerTests {
@Test
func resolvesListURL() throws {
// Given
let sut = {Feature}DeepLinkHandler()
let url = try #require(URL(string: "challenge://{feature}/list"))
// When
let value = sut.resolve(url)
// Then
#expect(value as? {Feature}IncomingNavigation == .list)
}
@Test
func resolvesDetailURL() throws {
// Given
let sut = {Feature}DeepLinkHandler()
let url = try #require(URL(string: "challenge://{feature}/detail?id=42"))
// When
let value = sut.resolve(url)
// Then
#expect(value as? {Feature}IncomingNavigation == .detail(identifier: 42))
}
}
File Structure
code
Libraries/Core/
├── Sources/
│ ├── Feature/
│ │ └── Feature.swift # Feature protocol
│ └── Navigation/
│ ├── NavigationCoordinator.swift # @Observable, manages path + redirects
│ ├── NavigatorContract.swift # Protocol for navigation
│ ├── NavigationRedirectContract.swift # Protocol for redirects
│ ├── Navigation.swift # Base protocol
│ ├── AnyNavigation.swift # Type-erased wrapper for NavigationPath
│ └── DeepLinkHandler.swift # Protocol for deep links
└── Mocks/
├── NavigatorMock.swift
└── TrackerMock.swift
AppKit/Sources/ # Note: AppKit, not App (for testability)
├── AppContainer.swift # resolve() and handle(url:)
└── Presentation/
├── Navigation/
│ └── AppNavigationRedirect.swift # Connects features via redirects
└── Views/
└── RootContainerView.swift # Creates NavigationCoordinator
Features/{Feature}/
├── Sources/
│ ├── {Feature}Feature.swift
│ ├── {Feature}Container.swift
│ └── Presentation/
│ ├── Navigation/ # Inside Presentation folder
│ │ ├── {Feature}IncomingNavigation.swift # Destinations this feature handles
│ │ ├── {Feature}OutgoingNavigation.swift # Destinations to other features (optional)
│ │ └── {Feature}DeepLinkHandler.swift
│ └── {Screen}/
│ ├── Navigator/
│ │ ├── {Screen}NavigatorContract.swift
│ │ └── {Screen}Navigator.swift
│ └── Tracker/ # Same pattern as Navigator
│ ├── {Screen}TrackerContract.swift
│ ├── {Screen}Tracker.swift
│ └── {Screen}Event.swift
└── Tests/
└── Unit/
└── Presentation/
├── Navigation/
│ └── {Feature}DeepLinkHandlerTests.swift
└── {Screen}/
├── Navigator/
│ └── {Screen}NavigatorTests.swift
└── Tracker/
├── {Screen}TrackerTests.swift
└── {Screen}EventTests.swift
Checklist
Core Setup
- • Core has
NavigatorContractprotocol - • Core has
NavigationRedirectContractprotocol - • Core has
NavigationCoordinator(@Observable, manages path + redirects) - • Core has
NavigationContractprotocol - • Core has
AnyNavigationtype-erased wrapper - • Core has
DeepLinkHandlerContractprotocol - • Core has
FeatureContractprotocol withmakeMainView()andresolve()methods - • Core has
NavigatorMockfor testing
AppKit Configuration
- •
Project.swifthasCFBundleURLTypeswith URL scheme (e.g.,challenge) - •
AppNavigationRedirectinAppKit/Sources/Presentation/Navigation/ - •
RootContainerViewinAppKit/Sources/Presentation/Views/ - •
RootContainerViewuses.navigationDestination(for: AnyNavigation.self) - •
AppContainer.resolve()iterates features and falls back to NotFoundView - •
AppContainer.handle(url:navigator:)resolves deep links via feature handlers
Feature Implementation
- • Feature has
{Feature}IncomingNavigationinPresentation/Navigation/ - • Feature has
{Feature}OutgoingNavigationfor cross-feature navigation (if needed) - • Feature has
{Feature}DeepLinkHandlerreturningIncomingNavigationContract(if deep links needed) - • Feature implements
makeMainView(navigator:)returning default entry point - • Feature implements
resolve(_:navigator:)returning view or nil - • Each screen has
NavigatorContractandNavigator - • Each screen has
TrackerContract,Tracker, andEvent(same pattern as Navigator) - • Navigator uses
IncomingNavigationContractfor internal,OutgoingNavigationContractfor external - • Container factories receive
navigator: any NavigatorContract - • ViewModel receives specific
NavigatorContract(not generic)
Testing
- • Tests use
NavigatorMockto verify navigation - • Navigator tests verify correct Navigation enum is used
- • AppNavigationRedirect tests verify Outgoing → Incoming mapping
- • DeepLinkHandler tests verify URL → IncomingNavigationContract resolution