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
- ✅ Supports custom
fullScreenCover
transitions (e.g.,scale
,opacity
) from iOS 17 and stack transitions (e.g.,zoom
) from iOS 18 - ✅ Supports iPadOS 16, macOS 13, and Mac Catalyst 16 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 NavigationModel
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.
If you prefer to explore the framework on your own, check out Explore on Your Own and the Documentation.
Click to see details 👈
- The app is modularized using SPM to demonstrate its compatibility with a modular architecture. However, when integrating it into your app, you can keep everything in a single module if you prefer.
- Some modules follow the MV architecture, while others with a ViewModel use MVVM. The choice of architecture is entirely up to you—SwiftUINavigation solely provides a solution for the navigation layer.
- Dependencies for
NavigationModel
s are handled via initializers. To avoid passing them in every init, you can use a dependency manager like swift-dependencies. - There is a
Shared
module that contains e.g. objects for deep linking, which can be used across any module. Implementations of certain services are located in the main app within theDependencies
folder. - The
ActionableList
module serves as a generic module for list screens with items. To see what items each list contains, check the implementation of factories in the module’sData/Factories/...
folder.
-
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)
-
(optional) The app might fail to build if SwiftUINavigation’s macros aren’t enabled. In some cases, you may need to explicitly enable them. In Xcode, simply click on the error message and choose Trust & Enable to resolve it.
-
Explore the app
Click to see details 👈
- 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.2.2
- Alternatively, add it to your
Package.swift
file:.package(url: "https://github.com/RobertDresler/SwiftUINavigation", from: "2.2.2")
- (optional) The app or package might fail to build if SwiftUINavigation’s macros aren’t enabled. In some cases, you may need to explicitly enable them. In Xcode, simply click on the error message and choose Trust & Enable to resolve it.
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 FinishedNavigationMessage:
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 FinishedNavigationMessage:
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!")
}
}
Important
If you’re just getting started with SwiftUINavigation, I recommend reading the following sections of the README first:
- 💡 Core Idea -
NavigationModel
- Understand the core concepts behind SwiftUINavigation. - 🚦 Getting Started - Explore minimal working examples or try out 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 (or other SwiftUI based) navigation. In this case, RootNavigationView
allows you to bridge between SwiftUINavigation and your existing codebase - see Migration from UIKit based navigation and Migration from other SwiftUI based navigation.
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
See Stack Navigation -
@TabsRootNavigationModel
See Tabs Navigation -
@SwitchedNavigationModel
See Switchable Navigation
-
.stacked
/DefaultStackRootNavigationModel
See Stack Navigation -
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).
You can also define custom NavigationCommand
s to encapsulate specific behavior, such as handling deep links or implementing step-by-step navigation.
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 👈
Represents the concept typically associated with a NavigationStack
or UINavigationController
container. To implement stack navigation, you use an instance of StackRootNavigationModel
, which maintains a path of NavigationModel
s, including the root model. Each element in this path is a StackNavigationModel
, which holds the NavigationModel
itself and which can optionally include a StackNavigationTransition
, such as zoom, available since iOS 18.
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 using @StackRootNavigationModel
macro, you can update the model's body using body(for:)
.
A .stacked
/ DefaultStackRootNavigationModel
is generic @StackRootNavigationModel
container that you can use in most cases without needing to create your own. You can create it using either by using DefaultStackRootNavigationModel
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.
The fundamental commands for modifying the stack navigation state are stackAppend
and stackDropLast
. Additionally, there are other predefined commands.
execute(.stackAppend(DetailNavigationModel()))
execute(.stackAppend(StackNavigationModel(model: DetailNavigationModel(), transition: .zoom(sourceID: "item1"))))
execute(.stackDropLast())
execute(.stackDropToRoot())
...
Click here to see more 👈
Represents the concept typically associated with a TabView
or UITabBarController
container. To implement tabs navigation, use an instance of TabsRootNavigationModel
, which manages a collection of TabModel
s and tracks the selectedTabModelID
. The TabModel is a protocol, and the framework provides a default implementation, DefaultTabModel
, which holds the NavigationModel
along with an image and title for the tab bar.
To implement tabs container, you can create your own implementation of TabsRootNavigationModel
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
}
}
navigationModel
of DefaultTabModel
in a .stacked
model. However, avoid wrapping TabsRootNavigationModel
in another stack (e.g., at the App level), as this may lead to unexpected behavior.
The fundamental command for modifying the tabs navigation state is tabsSelectItem(id:)
which selects the tab with given ID.
execute(.tabsSelectItem(id: Tab.more))
Click here to see more 👈
Switchable navigation is a navigation pattern that allows dynamically changing the currently active NavigationModel
, which is then displayed in the view hierarchy. This is useful for scenarios such as:
- 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.
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()
)
)
}
}
The fundamental command for modifying the switchable navigation state is switchModel(_:)
which switches the model to model from the command's argument.
execute(.switchModel(LoginNavigationModel()))
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 presentedModel
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
. To dismiss modal you use DismissNavigationCommand
/.dismiss
.
@NavigationModel
final class ProfileNavigationModel {
...
func showEditor() {
// Present fullScreenCover
execute(.present(.fullScreenCover(.stacked(ProfileEditorNavigationModel()))))
// Present fullScreenCover with opacity transition
execute(.present(.fullScreenCover(.stacked(ProfileEditorNavigationModel()), transition: .opacity.animation(.default))))
// 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
. On iOS 17+ you can pass customtransition
..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 👈
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 HandleDeepLinkNavigationCommandFactory
service resolves the custom HandleDeepLinkNavigationCommand
. This allows developers to handle deep links according to their needs. The exact implementation of this flow is entirely up to you.
Here’s a simple example of how you can handle different deep links.
enum DeepLink {
case detail(DetailInputData)
case detailWithPushedEditor(DetailInputData, DetailEditorInputData)
case detailWithPresentedEditor(DetailInputData, DetailEditorInputData)
}
@NavigationModel
final class AppNavigationModel {
...
func handleDeepLink(_ deepLink: DeepLink) {
switch deepLink {
/// Present just detail as sheet
case let .detail(detailInputData):
let detailModel = DetailNavigationModel(inputData: detailInputData)
execute(.present(.sheet(.stacked(detailModel))))
/// Present detail as sheet with already pushed detail editor
case let .detailWithPushedEditor(detailInputData, detailEditorInputData):
let detailModel = DetailNavigationModel(inputData: detailInputData)
let detailEditorModel = DetailEditorNavigationModel(inputData: detailEditorInputData)
execute(.present(.sheet(.stacked([detailModel, detailEditorModel]))))
/// Present detail as sheet and after present detail editor as sheet over detail sheet
case let .detailWithPresentedEditor(detailInputData, detailEditorInputData):
let detailModel = DetailNavigationModel(inputData: detailInputData)
let detailEditorModel = DetailEditorNavigationModel(inputData: detailEditorInputData)
execute(.present(.sheet(.stacked(detailModel))))
detailModel.execute(.present(.sheet(.stacked(detailEditorModel))))
}
}
}
Click here to see more 👈
For inter-app deep linking, you let the system to open a URL. With SwiftUINavigation, you can handle this cleanly by executing a .openURL
or OpenURLNavigationCommand
command from NavigationModel
which internally sends OpenURLNavigationEnvironmentTrigger
with the specified URL.
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 👈
If you want to pass data from one NavigationModel
to another, you can do so via the init
method. You’re free to pass any type—whether it’s a simple String
, a struct like DetailEditorInputData
, or even a callback closure.
If you’d like to handle communication through abstraction, see Communication Between Modules - NavigationMessage.
These approaches can be combined—for example, use init
to pass input data and NavigationMessage
for callbacks.
@NavigationModel
class DetailEditorNavigationModel {
private let id: String
private let onSave: () -> Void
init(id: String, onSave: @escaping () -> Void) {
self.id = id
self.onSave = onSave
}
...
}
...
@NavigationModel
class HomeNavigationModel {
...
func showDetail(onSave: @escaping () -> Void) {
execute(
.present(
.fullScreenCover(
DetailEditorNavigationModel(
id: "123",
onSave: onSave
)
)
)
)
}
}
NavigationModel
instance—typically self
—inside a closure, you should capture it as weak
or unowned
. This prevents creating a strong reference to NavigationModel
that could lead to a memory leak.
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.
If you prefer implementing communication via init
, see Communication Between Modules - NavigationMessage.
execute(
.stackAppend(
DetailNavigationModel()
.onMessageReceived { [weak self] in
switch message {
case _ as FinishedNavigationMessage:
// You can handle it how you want, these are just examples
// When using MV you can call closure from method's argument
onDetailRemoval()
// When using MVVM you can access `viewModel` from your `NavigationModel`
self?.viewModel.handleDetailRemoval()
default:
// Or you can do nothing
break
}
}
)
)
The framework provides a predefined message, FinishedNavigationMessage
, which is triggered whenever a NavigationModel
is finished (removed from its parent
), so you know it is being deallocated, dismissed, or dropped from the stack.
NavigationModel
instance—typically self
—inside a closure, you should capture it as weak
or unowned
. This prevents creating a strong reference to NavigationModel
that could lead to a memory leak.
Click here to see more 👈
Sometimes, you may need to access a service—such as UserService
—within your NavigationModel
. How you approach this is entirely up to you, and there are multiple valid options:
- Inject dependencies through the initializer (
init
). - Use a dependency manager like swift-dependencies.
- Or any other method that fits your architecture.
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 👈
Custom fullScreenCover
transitions, such as opacity
, scale
, or custom transitions, are supported in iOS 17+ and can be passed to fullScreenCover(_, transition:)
. Make sure to include an animation
(e.g. opacity.animation(.default)
). For in-app usage, check out the Examples App.
Custom stack transitions like zoom are supported since iOS 18+ (see Examples App).
Click here to see more 👈
To bridge SwiftUINavigation solution into your existing UIKit codebase, you use RootNavigationView
. You are responsible for keeping state of the rootModel
.
class SomeViewController: UIViewController {
...
func presentNewFlow() {
let rootModel = DefaultStackRootNavigationModel(NewFlowNavigationModel())
let hostingController = UIHostingController(rootView: RootNavigationView(rootModel: rootModel))
hostingController.modalPresentationStyle = .fullScreen
present(hostingController, animated: true)
}
}
Click here to see more 👈
To bridge SwiftUINavigation solution into your existing SwiftUI codebase, you use RootNavigationView
. You are responsible for keeping state of the rootModel
.
struct SomeView: View {
@StateObject var presentedNavigationModel = DefaultStackRootNavigationModel(NewFlowNavigationModel())
@State var isPresented = false
var body: some View {
Button("Present") {
isPresented = true
}.fullScreenCover(isPresented: $isPresented) {
RootNavigationView(rootModel: presentedNavigationModel)
}
}
}
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
.
Q: Does the SwiftUINavigation Approach Impact Performance Compared to Native SwiftUI Approach?
A: I conducted performance tests measuring hitches and hangs while navigating through a graph where each node represented a screen containing a long List
with expensive animations in each row. It’s true that as the number of nodes in the navigation graph increases, performance with SwiftUINavigation gradually decreases. However, even with 80 nodes, the average hangs duration is only about 17% worse compared to the native SwiftUI approach, while hitch ratios remain very similar. In terms of absolute numbers, SwiftUINavigation produces about 30% more hitches and hangs than the native approach. However, in real-world apps, this difference is unlikely to be noticeable to the human eye and should not be a significant issue.
Q: Does using a class for NavigationModel cause any issues?
A: SwiftUINavigation requires NavigationModel
to be implemented as a class. This is in line with Apple’s own guidance for managing an app’s data model. When using a class-based model, it’s important to capture self
weakly in closures to avoid retain cycles. If you’re concerned about potential memory leaks, see the Debugging section for tips on how to verify your implementation.
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: [email protected]
- LinkedIn: Robert Dresler
If this repo has been helpful to you, consider supporting me using the link below: