Welcome to the demonstration of different ways to implement scalable navigation in SwiftUI projects.
Showcase of 2 most reliable, currently available ways to implement SwiftUI navigation:
- using SwiftUIRouter component
- using UIKit-based navigation
The grounds on which to assess the navigation solutions are:
- precision - we can define precisely which view (and how) will be shown
- scalability - as the application grows, the navigation component allows adding new views and app flows
- being stateful - it is possible to set up or restore the entire navigation stack (e.g. when activating a deep link)
- an ability to self-dismiss - a popup or a view can be dismissed / popped programmatically
- testability - it is possible to test the navigation component in isolation
- iOS 16.0 (SwiftUI-based navi)
- iOS 13.0 (UIKit-based navi)
- Clone the repo.
- Open
SwiftUI Navigation.xcodeprojfile. - Edit
AppConfiguration.swiftfile and enter valid https://metalpriceapi.com/ API key. - Use
SwiftUI Navigationscheme to run the application. - Use
Testsscheme to run unit tests.
Utilises iOS 16 Navigation Stack and navigationDestination API to handle navigation
- Uses
Routercomponent to execute navigation commands (e.g. push, pop, present, etc.) - The
Routeris bound strictly with the View implementingNavigation Stack - API very similar to
UINavigationController
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
This type of navigation relies on 2 components bound together:
-
A SwiftUI
Root View:- Embeds
NavigationStackcomponent. - Implements
@ViewBuilderfunctions that creates subviews to show in the NavStack. - Implements view modifiers telling the compiler how to present a particular view (e.g. as sheet or an alert)
- Sample implementation see below, go to code
- Embeds
-
A
Router:- A single source of truth about what view is shown at a given moment.
- Provides reference to the navigation stack.
- Implements methods to operate on the navigation stack (e.g. push, pop, return to root, etc.)
- Protocol definition see below
- Sample implementation go to code
struct SwiftUIRouterHomeView<Router: NavigationRouter>: View {
@ObservedObject var router: Router
var body: some View {
NavigationStack(
path: .init(
get: {
router.navigationStack
},
set: { stack in
router.set(navigationStack: stack)
})
) {
MyInitialView()
.navigationDestination(for: NavigationRoute.self) { route in
// Handling app screens, pushed to the navigation stack
}
.sheet(item: $router.presentedPopup) { _ in
if let $popup = Binding($router.presentedPopup) {
// Handling app popups, presented as sheets:
}
}
.alert(
presenting: $router.presentedAlert,
confirmationActionTitle: router.presentedAlert?.title.orEmpty,
confirmationActionCallback: { alertRoute in
// Handling app alert confirmation action:
}
)
}
}
}
protocol NavigationRouter: AnyObject, ObservableObject {
/// A currently presented popup.
var presentedPopup: PopupRoute? { get set }
var presentedPopupPublished: Published<PopupRoute?> { get }
var presentedPopupPublisher: Published<PopupRoute?>.Publisher { get }
/// A currently presented alert.
var presentedAlert: AlertRoute? { get set }
var presentedAlertPublished: Published<AlertRoute?> { get }
var presentedAlertPublisher: Published<AlertRoute?>.Publisher { get }
/// A currently presented navigation route.
var navigationRoute: NavigationRoute? { get }
/// A complete navigation stack.
/// Contains all navigation routes pushed to navigation stack.
var navigationStack: [NavigationRoute] { get }
/// Pushes screen to navigation stack.
///
/// - Parameter screen: a screen to be pushed.
func push(screen: NavigationRoute.Screen)
/// Removes last view from the navigation stack.
func pop()
/// Pops navigation stack to root.
func popAll()
/// Replaces navigation stack.
///
/// - Parameter navigationStack: a collection of routes to replace the stack with.
func set(navigationStack: [NavigationRoute])
/// Presents provided popup as sheet.
///
/// - Parameter popup: a popup to present.
func present(popup: PopupRoute.Popup)
/// Dismisses current popup.
func dismiss()
/// Shows an alert.
///
/// - Parameter alert: an alert to show.
func show(alert: AlertRoute.Alert)
/// Removes currently displayed alert from the navigation stack.
func hideCurrentAlert()
}
- Precise
You can explicitly set which View is shown (and how) - though it’s not as clear as in UIKit (e.g. separate bindings exposed for controlling an alert, popup and navigation stack). - Scalable to a degree
You can display independent app flow on a popup, that opens another popup showing yet another flow, etc. - Stateful
You can save navigation path and then restore it on the Router to trigger Root View / Navigation Stack rebuilding the Views (a.k.a. drill-down navigation). - Testable
Router is fully testable. Router + Root View binding can be tested using integration tests like Snapshots.
- iOS 16+ only
requiresNavigation Stack. - Tight coupling between the
Root View(the one with embedded NavigationStack) and theRouter. - Messy View factories in the Root View (thanks to
@ViewBuilder). - Only a single alert or popup can be shown at a given moment.
- Simple project, POC, etc.
Rule of the thumb: if we can manage with just one NavigationStack in the app, you'll be ok. - SwiftUI-only modules
Such modules have limited amount of screens to show and distinct point of entry where the these screens
- Implements the navigation classical way - using
UINavigationController - SwiftUI Views are embedded into
HostingViewControllers - Uses a single point of entry to execute navigation commands -
UIKitNavigationRouter - Leverages
FlowCoordinatorto handle navigation flow for a given feature (e.g. registration, authentication, etc.) - Every view shown is represented by a distinct
Routeobject
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
This type of navigation relies on 4 components / concepts:
- A
Route:- Represents an unique
View Component: a single view or an entire app flow. - Must have n unique name across the app.
- Protocol definition see below.
- Represents an unique
- A
View Component:- A convenience wrapper for a view.
AUIViewControllerwrapping a SwiftUI or a UIKit view. - Contains information about a
Routeit represents.
- A convenience wrapper for a view.
- A
Flow Coordinator:- Has knowledge about currently displayed view, including a popup.
- Has its own navigation stack (wrapped UINavigationController).
- Handles precisely defined set of Routes.
- Can display a
Routein many ways, as defined in a given Route properties:- Inline - push a view(s) on navigation stack
- As a popup - present as popup (modality is defined in Route properties)
- As a separate flow - starts a child flow
- Can go back to previously displayed routes:
- Manually - by pressing back nav button.
- Programmatically - by calling
navigateBackTo(route:)).
- Can switch to any supported route.
- Can embed or present a copy of itself as a child flow (a.k.a inception navigation).
- Can restore navigation state (e.g. entire sequence of views preceding a current one).
- Protocol definition see below.
- A
Router:- A single point of entry for navigation.
- Embeds all active Flow Coordinators, maintaining their hierarchy.
- Has knowledge which of the active app flows is the currently shown to the user.
- Protocol definition see below.
To add a new flow into the app you need to:
- Create an object implementing
FlowCoordinatorprotocol - Implement
canShow(route:)to set whichRoutesdoes the flow support. - Implement
start()&stop()methods to handle flow lifecycle. - Implement
makeViewComponents(forRoute:)method to produce a view for each supportedRoute. - Implement
makeFlowCoordinator(forRoute:)method to produce a child flow coordinator for a givenRoute.
The remaining mechanical operations (like presenting a flow, handling back nav button, manually dismissing a popup, etc.) are provided by FlowCoordinator default implementation!
See example
/// A navigation route that can be used to navigate to a specific screen or flow.
protocol Route: Equatable {
/// The name of the route.
var name: String { get }
/// Whether the route is a separate flow.
var isFlow: Bool { get }
/// A route popup presentation mode.
var popupPresentationStyle: PopupPresentationStyle { get }
}
/// An abstraction describing a navigation flow.
protocol FlowCoordinator: ViewComponent, ViewComponentFactory, FlowCoordinatorFactory {
/// A navigator the flow operates on.
var navigator: Navigator { get }
/// A parent flow coordinator.
var parent: FlowCoordinator? { get }
/// A child flow coordinator.
/// Important: It's NOT recommended to set child manually OUTSIDE of a given FlowCoordinator!
/// The setter is exposed only to set Flow's child to nil after it's finished.
var child: FlowCoordinator? { get set }
/// A coordinator completion callback.
var completionCallback: (() -> Void)? { get set }
/// A starts the flow.
///
/// - Parameter animated: a flag indicating whether the flow should be started with animation.
func start(animated: Bool)
/// Stops the flow.
func stop()
/// Shows a route in the flow.
///
/// - Parameters:
/// - route: a route to show.
/// - withData: an optional data necessary to create a view.
func show(route: any Route, withData: AnyHashable?)
/// Checks whether a route can be shown in the flow.
///
/// - Parameter route: a route to check.
/// - Returns: a flag indicating whether a route can be shown in the flow.
func canShow(route: any Route) -> Bool
/// Switches to a route.
func `switch`(toRoute route: any Route, withData: AnyHashable?)
/// Navigates back one view.
///
/// - Parameter animated: a flag indicating whether the navigation should be animated.
func navigateBack(animated: Bool)
/// Navigates back to the root view of the flow.
///
/// - Parameter animated: a flag indicating whether the navigation should be animated.
func navigateBackToRoot(animated: Bool)
/// Navigates back to an already shown route.
///
/// - Parameters:
/// - route: a route to navigate back to.
/// - animated: a flag indicating whether the navigation should be animated.
func navigateBack(toRoute route: any Route, animated: Bool)
}
/// An abstraction describing a UIKit navigation router.
protocol UIKitNavigationRouter: AnyObject {
/// Provides a currently shown application flow.
var currentFlow: FlowCoordinator? { get }
/// Shows a route in the flow.
///
/// - Parameters:
/// - route: a route to show.
/// - withData: an optional data necessary to create a view.
func show(route: any Route, withData: AnyHashable?)
/// Switches to a route.
func `switch`(toRoute route: any Route, withData: AnyHashable?)
/// Navigates back one view.
///
/// - Parameter animated: a flag indicating whether the navigation should be animated.
func navigateBack(animated: Bool)
/// Stops the current flow.
func stopCurrentFlow()
/// Navigates back to the root view of the flow.
///
/// - Parameter animated: a flag indicating whether the navigation should be animated.
func navigateBackToRoot(animated: Bool)
/// Navigates back to an already shown route.
///
/// - Parameters:
/// - route: a route to navigate back to.
/// - animated: a flag indicating whether the navigation should be animated.
func navigateBack(toRoute route: any Route, animated: Bool)
/// Starts the initial flow.
///
/// - Parameters:
/// - initialFlow: an initial flow to start.
/// - animated: a flag indicating whether the navigation should be animated.
func start(initialFlow: FlowCoordinator, animated: Bool)
}
See also the list of contributors who participated in this project.
This project is licensed under the MIT License. More info











