I am looking for solutions to the following bug in my example code below. I have tried to implement the Navigator Pattern with SwiftUI 4 and the iOS 16.0 Navigation API changeset.
The example below will compile in Xcode 14.0+ and if run in simulator or devices with iOS 16.0 will produce the bug I am describing. I am wondering if this is a lack of knowledge or a platform bug. With my logs I can see that when I induce the bug with an incomplete swipe-back gesture, the element count of the nav path rises to 2, when in fact it should return to 0 at root and only hold 1 element at the first layer view.
Is there a way I can better manage the path for such a view hierarchy? Or, is this a platform level bug?
import SwiftUI
enum AppViews: Hashable {
case kombuchaProductsView
case coffeeProductsView
case customerCartView
}
struct RootView: View {
@StateObject var drinkProductViewModel = DrinkProductViewModel()
var body: some View {
NavigationStack(path: self.$drinkProductViewModel.navPath) {
List {
Section("Products") {
NavigationLink(value: AppViews.kombuchaProductsView) {
HStack {
Text("View all Kombuchas")
Spacer()
Image(systemName: "list.bullet")
}
}
NavigationLink(value: AppViews.coffeeProductsView) {
HStack {
Text("View all Coffees")
Spacer()
Image(systemName: "list.bullet")
}
}
}
Section("Checkout") {
NavigationLink(value: AppViews.customerCartView) {
HStack {
Text("Cart")
Spacer()
Image(systemName: "cart")
}
}
}
}
.navigationDestination(for: AppViews.self) { appView in
switch appView {
case .kombuchaProductsView:
KombuchaProductsView(drinkProductViewModel: self.drinkProductViewModel)
case .coffeeProductsView:
CoffeeProductsView(drinkProductViewModel: self.drinkProductViewModel)
case .customerCartView:
Text("Not implemented")
}
}
}
.onAppear {
print("RootView appeared.")
print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (RootView)")
}
}
}
struct KombuchaProductsView: View {
@ObservedObject var drinkProductViewModel: DrinkProductViewModel
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(drinkProductViewModel.kombuchaProducts, id: \.self) { kombucha in
NavigationLink {
KombuchaView(
drinkProductViewModel: self.drinkProductViewModel,
kombucha: kombucha
)
} label: {
HStack {
Text(kombucha.name)
Spacer()
Text("$\(kombucha.price)")
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
}
Divider()
}
.padding()
}
}
.navigationTitle("Kombucha Selection")
.onAppear {
print("KombuchaProductsView appeared.")
print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (KombuchaProductsView)")
}
.onDisappear {
print("KombuchaProductsView disappeared")
}
}
}
struct CoffeeProductsView: View {
@ObservedObject var drinkProductViewModel: DrinkProductViewModel
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(drinkProductViewModel.coffeeProducts, id: \.self) { coffee in
NavigationLink {
CoffeeView(
drinkProductViewModel: self.drinkProductViewModel,
coffee: coffee
)
} label : {
HStack {
Text(coffee.name)
Spacer()
Text("$\(coffee.price)")
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
}
Divider()
}
.padding()
}
}
.navigationTitle("Coffee Selection")
.onAppear {
print("CoffeeProductsView appeared")
print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (CoffeeProductsView)")
}
.onDisappear {
print("CoffeeProductsView disappeared")
}
}
}
struct KombuchaView: View {
@ObservedObject var drinkProductViewModel: DrinkProductViewModel
@State var kombucha: Kombucha
var body: some View {
VStack {
Text("Price:")
.font(.title)
Text("\(kombucha.price)")
.font(.callout)
}
.navigationTitle(kombucha.name)
.onAppear {
print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (KombuchaView)")
}
}
}
struct CoffeeView: View {
@ObservedObject var drinkProductViewModel: DrinkProductViewModel
@State var coffee: Coffee
var body: some View {
VStack {
Text("Price:")
.font(.title)
Text("\(coffee.price)")
.font(.callout)
}
.navigationTitle(coffee.name)
.onAppear {
print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (CoffeeView)")
}
}
}
For those interested in compiling my example precisely, here is my mock ViewModel below (it is just holding static data - it was built purely for this exploration):
class DrinkProductViewModel: ObservableObject {
@Published var navPath = NavigationPath()
@Published var customerCart = [Any]()
@Published var kombuchaProducts = [Kombucha]()
@Published var coffeeProducts = [Coffee]()
init() {
// Let's ignore networking, and assume a bunch of static data
self.kombuchaProducts = [
Kombucha(name: "Ginger Blast", price: 4.99),
Kombucha(name: "Cayenne Fusion", price: 6.99),
Kombucha(name: "Mango Tango", price: 4.49),
Kombucha(name: "Clear Mind", price: 5.39),
Kombucha(name: "Kiwi Melon", price: 6.99),
Kombucha(name: "Super Berry", price: 5.99)
]
self.coffeeProducts = [
Coffee(name: "Cold Brew", price: 2.99),
Coffee(name: "Nitro Brew", price: 4.99),
Coffee(name: "Americano", price: 6.99),
Coffee(name: "Flat White", price: 5.99),
Coffee(name: "Espresso", price: 3.99)
]
}
func addToCustomerCart() {
}
func removeFromCustomerCart() {
}
}
Please note: by an incomplete swipe-gesture, I mean that a user begins to drag the screen from the leading edge, then holds it, and returns it to its starting position and releases it so the user remains in the current view by not going back. Then going back to the parent view (not root) will cause navigation links to die.
You can observe the bug I am describing by failing to complete a swipe-back gesture from the kombucha or coffee detail views (deepest child view), and then afterward, returning to one of the product list views and attempting to click one of the navigation links (which should be dead).
Returning to the root view typically cleanses this scenario at runtime and restores the NavigationLink functionality.
NavigationStack
. Additionally making sure to use only oneNavigationStack
in your app at the root level is suggested. It is good that you are not using deprecatedNavigationLinks
. Using the value-based links will ensure you honor the design of the new API. Models must also conform toIdentifiable
to prevent issues, is my understanding. I may have time next week to keep tinkering and hopefully post a solution.