English | 日本語
Type-safe routing for SwiftUI: navigation, sheets, covers, alerts, tabs, and split views behind one pattern.
- Type-safe — every destination is an enum case, checked at compile time
- Reachable from anywhere — routers and presenters live in
@Environment, so a button several levels down can navigate without the views above it knowing - Closures in payloads — a case can carry a callback and still be
Hashable; you never writeid,==, orhash(into:) - One pattern for all of it — NavigationStack, Sheet, FullScreenCover, CustomHeightSheet, Alert, TabView, NavigationSplitView
enum AppRoute: Routable {
case detail(id: String)
@ViewBuilder
var body: some View { DetailView(id: id) }
}
enum AppSheet: Sheetable {
case settings
@ViewBuilder
var body: some View { SettingsView() }
}
enum AppAlert: Alertable {
case delete(onConfirm: () -> Void)
var title: String { "Delete?" }
var message: String? { nil }
var actions: [AlertAction] {
switch self {
case .delete(let onConfirm):
return [
AlertAction(title: "Cancel", role: .cancel) {},
AlertAction(title: "Delete", role: .destructive, action: onConfirm)
]
}
}
}// Create the router and presenters once and inject them.
@main
struct MyApp: App {
@State private var router = Router<AppRoute>()
@State private var sheetPresenter = SheetPresenter<AppSheet>()
@State private var alertPresenter = AlertPresenter<AppAlert>()
var body: some Scene {
WindowGroup {
ContentView()
.routing(
router: router,
sheetPresenter: sheetPresenter,
alertPresenterOnNavigation: alertPresenter,
alertPresenterOnSheet: AlertPresenter<AppAlert>()
)
}
}
}
// The routing scope gives you the NavigationStack.
struct ContentView: View {
var body: some View {
HomeView()
.routingScope(for: AppRoute.self, alert: AppAlert.self)
}
}routing(...) publishes each presenter and attaches the sheet (and cover, and custom-height
sheet) it drives, so there is no .sheet(item:) to write.
Two alert presenters, not one: SwiftUI cannot raise an alert from a view a sheet already covers, so the sheet layer gets its own.
struct HomeView: View {
@Environment(.router(AppRoute.self)) private var router
@Environment(.sheet(AppSheet.self)) private var sheetPresenter
@Environment(.alert(AppAlert.self, context: .navigation)) private var alertPresenter
var body: some View {
Button("Detail") { router.navigate(to: .detail(id: "123")) }
Button("Settings") { sheetPresenter.present(.settings) }
Button("Delete") { alertPresenter.present(.delete { print("deleted") }) }
}
}Each tab keeps its own router, so a user can leave one tab deep in a stack, visit another, and come back to where they were.
enum AppTab: Tabbable {
case home, settings
typealias Route = AppRoute
typealias Sheet = AppSheet
@ViewBuilder
var contentView: some View {
switch self {
case .home: HomeView()
case .settings: SettingsView()
}
}
@ViewBuilder
var tabLabel: some View {
switch self {
case .home: Label("Home", systemImage: "house")
case .settings: Label("Settings", systemImage: "gearshape")
}
}
}
@State private var tabPresenter = TabPresenter(initialTab: AppTab.home)
TabRouting(tabPresenter: tabPresenter, tabs: [.home, .settings])Switching tab and pushing in one gesture — the callback runs once that tab's router exists, so a
push asked for before the tab first appears waits instead of being dropped. The lookup is nil
outside TabRouting (in a preview, say):
@Environment(.tab(AppTab.self)) private var tabPresenter
tabPresenter?.select(.home) { context in
context.router.navigate(to: .detail(id: "123"))
}URLs, notifications, widgets, and App Intents all land on a TabDestination — a tab plus the
screens to stack on its root. The app owns what its URLs mean; the presenter opens the result and
waits for the tab's router if it is not there yet. Keep the presenter at app level so entry points
that fire before the first frame can reach it.
enum AppLink {
static func destination(for url: URL) -> TabDestination<AppTab>? {
guard url.host() == "detail" else { return nil }
return TabDestination(tab: .home, path: [.detail(id: url.lastPathComponent)])
}
}
TabRouting(tabPresenter: tabPresenter, tabs: [.home, .settings])
.onOpenURL { url in
tabPresenter.open(url, resolve: AppLink.destination(for:))
}Presented as an ordinary sheet on macOS, which has no full-screen cover.
enum AppFullScreenCover: FullScreenCoverable {
case camera
case editor(id: String)
@ViewBuilder
var body: some View {
switch self {
case .camera: CameraView()
case .editor(let id): EditorView(id: id)
}
}
}
// Inject it with the full routing overload, which also attaches the cover.
ContentView()
.routing(
router: router,
sheetPresenter: sheetPresenter,
customHeightSheetPresenter: CustomHeightSheetPresenter<Never>(),
fullScreenCoverPresenter: fullScreenCoverPresenter,
alertPresenterOnNavigation: AlertPresenter<AppAlert>(),
alertPresenterOnSheet: AlertPresenter<AppAlert>(),
splitViewPresenter: SplitViewPresenter<Never>()
)
@Environment(.fullScreenCover(AppFullScreenCover.self)) private var presenter
presenter.present(.camera)Each case declares the heights it rests at, so the call site never mentions detents.
enum AppCustomSheet: CustomHeightSheetable {
case picker
case quickAdd
@ViewBuilder
var body: some View {
switch self {
case .picker: PickerView()
case .quickAdd: QuickAddView()
}
}
var detents: Set<PresentationDetent> {
switch self {
case .picker: return [.medium, .large]
case .quickAdd: return [.height(200)]
}
}
}
// Or, without `routing(...)`: this modifier creates the presenter, publishes it on the layer
// you name, and attaches the sheet. Apply it to the parent of the view that reads it.
ContentView()
.customHeightSheetPresenter(for: AppCustomSheet.self, context: .navigation)
// Inside ContentView — the same layer as above.
@Environment(.customHeightSheet(AppCustomSheet.self)) private var presenter
presenter.present(.picker)enum Sidebar: String, SidebarItem {
case inbox, sent
typealias DetailRoute = MailRoute
var id: String { rawValue }
var label: some View { Label("Inbox", systemImage: "tray") }
var detail: some View { InboxView() }
}
@State private var presenter = SplitViewPresenter<Sidebar>(initialSelection: .inbox)
SplitViewRouting(splitViewPresenter: presenter, items: [.inbox, .sent])The middle and detail columns each get their own router, so a push in one leaves the other alone.
enum Sidebar: String, SidebarItem {
case inbox
typealias ContentItem = Email // selected in the middle column
typealias ContentRoute = FilterRoute // pushes inside the middle column
typealias DetailRoute = MailRoute // pushes inside the detail column
var id: String { rawValue }
var label: some View { Label("Inbox", systemImage: "tray") }
var contentView: some View { MailListView() }
var detail: some View { MailDetailView() }
}
@State private var presenter = SplitViewPresenter<Sidebar>(initialSelection: .inbox)
ThreeColumnSplitViewRouting(splitViewPresenter: presenter, items: [.inbox])The middle column's selection binding is installed for you — hand it straight to List:
@Environment(.selectedContentBinding(Email.self)) private var selectedContentBinding
List(selection: selectedContentBinding) {
ForEach(emails) { email in
NavigationLink(value: email) { email.label }
}
}Full API reference and guides: no-problem-dev.github.io/swift-ui-routing
Runnable apps: TodoExample (navigation, sheets, alerts, tabs, covers) and MailExample (3-column split view).
// Package.swift
dependencies: [
.package(url: "https://github.com/no-problem-dev/swift-ui-routing.git", from: "3.0.0")
]Or in Xcode: File > Add Package Dependencies, then enter the URL.
- iOS 18.0+ / macOS 15.0+
- Swift 6.0+
MIT License — see LICENSE for details.