DPCoordinator is a SwiftUI navigation coordinator. It owns a navigation path, builds destinations from typed screen values, presents screens or entire flows as sheets/full-screen covers, and can pass dismissal requests up to a parent coordinator.
The included example demonstrates three independent tab stacks and a three-step account-setup flow. It is the best reference for how the package is intended to be wired into an app.
- Requirements
- Install
- How the package works
- Step-by-step setup
- Navigation and presentation recipes
- Example app flow
- Use cases
- Important constraints
- iOS 17 or later (as declared in
Package.swift) - SwiftUI
- Swift 6.1 toolchain (the package manifest uses Swift tools version 6.1)
In Xcode, choose File → Add Package Dependencies…, then enter:
https://github.com/Datt1994/DPCoordinator.git
Add the DPCoordinator product to the iOS app target that will use it, then import it where needed:
import SwiftUI
import DPCoordinatorCopy the Swift files in DPCoordinator/DPCoordinator into your app target. Ensure the copied files belong to the target, then omit import DPCoordinator because the code is now compiled in the same module.
There are four core pieces:
| Piece | Responsibility |
|---|---|
Screen |
A typed destination and the SwiftUI view it builds. |
Flow |
A typed feature container, normally used for a sheet or full-screen flow with its own coordinator. |
BaseCoordinator<Screen, Flow> |
Holds the navigation path and current presentations, and exposes navigation/dismissal methods. |
addCoordinatorNavigationStack |
Installs the NavigationStack, routes pushed screen IDs to views, handles sheets/full-screen covers, and places the coordinator in the SwiftUI environment. |
The optional dismissal callback is named onDissmiss in the current public API. Use that spelling when conforming to Screen or Flow.
The following is a compact, standalone pattern you can adapt in another project.
Keep route cases free of SwiftUI views. The Screen type is the single place that maps a route to its view.
import SwiftUI
import DPCoordinator
enum AppRoute {
case home
case product(id: String)
case settings
}
struct AppScreen: Screen {
let id = UUID()
let route: AppRoute
var onDissmiss: (() -> Void)?
init(_ route: AppRoute, onDissmiss: (() -> Void)? = nil) {
self.route = route
self.onDissmiss = onDissmiss
}
@ViewBuilder
func build() -> some View {
switch route {
case .home:
HomeView()
case let .product(id):
ProductView(productID: id)
case .settings:
SettingsView()
}
}
}Each AppScreen gets a new UUID, so the same route can be pushed more than once with different path entries. Do not reuse a screen instance for multiple pushes.
Use a Flow when a modal feature needs its own root view and usually its own navigation stack—for example onboarding, checkout, or account setup.
enum AppFlowRoute {
case onboarding
}
struct AppFlow: Flow {
let id = UUID()
let route: AppFlowRoute
var onDissmiss: (() -> Void)?
init(_ route: AppFlowRoute, onDissmiss: (() -> Void)? = nil) {
self.route = route
self.onDissmiss = onDissmiss
}
@ViewBuilder
func build() -> some View {
switch route {
case .onboarding:
OnboardingFlowContainer()
}
}
}If a modal only displays one screen and does not need a separate stack, present an AppScreen directly instead of creating a flow.
BaseCoordinator already implements the complete core coordinator behavior. A project-specific subclass gives your views a concrete environment type and is a natural home for app-specific commands later.
@MainActor
final class AppCoordinator: BaseCoordinator<AppScreen, AppFlow> { }
extension EnvironmentValues {
@Entry var appCoordinator: AppCoordinator? = nil
}The modifier creates the NavigationStack and injects the coordinator into the environment. A root container is the right place to retain it with @StateObject.
struct AppRootView: View {
@StateObject private var coordinator = AppCoordinator()
var body: some View {
HomeView()
.addCoordinatorNavigationStack(
using: coordinator,
environmentKeyPath: \.appCoordinator
)
}
}
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
AppRootView()
}
}
}Read the injected coordinator in any descendant view and call its methods. Views describe user interaction; the coordinator owns navigation state.
struct HomeView: View {
@Environment(\.appCoordinator) private var coordinator
var body: some View {
VStack {
Button("Open product") {
coordinator?.push(AppScreen(.product(id: "42")))
}
Button("Settings") {
coordinator?.present(sheet: AppScreen(.settings))
}
Button("Start onboarding") {
coordinator?.present(full: AppFlow(.onboarding))
}
}
}
}A flow container is responsible for its own @StateObject. Pass the presenting coordinator as its parent; this lets dismiss() fall back to the coordinator that presented the flow when the child has nothing local to dismiss.
extension EnvironmentValues {
@Entry var onboardingCoordinator: OnboardingCoordinator? = nil
}
@MainActor
final class OnboardingCoordinator: BaseCoordinator<AppScreen, AppFlow> { }
struct OnboardingFlowContainer: View {
@StateObject private var coordinator = OnboardingCoordinator()
@Environment(\.appCoordinator) private var parentCoordinator
var body: some View {
WelcomeView()
.addCoordinatorNavigationStack(
using: coordinator,
environmentKeyPath: \.onboardingCoordinator,
parentCoordinator: parentCoordinator
)
}
}The WelcomeView can now use @Environment(\.onboardingCoordinator) to push onboarding screens. Calling dismiss() from this child coordinator dismisses its sheet/full-screen flow through the parent coordinator when appropriate.
| Goal | Call | Result |
|---|---|---|
| Push a screen | push(AppScreen(.product(id: "42"))) |
Adds a destination to the current stack. |
| Go back one screen | pop() |
Removes the last pushed screen; returns false at root. |
| Go back several screens | pop(2) |
Removes that many path entries; returns false if there are too few. |
| Return to the stack root | popToRoot() |
Clears the current navigation path. |
| Present one screen as a sheet | present(sheet: AppScreen(.settings)) |
Shows the screen with SwiftUI sheet. |
| Present a flow as a sheet | present(sheet: AppFlow(.onboarding)) |
Shows the flow’s root container in a sheet. |
| Present one screen full screen | present(full: AppScreen(.settings)) |
Shows the screen with fullScreenCover. |
| Present a flow full screen | present(full: AppFlow(.onboarding)) |
Shows the flow’s root container full screen. |
| Show a system alert | showAlert(AlertConfig(...)) |
Shows a SwiftUI alert from the current coordinator. |
| Dismiss a sheet | dismissSheetScreen() |
Dismisses the local sheet/flow, otherwise asks the parent. |
| Dismiss a full-screen cover | dismissFullScreen() |
Dismisses the local full-screen cover/flow, otherwise asks the parent. |
| Dismiss whatever is active | dismiss() |
Dismisses local presentations, otherwise asks the parent. |
pop, popToRoot, and the dismissal methods return Bool. Use that return value when a failed action matters, such as disabling a custom back button at the root.
The navigation-stack modifier automatically attaches the alert presenter. Create an AlertConfig with one or two buttons, then pass it to showAlert from a view that has access to the coordinator.
Button("Delete account") {
coordinator?.showAlert(
AlertConfig(
title: "Delete account?",
message: "This action cannot be undone.",
primaryButton: AlertConfigButton(label: "Delete") {
// Perform the destructive action.
},
secondaryButton: AlertConfigButton(label: "Cancel")
)
)
}Call coordinator?.dismissAlert() when you need to close an active alert in code. Tapping either configured SwiftUI button runs its action; SwiftUI also dismisses the alert.
Both Screen and Flow have an optional onDissmiss closure. Attach it when constructing the route when you need cleanup associated with that route:
coordinator?.push(
AppScreen(.product(id: "42"), onDissmiss: {
// Stop work or release feature-specific state.
})
)For pushed screens, BaseCoordinator invokes this when an ID disappears from its navigation path. Treat the callback as a cleanup hook, not as the primary place for essential persistence or business logic.
The included app separates navigation state per tab and starts a child account-setup coordinator when the setup flow is presented modally.
flowchart TD
App["DPCoordinatorExample1App"] --> Root["AppFlow(.tabbar)"]
Root --> Tabs["TabbarContainer"]
Tabs --> Home["HomeCoordinator + HomeView"]
Tabs --> Account["AccountCoordinator + AccountView"]
Tabs --> Settings["SettingCoordinator + SettingView"]
Account -->|"push"| Step1["Setup step 1"]
Step1 -->|"push"| Step2["Setup step 2"]
Step2 -->|"push"| Step3["Setup step 3"]
Account -->|"sheet or full screen"| Flow["SetUpAccountFlowContainer"]
Flow --> Child["SetUpAccountFlowCoordinator"]
Child --> Step1
| Example file | What it demonstrates |
|---|---|
TabbarContainer.swift |
A separate @StateObject coordinator and navigation stack for each tab. |
AppScreen.swift |
One screen wrapper dispatching to main and account screen builders. |
AppFlow.swift |
A root tab flow and a modal account-setup flow. |
AccountView.swift |
Push, modal-flow sheet, and modal-flow full-screen entry points. |
SetUpAccountFlowCoordinator.swift |
A child flow’s coordinator, environment key, and parent relationship. |
SetUpAccountStepThreeView.swift |
pop, popToRoot, multi-pop, and dismiss. |
The example also defines AppCoordinator, AppBaseCoordinator, loading UI, and a custom popup alert under DPCoordinatorExample1/Coordinators. Those are example-app extensions, not public features supplied by the DPCoordinator package. Copy or adapt them only if your app needs them.
- Independent navigation histories per tab: create one coordinator and one
addCoordinatorNavigationStackper tab, as the example does. - Multi-step forms or wizards: model each step as a
Screen; usepush,pop, andpopToRootfor linear progress and reset. - Onboarding, authentication, checkout, or account setup: model the feature as a
Flow, present it modally, and install a child coordinator inside its container. - Detail and drill-down navigation: carry the required identifier or value in the route enum, such as
case product(id: String). - Feature-scoped cleanup: supply
onDissmissfor navigation-path cleanup after a pushed screen is removed. - Cross-tab navigation: keep tab selection in a small separate
ObservableObject(likeTabCoordinatorin the example); let individual tab coordinators continue to own their own stacks. - App-specific overlays: subclass
BaseCoordinatorin the app and add published overlay state, then apply an app-owned view modifier around the package navigation modifier.
- Do not put a second
NavigationStackinside a view that is already wrapped withaddCoordinatorNavigationStack. A presentedFlowis the intended boundary for a new stack. - Use
@StateObjectonly at a coordinator’s owner (root container, tab container, or flow container). Descendant views should obtain it with@Environment. - Do not mutate
navigationPathdirectly. Usepush,pop, orpopToRootso the package can retain and clean up the matching screen builders. Screen.idandFlow.idmust be stable for the lifetime of their individual value and distinct for separately presented/pushed instances. A storedUUID()meets this requirement.- The package has no test target at present. Validate your route builders and presentation behavior in an app target when adding new flows.
See LICENSE.