Type-safe, data-driven navigation for SwiftUI. One source of truth. Zero external dependencies.
Features • Quickstart • Architecture • API Cheat Sheet • Advanced Recipes • Example App • Docs & Wiki • Contributing • License
Minimums declared in Package.swift: iOS 17+, macOS 14+. tvOS / watchOS / visionOS compile via conditional code paths (full screen cover applied only where supported).
SwiftUI's built-in navigation is powerful, but state tends to sprawl across views. BaseNavigationStack unifies push, sheet, and full screen cover transitions under a single, observable router — keeping navigation predictable, testable, and safe under concurrency.
| Aspect | Vanilla SwiftUI Navigation | BaseNavigationStack 2.0 |
|---|---|---|
| State ownership | Scattered @State / bindings across views |
Single Source of Truth via NavigationRouter |
| Modal + Stack | Managed separately with easy desync | Unified path, sheet, cover in one router |
| Type-safety | Manual navigationDestination wiring, error-prone routes |
Strongly typed Route, Sheet, Cover (Hashable & Sendable) |
| Race conditions | Double-taps can push duplicates | Auto-debounce guards against immediate duplicate pushes |
| Deep linking / programmatic nav | Hard to coordinate across the tree | Centralized API: push, pop, popToRoot, present, dismiss, replacePath |
| Concurrency | Easy to call from wrong isolation | Swift 6 ready — @MainActor + send(_:) for safe cross-actor dispatch |
| Testability | Often requires UI tests | Headless-testable — inspect pure [Route] arrays with no UI overhead |
| Nested flows | Complex and easy to leak state | Isolated sub-routers per sheet/tab with independent lifecycles |
┌─────────────────────────────────────────────────────────────────────────────┐
│ BaseNavigationContainer │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │
│ │ NavigationStack│ │ navigationDestination │ │ sheet(item:) │ │ fullScreenCover │ │
│ │ (path) │ │ (Route) │ │ (Sheet) │ │ (Cover, iOS/tvOS/ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │ visionOS/watchOS)│ │
└─────────────────────────────────────────────────────────────────────────────┘
│ injects .environment(router)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ NavigationRouter<Route, Sheet, Cover> │
│ path: [Route] │ sheet: Sheet? │ cover: Cover? │ maxDepth: Int │
│ push / pop / popToRoot / replacePath / present / dismiss / dismissModals │
│ send(_:) for actor-safe dispatch │ debounce + depth guard │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
Any View via @Environment
Flow: Route Push → Debounce/Depth Guard → NavigationStack → navigationDestination → Sheet / FullScreenCover → Deep Link via central router/state.
- Swift 6 Strict Concurrency — Fully
@MainActor,Sendable-compliant, ready for complete concurrency checking. - Zero External Dependencies — Built on native SwiftUI,
Observation, and Foundation only. - Unified Multi-Modal Routing — Manage stack, sheet, and full screen cover from one
@Observablerouter. - Auto-Debounce Rapid Taps — Prevents duplicate pushes when the same route hits back-to-back.
- Stack Depth Protection —
maxDepthguard (default30) helps avoid layout recursion/memory pressure. - Actor-Safe Navigation —
send(_:)lets you dispatch navigation safely fromTask.detached, background actors, or async callbacks. - Nested & Tabbed Navigation — Spin up isolated routers per tab or inside sheets for independent flows.
- Headless Unit-Testable — Mutate router state directly in tests; assert on
[Route], modals, and transitions. - Cross-Platform Aware — Works across iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+. Full-screen covers are applied on platforms that support them.
- Backward Compatible (v1.x) —
BaseNavigationStack<ScreenView>remains supported with fixes fordismiss(),replaceNavigationStack(path:), and@Bindablecompatibility.
Swift Package Manager
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/iletai/BaseNavigationStack.git", from: "2.0.0")
],
targets: [
.target(name: "YourTarget", dependencies: ["BaseNavigationStack"])
]Or in Xcode: File > Add Package Dependencies... and paste the repo URL.
import Foundation
enum AppRoute: Hashable, Sendable {
case detail(id: Int)
case profile(userID: String)
}
struct AppSheet: Identifiable, Hashable, Sendable {
let id: String
}
struct AppCover: Identifiable, Hashable, Sendable {
let id: String
}
typealias AppRouter = NavigationRouter<AppRoute, AppSheet, AppCover>import SwiftUI
import BaseNavigationStack
struct RootView: View {
@State private var router = AppRouter()
var body: some View {
BaseNavigationContainer(
router: router,
content: {
HomeView()
},
destination: { route in
switch route {
case .detail(let id):
DetailView(id: id)
case .profile(let userID):
ProfileView(userID: userID)
}
},
sheet: { sheet in
LoginSheet(id: sheet.id)
},
cover: { cover in
OnboardingCover(id: cover.id)
}
)
}
}Already have a
NavigationStack? Use the modifier instead:.navigationRouter(router, destination:sheet:cover:)to attach routing to your existing hierarchy. See Advanced Recipes.
import SwiftUI
import BaseNavigationStack
struct HomeView: View {
@Environment(AppRouter.self) private var router
var body: some View {
VStack(spacing: 16) {
Button("Push Detail") { router.push(.detail(id: 42)) }
Button("Present Sheet") { router.present(sheet: AppSheet(id: "login")) }
Button("Present Full Screen Cover") { router.present(cover: AppCover(id: "onboarding")) }
Button("Pop") { router.pop() }
Button("Pop to Root") { router.popToRoot() }
Button("Dismiss (Sheet/Cover/Stack)") { router.dismiss() }
}
.navigationTitle("Home")
}
}Keep each tab's stack independent. Dismissing/modals in one tab won't affect others.
struct TabRootView: View {
@State private var homeRouter = AppRouter()
@State private var searchRouter = AppRouter()
var body: some View {
TabView {
BaseNavigationContainer(router: homeRouter) {
HomeView()
} destination: { HomeDestination($0) }
.tabItem { Label("Home", systemImage: "house") }
BaseNavigationContainer(router: searchRouter) {
SearchView()
} destination: { SearchDestination($0) }
.tabItem { Label("Search", systemImage: "magnifyingglass") }
}
}
}Perfect for multi-step flows inside a modal without polluting the parent stack.
struct FlowSheetView: View {
@State private var subRouter = AppRouter()
@Environment(AppRouter.self) private var parentRouter
var body: some View {
BaseNavigationContainer(
router: subRouter,
content: { SheetStartView() },
destination: { route in
switch route {
case .detail(let id): SheetDetailView(id: id)
case .profile(let userID): SheetProfileView(userID: userID)
}
}
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { parentRouter.dismiss() }
}
}
}
}Use send(_:) to navigate safely from async work or background actors.
func handleDeepLink(_ url: URL) async {
// Parse route from URL...
let route = AppRoute.detail(id: 123)
await MainActor.run {
router.push(route)
}
// Or from a detached task/actor:
Task.detached {
router.send { r in
r.popToRoot()
r.push(.profile(userID: "u_123"))
}
}
}struct CustomRootView: View {
@State private var router = AppRouter()
var body: some View {
NavigationStack(path: $router.path) {
ContentView()
.navigationRouter(
router,
destination: { route in DestinationView(route: route) },
sheet: { sheet in SheetView(sheet: sheet) },
cover: { cover in CoverView(cover: cover) }
)
}
}
}| Method / Property | Type | Description | Complexity |
|---|---|---|---|
init(initialPath:initialSheet:initialCover:maxDepth:) |
Initializer | Create router with optional initial state. maxDepth clamped to >= 1 (default 30). |
O(1) |
path |
[Route] |
Active navigation stack. Bind to NavigationStack(path:). |
O(1) access |
sheet |
Sheet? |
Currently presented sheet. | O(1) |
cover |
Cover? |
Currently presented full screen cover. | O(1) |
maxDepth |
Int |
Maximum allowed stack depth. push is a no-op if path.count >= maxDepth. |
O(1) |
push(_:) |
func(Route) |
Push route if different from last and under maxDepth (auto-debounce). |
O(1) |
pop() |
func() |
Pop top route if exists. | O(1) |
pop(count:) |
func(Int) |
Safely pop up to count routes. |
O(k), k = removed |
popToRoot() |
func() |
Clear path and dismiss all modals. | O(n) path clear |
replacePath(_:) |
func([Route]) |
Replace stack with new path (truncated to maxDepth), clears modals. |
O(n) |
present(sheet:) |
func(Sheet) |
Present a sheet. | O(1) |
present(cover:) |
func(Cover) |
Present a full screen cover. | O(1) |
dismiss() |
func() |
Dismiss ladder: sheet → cover → pop top route. No-op on empty stack. | O(1) |
dismissModals() |
func() |
Clear sheet/cover without touching stack. | O(1) |
send(_:) |
nonisolated func(@MainActor (NavigationRouter) -> Void) |
Dispatch navigation from any actor/context via Task { @MainActor in ... }. |
O(1) dispatch |
| Initializer | Notes |
|---|---|
init(router:content:destination:sheet:cover:) |
Full container: wraps NavigationStack, navigationDestination, .sheet, .fullScreenCover (platform-aware), injects .environment(router). |
init(router:content:destination:) where SheetContent == EmptyView, CoverContent == EmptyView |
Stack-only convenience initializer. |
| Overload | Notes |
|---|---|
navigationRouter(_:destination:sheet:cover:) |
Attach routing to an existing view/hierarchy. Use inside your own NavigationStack(path:). Injects .environment(router). |
navigationRouter(_:destination:) |
Stack-only convenience overload. |
| Method | Notes |
|---|---|
pushToView(_:) |
Debounced push with duplicate prevention. |
popBack() |
Safe pop via removeLastSafe(). |
navigateToRoot() |
Clears modals and stack. |
replaceNavigationStack(path:) |
Replaces path and clears modals. |
presentSheet(_:) / presentFullScreen(_:) |
Modal presentation. |
dismiss() |
Sheet → FullScreen → Stack → parent binding fallback. |
handleOpenURL(url:) |
Route via optional urlHandler. |
Explore a live, interactive showcase in NavigationStackExample/.
Tabs included:
- Stack Explorer — Push/pop, breadcrumb trail, depth counter, present sheet/cover, pop to root.
- Async & Concurrency Lab — Background async navigation via
send(_:), rapid-tap stress test to verify debounce/guards.
Run it in Xcode by opening NavigationStackExample/NavigationStackExample.xcodeproj or via the scheme in your workspace.
Run the full test suite:
# With SwiftPM
swift test
# With coverage
swift test --enable-code-coverage
# iOS Simulator (example from CI)
SIM_ID=$(xcrun simctl list devices available | grep -E "iPhone" | head -1 | grep -o -E "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}")
xcodebuild test -scheme BaseNavigationStack -destination "id=$SIM_ID" CODE_SIGNING_ALLOWED=NOCoverage includes: push/pop/popToRoot/replace, duplicate-debounce, dismiss ladder, stack depth guard, async send from background, nested sub-routers, tab isolation, container/modifier initialization.
- Design Specs:
docs/superpowers/specs/— Container, async ergonomics, performance goals. - Implementation Plans:
docs/superpowers/plans/— Step-by-step rollout notes. - GitHub Wiki: BaseNavigationStack Wiki — Extended guides, migration notes, and FAQs (if enabled).
- Issues: Report bugs / request features
- Discussions: Community Q&A
Contributions are welcome!
- Fork the repo and create a feature branch off
develop. - Follow style — Match existing Swift style; CI runs
swiftlint --strict. - Test — Add/update tests for behavior changes. Run
swift testlocally. - Commit — Use clear, conventional commits (
feat:,fix:,refactor:,test:,docs:). - Open a PR — Target
developwith a concise description and test notes.
See .github/PULL_REQUEST_TEMPLATE.md and .github/ISSUE_TEMPLATE/ for details.
Released under the MIT License. Copyright (c) 2023 Tài Lê.