Introduction
After a long hiatus from releasing new blog content, I'm back with iOS content.
I've gained valuable insights into iOS and Swift Programming at the Apple
Developer Academy. Now, with the iOS 17 updates, we have a new Observable
macro and Observable Framework. Additionally, the latest navigation system
introduced in iOS 16, NavigationStack
, is quite robust, and we'll leverage
that to our advantage.
NavigationStack
usage
The usual implementation of NavigationStack
is that you can put
NavigationLink
to allow user to navigate by pressing it.
struct ContentView: View {
let products: [Product]
var body: some View {
NavigationStack {
List(products) { product in
NavigationLink(product.title) {
ProductDetailView(product: product)
}
}
.navigationTitle("Products")
}
}
}
struct ProductDetailView: View {
let product: Product
var body: some View {
Text(product.title)
.font(.title)
.navigationTitle(product.title)
}
}swift
In the example above, we set up a basic master-detail setup. We plant the
NavigationStack
at the top of our view setup. Then, we list out messages, each
serving as a link to its details screen. Notice, we're sticking with the
old-school NavigationLink
type, and it does the job just fine here.
Navigator Pattern
I enjoy organizing my feature's navigation flow in one central location. That's why I typically use the Navigator pattern to manage navigation in a type-safe manner. Implementing the Navigator pattern with SwiftUI's new data-driven Navigation API is straightforward. Initially, we need to establish an enum type that outlines all the routes for our app, feature, or module.
enum Route: Hashable {
case first
case second
case third
case fourth
}swift
Next up, we define the view based on the route we define earlier
struct Routes: View {
let route: Route
var body: some View {
switch route {
case .first:
FirstView()
case .second:
SecondView()
case .third:
ThirdView()
case .fourth:
FourthView()
}
}
}swift
After that, we should create a single controller for the navigation. We can do
this by using a ViewModel. Remember when I said earlier that we can utilize the
new Observable Framework? We will define the ViewModel
by using the
Observable
macro.
@Observable
class RouteViewModel {
static var shared = RouteViewModel()
var navPath = [Route]() {
willSet {
previousRoute = navPath.last
}
}
private(set) var previousRoute: Route?
var currentRoute: Route? {
navPath.last
}
// MARK: - Append route to navigation path
/// Append AKA go to next route
func append(_ route: Route, before: (() -> Void)? = nil) {
before?()
navPath.append(route)
}
// MARK: - Pop route from navigation path
/// Pop AKA return to previous view in the navigation stack
func pop(before: (() -> Void)? = nil) {
guard !navPath.isEmpty else {
print("navPath is empty")
return
}
before?()
navPath.removeLast()
}
// MARK: - Pop multiple routes
/// Return to previous view multiple times in the navigation stack
func pop(_ count: Int, before: (() -> Void)? = nil) {
guard navPath.count >= count else {
print("count must not be greater than navPath.count")
return
}
before?()
navPath.removeLast(count)
}
// MARK: - Pop to root
/// Back to root view
func popToRoot(before: (() -> Void)? = nil) {
before?()
navPath.removeLast(navPath.count)
}
// MARK: - Append multiple routes
/// Append multiple routes to navigation stack
func append(_ routes: Route..., before: (() -> Void)? = nil) {
before?()
routes.forEach { route in
navPath.append(route)
}
}
}swift
Looks like pretty lengthy code, doesn't it? The navPath
variable is the source
of truth, storing the list of routes. It is an array of route and we can modify
it like any other array. We've defined some methods there to assist us in
navigating and modifying the navPath
itself. This ViewModel
will be used
globally across the app, so we should create an Environment
.
Defining the Environment
To define the navigate
Environment, we can extend the EnvironmentValues
from
SwiftUI. But first, we need to define the EnvironmentKey
with the defaultValue
of the ViewModel
itself.
struct NavigationEnvironmentKey: EnvironmentKey {
static var defaultValue: RouteViewModel = .init()
}swift
After that, we can extend the EnvironmentValues
. I'm naming it navigate
but
you can use any other names as you like.
extension EnvironmentValues {
var navigate: RouteViewModel {
get { self[NavigationEnvironmentKey.self] }
set { self[NavigationEnvironmentKey.self] = newValue }
}
}swift
Now we can use the environment globally. Next up we need to use the ViewModel
in our NavigationStack
. In this implementation we are going to do it in the
App
struct.
Implementing the NavigationStack
We must use Bindable
macro instead of State
because NavigationStack
needs
to bind the NavigationPath
.
struct SwiftRoutingApp: App {
@Bindable private var routeViewModel = RouteViewModel.shared
var body: some Scene {
WindowGroup {
NavigationStack(path: $routeViewModel.navPath) {
ContentView().navigationDestination(for: Route.self) { route in
Routes(route: route)
}
}.environment(\.navigate, routeViewModel)
}
}
}swift
We now have exactly one NavigationStack
wrapping the ContentView
, and we
attach the navigationDestination
view modifier and use our previously defined
Routes struct to map the view inside of the NavigationStack
. Don't forget to
use the environment
view modifier to be able to access the view model inside
the children view.
Usage
You should note that in the previously defined Routes mapping, there are several views. Now, we will use it to demonstrate our new navigation system. The view itself isn't very complex but is sufficient for demonstration.
struct FirstView: View {
@Environment(\.navigate) private var navigate
var body: some View {
Text("First View")
Button("To second view") {
navigate.append(.second)
}
}
}swift
struct SecondView: View {
@Environment(\.navigate) private var navigate
var body: some View {
Text("Second View")
Button("To third view") {
navigate.append(.third)
}
Button("Back to root") {
navigate.popToRoot()
}
}
}swift
struct ThirdView: View {
@Environment(\.navigate) private var navigate
var body: some View {
Text("Third View")
Button("To fourth view") {
navigate.append(.fourth)
}
Button("Pop two view") {
navigate.pop(2)
}
Button("Back to root") {
navigate.popToRoot()
}
}
}swift
struct FourthView: View {
@Environment(\.navigate) private var navigate
var body: some View {
Text("Fourth View")
Button("Pop") {
navigate.pop()
}
Button("Pop to second view") {
navigate.pop(2)
}
Button("Back to root") {
navigate.popToRoot()
}
}
}swift
struct ContentView: View {
var body: some View {
FirstView()
}
}swift
When you run the app, the first view will be presented. Press the button to
navigate to the second view. Now, try interacting with all the buttons and see
how robust it is. You don't need to use NavigationLink
all over the place;
just call the navigate
method. You can also call this inside any functions or
triggers.
Of course, the source code is available here on GitHub.
Conclusion
I'm thrilled to use the new data-driven Navigation API in SwiftUI. I hope you enjoy the post. Feel free to contact me and ask your questions related to this post. Thanks for reading, and see you in the next blog!