New in v2.0: Deep linking is now optional!
- Without deep links: Just conform to
DestinationType- no Codable needed - With deep links: Conform to
DeepLinkableDestination- keeps Codable + URL methods - Mixed scenarios: Use two enums - internal routes + deep link routes
See MIGRATION_V2.md for upgrade guide.
A simple router for SwiftUI iOS 18+ with support for:
- stacks for each tab (
NavigationStack), - deeplinks with stable segments,
- navigation policies (
replace,append,replaceTop), - multi-layer
sheetandfullScreenCoverstacks, - navigation interceptors for auth guards, analytics, and middleware,
- MVVM-friendly via a thin adapter.
This is an independent implementation that fixes common shortcomings: unstable
Sheet.id, incorrect builder URL, missingtabparameter in deeplink, one-step deep stack building, etc.
- Download the archive or copy the
AppRouterPlusfolder into your project. - In Xcode:
File → Add Packages… → Add Local…and specify the path to this folder.
- iOS 18+
- Swift 6 (
swift-tools-version: 6.0)
- Declare types:
enum AppTab: String, TabType, CaseIterable { case home, profile }
// Option 1: Simple destinations (no deep links)
enum Destination: DestinationType {
case home
case detail(viewModel: DetailViewModel) // Can use ViewModels!
}
// Option 2: With deep links (add Codable + URL methods)
enum Destination: DeepLinkableDestination {
case home
case detail(id: String)
static func path(for d: Destination) -> String {
switch d { case .home: "home"; case .detail: "detail" }
}
static func from(path: String, fullPath: [String], parameters: [String:[String]]) -> Destination? {
switch path {
case "home": return .home
case "detail":
guard let id = parameters["id"]?.last else { return nil }
return .detail(id: id)
default: return nil
}
}
}
enum Sheet: String, SheetType { case settings; var id: String { rawValue } } // stable ID- Mount the router at the root and connect with
TabViewandNavigationStack:
@State var router = Router<AppTab, Destination, Sheet>(initialTab: .home)
TabView(selection: $router.selectedTab) {
NavigationStack(path: router.binding(for: .home)) { /* ... */ }
.tabItem { Label("Home", systemImage: "house") }.tag(AppTab.home)
NavigationStack(path: router.binding(for: .profile)) { /* ... */ }
.tabItem { Label("Profile", systemImage: "person") }.tag(AppTab.profile)
}
.sheet(item: router.activeSheetBinding) { sheet in /* build sheet */ }
.fullScreenCover(item: router.activeFullScreenCoverBinding) { sheet in /* build full-screen cover */ }
.onOpenURL { url in _ = router.navigate(to: url) } // deeplink- Inject a thin adapter into the VM:
@MainActor final class RouterAdapter {
private let router: Router<AppTab, Destination, Sheet>
init(_ router: Router<AppTab, Destination, Sheet>) { self.router = router }
// Sync navigation (no interceptors)
func showDetail(_ id: String) { router.navigateTo(.detail(id: id), policy: .append) }
// Async navigation (with interceptors)
func showProfile(_ id: String) async {
await router.navigateToAsync(.profile(userId: id))
}
func showSettings() { _ = router.presentSheet(.settings) }
func showOnboarding() { _ = router.presentFullScreenCover(.onboarding) }
}- Any URL in format
scheme://<segment>/<segment>?tab=<tab>&key=value. - Parameters are parsed as a multi-map:
[String:[String]](duplicate keys supported). - Stable segments are generated via
Destination.path(for:). - Builder available:
URLNavigationHelper.build(scheme:tab:destinations:extraQuery:).
// Example: myapp://home/detail?id=123&tab=profile
.onOpenURL { url in _ = router.navigate(to: url) }router.navigateTo(.detail(id: "123"), policy: .append)
router.navigateTo([.home, .detail(id: "1")], policy: .replace)
router.popNavigation()
router.popToRoot()
router.popTo { $0 == .home }- Supports stack of sheets: top is the last element.
- Public binding
activeSheetBindingworks conveniently with.sheet(item:).
.sheet(item: router.activeSheetBinding) { sheet in /* build sheet view */ }
router.presentSheet(.settings)
router.dismissSheet()
router.dismissSheets(count: 2)- Supports stack of full-screen covers: top is the last element.
- Public binding
activeFullScreenCoverBindingworks with.fullScreenCover(item:). - Use for immersive experiences: onboarding, login, camera, etc.
.fullScreenCover(item: router.activeFullScreenCoverBinding) { sheet in /* build full-screen view */ }
router.presentFullScreenCover(.onboarding)
router.dismissFullScreenCover()
router.dismissFullScreenCovers(count: 2)
router.dismissFullScreenCovers(to: .onboarding) // dismiss until targetInterceptors allow you to run middleware-style hooks before navigation happens. Use cases include:
- Auth guards - block navigation to protected screens
- Analytics tracking - log all navigation events
- Unsaved changes warnings - prompt before leaving
- Feature flags - conditionally enable/disable routes
- Rate limiting - prevent rapid navigation spam
// 1. Implement the NavigationInterceptor protocol
struct AuthInterceptor: NavigationInterceptor {
func shouldNavigate(from: Destination?, to: Destination) async -> Bool {
// Check if destination requires authentication
if case .profile = to {
let isLoggedIn = await AuthManager.shared.isLoggedIn
if !isLoggedIn {
// Block navigation and show login
return false
}
}
return true // Allow navigation
}
}
// 2. Add interceptors to router
router.addInterceptor(AuthInterceptor())
router.addInterceptor(AnalyticsInterceptor())
// 3. Use async navigation methods (required for interceptors)
await router.navigateToAsync(.profile(userId: "42"))- Interceptors run in the order they were added
- All interceptors must return
truefor navigation to proceed - If any interceptor returns
false, navigation is blocked - Interceptors run only for
navigateToAsync()methods (not syncnavigateTo())
struct AnalyticsInterceptor: NavigationInterceptor {
func shouldNavigate(from: Destination?, to: Destination) async -> Bool {
await Analytics.track(.screenView(to))
return true // Never blocks navigation
}
}// In your ViewModel/Adapter:
func openProfile() async {
let success = await router.navigateToAsync(.profile)
if !success {
// Navigation was blocked by interceptor
router.presentSheet(.login)
}
}// Add interceptors
router.addInterceptor(AuthInterceptor())
router.addInterceptor(AnalyticsInterceptor())
// Remove all interceptors (useful for testing)
router.removeAllInterceptors()See the Examples/ folder:
SimpleNavigationExample.swift- Internal-only navigation (no deep links, ViewModels in destinations)URLRoutingExample.swift- Deep link navigation with URL parsing
- ViewModels do not depend on SwiftUI. They call protocol/adapter with
go/present/popmethods. - For cross-tab navigation, change
selectedTabbeforenavigateTointo the target stack.
MIT. You may freely use, modify, and embed this package in your projects.