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
@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
NavigationPilotHostsubtree
- ~100 lines of library code across two files
- Zero dependencies — only imports SwiftUI
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 Ventura 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/NavigationPilot/
│ ├── NavigationPilot.swift # Observable router — push, pop, replace
│ └── NavigationPilotHost.swift # Root view + NavigationStack binding
├── Examples/NavigationPilotExamples/
│ ├── NavigationPilotExamplesApp.swift # Change activeExample to 1–4
│ ├── DesignSystem.swift # Shared UI components
│ ├── Example1_SimplePushPop.swift # push / pop / popTo / popToRoot
│ ├── Example2_TypesafeParameters.swift # Typed struct params, replace
│ ├── Example3_CallbackRoute.swift # Closure as route parameter
│ └── Example4_SplitScreen.swift # Two independent pilots, vertical split
├── Tests/NavigationPilotTests/
│ └── NavigationPilotTests.swift # 18 unit 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 = NavigationPilot(initial: AppRoute.home)
var body: some Scene {
WindowGroup {
NavigationPilotHost(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: NavigationPilot<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 NavigationPilot
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])- 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
NavigationPilotHostinjects its typed pilot into its own subtree NavigationPilot<FeedRoute>andNavigationPilot<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 |
popToRoot() |
Clear everything above the root |
replace(_ routes) |
Replace the entire stack |
replaceCurrent(with:) |
Swap only the top-most route |
| 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 NavigationPilotExamplesApp.swift and change activeExample 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 |
- 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
- 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




