SwiftUI Routes centralizes navigation destinations so you can describe navigation by path strings or strongly typed values.
Start by creating a Routes
instance and registering destinations. Registrations accept either a resource path (string) or a Routable
value.
let routes = Routes()
routes.register(path: "/album/:id") { route in
AlbumView(id: route.param("id") ?? "unknown")
}
routes.register(type: Album.self) { album in
AlbumDetailView(album: album)
}
- Path registrations use URL-style patterns. The closure receives a
Route
so you can pull out parameters or query items withroute.param(_:)
orroute.params
. - Type registrations work with any
Routable
. Conforming types define how to turn a value into the resource path that should be presented.
struct Album: Routable {
var id: String
var route: Route { Route("/album/\(id)") }
}
Attach your routes to a NavigationStack
by keeping a RoutePath
binding. The modifier installs every registered destination and exposes the binding through EnvironmentValues.routePath
.
struct AppScene: View {
private let routes = Routes()
@State private var path = RoutePath()
init() {
register(routes: routes)
}
var body: some View {
NavigationStack(path: $path) {
HomeView()
.routesDestination(routes: routes, path: $path)
}
}
}
// Expose routes registration in your package (optional)
public func register(routes: Routes) {
routes.register(path: "/album/:id") { route in
AlbumView(id: route.param("id") ?? "unknown")
}
routes.register(type: Album.self) { album in
AlbumDetailView(album: album)
}
}
Views inside the stack can push routes directly or use the provided view modifiers.
struct HomeView: View {
@Environment(\.routePath) private var path
var body: some View {
VStack(spacing: 24) {
Button("Album (123)") {
path.push("/album/123")
}
Button("Featured Album") {
path.push(Album(id: "featured"))
}
Text("Tap to open Latest")
.push(Album(id: "123"), style: .tap)
}
}
}
The push(_:style:)
modifier wraps any view in a navigation trigger while still using the same registrations.
Reuse the same routes for modal sheets by keeping a Routable?
binding and attaching routesSheet
.
struct ContentView: View {
private let routes = Routes()
@State private var path = RoutePath()
@State private var sheet: Routable?
init() {
register(routes: routes)
}
var body: some View {
NavigationStack(path: $path) {
HomeView()
.routesDestination(routes: routes, path: $path)
.routesSheet(routes: routes, item: $sheet, path: $path)
}
}
}
func register(routes: Routes) {
routes.register(path: "/album/:id") { route in
AlbumView(id: route.param("id") ?? "unknown")
}
routes.register(type: Album.self) { album in
AlbumDetailView(album: album)
}
}
When routesSheet
is present you can present any registered destination with the same APIs used for stacks.
struct HomeView: View {
@Environment(\.routeSheet) private var sheet
var body: some View {
VStack(spacing: 24) {
Button("Preview Album") {
sheet.wrappedValue = Route("/album/123")
}
Text("Show Album")
.sheet(Album(id: "123"))
}
}
}
- iOS 17.0+ / macOS 15.0+
- Swift 6.0+
Add the dependency to your Package.swift
:
dependencies: [
.package(url: "https://github.com/gabriel/swiftui-routes", from: "0.2.1")
]
.target(
dependencies: [
.product(name: "SwiftUIRoutes", package: "swiftui-routes"),
]
)
Share a single Routes
instance across packages without creating cyclical dependencies by letting each package contribute its own registrations. The app owns the Routes
instance and passes it to package-level helpers that fill in the routes it knows about.
import PackageA
import PackageB
import SwiftUI
import SwiftUIRoutes
public struct ExampleView: View {
private let routes = Routes()
@State private var path = RoutePath()
public init() {
PackageA.register(routes: routes)
PackageB.register(routes: routes)
}
public var body: some View {
NavigationStack(path: $path) {
List {
Button("Package A (Type)") {
path.push(PackageA.Value(text: "Hello World!"))
}
Button("Package A (Path)") {
path.push("/package-a/value", params: ["text": "Hello!"])
}
Button("Package B (Type)") {
path.push(PackageB.Value(systemImage: "heart.fill"))
}
Button("Package B (Path)") {
path.push("/package-b/value", params: ["systemName": "heart"])
}
}
.routesDestination(routes: routes, path: $path)
.navigationTitle("Example")
}
}
}
Each package exposes a simple register(routes:)
entry point so it never needs to import another package’s views.
import SwiftUI
import SwiftUIRoutes
public func register(routes: Routes) {
routes.register(type: Value.self) { value in
PackageBView(value: value)
}
routes.register(path: "/package-b/value") { route in
PackageBView(value: Value(systemImage: route.params["systemName"] ?? "heart.fill"))
}
}
This keeps navigation declarative and avoids mutual dependencies between packages because the shared Routes
instance lives in the root target while features register themselves.*** End Patch