Framework for Implementing Clean Navigation in SwiftUI
If anything is unclear, feel free to reach out! I'm happy to clarify or update the documentation to make things more straightforward. π
If you find this repository helpful, feel free to give it a β or share it with your colleagues π©βπ»π¨βπ» to help grow the community of developers using this framework!
- β Handles both simple concepts, like presenting/stack navigation, and complex concepts, such as content-driven (deep linking) and step-by-step navigation (See Examples app)
- β Built on native SwiftUI components, leveraging state-driven architecture in the background
- β Clearly separates the navigation and presentation layers
- β Compatible with modular architecture
- β Perfect for everything from simple apps to large-scale projects
- β You can choose any architecture that fits your needsβMV, MVVM, or even TCA
- β Fully customizable and extendable to fit your specific needs
- β Inspired by the well-known Coordinator pattern but without the hassle of manually managing parent-child relationships
- β Supports iOS 16 and laterβwith zoom transition on the stack available starting from iOS 18
- β Supports iPad as well β optimized for multi-window experiences
- β
Enables calling environment actions, such as
requestReview
- β
Supports backward compatibility with UIKit via
UIViewControllerRepresentable
β easily presentSFSafariViewController
orUIActivityViewController
- β Supports Swift 6 and is concurrency safe
In SwiftUI, State
/Model
/ViewModel
serves as the single source of truth for the view's content. This framework separates the state of navigation into a dedicated model called NavigationModel
.
Think of it as a screen/module or what you might recognize as a coordinator or router. These NavigationModels
form a navigation graph, where each NavigationNodel
maintains its own state using @Published
properties. This state is rendered using native SwiftUI mechanisms, and when the state changes, navigation occurs.
For example, when you update presentedModel
, the corresponding view for the new presentedModel
is presented. The NavigationModel
is also responsible for providing the screen's content within its body
, which is then integrated into the view hierarchy by the framework.
Below is a diagram illustrating the relationships between components when using SwiftUINavigation
alongside MVVM or MV architecture patterns:
NavigationCommand
represents an operation that modifies the navigation state of NavigationModel
. For example, a PresentNavigationCommand
sets the presentedModel
. These operations can include actions like .stackAppend(_:animated:)
(push), .stackDropLast(_:animated:)
(pop), .present(_:animated:)
, .dismiss(animated:)
, .openURL(_)
and more.
To get started, I recommend exploring the Examples app to get a feel for the framework. Afterward, you can dive deeper on your own. For more detailed information, check out the Documentation.
I highly recommend starting by exploring the Examples app. The app features many commands that you can use to handle navigation, as well as showcases common flows found in many apps. It includes everything from easy login/logout flows to custom navigation bars with multiple windows.
-
Get the repo
- Clone the repo:
git clone https://github.com/RobertDresler/SwiftUINavigation
- Download the repo (don't forget to rename the downloaded folder to
SwiftUINavigation
)
- Clone the repo:
-
Open the app at path
SwiftUINavigation/Examples.xcodeproj
-
Run the app
- On simulator
- On a real device (set your development team)
-
Explore the app
To get started, first add the package to your project:
- In Xcode, add the package by using this URL:
https://github.com/RobertDresler/SwiftUINavigation
and choose the dependency rule up to next major version from2.0.1
- Alternatively, add it to your
Package.swift
file:.package(url: "https://github.com/RobertDresler/SwiftUINavigation", from: "2.0.1")
Once the package is added, you can copy this code and begin exploring the framework by yourself:
MV
Click to view the example code π
import SwiftUI
import SwiftUINavigation
@main
struct YourApp: App {
@StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(
HomeNavigationModel()
)
var body: some Scene {
WindowGroup {
RootNavigationView(rootModel: rootNavigationModel)
}
}
}
@NavigationModel
final class HomeNavigationModel {
var body: some View {
HomeView()
}
func showDetail(onRemoval: @escaping () -> Void) {
let detailNavigationModel = DetailNavigationModel()
.onMessageReceived { message in
switch message {
case _ as RemovalNavigationMessage:
onRemoval()
default:
break
}
}
execute(.present(.sheet(.stacked(detailNavigationModel))))
}
}
struct HomeView: View {
@EnvironmentNavigationModel private var navigationModel: HomeNavigationModel
@State private var dismissalCount = 0
var body: some View {
VStack {
Text("Hello, World from Home!")
Text("Detail dismissal count: \(dismissalCount)")
Button(action: { showDetail() }) {
Text("Go to Detail")
}
}
}
func showDetail() {
navigationModel.showDetail(onRemoval: { dismissalCount += 1 })
}
}
@NavigationModel
final class DetailNavigationModel {
var body: some View {
DetailView()
}
}
struct DetailView: View {
@EnvironmentNavigationModel private var navigationModel: DetailNavigationModel
var body: some View {
Text("Hello world from Detail!")
}
}
MVVM
Click to view the example code π
import SwiftUI
import SwiftUINavigation
@main
struct YourApp: App {
@StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(
HomeNavigationModel()
)
var body: some Scene {
WindowGroup {
RootNavigationView(rootModel: rootNavigationModel)
}
}
}
@NavigationModel
final class HomeNavigationModel {
private lazy var viewModel = HomeViewModel(navigationModel: self)
var body: some View {
HomeView(viewModel: viewModel)
}
func showDetail() {
let detailNavigationModel = DetailNavigationModel()
.onMessageReceived { [weak self] message in
switch message {
case _ as RemovalNavigationMessage:
self?.viewModel.dismissalCount += 1
default:
break
}
}
execute(.present(.sheet(.stacked(detailNavigationModel))))
}
}
@MainActor class HomeViewModel: ObservableObject {
@Published var dismissalCount = 0
private unowned let navigationModel: HomeNavigationModel
init(dismissalCount: Int = 0, navigationModel: HomeNavigationModel) {
self.dismissalCount = dismissalCount
self.navigationModel = navigationModel
}
}
struct HomeView: View {
@EnvironmentNavigationModel private var navigationModel: HomeNavigationModel
@ObservedObject var viewModel: HomeViewModel
var body: some View {
VStack {
Text("Hello, World from Home!")
Text("Detail dismissal count: \(viewModel.dismissalCount)")
Button(action: { navigationModel.showDetail() }) {
Text("Go to Detail")
}
}
}
}
@NavigationModel
final class DetailNavigationModel {
private lazy var viewModel = DetailViewModel(navigationModel: self)
var body: some View {
DetailView(viewModel: viewModel)
}
}
@MainActor class DetailViewModel: ObservableObject {
private unowned let navigationModel: DetailNavigationModel
init(navigationModel: DetailNavigationModel) {
self.navigationModel = navigationModel
}
}
struct DetailView: View {
@EnvironmentNavigationModel private var navigationModel: DetailNavigationModel
@ObservedObject var viewModel: DetailViewModel
var body: some View {
Text("Hello world from Detail!")
}
}
To see the framework in action, check out the code in the Examples App. If anything is unclear, feel free to reach out! I'm happy to clarify or update the documentation to make things more straightforward. π
Click here to see more π
The `RootNavigationView` is the top-level hierarchy element. It is placed inside a `WindowGroup` and holds a reference to the root `NavigationModel`. Avoid nesting one `RootNavigationView` inside anotherβuse it only at the top level.The only exception is when integrating SwiftUINavigation
into an existing project that uses UIKit-based navigation. In this case, RootNavigationView
allows you to bridge between SwiftUI and UIKit navigation patterns.
The root model should be created using the @StateObject
property wrapper, for example, in your App
:
@main
struct YourApp: App {
@StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(HomeNavigationModel())
var body: some Scene {
WindowGroup {
RootNavigationView(rootModel: rootNavigationModel)
}
}
}
Click here to see more π
A NavigationModel
represents a single model in the navigation graph, similar to what you might know as a Coordinator or Router. You typically have one NavigationModel
for each module or screen. A NavigationModel
manages the navigation state for the certain module.
In body
, you return the implementation of your moduleβs view.
The minimal working example is shown below. If you support iOS 17+, YourModel
can use the @Observable
macro instead. In that case, you would assign it as an environment value in body
rather than passing it in the initializer.
MVVM:
@NavigationModel
final class YourNavigationModel {
private lazy var viewModel = YourViewModel(navigationModel: self)
var body: some View {
YourView(viewModel: viewModel)
}
}
@MainActor class YourViewModel: ObservableObject {
private unowned let navigationModel: YourNavigationModel
init(navigationModel: YourNavigationModel) {
self.navigationModel = navigationModel
}
}
struct YourView: View {
@EnvironmentNavigationModel private var navigationModel: YourNavigationModel
@ObservedObject var viewModel: YourViewModel
var body: some View {
Text("Hello, World from Your Module!")
}
}
MV:
@NavigationModel
final class YourNavigationModel {
var body: some View {
YourView()
}
}
struct YourView: View {
@EnvironmentNavigationModel private var navigationModel: YourNavigationModel
var body: some View {
Text("Hello, World from Your Module!")
}
}
Keep in mind that any property of a class marked with one of these macros, which is settable (var
) and is not lazy, is automatically marked as @Published
.
-
@NavigationModel
The simplest Model youβll use most of the time, especially if your screen doesnβt require any tabs or switch logic. You will also use this macro if you want to create your own container model. -
@StackRootNavigationModel
Represents what you would typically associate with aNavigationStack
orUINavigationController
container. Most of the time, you don't have to create your own implementation; you can use the predefined container model.stacked
/DefaultStackRootNavigationModel
like this:.stacked(HomeNavigationModel())
If you want to create your own implementation, you can update the Model's body using
body(for:)
. -
@TabsRootNavigationModel
Represents what you would typically associate with aTabView
orUITabBarController
container. You can create your own implementation ofTabsRootNavigationModel
like this (for more, see Examples App):
@TabsRootNavigationModel
final class MainTabsNavigationModel {
enum Tab {
case home
case settings
}
var selectedTabModelID: AnyHashable
var tabsModels: [any TabModel]
init(initialTab: Tab) {
selectedTabModelID = initialTab
tabsModels = [
DefaultTabModel(
id: Tab.home,
image: Image(systemName: "house"),
title: "Home",
navigationModel: .stacked(HomeNavigationModel())
),
DefaultTabModel(
id: Tab.settings,
image: Image(systemName: "gear"),
title: "Settings",
navigationModel: .stacked(SettingsNavigationModel())
)
]
}
func body(for content: TabsRootNavigationModelView<MainTabsNavigationModel>) -> some View {
content // Modify default content if needed
}
}
-
@SwitchedNavigationModel
Use this macro to create aNavigationModel
container that can dynamically switch between different child Models.This
NavigationModel
is useful for scenarios like:- A root
NavigationModel
that displays either the tabs rootNavigationModel
or the loginNavigationModel
based on whether the user is logged in. - A subscription
NavigationModel
that shows different content depending on whether the user is subscribed. - And more...
See the example below, or for a practical implementation, check out the Examples App.
- A root
final class UserService {
@Published var isUserLogged = false
}
@SwitchedNavigationModel
final class AppNavigationModel {
var switchedModel: (any NavigationModel)?
let userService: UserService
init(userService: UserService) {
self.userService = userService
}
func body(for content: SwitchedNavigationModelView<AppNavigationModel>) -> some View {
content
.onReceive(userService.$isUserLogged) { [weak self] in self?.switchModel(isUserLogged: $0) }
}
private func switchModel(isUserLogged: Bool) {
execute(
.switchModel(
isUserLogged
? MainTabsNavigationModel(initialTab: .home)
: LoginNavigationModel()
)
)
}
}
-
.stacked
/DefaultStackRootNavigationModel
A generic@StackRootNavigationModel
container that you can use in most cases without needing to create your own. You can create it using either by usingDefaultStackRootNavigationModel
or with its static.stacked
getters.@main struct YourApp: App { @StateObject private var rootNavigationModel = DefaultStackRootNavigationModel(HomeNavigationModel()) var body: some Scene { WindowGroup { RootNavigationView(rootModel: rootNavigationModel) } } } ... in the app execute(.present(.sheet(.stacked(DetailNavigationModel()))))
- You can also pass
StackTabBarToolbarBehavior
as an argument like this:
.stacked(..., tabBarToolbarBehavior: .hiddenWhenNotRoot(animated: false))
. This will hide the tab bar toolbar when the root view is not visible..automatic
- Preserves the default behavior..hiddenWhenNotRoot(animated:)
- Hides the tab bar when the root view is not visible - could be animated or not.
- You can also pass
-
SFSafariNavigationModel
A Model that opens a URL in an in-app browser.
Click here to see more π
Each Model maintains its state as @Published
properties inside NavigationModel
. By using any of the navigation Model macros, all settable properties (var
) that are not lazy are automatically marked with the @Published
property wrapper, allowing you to observe these changes inside the body
.
Click here to see more π
To perform common navigation actions like append, present, or dismiss, you need to modify the navigation state. In the framework, this is handled using NavigationCommand
s. These commands allow you to dynamically update the state to reflect the desired navigation flow. Many commands are already predefined within the framework (see Examples App).
A command is executed on a NavigationModel
using the execute(_:)
method:
@NavigationModel
final class HomeNavigationModel {
...
func showDetail() {
execute(.present(.sheet(.stacked(DetailNavigationModel()))))
}
}
.stackAppend
/StackAppendNavigationCommand
Adds a newNavigationModel
to the stack - you can think of it as a push.stackDropLast
/StackDropLastNavigationCommand
Hides the lastk
NavigationModel
s in the stack - you can think of it as a pop.stackDropToRoot
/StackDropToRootNavigationCommand
Leaves only the firstNavigationModel
in the stack - you can think of it as a pop to root.stackSetRoot
/StackSetRootNavigationCommand
Replaces the rootNavigationModel
in the stack.stackMap
/StackMapNavigationCommand
Changes the stack - you can create your own command using this one
.present
/PresentNavigationCommand
Presents aNavigationModel
on the highest Model that can presentPresentOnGivenModelNavigationCommand
Presents aNavigationModel
on the specified Model
.dismiss
/DismissNavigationCommand
Dismisses the highest presented ModelDismissJustFromPresentedNavigationCommand
Dismisses theNavigationModel
on which it is called, if it is the highest presentedNavigationModel
.hide
/ResolvedHideNavigationCommand
Dismisses theNavigationModel
if possible, otherwise drops the lastNavigationModel
in the stack.tabsSelectItem
/TabsSelectItemNavigationCommand
Changes the selected tab in the nearest tab bar.switchModel
/SwitchNavigationCommand
If the calledNavigationModel
is aSwitchedNavigationModel
, it switches itsswitchedModel
.openWindow
/OpenWindowNavigationCommand
Opens a new window with ID.dismissWindow
/DismissWindowNavigationCommand
Closes the window with ID.openURL
/OpenURLNavigationCommand
Opens a URL usingNavigationEnvironmentTrigger
(see NavigationEnvironmentTrigger)
The framework is designed to allow you to easily create your own commands as well (see Examples App).
Click here to see more π
Since presenting views using native mechanisms requires separate view modifiers, this could lead to unintended scenarios where fullScreenCover
, sheet
, and alert
are presented simultaneously (or at least this is what your declaration looks like). To address this, I introduced the concept of PresentedNavigationModel
. Each NavigationModel
internally maintains a single presentedNode
property.
Instead of presenting a NavigationModel
directly, you present only one PresentedNavigationModel
, which holds your NavigationModel
(e.g., DetailNavigationModel
). The PresentedNavigationModel
could be for example FullScreenCoverPresentedNavigationModel
representing model which gets presented as fullScreenCover
.
This approach also allows for custom implementations, such as a photo picker. To present a model, execute PresentNavigationCommand
with the PresentedNavigationModel
.
@NavigationModel
final class ProfileNavigationModel {
...
func showEditor() {
// Present fullScreenCover
execute(.present(.fullScreenCover(.stacked(ProfileEditorNavigationModel()))))
// Present sheet
execute(.present(.sheet(.stacked(ProfileEditorNavigationModel()))))
// Present sheet with editor and pushed connected services detail from the editor
execute(.present(.sheet(.stacked([ProfileEditorNavigationModel(), ConnectedServicesDetailNavigationModel()])))
// Present not wrapped in stack
execute(.present(.sheet(SFSafariNavigationModel(...))))
// Present sheet and then immediately present another one
let presentedModel = ProfileEditorNavigationModel()
execute(.present(.sheet(.stacked(presentedModel))))
presentedModel.execute(.present(.sheet(.stacked(NameEditorNavigationModel()))))
}
}
struct ProfileView: View {
@EnvironmentNavigationModel private var navigationModel: ProfileNavigationModel
var body: some View {
Button("Show editor") {
navigationModel.showEditor()
}
}
}
.fullScreenCover
/FullScreenCoverPresentedNavigationModel
Displays a full-screen modal, similar tofullScreenCover
in SwiftUI. If you want to wrap a newly presented Model into a stack Model, use.stacked
orDefaultStackRootNavigationModel
..sheet
/SheetPresentedNavigationModel
Displays a sheet, similar tosheet
in SwiftUI (you can adjust the detents to show it as a bottom sheet). If you want to wrap a newly presentedNavigationModel
into a stack Model, use.stacked
orDefaultStackRootNavigationModel
..alert
/AlertPresentedNavigationModel
Presents a standardalert
.confirmationDialog
/ConfirmationDialogPresentedNavigationModel
Presents an alert asactionSheet
When presenting models like ConfirmationDialogPresentedNavigationModel
, you may want to present it from a specific view, so that on iPad, it appears as a popover originating from that view. To do this, use the presentingNavigationSource(_:)
modifier to modify the view:
Button(...) { ... }
.presentingNavigationSource(id: "logoutButton")
Then, when presenting it, pass the
sourceID
to the command's presentedModel
:
.present(
.confirmationDialog(
...,
sourceID: "logoutButton"
)
)
You can also define your own custom presentable models, such as for handling a PhotosPicker
. In this case, you need to register these models on the NavigationWindow
using the registerCustomPresentableNavigationModels(_:)
method (see Examples App).
Click here to see more π
A NavigationModel
can send a NavigationMessage
through a message listener. You can add the listener using onMessageReceived(_:)
/addMessageListener(_:)
, and then send the message using sendMessage(_:)
. The recipient can then check which type of message it is and handle it accordingly.
execute(
.stackAppend(
DetailNavigationModel()
.onMessageReceived { [weak self] in
switch message {
case _ as RemovalNavigationMessage:
// You can access `model` from you `NavigationModel`
self?.model.handleDetailRemoval()
default:
// Or you can do nothing
break
}
}
)
)
The framework provides a predefined message, RemovalNavigationMessage
, which is triggered whenever a NavigationModel
is removed from its parent
, so you know it is being deallocated, dismissed, or dropped from the stack.
Click here to see more π
Sometimes, you need content-driven navigation, such as when backend data or notifications direct users to specific screens. How you handle this data is entirely up to you.
The basic flow works as follows:
- Receive a deep linkβfor example, from the backend after a notification is tapped.
- Pass the deep link to a
NavigationModel
βfor example, by creating a service that observes deep links inAppNavigationModel
(as demonstrated in the Examples App). - Handle the deep link in a specific
NavigationModel
βyou can access properties likechildren
,presentedModel
, or castNavigationModel
toTabsRootNavigationModel
to retrievetabsModels
.
An example approach is shown in the Examples App, where the ExamplesNavigationDeepLinkHandler
service resolves the custom HandleNavigationDeepLinkCommand
. This allows developers to handle deep links according to their needs. The exact implementation of this flow is entirely up to you.
Click here to see more π
Sometimes, we need to use View
's API, which can only be triggered from the View
itself via its EnvironmentValues
. To do this, we can send a NavigationEnvironmentTrigger
using sendEnvironmentTrigger(_:)
on a NavigationModel
. This will invoke the DefaultNavigationEnvironmentTriggerHandler
which calls the value from EnvironmentValues
.
OpenURLNavigationEnvironmentTrigger
By default, callsEnvironmentValues.openURL
OpenWindowNavigationEnvironmentTrigger
By default, callsopenWindow
DismissNavigationEnvironmentTrigger
By default, callsEnvironmentValues.dismiss
DismissWindowNavigationEnvironmentTrigger
By default, callsEnvironmentValues.dismissWindow
If you want to customize the handler (e.g., sending a custom trigger), subclass DefaultNavigationEnvironmentTriggerHandler
and set it on a NavigationWindow
using navigationEnvironmentTriggerHandler(_:)
(see Examples App).
Click here to see more π
When creating a custom container view, like in SegmentedTabsNavigationModel
in the Examples App, use NavigationModelResolvedView
to display the Model within the view hierarchy (this is e.g. how DefaultStackRootNavigationModel
works internally).
Click here to see more π
Custom transitions like zoom are supported since iOS 18+ for Stack (see Examples App).
Click here to see more π
To enable debug printing, set the following:
NavigationConfig.shared.isDebugPrintEnabled = true
By default, this will print the start and finish (deinit) of Models with their IDs, helping you ensure there are no memory leaks.
. [SomeNavigationModel E34...]: Started
. [SomeNavigationModel F34...]: Finished
You can also print the debug graph from a given NavigationModel
and its successors using printDebugGraph()
. This will help you understand the hierarchy structure.
Click here to see more π
You can explore the graph using different relationships. It's important to know that the parent/child relationship is handled automatically, so you only need to call commands. This is true unless you're implementing a custom container, in which case you can simply override children
(see SegmentedTabsNavigationModel in Examples App).
Click here to see more π
Q: Does using AnyView
cause performance issues?
A: Based on my findings, it shouldn't. AnyView
is used only at the top of the navigation layer, and it doesn't get redrawn unless there's a navigation operation. This behavior is the same whether or not you use AnyView
.
Contributions are welcome! Feel free to report any issues or request featuresβI'll be happy to assist!
If you need further assistance, feel free to reach out:
- Email: robertdreslerjr@gmail.com
- LinkedIn: Robert Dresler
If this repo has been helpful to you, consider supporting me using the link below: