A modern, type-safe navigation library built entirely on SwiftUI's NavigationStack. NavigationPilot gives you a clean, router-style API — push, pop, popTo, replace — without UIKit, without type erasure, and without writing the same boilerplate across every project.
- Enum-driven routes — define every screen in one place
- Typed parameters — pass data through routes with compile-time guarantees
- Callback routes — carry closures as route parameters for decoupled navigation
push— push one or multiple routes at oncepop— pop the top screen, or popnscreens in one callpopTo— jump directly to any ancestor without stepping back manuallypopToRoot— clear the stack down to the root in one callreplace— replace the entire stack, perfect for post-action flowsreplaceCurrent— swap the top screen without changing stack depth
- Gesture sync — swipe-back updates the pilot's stack automatically via a
Binding - No UIKit — built entirely on
NavigationStackand SwiftUI state
- Native logging — pass
debug: truetoNavPilotto enable internalOSLog-based navigation logs
- Debug overlay — pass
showsStackInspector: truetoNavPilotHostto preview the live stack in development
- URL round-trip — generate and restore stack state for
Codableroutes using a lightweight URL format
- Opt-in restore — pass
persistState: trueforCodableroutes to save and reload the stack automatically
@EnvironmentObject— every child view receives the pilot automatically- No prop drilling — navigate from anywhere in the view hierarchy
- Multiple stacks — create independent pilots for split-screen or multi-pane layouts
- Scoped environments — each pilot injects into its own
NavPilotHostsubtree
- ~150 lines of library code across two files
- Zero third-party dependencies — only Apple frameworks
I have also written a detailed article on NavigationPilot explaining the design decisions and every pattern in depth. You can read it here: Type-Safe SwiftUI Navigation: Building a Better NavigationStack with NavigationPilot
| Ex1 — Push / Pop | Ex2 — Parameters | Ex3 — Callback | Ex4 — Split Screen |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
- Xcode 15.0+ or later
- iOS 16.0+ deployment target
- macOS 15.0+ or later (for development)
In Xcode: File → Add Package Dependencies and enter:
https://github.com/DKVekariya/NavigationPilot
Or add it directly to your Package.swift:
dependencies: [
.package(url: "https://github.com/DKVekariya/NavigationPilot", from: "1.0.0")
]NavigationPilot/
├── Sources/NavPilot/
│ ├── NavPilot.swift # Observable router — push, pop, replace
│ └── NavPilotHost.swift # Root view + NavigationStack binding
├── Examples/NavPilotDemo/
│ └── NavPilotDemo/
│ ├── ContentView.swift # Switch between the demo scenarios
│ ├── E1SimplePush.swift # push / pop / popTo / popToRoot
│ ├── E2PushWithData.swift # Typed struct params, replace
│ ├── E3CloserPass.swift # Closure as route parameter
│ └── E4SplitScreen.swift # Two independent pilots, vertical split
├── Tests/NavPilotTests/
│ └── NavPilotTests.swift # Core router behavior tests
├── Package.swift
└── README.md
// Every screen is a case. Parameters are typed — the compiler
// will reject the wrong type at the call site.
enum AppRoute: Hashable {
case home
case detail(id: Int)
case settings
}@main
struct MyApp: App {
@StateObject var pilot = NavPilot(initial: AppRoute.home, persistState: true)
var body: some Scene {
WindowGroup {
NavPilotHost(pilot) { route in
switch route {
case .home: HomeView()
case .detail(let id): DetailView(id: id)
case .settings: SettingsView()
}
}
}
}
}// The pilot is injected automatically — no passing through init.
struct HomeView: View {
@EnvironmentObject var pilot: NavPilot<AppRoute>
var body: some View {
VStack {
Button("Open Detail") { pilot.push(.detail(id: 42)) }
Button("Settings") { pilot.push(.settings) }
}
.navigationTitle("Home")
}
}Carry closures as route parameters to fully decouple destination views from navigation logic:
enum AppRoute: Hashable {
case profile(onSignOut: () -> Void)
// Implement Hashable manually for closure-carrying cases
func hash(into hasher: inout Hasher) { hasher.combine("profile") }
static func == (lhs: AppRoute, rhs: AppRoute) -> Bool { true }
}
// SignInView defines the navigation intent at the push call site
pilot.push(.profile(onSignOut: {
pilot.popTo(.home)
}))
// ProfileView has zero knowledge of NavPilot
struct ProfileView: View {
let onSignOut: () -> Void
var body: some View {
Button("Sign Out", role: .destructive) { onSignOut() }
}
}Perfect for post-action flows where the back-stack should differ from the forward path:
// Before: [home → cart → checkout]
// After: [home → confirmation]
// Swipe-back from confirmation now goes to home, not checkout.
pilot.replace([.home, .confirmation])For Codable routes, you can generate a deep-link URL for the current stack and restore it later:
let url = pilot.deepLinkURL()
pilot.handleDeepLink(url)When you want the stack to survive app relaunches, opt in with persistState: true:
@StateObject var pilot = NavPilot(initial: AppRoute.home, persistState: true)- Single
@Publishedarray owns all navigation state public private(set)enforces that only the pilot mutates the stack- All operations are safe by default — no guard calls needed at the call site
- Root is rendered as the
NavigationStackcontent view - Tail (
stack.dropFirst()) is bound to thepathparameter - Swipe-back gesture updates the binding;
syncTailreconstructs the full stack
- Each
NavPilotHostinjects its typed pilot into its own subtree NavPilot<FeedRoute>andNavPilot<ChatRoute>resolve independently- No conflicts in nested or split-screen layouts
| Method | Description |
|---|---|
push(_ route) |
Push one route onto the stack |
push(_ routes...) |
Push multiple routes in one call |
pop() |
Pop the top route. No-op at root |
pop(count: n) |
Pop n routes at once, clamped to root |
popTo(_ route) |
Pop back to the first occurrence of a route |
popToLast(_ route) |
Pop back to the last occurrence of a route |
popToRoot() |
Clear everything above the root |
replace(_ routes) |
Replace the entire stack |
replaceCurrent(with:) |
Swap only the top-most route |
deepLinkURL() |
Generate a URL that represents the current stack |
handleDeepLink(_:) |
Restore the stack from a deep-link URL |
persistState |
Opt-in initializer behavior for Codable routes that stores the stack |
| Property | Type | Description |
|---|---|---|
stack |
[T] |
Read-only live stack. Index 0 is always root |
current |
T? |
Route at the top of the stack |
depth |
Int |
Number of screens in the stack |
Open Examples/NavPilotDemo/NavPilotDemo/ContentView.swift and change navType to switch between examples:
// ▼ Change this number to switch examples ▼
private let activeExample: Int = 1| # | Example | Demonstrates |
|---|---|---|
| 1 | Simple Push / Pop | push, pop, popTo, popToRoot |
| 2 | Typesafe Parameters | Typed struct params, replace, replaceCurrent |
| 3 | Callback Route | Closure passed as a route parameter |
| 4 | Split Screen | Two independent pilots in a vertical split layout |
| 5 | Deep Linking | Generate and restore a stack from a URL |
| 6 | State Persistence | Save and restore stack across app relaunch |
| 7 | Stack Inspector | Show the live route stack as an overlay |
- iOS 16+ / macOS 13+ — requires
NavigationStack, which is not available on earlier OS versions - No animation customisation — transitions are handled by
NavigationStacknatively - No tab bar integration — tab selection is a separate concern; manage it independently
- No undo/redo — not in scope for a routing primitive
Divyesh Vekariya
- GitHub: @DKVekariya
- Medium: @dkvekariya
- daily.dev: @divyesh_vekariya
- Twitter: @D_K_Vekariya
- LinkedIn: Divyesh Vekariya
- Inspired by UIPilot by Canopas
- Built on Apple's
NavigationStackintroduced in iOS 16 - Special thanks to the SwiftUI community
MIT. See LICENSE for details.
Built with ❤️ using SwiftUI
Last Updated: April 2026




