Departure is a lightweight, expressive routing framework for SwiftUI.
It lets visible views declare the routes they own, then lets any active route ask Departure to present a route by type. Departure finds the closest visible owner and uses SwiftUI presentation APIs such as pushes, sheets, and full-screen covers.
routing(.present(SettingsRoute()))The point is to keep navigation local without forcing every feature to receive a pile of callbacks.
Departure is available via Swift Package Manager, and requires Swift 6.2.
dependencies: [
.package(url: "https://github.com/mtzaquia/departure.git", from: "1.0.0"),
],Use WithRouter near the root of your SwiftUI tree:
@main
struct ExampleApp: App {
var body: some Scene {
WindowGroup {
WithRouter {
NavigationStack {
HomeView()
}
}
}
}
}Note
Departure installs presentation handlers from .routes { ... }, but push presentation still needs a NavigationStack somewhere above the declaring view.
A route is a value that builds its destination.
struct SettingsRoute: Route {
func destination() -> some View {
SettingsView()
}
}Routes are matched by type. Two SettingsRoute() values target the same declaration.
Attach routes to the view scope that owns them:
struct HomeView: View {
@Environment(\.routing) private var routing
var body: some View {
Button("Settings") {
routing(.present(SettingsRoute()))
}
.routes {
Sheet(SettingsRoute.self)
}
}
}When SettingsRoute() is requested, Departure starts from the active route and looks backward through visible route scopes until it finds the nearest scope that declared SettingsRoute.self.
Use Push, Sheet, or Cover inside .routes { ... }.
.routes {
Push(ProfileRoute.self)
Sheet(SettingsRoute.self)
Cover(OnboardingRoute.self)
}| Declaration | SwiftUI behavior | Notes |
|---|---|---|
Push(Route.self) |
navigationDestination(item:) |
Requires a NavigationStack above the scope. |
Sheet(Route.self) |
sheet(item:) |
Wraps the destination in a NavigationStack by default. |
Cover(Route.self) |
fullScreenCover(item:) on UIKit, sheet(item:) fallback elsewhere |
Wraps the destination in a NavigationStack by default. |
Disable the automatic navigation wrapper for sheets and covers when the destination provides its own container:
.routes {
Sheet(EditorRoute.self, providesNavigation: false)
Cover(PlayerRoute.self, providesNavigation: false)
}Route requests are resolved by visible ownership.
flowchart TD
request["routing(.present(ReceiptRoute()))"]
active["Start at active route scope"]
owns{"Did this scope declare ReceiptRoute?"}
previous["Move to previous visible owner"]
present["Present from matching scope"]
drop["Drop request"]
request --> active --> owns
owns -- yes --> present
owns -- no --> previous --> owns
previous -- no owner left --> drop
That gives feature views a simple rule:
Declare the routes your feature can present. If a child asks for one of those routes, the request comes back to you.
If no visible scope declared the route type, the request is ignored.
Use branches for selection-based containers such as tabs.
enum AppTab: Hashable, Sendable {
case home
case wallet
}
struct RootView: View {
@State private var tab: AppTab = .home
var body: some View {
TabView(selection: $tab) {
NavigationStack {
HomeView()
.routeBranch(AppTab.home)
}
.tag(AppTab.home)
NavigationStack {
WalletView()
.routeBranch(AppTab.wallet)
}
.tag(AppTab.wallet)
}
.routes(branch: $tab) {
Cover(LoginRoute.self, priority: .high)
Branch(.home) {
Push(HomeDetailRoute.self)
}
Branch(.wallet) {
Sheet(TransactionRoute.self)
}
}
}
}.routes(branch:) declares the full route map for a selection container. This lets Departure find routes in lazy branches that have not been built yet. Declarations inside Branch(...) are used for crawling and branch selection at the container, then adopted by the matching .routeBranch(...) view as local presentation declarations.
Top-level declarations in the same .routes(branch:) builder, such as Cover(LoginRoute.self, priority: .high), belong to the container itself.
If a request matches a route declared in an inactive branch, Departure selects that branch before presenting the route from the mounted .routeBranch(...) host.
When a branch is not lazy, declaring the same route locally is equivalent:
HomeView()
.routeBranch(AppTab.home)
.routes {
Push(HomeDetailRoute.self)
}Sheets and covers can be normal or high priority.
.routes {
Sheet(ProfileRoute.self)
Cover(LoginRoute.self, priority: .high)
}| Priority | Behavior |
|---|---|
.normal |
Presents from the nearest visible owner, unless a high-priority presentation is already covering the normal flow. |
.high |
Presents above the normal flow in a separate high-priority window on UIKit. A new high-priority request from the normal flow replaces the active high-priority presentation. |
Inside a high-priority route, normal and high-priority declarations behave like local navigation for that high-priority flow.
High priority changes presentation context, not route lookup. Branch routes are still resolved with the same crawling rules; when a high-priority branch route is selected, the high-priority window uses the active branch presentation scope.
flowchart TD
normal["Normal app flow"]
login["High-priority LoginRoute"]
twofactor["TwoFactorRoute inside login"]
settings["Normal SettingsRoute request"]
normal -->|"Cover(LoginRoute.self, priority: .high)"| login
login -->|"local route request"| twofactor
settings -.->|"blocked while high-priority flow is active"| login
Routes can redirect or cancel themselves before ownership is resolved.
struct ProtectedSettingsRoute: Route {
let isLoggedIn: Bool
func resolveRoute() async -> (any Route)? {
isLoggedIn ? self : LoginRoute()
}
func destination() -> some View {
SettingsView()
}
}Return:
| Value | Meaning |
|---|---|
self |
Continue with this route. |
| another route | Resolve and present that route instead. |
nil |
Drop the request. |
Actions are fire-and-forget work values that run against the active route context.
struct SaveDraftAction: Action {
func attemptAction(in context: ActionContext) async throws(ActionInvocationError) {
guard context.isRunning(in: EditorRoute.self) else {
throw .reroute(EditorRoute())
}
// Save the draft.
}
}Run an action from SwiftUI through routing:
struct ToolbarView: View {
@Environment(\.routing) private var routing
var body: some View {
Button("Save") {
routing(.perform(SaveDraftAction()))
}
}
}If an action throws .reroute(route), Departure requests that route using the same ownership rules, then retries the action once.
Note
Route requests crawl backward to find an owner. Actions do not crawl for work execution; they run in the active route context.
Hooks are route-scoped behavior declarations. Attach them with .hooks { ... } on the view that owns the behavior.
struct EditorView: View {
var body: some View {
EditorContent()
.hooks {
ActionInterceptor(SaveDraftAction.self) { invocation in
do {
try await invocation()
} catch {
// React to the failed save attempt.
}
}
}
}
}Hooks attach to the current route scope and disappear when that scope is dismissed. Inside a selected .routeBranch(...), hooks attach to that branch-local scope, so selected tab content can intercept actions without changing route ownership. For actions, Departure checks only the active scope for a matching ActionInterceptor. If no interceptor matches, the action runs normally. If an interceptor matches, that interceptor owns the action flow and must call invocation() when the original action should run.
ActionInterceptor lets a scope wrap or replace execution for a matching action type.
.hooks {
ActionInterceptor(SaveDraftAction.self) { invocation in
try? await invocation()
}
}The interceptor receives an invocation closure for the original action. Call it to continue action execution, or do not call it to consume the action.
.hooks {
ActionInterceptor(DeleteDraftAction.self) { _ in
// Consume the action without running DeleteDraftAction.attemptAction(in:).
}
}If an intercepted action throws .reroute(route) from its original implementation, calling invocation() preserves the normal reroute behavior: Departure presents the route, then retries the action once from the new active scope.
Unwind is the counterpart to presentation: instead of asking for a new route, a view asks Departure to dismiss the current route or dismiss back to a known route scope.
routing(.unwind())routing(.unwind()) dismisses the current route. If the current route is the only route in the path, it unwinds to the root scope.
Use an explicit target when you want to dismiss more than one route:
routing(.unwind(to: .root))
routing(.unwind(to: .id("settings-flow")))| API | Behavior |
|---|---|
routing(.unwind()) |
Dismisses the current route. |
routing(.unwind(to: .root)) |
Clears all presented routes and returns to the root scope. |
routing(.unwind(to: .id(id))) |
Keeps the matching route scope and dismisses everything after it. |
Scope IDs are useful when a view owns a named flow:
SettingsFlowView()
.routes(id: "settings-flow") {
Push(AdvancedSettingsRoute.self)
Sheet(AccountRoute.self)
}You can also ask Departure to present another route after the unwind completes:
routing(.unwind(thenPresent: ProfileRoute()))
routing(.unwind(to: .root, thenPresent: LoginRoute(nextRoute: ProfileRoute())))For example, a completion screen can dismiss itself and continue with a route owned by an earlier visible scope:
struct CompletionView: View {
@Environment(\.routing) private var routing
var body: some View {
Button("Done") {
routing(.unwind(thenPresent: SummaryRoute()))
}
}
}Departure removes the route scopes from its path first, then waits for any mounted dismissed route views to leave SwiftUI before requesting the continuation route. The continuation is a normal route request: it resolves, crawls visible scopes, selects branches, observes priority, and may still be dropped if no eligible declaration exists.
Note
thenPresent does not bypass routing rules. It only sequences dismissal before the next request.
Copyright (c) 2026 @mtzaquia
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.