SwiftUI — Different ways to navigate using NavigationStack 🗺️

Tales Silveira
Poatek
Published in
3 min readApr 29, 2024

In this article I'm gonna show you two approaches to make the navigation for your app :)

It's funny to see that there's no right/perfect way to create a navigation flow. I've seen it in different shapes and it depends a lot on the size and architecture of the app.

So, let's give each navigation way a name to make it more understandable.
The first approach is more direct because our views will know the navigation, while the second doesn't. Given that, let's call them:
- DirectNavigation
- IndirectNavigation

Direct navigation

This one is more like SwiftUI, since we're passing the navigation as an EnvironmentObject.

So, why don't I use only this one and call it a day?

You'll notice that our screens/components must know the navigation and which page to go, and depending on the architecture, it could be a bad choice (modularized, for example).

DirectNavigationBaseView

In this approach, we could instantiate our firstView inside the NavigationStack and start navigating by it.

struct DirectNavigationBaseView: View {

@StateObject var navigation = DirectNavigation()

// MARK: - Content
public var body: some View {
NavigationStack(path: $navigation.path) {
FirstView()
.navigationDestination(for: Page.self) { page in
navigate(to: page)
}
}
.environmentObject(navigation)
}

@ViewBuilder
func navigate(to page: Page) -> some View {
AnyView(page.destination)
}
}

DirectNavigation

public class DirectNavigation: ObservableObject {
@Published var path: [Page] = []
}

public enum Page {
case firstView
case secondView

var destination: any View {
switch self {
case .firstView:
return FirstView()
case .secondView:
return SecondView()
}
}

FirstPage

struct FirstView: View {

@EnvironmentObject var navigation: DirectNavigation
@State private var shouldPresentSheet: Bool = false

var body: some View {
VStack {
Button(action: {
navigation.path.append(.secondView)
}, label: {
Text("Go next")
})
Button(action: {
shouldPresentSheet = true
}, label: {
Text("Present sheet")
})
}
}.sheet(isPresented: $shouldPresentSheet) {
SecondView()
}
}

So, this is a very simple approach that works fine.

Now, let's see the other one

Indirect Navigation

This one concentrates the whole navigation flow knowledge, similar to UIKit's Coordinators.
Your view is going to be stupid (as it should, in my opinion), and the Navigation object is the only one who knows where to navigate:

IndirectNavigationBaseView

import SwiftUI

struct IndirectNavigationBaseView: View {

@State var path = NavigationPath()
@State var sheet: Page?

// MARK: - Content
var body: some View {
NavigationStack(path: $path) {
build(page: .firstView)
.navigationDestination(for: Page.self) { page in
build(page: page)
}
.sheet(item: $sheet) { sheet in
build(page: sheet)
}
}
}

@ViewBuilder
func build(page: Page) -> some View {
switch page {
case .firstView:
FirstView(goNextAction: {
path.append(secondView)
}, presentSheetAction: {
self.sheet = .secondView
})
case .secondView:
SecondView()
}
}
}

Page

enum Page: Identifiable {

var id: UUID {
UUID()
}

case firstView
case secondView
}

FirstView

struct FirstView: View {

var goNextAction: (() -> Void)
var presentSheetAction: (() -> Void)

var body: some View {
VStack {
Button(action: goNextAction, label: {
Text("Go next")
})
Button(action: presentSheetAction, label: {
Text("Present sheet")
})
}
}
}

This approach is simple too, but the architecture difference between them is huge

Which one is better?

Just like a lot of decisions when creating an app: It depends.
I personally prefer the indirect approach due to its enhanced separation of responsibilities.

Choose which one fits best for your architecture/personal preference!

Hopefully I've brought some insights for you today :)

--

--