SwiftUI — Different ways to navigate using NavigationStack 🗺️
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 :)