pkozielecki/ios-swiftui-navigation
An app showcasing robust, scalable, and testable navigation in SwiftUI and UIKit
SwiftUI Navigation Showcase
Welcome to the demonstration of different ways to implement scalable navigation in SwiftUI projects.
Main Features
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
Integration
Requirements
- iOS 16.0 (SwiftUI-based navi)
- iOS 13.0 (UIKit-based navi)
Running the app
- 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.
Showcased navigation types
SwiftUI Router
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
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
Using SwiftUI Router
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
View Example
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:
}
)
}
}
}
Router protocol definition
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()
}
Pros:
- 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.
Cons:
- 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.
Where to use:
- 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
UIKit-based navigation
- 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
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
|---|
Using UIKit-based navigation
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
Route protocol definition
/// 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 }
}
FlowCoordinator protocol definition
/// 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)
}
Router protocol definition
/// 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)
}
Project maintainer
See also the list of contributors who participated in this project.
License
This project is licensed under the MIT License.
More info











