Mastering the new iOS 16 Navigation API in SwiftUI
If you used SwiftUI before you probably know about the already “old” NavigationView API. It is important to note down that it’s got deprecated now, so whether you know of it or not, just know that you should not use it anymore, if the nature of the project allows of course.
LET’S HAVE A FIRST LOOK
Let’s see a basic example of how it looks in practice.
struct Item: Identifiable, Codable, Hashable {
var id: String {
“\(price)“ + “\(name)“ + “\(description)“
}
let name: String
let description: String
let price: Double
}
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack { // 1
Form {
Section {
ForEach(viewModel.dummyItems) { item in
NavigationLink(value: item) { // 2
LabeledContent(item.name, value: item.price, format: .number)
}
}
}
}
.navigationDestination(for: Item.self) { item in // 3
ItemDetailView(item: item)
}
.navigationTitle(“List”)
}
}
}
1 – As we can notice, we used the new NavigationStack api instead of the old, now deprecated, NavigationView. At the first glance it may not look any different, except for the syntax difference of course, but in the next example we will dive a little deeper into this api.
2 – Here we have also used a new api for the NavigationLink view. Instead of using the old deprecated NavigationLink(destination: () -> Destination, label: () -> Label) we have used the new one, namely NavigationLink(value: (Decodable & Encodable & Hashable)?, label: () -> _). The difference compared to the old api is that now we no longer provide the detail destination view as a parameter, but only the associated value for that detail view. As for the label argument it’s still the same.
3 – As we can see we, once again, use a new api, namely the one responsible for pushing the detail view. Remember the value argument that the NavigationLink used at step 2? Here, we actually make use of that value. The navigationDestination modifier associates a detail destination view using a given data type that we want to use when pushing a destination view on the navigation path stack. The first argument this view modifier takes in is the data type of the object that we used as the value in step 2. In this case I’m passing an Item type. As for the second argument we grab the actual value of the item we passed in step 2. It looks pretty neat. As a small note the value must conform to the Codable and Hashable protocols. Also this view modifier must be inside the NavigationStack view, or else it won’t push the detail destination view.
GOING FORTH IN DETAIL
Now let’s make better use of the new navigation apis and test them a bit. Using the same example as above, let’s dive a bit deeper.
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack(path: $viewModel.itemsPath) { // 1
Form {
Section {
ForEach(viewModel.dummyItems) { item in
NavigationLink(value: item) {
LabeledContent(item.name, value: item.price, format: .number)
}
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(navStackPath: $viewModel.itemsPath, item: item) // 2
}
.navigationTitle(“List”)
}
.environmentObject(viewModel) // 3
}
}
This time we can already spot a few key differences, so let’s talk about them in order.
1 – Here we notice the usage of the path argument. This takes in a binding for the state of the navigation stack. We could have had path as state property in the view itself, for the sake of this example, like so:
@State var itemsPath: [Item] = []
But let’s actually behave like it’s more than just an example. So, of course, by moving it into a view model, instead of annotating the property with @State we can use the @Published, like this:
@Published var itemsPath: [Item] = []
We can further change it to the following:
@Published var itemsPath = NavigationPath()
Now, a NavigationPath, according to Apple’s official doc is “a type-erased list of data representing the content of a navigation stack”.
They do also offer a great, great overview and code snippet on how to actually serialize the path, making a neat use of the JSONDecoder and JSONEncoder apis. Definitely do check that one out. Now back onto our example.
2 – As we can see, this time around we do not only pass in the item value, but we actually pass in the binding for the navigation path, giving us a lot of control over the navigation stack from within the detail view itself, or detail views themselves. We are gonna take a deeper look into this shortly.
3 – This shouldn’t come as a surprise for anyone that played around with SwiftUI for a bit, but if you’re not familiar with this modifier I’m going to give a short explanation here. By passing in the view model, which conforms to the ObservableObject protocol, we can actually inject the view model instance down the view hierarchy.
DETAIL DESTINATION VIEW
Now let’s take a look at how our view model implementation looks and then at the detail view.
final class ViewModel: ObservableObject {
@Published var itemsPath = NavigationPath()
@Published var selectedMenuItem: Menu?
@Published var selectedItem: Item?
var dummyItems: [Item] {
[
Item(
name: “Laptop”,
description: “Wow this is such a cool looking laptop”,
price: 1150.50
),
Item(
name: “Macbook”,
description: “Wow this is such a cool looking macbook”,
price: 2150.50
),
Item(
name: “LG TV”,
description: “Wow this is such a cool looking lg tv”,
price: 550.50
),
Item(
name: “AC”,
description: “Wow this is such a COOL AC unit, got it?”,
price: 250.50
)
]
}
func itemsFor(menuItem: Menu?) -> [Item] {
guard let menuItem else {
return []
}
switch menuItem {
case .shop:
return dummyItems
case .summerSale:
return [dummyItems[3]]
case .myOrders:
return [dummyItems[1], dummyItems[3]]
}
}
func makeRandomItem(from item: Item) -> Item {
let items = dummyItems.filter({ $0.id != item.id })
let randomItem = items[Int.random(in: 0..<items.count)]
return randomItem
}
}
struct ItemDetailView: View {
@Binding var navStackPath: NavigationPath // 1
let item: Item?
@EnvironmentObject private var viewModel: ViewModel // 2
var body: some View {
VStack {
if let item {
Text(item.name)
.font(.title)
Text(item.description)
.lineLimit(nil)
.multilineTextAlignment(.leading)
Divider()
Button {
navStackPath.append(viewModel.makeRandomItem(from: item)) // 3
} label: {
Text(“Go to a random item”)
}
Button {
navStackPath = .init() // 4
} label: {
Text(“Go to root”)
}
Spacer()
} else {
VStack {
Spacer()
Text(“Detail view”)
Spacer()
}
}
}
.padding()
.navigationTitle(item?.name ?? “item name”)
}
}
1 – This is the binding for our navigation path, giving us access to a whole new set of operations in SwiftUI.
2 – This is the view model that we injected in the environment in the parent view.
3 – As we can see here we have a simple action in which we append a new item to the navigation path. By doing so we can programmatically push a new view on the screen, appending it on our navigation stack as well. For the sake of simplicity in this example we actually push a random item from our dummy array of items, excluding the one that’s currently on screen.
4 – As we can see we can also programmatically pop to root. If the navigation path binding was not of type NavigationPath but of type [Item] we could have instead written this: navStackPath = [], achieving the same result, popping to the root view.
Of course we can use the new navigation api in a lot of different ways, and now it truly feels like we have a proper navigation api in place that’s fully ready for production.
ROUTING
We can also have multiple modifiers for the navigationDestination in a certain view, for pushing an Item type, a Menu type or any other type (for as long as it conforms to Codable and Hashable).
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack(path: $viewModel.itemsPath) {
Form {
Section {
ForEach(viewModel.dummyItems) { item in
NavigationLink(value: item) {
LabeledContent(item.name, value: item.price, format: .number)
}
}
}
Section {
ForEach(viewModel.dummyMenus) { menu in
NavigationLink(value: menu) {
LabeledContent(menu.name, value: menu.category)
}
}
}
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(navStackPath: $viewModel.itemsPath, item: item)
}
.navigationDestination(for: Menu.self) { menu in
MenuDetailView(navStackPath: $viewModel.menusPath, menu: menu)
}
.navigationTitle(“List”)
}
.environmentObject(viewModel)
}
}
But this can get quite repetitive, but thankfully we can actually follow the routing system that React follows, similar to its useReducer hook, or at least inspired by that. It feels like using the Coordinator pattern for UIKit, but this is in SwiftUI, so we finally have some sort of an equivalent in this declarative realm as well. Not that this wasn’t possible in previous releases of SwiftUI, but it just didn’t have the same control level that it has now.
So we just start by simply creating an enum with a naming of our own choice, but some suggestive, like Path, Route, Destination, etc.
enum Route: Hashable {
case item(Item)
case menu(Menu)
}
So now, with such a small addition we can replace our multiple navigationDestination call sites, regardless of their number, with just a single one, like so:
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
NavigationStack(path: $viewModel.itemsPath) {
…
}
.navigationDestination(for: Route.self) { route in
switch route {
case let .item(item):
ItemView…
case let .menu(menu):
MenuView…
}
}
.navigationTitle(“List”)
.environmentObject(viewModel)
}
}
BONUS PART
As a bonus I’m going to leave an extra code snippet on how to use the new NavigationSplitView api and also how to combine it with NavigationStack.
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {…}
private var navSplitViewContentDetail: some View {
NavigationSplitView {
List(Menu.allCases, selection: $viewModel.selectedMenuItem) { menuItem in
NavigationLink(menuItem.name, value: menuItem)
}
.navigationTitle(“Siderbar menu”)
} content: {
List(viewModel.itemsFor(menuItem: viewModel.selectedMenuItem), selection: $viewModel.selectedItem) { item in
NavigationLink(item.name, value: item)
}
.navigationTitle(“Content menu”)
} detail: {
ItemDetailView(navStackPath: $viewModel.itemsPath, item: viewModel.selectedItem)
}
.environmentObject(viewModel)
}
private var navSplitViewDetail: some View {
NavigationSplitView {
List(Menu.allCases, selection: $viewModel.selectedMenuItem) { menuItem in
NavigationLink(menuItem.name, value: menuItem)
}
.navigationTitle(“Siderbar menu”)
} detail: {
NavigationStack(path: $viewModel.itemsPath) {
List(viewModel.itemsFor(menuItem: viewModel.selectedMenuItem)) { item in
NavigationLink(value: item) {
LabeledContent(item.name, value: item.price, format: .number)
}
}
.navigationTitle(“Content menu”)
.navigationDestination(for: Item.self) { item in
ItemDetailView(navStackPath: $viewModel.itemsPath, item: item)
}
}
}
.environmentObject(viewModel)
}
}
CONCLUSION
The new api is really powerful and feels more in place with the declarative nature of SwiftUI and its data-driven apis. I really hope you enjoyed this article. Please feel free to Follow @wizlab_systems on Twitter. If you have any questions, please do not hesitate to reach out.
Thanks for reading, adventurers!