FlowStack is a SwiftUI navigation router for iOS 16+. It gives an app-level navigation layer for NavigationStack, push/pop flows, sheet presentation, full-screen cover presentation, root replacement, global dialogs, route identity, navigation guards, async transition queues, and default navigation styling.
Use FlowStack when you want navigation to be driven from a single router instead of scattering NavigationLink, .sheet, .fullScreenCover, and alert state across many views.
- SwiftUI
NavigationStackrouter for iOS 16+. - Type-safe route enum support with
HashableorRouteHashable. - Push, multi-push, pop, pop-to-root, pop-to-route, and replace current screen.
- Push then remove the previous screen with
pushRemovingPrevious. - Replace root with refreshed root identity, even when setting the same route again.
- Present nested
sheetandfullScreenCoverstacks; each presentation owns its own navigation path. - Dismiss active, specific, nested, or all presented stacks.
- Global dialog overlay above root, pushed screens, sheets, and full-screen covers.
- Queue, replace, ignore, or clear global dialogs.
- Default style system for every stack, modal, root screen, and pushed destination.
- Context-aware styling by stack index, stack id, presentation style, root route, screen route, and screen depth.
- Sync and async preconditions for navigation guards, login checks, permissions, paywall gates, and save-before-leave flows.
- Serialized async navigation queue to avoid overlapping SwiftUI transitions.
- Operation history for debugging applied and blocked navigation actions.
SwiftUI navigation, NavigationStack router, iOS navigation, Swift Package Manager, SPM, push pop navigation, sheet router, fullScreenCover router, global dialog, alert coordinator, route enum, deep link navigation, app router, navigation coordinator, SwiftUI coordinator, iOS 16.
Add FlowStack to an iOS app with Swift Package Manager.
In Xcode:
- Open
File>Add Package Dependencies.... - Enter the package URL:
https://github.com/tu-konnichiwa/FlowStack.git
- Select a version rule, for example
Up to Next Major Version. - Add the
FlowStackproduct to your app target.
In another Swift package, add FlowStack to Package.swift:
dependencies: [
.package(url: "https://github.com/tu-konnichiwa/FlowStack.git", from: "1.0.0")
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "FlowStack", package: "FlowStack")
]
)
]Then import it:
import FlowStackFlowStack is ready to be submitted to the Swift Package Index once the GitHub repository is public and has at least one semantic version tag, for example 1.0.0.
Submit the package URL here:
https://swiftpackageindex.com/add-a-package
Use this repository URL when submitting:
https://github.com/tu-konnichiwa/FlowStack.git
For Swift Package Index compatibility, the package should keep:
- A public GitHub repository.
- A valid
Package.swiftat the repository root. - A semantic version release tag such as
1.0.0. - A package URL with
httpsand.git. - A build that passes on supported platforms.
After the package appears on Swift Package Index, open its package page and use Do you maintain this package? to get official compatibility badges for this README.
For simple routes, use any Hashable type. For routes with closures, bindings, view models, or custom payloads, conform to RouteHashable:
enum AppRoute: RouteHashable {
case splash
case main
case webView(String)
case colorPicker(initialHex: String, didPick: (Color) -> Void)
}RouteHashable auto-generates equality and hashing from a route id. Override id when the associated payload is complex and needs a stable production identity:
enum AppRoute: RouteHashable {
case detail(User)
var id: String {
switch self {
case .detail(let user):
return "detail_\(user.id)"
}
}
}@StateObject private var router = FlowRouter<AppRoute, AppDialog>(root: .splash)
var body: some View {
FlowStackView(router: router) { route in
screen(for: route)
} destination: { route in
screen(for: route)
} dialog: { dialog in
AnyView(AppDialogView(dialog: dialog))
}
}FlowStackView supports nested sheet and fullScreenCover presentations. Each presented stack owns its own NavigationStack.
The dialog builder is optional. When provided, it is rendered as a global overlay above the whole navigation tree, including pushed screens, sheets, and full-screen covers.
The default style mirrors the base app pattern: navigation bars are hidden and text alignment is leading.
FlowStackView(router: router) { route in
screen(for: route)
} destination: { route in
screen(for: route)
}Customize the style once and it is applied to every root screen, pushed destination, sheet, and full-screen stack:
let style = FlowNavigationStyle.default
.navigationBarHidden(true)
.multilineTextAlignment(.leading)
.screenStyle { view in
view
.background(Color.appBackground)
.preferredColorScheme(.light)
}
.stackStyle { view in
view
.tint(.primary)
}
.modalStyle { view in
view
.presentationDragIndicator(.visible)
}
FlowStackView(router: router, style: style) { route in
screen(for: route)
} destination: { route in
screen(for: route)
}Use screenStyle for all screens, stackStyle for each NavigationStack, and modalStyle for every presented stack.
Each style hook also has a context-aware overload with the stack index:
let style = FlowNavigationStyle.default
.screenStyle { view, context in
view
.background(context.isRootStack ? Color.white : Color.secondarySystemBackground)
}
.modalStyle { view, context in
view
.presentationDragIndicator(context.stackIndex > 0 ? .visible : .hidden)
}
.stackStyle { view, context in
view
.tint(context.presentationStyle == .fullScreenCover ? .red : .primary)
}FlowNavigationStyle.Context exposes stackIndex, stackID, presentationStyle, isRootStack, rootRoute, screenRoute, screenDepth, and isRootScreen.
It also exposes route information, which is the preferred way to style a screen. Checking the concrete SwiftUI view type after type erasure is not reliable.
let style = FlowNavigationStyle.default
.screenStyle { view, context in
if context.screenRoute(as: AppRoute.self) == .home {
view.background(Color.homeBackground)
} else {
view
}
}
.stackStyle { view, context in
if context.rootRoute(as: AppRoute.self) == .editor {
view.tint(.orange)
} else {
view
}
}Global dialogs are app-wide overlays controlled by the router. Use them for alerts, confirmation dialogs, loading blockers, permission prompts, session-expired dialogs, and other UI that must sit above every stack.
Define your dialog model as Identifiable:
enum AppDialog: Identifiable, Equatable {
case networkError
case sessionExpired
case deleteLogo(id: String)
var id: String {
switch self {
case .networkError:
return "networkError"
case .sessionExpired:
return "sessionExpired"
case .deleteLogo(let id):
return "deleteLogo_\(id)"
}
}
}Render it once at the root host:
FlowStackView(router: router) { route in
screen(for: route)
} destination: { route in
screen(for: route)
} dialog: { dialog in
AnyView(
AppDialogView(dialog: dialog)
)
}The dialog view receives a Binding<AppDialog?>. Set it to nil when the user closes the current dialog:
struct AppDialogView: View {
@Binding var dialog: AppDialog?
var body: some View {
switch dialog {
case .networkError:
ConfirmDialog(
title: "Network Error",
onClose: { dialog = nil }
)
case .sessionExpired:
ConfirmDialog(
title: "Session Expired",
onClose: { dialog = nil }
)
case .deleteLogo:
ConfirmDialog(
title: "Delete Logo",
onClose: { dialog = nil }
)
case nil:
EmptyView()
}
}
}Show a dialog from any place that can access the router:
router.showGlobalDialog(.networkError, policy: .queue)
router.showGlobalDialog(.sessionExpired, policy: .replace)Policy behavior:
| Policy | Behavior | Use case |
|---|---|---|
.replace |
Show immediately and clear queued dialogs. | Critical dialogs such as session expired, forced update, hard blocker. |
.replaceKeepingQueue |
Show immediately but keep queued dialogs for later. | Temporarily interrupt current dialog flow, then continue queued dialogs. |
.queue |
Show now if no dialog is visible, otherwise enqueue. | Non-critical alerts that should be shown one by one. |
.ignoreIfVisible |
Show only when no dialog is visible. | Toast-like or low-priority dialogs that should not interrupt current UI. |
Dismiss and queue control:
router.hideGlobalDialog()
router.clearGlobalDialogs()
router.queuedGlobalDialogCounthideGlobalDialog() hides the current dialog. If the queue has another dialog, it becomes visible immediately. clearGlobalDialogs() removes both the visible dialog and all queued dialogs.
Example queue:
router.showGlobalDialog(.networkError, policy: .queue)
router.showGlobalDialog(.deleteLogo(id: "1"), policy: .queue)
router.hideGlobalDialog()
// .deleteLogo(id: "1") is now visible.Example replace:
router.showGlobalDialog(.networkError, policy: .queue)
router.showGlobalDialog(.deleteLogo(id: "1"), policy: .queue)
router.showGlobalDialog(.sessionExpired, policy: .replace)
router.hideGlobalDialog()
// No old queued dialog appears because .replace clears the queue.router.setRoot(.main)
router.push(.detail(id))
router.pushRemovingPrevious(.main)
router.replaceCurrent(with: .settings)
router.pop()
router.popToRoot()
router.sheet(.colorPicker(initialHex: "#FFFFFF") { _ in })
router.fullScreenCover(.paywall)
router.dismiss()
router.dismissAll()Each action can receive a one-off condition. The condition sees the operation and the previous stacks.
router.push(.settings) { operation, previousStacks in
session.isLoggedIn
}Default conditions can be registered per action kind:
router.setDefaultPrecondition(for: .push) { operation, previousStacks in
session.isLoggedIn
}The one-off condition takes priority over the default condition.
Use async variants when navigation depends on permission, paywall, saving draft, login, or another asynchronous check:
await router.pushAsync(.editor) { operation, previousStacks in
await permissions.requestPhotoAccess()
}Async actions are serialized through the router queue. Set a small delay if SwiftUI modal transitions need spacing:
router.queuedTransitionDelayNanoseconds = 250_000_000
await router.sheetAsync(.picker)
await router.fullScreenCoverAsync(.paywall)The router records applied and blocked operations:
router.operationHistory
router.clearOperationHistory()
router.maximumOperationHistoryCount = 100Disable fallback route id warnings if needed:
FlowRouteIDDiagnostics.warnsOnFallbackID = falseFlowStack is available under the MIT license. See LICENSE for details.