SkillAgentSearch skills...

Navigator

Advanced Navigation Support for SwiftUI.

Install / Use

/learn @hmlongco/Navigator
About this skill

Quality Score

0/100

Supported Platforms

Universal

README

swift-versions platforms License

Advanced Navigation Support for SwiftUI.

Navigator 2.0.0

Navigator provides SwiftUI with a simple yet powerful navigation layer based on NavigationStack.

This is not just another push/pop navigation stack library. It supports...

  • Simple and easy navigation linking and presentation of views.
  • Coordination patterns with well-defined separation of concerns.
  • Extensive support for modular applications including cross-module navigation and views.
  • True deep linking and internal application navigation via navigation send.
  • Easily returning to a specific spot in the navigation tree via navigation checkpoints.
  • Returning callback values via navigation checkpoints.
  • Both Declarative and Imperative navigation and control.
  • Navigation state restoration.
  • Event logging and debugging.
  • No navigationDestination registration operations are required.

You might want to reread that last bullet point again...

Navigator 2.0 is written entirely in Swift and SwiftUI, supports Observation, and runs on iOS 17 and above.

The Code

Defining Navigation Destinations

Destinations (or routes) are typically just public lists of enumerated values, one for each view desired.

nonisolated public enum HomeDestinations: NavigationDestination {

    case page2
    case page3
    case pageN(Int)
    
}

SwiftUI requires navigation destination values to be Hashable, and so do we.

With our enum in place, we now need to provide each destination with a variable that returns the correct view for each case. That's easy, since NavigationDestination also conforms to View!

Just provide the view body as part of the enumeration.

    ...
    case pageN(Int)
    
    public var body: some View {
        switch self {
        case .page2:
            HomePage2View()
        case .page3:
            HomePage3View()
        case .pageN(let value):
            HomePageNView(number: value)
        }
    }
    
}

Note how associated values can be used to pass parameters to views as needed.

To build views that have external dependencies or that require access to environmental values, see Advanced Destinations below.

Managed Navigation Stacks

The next step is to use ManagedNavigationStack when you once used NavigationStack in your code.

struct RootView: View {
    var body: some View {
        ManagedNavigationStack {
            List {
                NavigationLink(to: HomeDestinations.page3) {
                    Text("Link to Page 3!")
                }
            }
        }
    }
}

It's that simple.

ManagedNavigationStack creates a NavigationStack for you and installs the associated Navigator environment variable that "manages" that particular NavigationStack. It provides it with the NavigationPath and also supports navigation options like automatically presenting sheets and fullScreenCovers.

Those with sharp eyes might have noticed something missing in the above code. We're using NavigationLink with a destination value, but where's the .navigationDestination(for: HomeDestinations.self) { ... ) modifier?

Or, as done in earlier versions of Navigator, the .navigationDestination(HomeDestinations.self) modifier?

As of Navigator 1.2.0, there's no need for them.

Seriously.

Eliminating Navigation Destination Registrations

As you're no doubt aware, SwiftUI's NavigationStack requires destination types to be registered in order for NavigationLink(value:label:) transitions to work correctly.

But that seems redundant, doesn't it? Our NavigationDestination enumerations already define the views to be provided, so why is registration needed?

Turns out that it's not!

Just use NavigationLink(to:label) instead of NavigationLink(value:label) in your code and let Navigator handle the rest:

import NavigatorUI

struct SettingsTabView: View {
    var body: some View {
        ManagedNavigationStack {
            List {
                NavigationLink(to: ProfileDestinations.main) {
                    Text("User Profile")
                }
                NavigationLink(to: SettingsDestinations.main) {
                    Text("Settings")
                }
                NavigationLink(to: AboutDestinations.main) {
                    Text("About Navigator")
                }
            }
            .navigationTitle("Settings")
        }
    }
}

Here we use three different NavigationDestination types, but provide no registrations.

So what black magic is this? Simple. Navigator provides an initializer for NavigationLink that takes NavigationDestination types and maps them to an internal type that ManagedNavigationStack has already registered for you.

This small change allows a single navigationDestination handler to push NavigationDestination views of any type!

Consider a modular application whose home screen uses "cards" provided from different modules. Clicking on a card from Module A should push an internal view from that module... but that's only possible if we somehow knew how to register the needed types from module A.

Now there's no need to do so!

Note that this is potentially a breaking change. Use the old NavigationLink(value:label) view without defining the destination and navigation will fail.

Use NavigationLink(to:label) and you'll be fine.

If you prefer or need the registration mechanism to support older code, don't worry. Just continue to use NavigationLink(value:label:) and navigationDestination registrations just like you did before.

Programatic Navigation Destinations

Navigation Destinations can also be dispatched programmatically via Navigator, or declaratively using modifiers.

// Sample using optional destination
@State var page: SettingsDestinations?
...
Button("Modifier Navigate to Page 3!") {
    page = .page3
}
.navigate(to: $page)

// Sample using trigger value
@State var triggerPage3: Bool = false
...
Button("Modifier Trigger Page 3!") {
    triggerPage3.toggle()
}
.navigate(trigger: $triggerPage3, destination: SettingsDestinations.page3)

Or imperatively by asking a Navigator to perform the desired action.

@Environment(\.navigator) var navigator: Navigator
...
Button("Button Navigate To Home Page 55") {
    navigator.navigate(to: HomeDestinations.pageN(55))
}
Button("Button Push Home Page 55") {
    navigator.push(HomeDestinations.pageN(55))
}

In case you're wondering, calling push pushes the associated view onto the current NavigationStack, while navigate(to:) will push the view or present the view, based on the NavigationMethod specified (coming up next).

Navigation Methods

Your NavigationDestination type can be extended to provide a distinct NavigationMethod for each enumerated type.

extension HomeDestinations {
    public var method: NavigationMethod {
        switch self {
        case .page3:
            .sheet
        default:
            .push
        }
    }
}

In this case, should navigator.navigate(to: HomeDestinations.page3) be called, Navigator will automatically present that view in a sheet. All other views will be pushed onto the navigation stack.

The current navigation methods are: .push (default), .sheet, .managedSheet, .cover, .managedCover, and .send.

Predefined methods can be overridden using Navigator's navigate(to:method:) function.

Button("Present Home Page 55 Via Sheet") {
    navigator.navigate(to: HomeDestinations.pageN(55), method: .sheet)
}

Note that destinations dispatched via NavigationLink will always push onto the NavigationStack. That's just how SwiftUI works.

Checkpoints

Like most systems based on NavigationStack, Navigator supports operations like popping back to a previous view, dismissing a presented view, and so on.

Button("Pop To Previous Screen") {
    navigator.pop()
}
Button("Dismiss Presented View") {
    navigator.dismiss()
}

But those are all imperative operations. While one can programmatically pop and dismiss their way out of a screen, that approach is problematic and tends to be fragile. One could pass bindings down the tree, but that can also be cumbersome and difficult to maintain.

Fortunately, Navigator supports checkpoints; named points in the navigation stack to which one can easily return.

Checkpoints are easy to define and use. Let's create one called "home" and then use it.

struct KnownCheckpoints: NavigationCheckpoints {
    public static var home: NavigationCheckpoint<Void> { checkpoint() }
}

struct RootHomeView: View {
    var body: some View {
        ManagedNavigationStack(scene: "home") {
            HomeContentView(title: "Home Navigation")
                .navigationCheckpoint(KnownCheckpoints.home)
        }
    }
}

Once defined, they're easy to use.

Button("Return To Checkpoint Home") {
    navigator.returnToCheckpoint(KnownCheckpoints.home)
}
.disabled(!navigator.canReturnToCheckpoint(KnownCheckpoints.home))

When fired, checkpoints will dismiss any presented screens and pop any pushed views to return exactly to the point desired.

One might ask why we needed to add <Void> to our original checkpoint definition?

That's because checkpoints can also be used to return values to a caller!

// Define a checkpoint with an Int value handler.
extension KnownCheckpoints {
    public static var settings: NavigationCheckpoint<Int> { checkpoin

Related Skills

View on GitHub
GitHub Stars523
CategoryCustomer
Updated4d ago
Forks36

Languages

Swift

Security Score

100/100

Audited on Mar 30, 2026

No findings