Contents

ricocrescenzio95/managednavigation

SwiftUI's `NavigationStack` and `NavigationPath` give you the building blocks for programmatic navigation, but they leave significant gaps when building real apps:

Why ManagedNavigation?

SwiftUI's NavigationStack and NavigationPath give you the building blocks for programmatic navigation, but they leave significant gaps when building real apps:

  • NavigationPath is opaque — you can append and remove items, but you can't inspect the stack content. Want to know what's at position 2? Or find the last occurrence of a certain screen? You can't.
  • No typed pop operations — there's no popTo(SettingsScreen.self) or popToFirst(HomeScreen.self). You can only removeLast(_:) by count, so you need to track positions yourself.
  • No pop-by-predicate — navigating back to "the first screen where condition X is true" requires manual bookkeeping.
  • No unified modal managementNavigationPath drives push navigation only. Sheets and full-screen covers use separate @State bindings with no coordination.
  • No state persistence out of the boxNavigationPath.CodableRepresentation exists but only encodes opaque data. Restoring a path requires re-registering every type, and there's no way to inspect what was saved.
  • Deep links are fragile — pushing multiple destinations at once works, but coordinating that with modals or knowing exactly where you are in the stack is left entirely to you.

ManagedNavigation solves all of this with a thin layer that keeps a typed [any NavigationDestination] array in sync with NavigationPath, giving you full visibility and control over your navigation state.

Features

  • Typed navigation path — inspect any destination by index, type-check, cast, iterate
  • Push single or multiple destinations in one call
  • Pop to typepopTo(HomeDestination.self) pops to the last occurrence
  • Pop to firstpopToFirst(HomeDestination.self) pops to the first occurrence
  • Pop by predicatepopTo(where: { $0.destination is DetailsDestination }) for custom logic
  • Pop by indexpopTo(at: 2) with optional type safety via popTo(HomeDestination.self, at: 2)
  • Replace in placereplace(ProfileDestination(), at: 1) swaps a destination while preserving the rest of the stack
  • Modal presentations — sheets and full-screen covers driven by the same path, with nested presentation support
  • Disable animationswithoutAnimation { } for instant navigation (state restoration, deep links)
  • State persistenceCodable representation that preserves type information, encode/decode with any encoder
  • Environment-based proxy — child views navigate via @Environment(\.navigator) without coupling to the manager

Installation

ManagedNavigation can be installed using Swift Package Manager.

  1. In Xcode open File/Swift Packages/Add Package Dependency... menu.
  1. Copy and paste the package URL:
https://github.com/ricocrescenzio95/ManagedNavigation

For more details refer to Adding Package Dependencies to Your App documentation.

Usage

Navigation Stack

Use ManagedNavigationStack to wrap SwiftUI's NavigationStack with a typed, programmatic path:

@State var manager = NavigationManager()

ManagedNavigationStack(manager: $manager) {
    Button("Go to Details") {
        manager.push(DetailsDestination(id: "abc"))
    }
    .navigationDestination(for: DetailsDestination.self) { destination in
        DetailsView(id: destination.id)
    }
}

Pop Operations

This is where ManagedNavigation shines — operations that SwiftUI simply doesn't offer:

// Pop to the last occurrence of a type
manager.popTo(HomeDestination.self)

// Pop to the first occurrence
manager.popToFirst(HomeDestination.self)

// Pop with a custom predicate
manager.popTo(where: { context in
    context.destination is DetailsDestination && context.index > 0
})

// Pop to a specific index (with type safety)
manager.popTo(HomeDestination.self, at: 2)

// Inspect the stack at any time
for (i, destination) in manager.path.enumerated() {
    print("\(i): \(destination.navigationID)")
}

Replace Operations

Swap a destination at a specific index while keeping the rest of the stack intact:

// Stack: [Home, Details, Settings]
manager.replace(ProfileDestination(), at: 1)
// Stack: [Home, Profile, Settings]

Modal Presentations

Use ManagedPresentation to manage sheets and full-screen covers with the same path-driven approach — no more juggling separate @State booleans:

@State var manager = NavigationManager()

ManagedPresentation(manager: $manager) {
    Button("Open Settings") {
        manager.push(SettingsDestination())
    }
    .sheet(for: SettingsDestination.self) { _ in
        SettingsView()
    }
    .fullScreenCover(for: AccountDestination.self) { _ in
        AccountView()
    }
}

Presentations nest automatically. If SettingsView registers its own .sheet(for:), pushing the corresponding destination presents it on top:

// Push a chain of modals in one call
manager.push([
    SettingsDestination(),
    NotificationsDestination(),
])
// Result: Settings sheet appears, then Notifications sheet on top

Destinations

Define your destinations by conforming to NavigationDestination. Add Codable for state persistence:

struct DetailsDestination: NavigationDestination, Codable {
    var id: String
}

State Persistence

Save and restore the entire navigation state — something that requires significant boilerplate with vanilla NavigationPath:

// Save
if let codable = manager.codable {
    let data = try JSONEncoder().encode(codable)
    UserDefaults.standard.set(data, forKey: "savedPath")
}

// Restore
if let data = UserDefaults.standard.data(forKey: "savedPath"),
   let codable = try? JSONDecoder().decode(
       NavigationManager.CodableRepresentation.self, from: data
   ) {
    manager = NavigationManager(codable)
}

Disable Animations

Use withoutAnimation to perform navigation changes instantly — useful for state restoration or deep-link handling:

manager.withoutAnimation {
    $0.push([
        HomeDestination(),
        DetailsDestination(id: "abc"),
    ])
}

Child views can do the same through the navigator:

navigator?.withoutAnimation {
    $0.popToRoot()
}

Navigator

Child views can access navigation through the environment without any coupling to the manager:

struct ChildView: View {
    @Environment(\.navigator) private var navigator

    var body: some View {
        Button("Pop to root") {
            navigator?.popToRoot()
        }
        Button("Pop to Settings") {
            navigator?.popTo(SettingsDestination.self)
        }
    }
}

For advanced usages, please refer to the full Documentation.

Limitations

  • NavigationLink is not supported. ManagedNavigation takes full control of the navigation path — using NavigationLink(value:) will push items directly onto NavigationPath and bypass the typed tracking, causing the internal state to go out of sync. Always use manager.push() or navigator?.push() instead.
  • watchOS is not supported. The modal presentation system relies on UIViewControllerRepresentable (iOS/tvOS/visionOS) or NSViewControllerRepresentable (macOS) to detect when a modal has finished presenting. watchOS has no equivalent API.

Documentation

Use Apple DocC generated documentation, from Xcode, Product > Build Documentation.

Found a bug or want new feature?

If you found a bug, you can open an issue as a bug here

Want a new feature? Open an issue here

You can also open your own PR and contribute to the project! Contributing

License

This software is provided under the MIT license

Package Metadata

Repository: ricocrescenzio95/managednavigation

Default branch: main

README: README.md