A type-safe, coordinator-based navigation framework for SwiftUI that makes complex navigation hierarchies simple and predictable.
- ✅ Type-Safe Navigation - Enum-based routes ensure compile-time safety
- ✅ Universal Navigate API - Call
navigate(to:)from anywhere and the framework finds the right path - ✅ Smart Navigation - Automatic backward detection, modal dismissal, and state cleanup
- ✅ Hierarchical Coordinators - Nest coordinators for modular, scalable navigation
- ✅ Tab Coordination - Built-in support for tab-based navigation
- ✅ Modal Management - Multiple modal coordinators with automatic lifecycle management
- ✅ Detour Navigation - Preserve context during deep linking with fullscreen detours
- ✅ Pushed Child Coordinators - Push entire coordinator hierarchies onto navigation stacks
- ✅ Two-Phase Navigation - Validation before execution prevents broken navigation states
- ✅ Zero Configuration - Presentation contexts and back button behavior handled automatically
- ✅ Comprehensive Error Handling - Type-safe error reporting with global handler
- ✅ Full Documentation - Complete DocC documentation with guides and examples
- iOS 17.0+ / macOS 14.0+
- Xcode 15.0+
- Swift 5.9+
Add SwiftUIFlow to your project using Swift Package Manager:
- In Xcode, select File → Add Package Dependencies
- Enter the repository URL:
https://github.com/JohnnyPJr/SwiftUIFlow - Select the version you want to use
- Click Add Package
Alternatively, add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/JohnnyPJr/SwiftUIFlow.git", from: "1.0.1")
]import SwiftUIFlow
enum AppRoute: Route {
case home
case profile
case settings
var identifier: String {
switch self {
case .home: return "home"
case .profile: return "profile"
case .settings: return "settings"
}
}
}class AppCoordinator: Coordinator<AppRoute> {
init() {
let factory = AppViewFactory()
super.init(router: Router(initial: .home, factory: factory))
factory.coordinator = self
}
override func canHandle(_ route: AppRoute) -> Bool {
return true
}
override func navigationType(for route: AppRoute) -> NavigationType {
switch route {
case .home, .profile:
return .push
case .settings:
return .modal
}
}
}class AppViewFactory: ViewFactory<AppRoute> {
weak var coordinator: AppCoordinator?
override func buildView(for route: AppRoute) -> AnyView {
guard let coordinator else {
return AnyView(Text("Error: Coordinator not set"))
}
switch route {
case .home:
return AnyView(HomeView(coordinator: coordinator))
case .profile:
return AnyView(ProfileView(coordinator: coordinator))
case .settings:
return AnyView(SettingsView(coordinator: coordinator))
}
}
}struct HomeView: View {
let coordinator: AppCoordinator
var body: some View {
VStack {
Button("View Profile") {
coordinator.navigate(to: .profile)
}
Button("Settings") {
coordinator.navigate(to: .settings) // Presents as modal
}
}
.navigationTitle("Home")
}
}// Manual state management
@State private var path = NavigationPath()
@State private var showingModal = false
@State private var modalContent: ModalType?
// Fragile navigation prone to bugs
Button("Navigate") {
path.append(someRoute)
// Hope this works across the app...
}
// Complex cross-screen navigation
// Requires passing bindings through multiple levels// Type-safe, predictable navigation
coordinator.navigate(to: .profile)
// Works from anywhere in your app
coordinator.navigate(to: .settings) // Automatically presents as modal
// Framework handles all navigation state automatically
// Automatic modal/detour dismissal and state cleanupImportant: Not every modal needs a coordinator! For simple pickers, selectors, or forms without navigation, use SwiftUI's .sheet() directly:
struct HomeView: View {
let coordinator: AppCoordinator
@State private var showThemePicker = false
var body: some View {
Button("Pick Theme") { showThemePicker = true }
.sheet(isPresented: $showThemePicker) {
ThemePickerView(selectedTheme: $theme)
}
}
}Use coordinator-based modals only when you need:
- Deep linking to the modal
- Navigation within the modal (calling
.navigate(), not just dismissing) - Route-based presentation tracking
- Custom modal detents (automatic content-sizing with
.custom)
SwiftUIFlow is for navigation - if your modal doesn't navigate anywhere, you don't need a coordinator!
Call navigate(to:) from any view, any coordinator, any level deep. The framework automatically finds the right coordinator to handle the route:
// From a deeply nested view in Tab1
coordinator.navigate(to: Tab2Route.settings)
// Automatically switches to Tab2 and navigates to settingsThe framework automatically cleans up navigation state when navigating across coordinators:
// Modal is currently open
coordinator.navigate(to: AnotherTabRoute.details)
// Framework automatically:
// 1. Dismisses the modal
// 2. Switches tabs
// 3. Navigates to the target routeNavigate to a route already in the stack and the framework automatically pops instead of pushing:
// Current stack: [Home, Profile, Settings]
coordinator.navigate(to: .profile)
// Framework detects .profile is in stack
// Automatically pops back to Profile (doesn't push again)Use the .custom detent for modals that automatically size to their content:
override func modalDetentConfiguration(for route: AppRoute) -> ModalDetentConfiguration {
switch route {
case .settings:
// Modal automatically sizes to content height
return ModalDetentConfiguration(detents: [.custom, .medium])
default:
return ModalDetentConfiguration(detents: [.large])
}
}Present deep links as detours to preserve the user's current navigation context:
func handleDeepLink(to route: any Route) {
// User is deep in a flow: Tab2 → Unlock → EnterCode → Loading
let profileCoordinator = ProfileCoordinator()
presentDetour(profileCoordinator, presenting: .profile)
// User can tap back to return to Loading screen
// Their context is preserved!
}Build navigation paths that guide users through sequential flows:
override func navigationPath(for route: OceanRoute) -> [any Route]? {
switch route {
case .shallow:
return [.shallow]
case .deep:
return [.shallow, .deep]
case .abyss:
return [.shallow, .deep, .abyss]
default:
return nil
}
}
// Navigate directly to the abyss
coordinator.navigate(to: .abyss)
// Framework builds: shallow → deep → abyss
// User can navigate back through each levelNavigate to any tab's routes from anywhere in your app:
// From Tab1's deeply nested view
coordinator.navigate(to: Tab3Route.userProfile(id: "123"))
// Framework automatically:
// 1. Switches to Tab3
// 2. Navigates to the profile within Tab3Break your app into modular, reusable coordinator hierarchies:
class MainTabCoordinator: TabCoordinator<AppRoute> {
init() {
super.init(router: Router(initial: .home, factory: factory))
// Each tab is its own coordinator hierarchy
addChild(HomeCoordinator()) // Manages home flow
addChild(SearchCoordinator()) // Manages search flow
addChild(ProfileCoordinator()) // Manages profile flow
}
}Present modals from within modals with full navigation support:
class SettingsCoordinator: Coordinator<SettingsRoute> {
let privacyModal: PrivacyCoordinator
init() {
super.init(router: Router(initial: .main, factory: factory))
// Modal can present its own modal
privacyModal = PrivacyCoordinator()
addModalCoordinator(privacyModal)
}
}Push entire coordinator hierarchies onto navigation stacks:
class RedCoordinator: Coordinator<RedRoute> {
let rainbowCoordinator: RainbowCoordinator
init() {
super.init(router: Router(initial: .red, factory: factory))
// Push entire rainbow flow as a child
rainbowCoordinator = RainbowCoordinator()
addChild(rainbowCoordinator)
}
}
// Navigate to rainbow route
coordinator.navigate(to: RainbowRoute.red)
// Framework pushes rainbowCoordinator onto the stack
// Full navigation support within the childSet up a global error handler to respond to all framework errors:
class AppState: ObservableObject {
init() {
SwiftUIFlowErrorHandler.shared.setHandler { [weak self] error in
DispatchQueue.main.async {
self?.showError(error)
}
}
}
}Common errors are automatically reported:
- Navigation failures (no coordinator can handle route)
- Missing modal coordinators
- View creation failures
- Configuration errors
- Getting Started - Step-by-step setup guide
- Important Concepts - Critical patterns and best practices
- Navigation Patterns - Advanced features and techniques
- Error Handling - Comprehensive error handling guide
- SwiftUI Limitations - Known framework bugs and workarounds
Want to see SwiftUIFlow in action? The repository includes a comprehensive example app demonstrating:
- Tab-based navigation with multiple coordinators
- Modal presentations with various detent configurations
- Pushed child coordinators
- Deep linking and detour navigation
- Cross-coordinator navigation flows
- Error handling patterns
To run the example:
- Clone this repository:
git clone https://github.com/JohnnyPJr/SwiftUIFlow.git - Open
SwiftUIFlow.xcodeprojin Xcode - Select the
SwiftUIFlowExamplescheme - Build and run (⌘R)
Create a tab coordinator and child coordinators with tabItem overrides:
class MainTabCoordinator: TabCoordinator<AppRoute> {
init() {
let factory = AppViewFactory()
super.init(router: Router(initial: .home, factory: factory))
factory.coordinator = self
addChild(HomeCoordinator())
addChild(SearchCoordinator())
addChild(ProfileCoordinator())
}
}
class HomeCoordinator: Coordinator<HomeRoute> {
override var tabItem: (text: String, image: String)? {
("Home", "house.fill")
}
}Rendering Tabs: Choose Your Approach
Option 1: Native iOS Tab Bar (Easiest)
TabCoordinatorView(coordinator: mainTabCoordinator)Option 2: Custom Tab Bar with Wrapper (Recommended)
CustomTabCoordinatorView(coordinator: mainTabCoordinator) {
MyCustomTabBarUI(coordinator: mainTabCoordinator)
}Option 3: Custom Tab Bar with Manual Modifier (Advanced)
ZStack {
// Custom tab UI
}.withTabCoordinatorPresentations(coordinator: mainTabCoordinator)Handle external triggers (push notifications, universal links, app links, URL schemes) from a central location:
class DeepLinkHandler {
// Option 1: Navigate (Cleans State) - User loses their context
static func handleNavigateDeepLink(to route: any Route) {
guard let mainTab = appCoordinator.currentFlow as? MainTabCoordinator else { return }
// Dismisses modals, cleans stacks, navigates to destination
// Use when: User SHOULD lose their context (e.g., "View this specific page")
mainTab.navigate(to: route)
}
// Option 2: Detour (Preserves State) - User keeps their context
static func handleDetourDeepLink(to route: any Route) {
guard let mainTab = appCoordinator.currentFlow as? MainTabCoordinator else { return }
// Present fullscreen, preserve ALL context underneath
// Use when: User should return to where they were (e.g., "You have a message")
let detourCoordinator = MessageCoordinator(root: .message)
mainTab.presentDetour(detourCoordinator, presenting: .message)
// When dismissed: returns to EXACT state before deep link
}
}Choose based on user intent:
- Navigate: "Take me to X" - Clean slate navigation (e.g., marketing deep link)
- Detour: "Show me X, then let me continue" - Temporary interruption (e.g., notification)
class ParentCoordinator: Coordinator<AppRoute> {
let settingsModal: SettingsCoordinator
init() {
let factory = AppViewFactory()
super.init(router: Router(initial: .home, factory: factory))
factory.coordinator = self
// Modal must share parent's route type
settingsModal = SettingsCoordinator()
addModalCoordinator(settingsModal)
}
override func navigationType(for route: AppRoute) -> NavigationType {
return route == .settings ? .modal : .push
}
}
class SettingsCoordinator: Coordinator<AppRoute> {
init() {
super.init(router: Router(initial: .settings, factory: factory))
}
}class AppCoordinator: FlowOrchestrator<AppRoute> {
override func canHandleFlowChange(to route: any Route) -> Bool {
guard let appRoute = route as? AppRoute else { return false }
return appRoute == .login || appRoute == .mainApp
}
override func handleFlowChange(to route: any Route) -> Bool {
guard let appRoute = route as? AppRoute else { return false }
switch appRoute {
case .login:
transitionToFlow(LoginCoordinator(), root: .login)
return true
case .mainApp:
transitionToFlow(MainTabCoordinator(), root: .mainApp)
return true
default:
return false
}
}
}Contributions are welcome! Please feel free to submit a Pull Request.
SwiftUIFlow is available under the MIT license. See the LICENSE file for more info.
Created by Ioannis Platsis
Need Help? Check out the documentation or open an issue.