Programmatic SwiftUI navigation: routes as types (Routable), stack in Router, shell in NavigationHost.
iOS 15+ · Swift 6
| iOS | Mechanism |
|---|---|
| 16+ | NavigationStack + navigationDestination |
| 15 | NavigationView + hidden NavigationLink chain |
packages:
NavigationKit:
path: ../NavigationKit
targets:
MyApp:
dependencies:
- package: NavigationKit
# previews / tests only:
MyAppPreviews:
dependencies:
- package: NavigationKit
product: NavigationKitMocks| Product | Purpose |
|---|---|
NavigationKit |
Router, NavigationHost, protocols |
NavigationKitMocks |
MockNavigation for SwiftUI Previews |
App entry
└── NavigationHost(router:)
├── rootView ← SplashRouter.makeView()
└── stack[] ← push: MainRouter, PaywallRouter, …
| Type | Role |
|---|---|
Routable |
Hashable + makeView() — one screen |
Navigable |
route / routeBack / replaceRoot / routeToRoot |
Router |
Stack + root, ObservableObject |
*Router (in app) |
Knows destinations; holds Navigable |
DI, deeplinks, and splash logic stay in the app target, not in this package.
import NavigationKit
import SwiftUI
@MainActor
final class AppRouter: Router {
init(appDelegate: AppDelegate) {
super.init(root: SplashRouter(navigation: self))
setupDeeplinks(appDelegate)
}
}
extension AppRouter: Navigable {
// route(to:), routeBack(), … inherited from Router
}Router already conforms to Navigable. Subclass for deeplinks, Factory, etc.
import NavigationKit
import SwiftUI
@MainActor
final class SplashRouter {
private let navigation: Navigable
init(navigation: Navigable) {
self.navigation = navigation
}
func routeToMain() {
navigation.replaceRoot(with: MainRouter(navigation: navigation))
}
}
extension SplashRouter: Routable {
func makeView() -> AnyView {
let router = self
let feature = SplashFeature(router: router)
return AnyView(SplashView(feature: feature))
}
}
extension SplashRouter: Equatable {
static func == (lhs: SplashRouter, rhs: SplashRouter) -> Bool {
lhs === rhs
}
func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}For class routers,
Hashableis usuallyObjectIdentifier(self).
@MainActor
final class MainRouter {
private let navigation: Navigable
init(navigation: Navigable) {
self.navigation = navigation
}
func showPaywall() {
navigation.route(to: PaywallRouter(navigation: navigation))
}
}
extension MainRouter: Routable {
func makeView() -> AnyView {
AnyView(MainView(feature: MainFeature(router: self)))
}
}
extension MainRouter: Equatable {
static func == (lhs: MainRouter, rhs: MainRouter) -> Bool { lhs === rhs }
func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) }
}@MainActor
final class PaywallRouter: Routable {
private let navigation: Navigable
init(navigation: Navigable) {
self.navigation = navigation
}
func close() {
navigation.routeBack()
}
func makeView() -> AnyView {
AnyView(PaywallView(feature: PaywallFeature(router: self)))
}
}import NavigationKit
import SwiftUI
@main
struct MainApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
private let router = AppRouter(appDelegate: AppDelegate.shared)
var body: some Scene {
WindowGroup {
NavigationHost(
router: router,
preferredColorScheme: .dark,
navigationBarColorScheme: .dark,
hidesTabBar: true
)
}
}
}// Push onto the stack
navigation.route(to: PaywallRouter(navigation: navigation))
// Pop one screen
navigation.routeBack()
// Clear stack (stay on current root)
navigation.routeToRoot()
// Replace root (e.g. splash → main)
navigation.replaceRoot(with: MainRouter(navigation: navigation))All methods update router.stack. On iOS 16+, NavigationHost syncs an internal NavigationPath.
import NavigationKit
import NavigationKitMocks
import SwiftUI
#Preview {
let navigation = MockNavigation()
let router = SplashRouter(navigation: navigation)
return SplashView(feature: SplashFeature(router: router))
}MockNavigation is a no-op — useful for layout without a real stack.
NavigationHost(
router: router,
preferredColorScheme: .dark, // optional
navigationBarColorScheme: .dark, // iOS 16+: toolbarColorScheme; iOS 15: UINavigationBar
hidesTabBar: true // UITabBar.appearance().isHidden
)struct SettingsRoute: Routable {
let navigation: Navigable
func makeView() -> AnyView {
AnyView(SettingsView(onClose: { navigation.routeBack() }))
}
}
// Hashable is synthesized when all stored properties are Hashable@MainActor
final class AppRouter: Router {
private var deeplinkService: DeeplinkService
init(deeplinkService: DeeplinkService) {
self.deeplinkService = deeplinkService
super.init(root: SplashRouter(navigation: self))
observeDeeplinks()
}
private func observeDeeplinks() {
Task { [weak self] in
for await url in deeplinkService.urls {
self?.handle(url)
}
}
}
private func handle(_ url: URL) {
switch url.host {
case "paywall":
route(to: PaywallRouter(navigation: self))
default:
break
}
}
}| iOS 15 | iOS 16+ | |
|---|---|---|
| Container | NavigationView |
NavigationStack |
| Programmatic push | hidden NavigationLink |
stack → NavigationPath |
| Swipe back | isActive → trim stack |
path.count → trimStack |
| App API | same Navigable |
same Navigable |
Stack behavior on iOS 15 is slightly less flexible for deep nesting; typical route / routeBack / replaceRoot flows work well.
public typealias Routable = ViewFactory & Hashable
public protocol ViewFactory {
@MainActor func makeView() -> AnyView
}
public protocol Navigable: AnyObject {
@MainActor func route(to route: any Routable)
@MainActor func replaceRoot(with route: any Routable)
@MainActor func routeBack()
@MainActor func routeToRoot()
}| Member | Description |
|---|---|
stack |
Current push stack ([AnyRoutable]) |
rootView |
Root AnyView |
init(root:) |
Initial route |
setRoot(_:) |
Update root view |
Type eraser for NavigationStack / navigationDestination(for:).
public final class MockNavigation: Navigable
// + replace(with:) — no-op, for legacy compatibility// SplashRouter builds Feature + View
func makeView() -> AnyView {
AnyView(SplashView(feature: SplashFeature(router: self)))
}
// Feature triggers navigation
func onLoaded() {
router.routeToMain()
}FeatureKit and NavigationKit do not depend on each other.
NavigationKit/
├── Package.swift
└── Sources/
├── NavigationKit/
│ ├── Routable.swift
│ ├── AnyRoutable.swift
│ ├── Router.swift
│ ├── NavigationHost.swift
│ ├── ModernNavigationHost.swift # iOS 16+
│ └── LegacyNavigationHost.swift # iOS 15
└── NavigationKitMocks/
└── MockNavigation.swift
- Add the package in
project.yml. import NavigationKitin routers andMainApp.- Subclass
RouterasAppRouter, or composeRouterand forwardNavigable. - Replace
AppNavigationViewwithNavigationHost(router: appRouter, ...). - Remove local duplicates of
AnyRoutableand push logic thatRouteralready provides.