A type-safe, lightweight, and elegant navigation routing solution for SwiftUI applications.
Router simplifies navigation in SwiftUI by providing a centralized, type-safe routing system that eliminates boilerplate code and makes navigation logic clear and maintainable.
Instead of managing multiple @State bindings and navigation presentation logic throughout your views, Router provides a single source of truth for all navigation events.
Traditional SwiftUI navigation often leads to:
- Scattered State Management: Navigation state spread across multiple
@Stateproperties - Complex Binding Logic: Manually managing presentation bindings for sheets, alerts, and navigation destinations
- Type Safety Issues: String-based or loosely-typed routing prone to runtime errors
- Boilerplate Code: Repetitive presentation logic duplicated across views
- Sequential Presentation Bugs: Issues when presenting multiple alerts or sheets in sequence
It's surprising that Apple hasn't addressed these obvious presentation issues with a built-in single source of truth for navigation. Router fills this gap as the missing piece of the SwiftUI ecosystem.
- Design Rationale for better understanding the basic idea behind Router package
- Using Router in subviews
- Universal links support
✨ Type-Safe Routing - Define routes as strongly-typed enums or structs conforming to Routable
🎯 Single Source of Truth - One Router instance manages all navigation state
🔄 Multiple Presentation Types - Support for:
- NavigationStack destinations
- Tabs
- Sheets
- Full-screen covers
- Alerts
- Confirmation dialogs
- Custom presentations
Add Router to your project using Xcode:
- File > Add Package Dependencies
- Enter the package URL:
https://github.com/claustrofob/Router.git - Select your version requirements
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/claustrofob/Router.git", from: "1.0.0")
]Create routes as enums or structs conforming to Routable:
import Router
enum AppRoute: String, Routable {
var id: String { rawValue }
case profile
case settings
case about
}Initialize a Router instance in your view:
import SwiftUI
import Router
struct ContentView: View {
@State private var router = Router()
var body: some View {
// Your view content
}
}Use the .route() modifier to handle navigation:
var body: some View {
NavigationStack {
Button("Go to Profile") {
router.show(AppRoute.profile)
}
.route(AppRoute.self, in: router, presentationType: .navigationStack) { route in
switch route {
case .profile:
ProfileView()
case .settings:
SettingsView()
case .about:
AboutView()
}
}
}
}Important
One Router instance is designed to manage navigation for one screen. Do not pass the same Router instance across different screens in your app. Each screen should have its own Router. However, you can freely share a Router instance among subviews within the same screen using @Environment or direct property passing.
Navigate between views in a navigation hierarchy:
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack {
List {
Button("View Details") {
router.show(DetailRoute(id: "123"))
}
}
.navigationTitle("Home")
.route(DetailRoute.self, in: router, presentationType: .navigationStack) { route in
DetailView(id: route.id)
}
}
}
}
struct DetailRoute: Routable {
var id: String
}Present content as a sheet:
struct ContentView: View {
@State private var router = Router()
var body: some View {
Button("Show Settings") {
router.show(SettingsRoute())
}
.route(SettingsRoute.self, in: router, presentationType: .sheet) { _ in
SettingsView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}
struct SettingsRoute: Routable {
var id: String { "settings" }
}Display alerts with type-safe message handling:
struct ContentView: View {
@State private var router = Router()
var body: some View {
Button("Show Alert") {
router.show(AlertRoute(message: "Are you sure?"))
}
.alertRoute(
AlertRoute.self,
in: router,
actionsContent: { _ in
Button("Confirm", role: .destructive) {
// Handle confirmation
router.dismiss()
}
Button("Cancel", role: .cancel) {
router.dismiss()
}
}
)
}
}
struct AlertRoute: Routable, MessageAwareProtocol {
var id: String { message }
let message: String
}Show action sheets with multiple options:
struct ContentView: View {
@State private var router = Router()
var body: some View {
Button("Choose City") {
router.show(CountrySelectionRoute())
}
.alertRoute(
CountrySelectionRoute.self,
in: router,
presentationType: .confirmation,
actionsContent: { _ in
ForEach(Country.allCases, id: \.self) { country in
Button(country.rawValue) {
router.show(AlertRoute(message: "You chose \(country.rawValue)"))
}
}
}
)
.alertRoute(
AlertRoute.self,
in: router
)
}
}
struct CountrySelectionRoute: Routable, MessageAwareProtocol {
var id: String { "countrySelection" }
var message: String { "Choose your destination:" }
}
struct AlertRoute: Routable, MessageAwareProtocol {
var id: String { message }
let message: String
}
enum Country: String, CaseIterable {
case poland = "Poland"
case germany = "Germany"
case france = "France"
case italy = "Italy"
}Manage tabs:
struct ContentView: View {
@State private var router = TabRouter()
var body: some View {
RoutableTabView(router: router) { tab in
tab.register(
ProfileRoute(),
label: { Label("Profile", systemImage: "person.crop.circle") }
) { route in
NavigationStack {
ProfileView()
}
}
tab.register(
SettingsRoute(),
label: { Label("Settings", systemImage: "gear") }
) { route in
NavigationStack {
SettingsView()
}
}
}
}
}
struct ProfileRoute: Routable {
var id: String { "profile" }
}
struct SettingsRoute: Routable {
var id: String { "settings" }
}Router supports custom transitions over UIKit. Just implement CustomPresentationTransitionDelegateFactory
and use .custom presentationType:
struct ContentView: View {
@State private var router = Router()
var body: some View {
Button("Show Custom") {
router.show(CustomRoute())
}
.route(
CustomRoute.self,
in: router,
presentationType: .custom { dismissAction in
MyCustomPresentationProvider(onDismiss: dismissAction)
}
) { _ in
CustomView()
}
}
}A complete example with custom presentation is in the RouterAppExample.
Compose multiple navigation flows seamlessly:
struct ContentView: View {
@State private var router = Router()
var body: some View {
NavigationStack {
Button("Start Journey") {
router.show(CityRoute(city: .paris))
}
.route(CityRoute.self, in: router, presentationType: .sheet) { route in
CityView(city: route.city)
}
.route(CityGuideRoute.self, in: router, presentationType: .navigationStack) { route in
CityGuideView(city: route.city)
}
.alertRoute(ConfirmationRoute.self, in: router)
}
}
}The .routeScope() modifier is the key to properly managing router instances across your navigation hierarchy. It creates a new Router instance and injects it into the SwiftUI environment for a specific page.
Important principles:
- One Router per Page: Each presented or pushed screen should have its own
Routerinstance - Shared within a Page: All subviews within the same page share the same router via
@Environment(Router.self) - Scope Boundaries: Use
.routeScope()when navigating to a new page to create a fresh router for that destination
struct ContentView: View {
var body: some View {
NavigationStack {
ProfileView()
.routeScope() // Creates a new router for the profile page
}
}
}Now within a single page, subviews access the same router from the environment:
struct ProfileView: View {
@Environment(Router.self) var router
var body: some View {
VStack {
ProfileHeaderView() // Shares the same router
ProfileActionsView() // Shares the same router
Button("Show Alert") {
router.show(AlertRoute(message: "Profile page"))
}
}
.alertRoute(AlertRoute.self, in: router)
}
}
struct ProfileHeaderView: View {
@Environment(Router.self) var router // Same router instance
var body: some View {
Button("Edit Profile") {
router.show(AlertRoute(message: "Profile updated"))
}
}
}
struct ProfileActionsView: View {
@Environment(Router.self) var router // Same router instance
var body: some View {
Button("Settings") {
router.show(AlertRoute(message: "Profile settings updated"))
}
}
}Why Route Scope Matters:
- Isolation: Each page's navigation state is isolated and won't interfere with parent or sibling pages
- Memory Management: Router instances are automatically cleaned up when their associated views are dismissed
- Clarity: Makes navigation boundaries explicit in your code
- Predictability: Prevents navigation state conflicts between different parts of your app
Tip
When in doubt, follow this rule: If you're navigating to a new full screen (push, sheet, or full screen cover), add .routeScope() to the destination view. If you're just splitting up a single screen into reusable subviews, share the router via @Environment.
Tip
You can follow the rule to never explicitely create Router instances but always use .routeScope() modifier and Environment. If the same view can be embeded as subview or presented as a new page .routeScope() will allow to define a new scope or use existing one.
Create rich route types with metadata protocols:
struct ErrorRoute: Routable, TitleAwareProtocol, MessageAwareProtocol {
var id: String { message }
var title: String? { "Error Occurred" }
let message: String
}
// Automatically uses title and message
view.alertRoute(ErrorRoute.self, in: router)// From anywhere with access to the router
router.dismiss()
// Or use the standart environment value in your destination view
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("Close") {
dismiss()
}
}
}if let profileRoute = router.item(as: ProfileRoute.self) {
// Currently showing a profile route
print("Viewing profile: \(profileRoute.userId)")
}Router is a good friend of Coordinator pattern. Check RouterAppExample for "Coordinator pattern example one" and "Coordinator pattern example two"
struct AppCoordinator: View {
@Environment(Router.self) var router
var body: some View {
RootView(output: .init(didSelectProfile: {
router.show(ProfileRoute())
}))
.route(ProfileRoute.self, in: router, presentationType: .navigationStack) { _ in
ProfileCoordinator()
.routeScope()
}
}
}Router uses a simple yet powerful architecture:
Router: An@Observableclass that holds the current route itemRoutable: A protocol requiringHashableandIdentifiable<String>- View Modifiers: SwiftUI extensions that bind router state to presentation APIs
The router automatically handles:
- State synchronization between multiple presentation types
- Sequential alert/sheet presentation edge cases
- Dismissal coordination
- Type-safe route matching
struct ContentView: View {
@State private var showProfile = false
@State private var showSettings = false
@State private var showAlert = false
@State private var alertMessage = ""
@State private var selectedCity: City?
var body: some View {
// Complex binding management
ZStack {
Button(action: {
showSettings = false
showAlert = false
selectedCity = nil
showProfile = true
}) {
Text("Open profile")
}
}
.sheet(isPresented: $showProfile) {
ProfileView()
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.alert(alertMessage, isPresented: $showAlert) { }
.sheet(item: $selectedCity) { city in
CityView(city: city)
}
}
}struct ContentView: View {
@State private var router = Router()
var body: some View {
ZStack {
Button(action: {
// no need to dismiss previous route
router.show(ProfileRoute())
}) {
Text("Open profile")
}
}
.route(ProfileRoute.self, in: router, presentationType: .sheet) { _ in
ProfileView()
}
.route(SettingsRoute.self, in: router, presentationType: .sheet) { _ in
SettingsView()
}
.alertRoute(AlertRoute.self, in: router)
.route(CityRoute.self, in: router, presentationType: .sheet) { route in
CityView(city: route.city)
}
}
}
struct ProfileRoute: Routable {
var id: String { "profile" }
}
struct SettingsRoute: Routable {
var id: String { "settings" }
}
struct CityRoute: Routable {
let city: City
var id: String { city.rawValue }
}
struct ProfileView: View {
var body: some View {
Text("Profile")
}
}or
struct ContentView: View {
@State private var router = Router()
var body: some View {
ZStack {
Button(action: {
// no need to dismiss previous route
router.show(SheetRoute.profile)
}) {
Text("Open profile")
}
}
.route(SheetRoute.self, in: router, presentationType: .sheet) { route in
switch route {
case .profile: ProfileView()
case .settings: SettingsView()
case let .city(city): CityView(city: city)
}
}
.alertRoute(AlertRoute.self, in: router)
}
}
enum SheetRoute: Routable {
var id: String {
switch self {
case .profile: return "profile"
case .settings: return "settings"
case let .city(city): return "city_\(city.rawValue)"
}
}
case profile
case settings
case city(City)
}- iOS 17.0+
- Swift 5.9+
- Xcode 15.0+
Contributions are welcome! Please feel free to submit a Pull Request.
Router is available under the MIT license. See the LICENSE file for details.
Created by Mikalai Zmachynski
Built with modern SwiftUI patterns and the @Observable macro for optimal performance and developer experience.