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)
}
}swiftIn 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
}swiftNext 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()
}
}
}swiftAfter 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)
}
}
}swiftLooks 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()
}swiftAfter 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 }
}
}swiftNow 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)
}
}
}swiftWe 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)
}
}
}swiftstruct 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()
}
}
}swiftstruct 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()
}
}
}swiftstruct 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()
}
}
}swiftstruct ContentView: View {
var body: some View {
FirstView()
}
}swiftWhen 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!
