SwiftUI Flow Coordinator pattern with NavigationStack to coordinate navigation between views (iOS 16 +)

Michał Ziobro
Mac O’Clock
Published in
9 min readNov 11, 2022

Last year, I wrote an article on applying the Flow Coordinator pattern to SwiftUI using NavigationView and NavigationLink. At the last WWDC, Apple unveiled in iOS 16 a new navigation using NavigationStack, which provides more possibilities. So I decided to update the last article and apply the new tools offered by SwiftUI to implement the Flow Coordinator pattern.

If you are interested in using the Flow Coordinator pattern in older versions of SwiftUI see this link.

Below is a diagram of the new implementation of the Flow Coordinator pattern in SwiftUI.

  1. New Navigation primitives in iOS 16

First, a brief introduction to the new tools introduced by Apple at WWDC 2022 in iOS 16. A new NavigationStack navigation component has been introduced instead of NavigationView.navigationStyle(.stack).

The usual NavigationView has been replaced by a new NavigationSplitView component.

Instead of two navigation links NavigationLink(… isActive: …) and NavigationLink(… tag: … selection: …) we now have NavigationLink(… value: …).

Currently NavigationStack takes as argument path that enables as to append (push) new screens or remove this screens (pop). We can even push/pop multiple screens at the same time.

We can define this path using both array of single type

@State private var path: [Recipe] = []

or using special NavigationPath() type that can stored type-erased objects of different types.


@State var path = NavigationPath()

Then you use

NavigationStack(path: $path) { 
ZStack {
...
NavigationLink(..., value: recipe)
}
.navigationDestination(for: Recipe.self) { recipe in RecipeDetailsView(recipe) }
}

You can even use it with List to define Master-Detail views like in the below example

NavigationSplitView {
List(Category.allCases, selection: $selectedCategory) { category in
NavigationLink(category.localizedName, value: category)
}
} detail: {
NavigationStack(path: $path) {
RecipeGrid(category: selectedCategory)
}
}

To learn more about this new navigation primitives it is worth to watch WWDC 2022 session SwiftUI cookbook for navigation.

More over for fullScreenCover() or sheet() we still use the same apis.

view .sheet(isPresented: $isShowingSheet, onDismiss: didDismiss) { 
Text(“License Agreement”)
}
view.sheet(item: sheetItem, content: sheetContent)

2. Prerequisites

In rewriting the article for the new version, I decided to simplify the code a bit and drop the definition of protocols for ViewModels and FlowStates. The main reason was to simplify the code, as well as some redundancy in them. As a rule, we write protocols to make code easier to test. With unit testing we write tests for individual view models. So in this case, we are interested in writing mocks for dependencies of ViewModels, rather than mocking the ViewModel itself. In the case of UI tests, on the other hand, we usually use a mock API, so mocking the view models themselves seems redundant and unnecessarily complicates the code. However, you can still define protocols for view models yourself if you find it necessary, based on last year’s article.

3. View and ViewModel

We usually separate the presentation and business logic (data preparation for views) layers by separating the code into View and ViewModel.

ViewModel should prepare all necessary data to display in view (outputs), and handle all actions coming from view (inputs).

View should just handle displaying of this data and layouting them on screen.

Simple View can look like this

struct ContentView: View {

@StateObject var viewModel: ContenViewModel

var body: some View {
ZStack {
Color.white.ignoresSafeArea()

VStack {
Text(viewModel.text)

Button("First view >", action: viewModel.firstAction)
}
}
.navigationBarTitle("Title", displayMode: .inline)
}
}

And ViewModal for this view preparing text to display and handling firstAction like this.

final class ContentViewModel {

let text: String = "Content View"

init() { }

func firstAction() {
// handle action
}
}

4. Creating FlowCoordinator

In SwiftUI all navigation primitives must be called in context of view to work correctly. So we can do some assumptions about flow coordinators:
1. Flow Coordinator is View
2. We have Flow Coordinator per screen
3. Navigation events should be passed to Flow Coordinator from ViewModel
4. We need some enum that will represent those navigation events

4.1 Creating flow coordinator state

Flow coordinator state will be open class implementing ObservableObject. It will be inherited by view model and will enable to pass events from this view model to flow coordinator.

open class ContentFlowState: ObservableObject {
@Published var path = NavigationPath()
@Published var presentedItem: ContentLink?
@Published var coverItem: ContentLink?
@Published var selectedLink: ContentLink? // old style
}

In the above code path will be used to handle navigation using new NavigationLinks and NavigationStack. The presentedItem will handle sheet presentation, while coverItem will handle full screen cover presentation. All of them will use ContentLink enum that will centralize all links leading from the current view.

4.2 Inheriting Flow state by view model

Our view models will inherit the corresponding flow state and communicate this way navigation to the flow coordinator.


final class ContentViewModel: ContentFlowState {

let text: String = "Content View"

func firstAction() {
path.append(ContentLink.firstLink(text: "Some param"))
}

func secondAction() {
path.append(ContentLink.secondLink(number: 2))
}

func thirdAction() {
path.append(ContentLink.thirdLink)
}

func customAction() {
path.append("Custom action")
}

func sheetAction() {
presentedItem = .sheetLink(item: "Sheet param")
}

func selectLinkAction() {
selectedLink = .firstLink(text: "Selected link action")
}

func coverAction() {
coverItem = .coverLink(item: "Cover param")
}
}

4.3 Creating ContentLink enum for navigation events

his enumaration defines different navigation events that can happen in our screen of application. This events can have some parameters passed along them. Moreover ContentLink enum should be Identifiable and Hashable.

enum ContentLink: Hashable, Identifiable {
case firstLink(text: String?)
case secondLink(number: Int?)
case thirdLink
case sheetLink(item: String)
case coverLink(item: String)

var id: String {
String(describing: self)
}
}

here important improvement in comparison with last year article is defining id: String as String(describing: self). This approach significantly improves definition and usage of ContentLink enum in flow coordinator. It can be used together with pre iOS 16 SwiftUI navigation primitives.

*4.4 Disclaimer for pre iOS 16 Navigation

To significantly simplify coordination of navigation in pre — iOS 16 code I recommand defining extention on NavigationLink that will make usage of NavigationLink more similar to sheet(item: ) or fullScreenCover(item: )

public extension NavigationLink {
init<T, ItemDestination: View>(
destination: @escaping (T) -> ItemDestination,
item: Binding<T?>,
label: () -> Label
) where Destination == AnyView {
let isActive = Binding(
get: {
item.wrappedValue != nil
},
set: { active in
guard !active else { return }
item.wrappedValue = nil
}
)

let itemDestination = {
AnyView(Unwrap(item.wrappedValue, content: destination))
}

self.init(isActive: isActive, destination: itemDestination, label: label)
}
}

4.5 Implementing per screen FlowCoordinator view

The most essential part of our Flow coordinator patter will be ContentFlowCoordinator view. It will handle all per screen navigation logic.

Firstly I will demonstrate how such coordinator can look and then explain some things.

struct ContentFlowCoordinator<Content: View>: View {

@ObservedObject var state: ContentFlowState
let content: () -> Content

var body: some View {
NavigationStack(path: $state.path) {
ZStack {
content()
.sheet(item: $state.presentedItem, content: sheetContent)
.fullScreenCover(item: $state.coverItem, content: coverContent)

navigationLinks
}
.navigationDestination(for: ContentLink.self, destination: linkDestination)
.navigationDestination(for: String.self, destination: customDestination)
}
}

private var navigationLinks: some View {
/// to make this link work you need to replace NavigationStack with NavigationView!
NavigationLink(destination: linkDestination, item: $state.selectedLink) { EmptyView() }
}

@ViewBuilder private func linkDestination(link: ContentLink) -> some View {
switch link {
case let .firstLink(text):
firstDestination(text)
case let .secondLink(number):
secondDestination(number)
case .thirdLink:
thirdDestination()
default:
EmptyView()
}
}

@ViewBuilder private func sheetContent(item: ContentLink) -> some View {
switch item {
case let .sheetLink(text):
SheetView(viewModel: SheetViewModel(text: text))
default:
EmptyView()
}
}

@ViewBuilder private func coverContent(item: ContentLink) -> some View {
switch item {
case let .coverLink(text):
CoverView(viewModel: CoverViewModel(text: text))
default:
EmptyView()
}
}

private func customDestination(text: String) -> some View {
Text(text)
}

private func firstDestination(_ text: String?) -> some View {
let viewModel = FirstViewModel(path: $state.path, text: text)
let view = FirstView(viewModel: viewModel)
return view
}

private func secondDestination(_ number: Int?) -> some View {
let viewModel = SecondViewModel(path: $state.path, number: number)
let view = SecondView(viewModel: viewModel)
return view
}

private func thirdDestination() -> some View {
let viewModel = ThirdViewModel(path: $state.path)
let view = ThirdView(viewModel: viewModel)
return view
}
}

Firstly its init (here implicit) will have to parameters.
1. state that is of type inheriting ContentFlowState.
2. content which will be screen view @ViewBuilder

Secondly state need to be stored as @ObservedObject and it shouldn’t be @StateObject as ContentFlowState is inherited by ContentViewModel and this view model will be already stored as @StateObject in screen ContentView.

All navigation logic is implemented inside ContentFlowCoordinator body computed property. There is added NavigationStack(path: $state.path), attached .navigationDestination(for: ContentLink.self, …) modifier and attached .sheet(item:…) and .fullScreenCover(item: ) modifiers.

Last but not least we have factory functions that build our destination/content views. They extract eventual navigation event parameters, construct view models with them and finally view using this view model.

5. Using FlowCoordinator with View

The final step that remain to complete ContentView screen is to put it all together and implement this view. This is the same view you saw at the beginning of this tutorial but with added our brand new ContentFlowCoordinator.

struct ContentView: View {

@StateObject var viewModel: ContentViewModel

var body: some View {
ContentFlowCoordinator(state: viewModel, content: content)
}

@ViewBuilder private func content() -> some View {
ZStack {
Color.white.ignoresSafeArea()

VStack {
Text(viewModel.text)

Text("Buttons")

Group {
Button("First view >", action: viewModel.firstAction)
Button("Second view >", action: viewModel.secondAction)
Button("Third view >", action: viewModel.thirdAction)
Button("Custom view >", action: viewModel.customAction)

Button("Select link (old) >", action: viewModel.selectLinkAction)

Text("Presentation")
Button("Sheet view", action: viewModel.sheetAction)
Button("Cover view", action: viewModel.coverAction)
}


Text("Links")

NavigationLink(value: ContentLink.firstLink(text: "Link param")) {
Text("First link >")
}
NavigationLink(value: ContentLink.secondLink(number: 200)) {
Text("Second link >")
}

NavigationLink(value: ContentLink.thirdLink) {
Text("Third link >")
}

NavigationLink(value: "Custom link") {
Text("Custom link >")
}
}
}
.navigationBarTitle("Title", displayMode: .inline)
}
}

In the above code inside content computed property you can test different kinds of navigations. Firstly you have buttons with actions executed on view model. This view model actions pass state to its parent flow state ex. appending values to NavigationPath, or assigning presentedItem or coverItem.
You can also skip view model actions and directly use brand new NavigationLinks like

NavigationLink(value: ContentLink.firstLink(text: "Link param")) {
Text("First link >")
}

Or even use different type of value for NavigationLink

NavigationLink(value: "Custom link") {
Text("Custom link >")
}

It will be handled by separate .navigationDestination(for: String.self, destination: customDestination) view modifier in content flow coordinator.

6. Extras added by new iOS16-based Navigation

The most exciting part of new NavigationLink primitives it that you can push/pop multiple screens at the same time or pop to root view very easily.
To achive this we pass NavigationPath between flow states, i.e. from parent flow state to child flow state.

open class SecondFlowState: ObservableObject {
@Published var presentedItem: SecondLink?

@Binding var path: NavigationPath

init(path: Binding<NavigationPath>) {
_path = path
}
}

and below is the definition of SecondView with usage of this SecondFlowState.

 private func secondDestination(_ number: Int?) -> some View {
let viewModel = SecondViewModel(path: $state.path, number: number)
let view = SecondView(viewModel: viewModel)
return view
}

On the other hand in the case of view presentations like .sheet(item:) or .fullScreenCover(item:) we start there with brand new NavigationStack(path:) that has separate NavigationPath().

open class SheetFlowState: ObservableObject {
@Published var sheetPath = NavigationPath()
@Published var presentedItem: SheetLink?
}

struct SheetFlowCoordinator<Content: View>: View {

@ObservedObject var state: SheetFlowState
let content: () -> Content

var body: some View {
NavigationStack(path: $state.sheetPath) {
content()
.sheet(item: $state.presentedItem, content: sheetContent)
.navigationDestination(for: SheetLink.self, destination: linkDestination)
}
}

@ViewBuilder private func linkDestination(link: SheetLink) -> some View {
EmptyView()
}

@ViewBuilder private func sheetContent(item: SheetLink) -> some View {
EmptyView()
}
}

And the last code snippet shows how easily you can pop to root from ThirdView to ContentView (root view)

final class ThirdViewModel: ThridFlowState {

let text = "Default Third View"

func popToRootAction() {
path = .init()
}
}

Link to Github project:

--

--