Navigator Navigation Framework

repository·main·Indexed 20 days ago

https://github.com/hmlongco/navigator

An advanced navigation framework for SwiftUI built on NavigationStack. It provides coordination patterns, deep linking via 'navigation send', and modular support through NavigationProvidedDestination. Key features include ManagedNavigationStack for automatic destination mapping, navigation checkpoints for returning to specific stack locations, and support for both declarative and imperative navigation. Requires iOS 17+ and the Observation framework.

Tokens
14.8K
Snippets
40
Records
51
Agent score
67%

What's inside Navigator

  1. Overview of Navigator

    main

    Navigator is an advanced navigation layer for SwiftUI built on top of NavigationStack. It is designed to provide more than simple push/pop functionality, offering features for coordination patterns, modular application support, and deep linking.

    Key capabilities include:

    • Simple view linking and presentation.
    • Coordination patterns with separation of concerns.
    • Cross-module navigation for modular apps.
    • Deep linking and internal navigation via navigation send.
    • Navigation checkpoints for returning to specific tree locations or retrieving callback values.
    • Support for both Declarative and Imperative navigation.
    • Navigation state restoration.
    • Event logging and debugging.
    • No manual navigationDestination registration required.
  2. Handle Dependencies in Advanced Destinations

    main

    If your NavigationDestination views require external dependencies or @Environment values, do not construct the views directly in the enum's body. Instead, delegate the view construction to a separate View struct. This allows you to use standard SwiftUI patterns like @Environment to resolve dependencies before initializing the final view.

    nonisolated public enum HomeDestinations: NavigationDestination {
        case page2
        
        public var body: some View {
            HomeDestinationsView(destination: self)
        }
    }
    
    private struct HomeDestinationsView: View {
        let destination: HomeDestinations
        @Environment(\.homeDependencies) var resolver // Resolve dependencies here
    
        var body: some View {
            switch destination {
            case .page2:
                HomePage2View(viewModel: HomePage2ViewModel(dependencies: resolver))
            // ...
            }
        }
    }
  3. When to use a NavigationFlow vs a Checkpoint

    main

    Choosing between a NavigationFlow and a Checkpoint depends on the complexity of the interaction:

    Use a Checkpoint when:

    • You have a simple two-screen interaction.
    • You only need to hand a single value back to a previous screen.
    • You want a lighter, more direct mechanism for typed returns.

    Use a NavigationFlow when:

    • The sequence accumulates state across three or more steps.
    • The same sequence needs to be reachable from multiple different entry points in the application.
    • The sequence concludes with an effect that requires the accumulated state from all steps to be available in one place.
  4. Use NavigationDestinations as standalone Views

    main

    Because NavigationDestination enums implement a body property that returns a View, you can treat an enum case as a fully resolved view. This is useful for embedding feature views directly into other views without manually managing their dependencies or view models.

    Simply drop the enum case into your view hierarchy. The NavigationDestination logic will handle the construction of the underlying view and its required dependencies.

    struct RootHomeView: View {
        var body: some View {
            ManagedNavigationStack {
                // This evaluates the enum case to obtain a fully resolved view
                HomeDestinations.home
                    .navigationDestination(HomeDestinations.self)
            }
        }
    }
  5. Use NavigationProvidedDestination for modular navigation

    main

    In modular applications, a shared module might define navigation destinations (e.g., SharedDestinations) that need to navigate to views located in other modules (e.g., Orders or Products). Since the shared module cannot import those specific view modules, it cannot provide the view bodies in its NavigationDestination implementation.

    To solve this, conform your destination enumeration to NavigationProvidedDestination instead of NavigationDestination. This tells Navigator that the view for these destinations will be provided by the application at runtime rather than being hardcoded in the destination itself.

    // In a Shared module that doesn't know about specific Views
    nonisolated public enum SharedDestinations: NavigationProvidedDestination {
        case newOrder
        case orderDetails(Order)
        case produceDetails(Product)
    }
  6. When to use Checkpoints vs Dismissal

    main

    Dismissal is an imperative and often fragile operation because it depends on the specific structure of how views were presented.

    Best Practice: If you are in a child view and simply want to return to a previous state, use Checkpoints instead of attempting to dismiss views. Dismissal should be reserved for scenarios like deep-linking or cross-module navigation where you need to clear the entire navigation stack to reach a known state.

  7. Use NavigationViewProviding for explicit dependency injection

    main

    For scenarios where you want to avoid the 'magic' of automatic lookup and prefer explicit dependency injection, you can use the NavigationViewProviding protocol.

    1. Define a protocol that requires a property conforming to NavigationViewProviding<D> where D is your destination type.
    2. The application implements this protocol, returning a NavigationViewProvider that maps destinations to views.
    3. The consuming module accesses the view via the injected provider.
    // 1. Define the requirement in the module
    public protocol HomeDependencies {
        @MainActor var homeExternalViewProvider: any NavigationViewProviding<HomeExternalViews> { get }
    }
    
    // 2. The destination type
    nonisolated public enum HomeExternalViews: NavigationViews {
        case external
    }
    
    // 3. The Application provides the implementation
    public class AppResolver: HomeDependencies {
        @MainActor public var homeExternalViewProvider: any NavigationViewProviding<HomeExternalViews> {
            NavigationViewProvider { destination in
                switch destination {
                case .external: SettingsDestinations.external
                }
            }
        }
    }
    
    // 4. The module consumes it
    @Environment(\.homeDependencies) var resolver
    // ...
    resolver.homeExternalViewProvider.view(for: .external)
  8. Resolve cross-module view dependencies via Dependency Injection

    main

    You can use NavigationDestination to bridge dependencies between modules. A feature module can define a requirement for an external view via its dependency resolver, while the main application (which has access to all modules) provides the actual implementation.

    1. Feature Module: Defines a dependency protocol and a NavigationDestination that calls a method on that protocol.
    2. App Layer: Implements the protocol by reaching into other modules and returning their views (often using .asAnyView()).
    // 1. Feature module defines the requirement
    private struct HomeDestinationsView: View {
        let destination: HomeDestinations
        @Environment(\.homeDependencies) var resolver
        var body: some View {
            switch self {
            case .external:
                resolver.externalView() // Calls the protocol method
            ... 
            }
        }
    }
    
    // 2. App layer implements the cross-module bridge
    class AppResolver: AppDependencies {
        @MainActor func externalView() -> AnyView {
            // Reach out to the settings module to provide the view
            SettingsDestinations.external.asAnyView()
        }
    }
  9. Expose modular features without exposing implementation details

    main

    To maintain strict modularity, you can expose a module's feature views through a NavigationDestination enum. This allows other modules to use the feature's views without having a direct dependency on the views themselves or knowing how they are constructed.

    Other modules only interact with the NavigationDestination enum. This pattern, combined with Navigator's ability to avoid explicit navigationDestination registrations, allows for seamless cross-module navigation.

    // In the Order Module
    public enum OrderDestinations: NavigationDestination {
        case orderSummaryCard(Order)
        case order(Item)
        case listPastOrders
    
        public var body: some View {
            OrderDestinationsView(destination: self)
        }
    }
    
    // In a consuming module
    struct CustomView: View {
        @State var order: Order
        var body: some View {
            VStack {
                // The consumer uses the enum case as a view without knowing the underlying View type
                OrderDestinations.orderSummaryCard(order)
            }
        }
    }
  10. Implement a Navigation Flow with the NavigationFlow protocol

    main

    Use the NavigationFlow protocol to manage complex, multi-step sequences like onboarding wizards or multi-step forms. A flow is a struct that acts as both the state container (holding data collected during the sequence) and the state machine (deciding the next step).

    To implement a flow, you must define:

    1. An associated Destination: NavigationDestination enum where each case carries the current flow state.
    2. A checkpoint property of type NavigationFlowCheckpoint<Value>? to manage return points.
    3. A start() method to define the first step.
    4. A next() method (async throws) that inspects the current state and returns a FlowResult.

    Because flows are structs, they use value semantics. Each step receives a copy of the flow, mutates it, and passes the updated copy back to the navigator.

    @MainActor
    public protocol NavigationFlow: Hashable {
        associatedtype Destination: NavigationDestination
        associatedtype Value
    
        var checkpoint: NavigationFlowCheckpoint<Value>? { get set }
    
        func start() -> FlowResult<Self>
        func next() async throws -> FlowResult<Self>
    
        func onComplete()
        func onComplete(_ value: Value)
        func onCancel()
        func onError(_ error: Error)
    }
  11. How the Navigation Tree works in Navigator

    main

    Navigator organizes navigation through a hierarchical tree of Navigator instances.

    • ManagedNavigationStack: Each instance creates its own Navigator and installs it into the SwiftUI environment. This allows views contained within that stack to access the specific navigator responsible for that stack.
    • Roots: The application typically starts with a 'root' Navigator configured at the app level. This root passes configuration to its children and enables communication across different branches (e.g., between tabs).
    • Hierarchy: When a new ManagedNavigationStack or ManagedPresentationView is created, it retrieves the current Navigator from the environment and sets it as its 'parent', effectively growing the tree.

    This tree structure allows independent navigation contexts. For example, in a TabView, each tab has its own ManagedNavigationStack and its own Navigator. A view in Tab 2 will only affect Tab 2's stack because it communicates with Tab 2's specific navigator, not the root or Tab 3's navigator.

    // Example of a root Navigator configuration
    func applicationNavigator() -> Navigator {
        let configuration: NavigationConfiguration = .init(
            restorationKey: nil,
            executionDelay: 0.4,
            verbosity: .info
        )
        return Navigator(configuration: configuration)
    }