Skip to content

About

Type-safe declarative routing for SwiftUI — router-driven navigation and sheet presentation

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

English | 日本語

UIRouting

Type-safe routing for SwiftUI: navigation, sheets, covers, alerts, tabs, and split views behind one pattern.

Swift Platforms License

Features

  • 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 write id, ==, or hash(into:)
  • One pattern for all of it — NavigationStack, Sheet, FullScreenCover, CustomHeightSheet, Alert, TabView, NavigationSplitView

Basic Usage

1. Describe the destinations

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)
            ]
        }
    }
}

2. Set up

// 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.

3. Use in views

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") }) }
    }
}

TabView

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"))
}

Links from outside the app

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:))
    }

Modal Presentations

FullScreenCover

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)

CustomHeightSheet

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)

NavigationSplitView

2-column (sidebar + detail)

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])

3-column (sidebar + list + detail)

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 }
    }
}

Documentation

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).

Installation

// 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.

Requirements

  • iOS 18.0+ / macOS 15.0+
  • Swift 6.0+

License

MIT License — see LICENSE for details.

About

Type-safe declarative routing for SwiftUI — router-driven navigation and sheet presentation

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages