Incomplete Swipe-back gesture causes NavigationPath mismanagement
Asked Answered
P

4

9

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.

Patrimony answered 6/10, 2022 at 17:52 Comment(4)
I have the same issue. It only occurs with NavigationStack not with NavigationView. Using NavigationStack without path-variable also has the bug. It seems not related to deprecated NavigationLinks as I have removed all and migrated to only not-deprecated ones.Volsci
There must be a single source of truth for your Navigation Path which is automatically managed by the navigation engine if you define NavigationStack. Additionally making sure to use only one NavigationStack in your app at the root level is suggested. It is good that you are not using deprecated NavigationLinks. Using the value-based links will ensure you honor the design of the new API. Models must also conform to Identifiable to prevent issues, is my understanding. I may have time next week to keep tinkering and hopefully post a solution.Patrimony
It looks like the problem is solved in iOS 16.1.Volsci
Please see my solution at github.com/andrejandre/NavStacker, it is resolvedPatrimony
P
0

iOS 16.0+ (tested on iOS 16.1)

Models for nav stack path (the basis for your value-based NavigationLinks):

enum ProductViews: Hashable {
    case allKombuchas([Kombucha])
    case allCoffees([Coffee])
}

enum DrinkProduct: Hashable {
    case kombucha(Kombucha)
    case coffee(Coffee)
}

Models (conformance to Identifiable is a best practice and prevents the need to use \.self in Lists or ForEach views, etc. Models not conforming to Identifiable could cause race-conditions or other issues with NavigationStack):

struct Kombucha: Hashable, Identifiable {
    let id = UUID()
    var name: String
    var price: Double
}

struct Coffee: Hashable, Identifiable {
    let id = UUID()
    var name: String
    var price: Double
}

Root view (navigation path could live in ViewModel object, or it could live as its own @State member within the View, which is still technically MVVM - please note, you can also use custom types for your NavigationPath, like an array of [MyCustomTypes], and then push and pop values onto that custom typed path):

struct ParentView: View {
    
    @StateObject var drinkProductViewModel = DrinkProductViewModel()
    
    var body: some View {
        ZStack {
            NavigationStack(path: self.$drinkProductViewModel.navPath) {
                List {
                    Section("Products") {
                        NavigationLink(value: ProductViews.allKombuchas(self.drinkProductViewModel.kombuchaProducts)) {
                            HStack {
                                Text("Kombuchas")
                                Spacer()
                                Image(systemName: "list.bullet")
                            }
                        }
                        NavigationLink(value: ProductViews.allCoffees(self.drinkProductViewModel.coffeeProducts)) {
                            HStack {
                                Text("Coffees")
                                Spacer()
                                Image(systemName: "list.bullet")
                            }
                        }
                    }
                }
                .navigationDestination(for: ProductViews.self) { productView in
                    switch productView {
                    case .allKombuchas(_):
                        KombuchaProductsView(drinkProductViewModel: self.drinkProductViewModel)
                    case .allCoffees(_):
                        CoffeeProductsView(drinkProductViewModel: self.drinkProductViewModel)
                    }
                }
            }
        }
    }
}

Child views (its important to use value-based NavigationLinks or else you can cause race-conditions or other bugs with the new Navigation API):

struct KombuchaProductsView: View {
    @State var drinkProductViewModel: DrinkProductViewModel
    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(drinkProductViewModel.kombuchaProducts) { kombucha in
                    NavigationLink(value: kombucha) {
                        HStack {
                            Text(kombucha.name)
                            Spacer()
                            Text("$\(kombucha.price)")
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                        }
                    }
                }
                .padding()
            }
        }
        .navigationDestination(for: Kombucha.self) { kombucha in
            KombuchaView(
                drinkProductViewModel: self.drinkProductViewModel,
                kombucha: kombucha
            )
        }
        .navigationTitle("Kombucha Selection")
        .onDisappear {
           print("KombuchaProductsView disappeared")
           print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (KombuchaProductsView)")
        }
    }
}

struct CoffeeProductsView: View {
    @State var drinkProductViewModel: DrinkProductViewModel
    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(drinkProductViewModel.coffeeProducts) { coffee in
                    NavigationLink(value: coffee) {
                        HStack {
                            Text(coffee.name)
                            Spacer()
                            Text("$\(coffee.price)")
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                        }
                    }
                    Divider()
                }
                .padding()
            }
        }
        .navigationDestination(for: Coffee.self) { coffee in
            CoffeeView(
                drinkProductViewModel: self.drinkProductViewModel,
                coffee: coffee
            )
        }
        .navigationTitle("Coffee Selection")
        .onDisappear {
            print("CoffeeProductsView disappeared")
            print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (CoffeeProductsView)")
        }
    }
}

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)")
        }
    }
}

ViewModel (for dummy purposes... again, the NavigationPath could just live in the root view, but this also shows possibilities):

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() {
        
    }
}

Finally, it's important that you consider the fact that you can use multiple enums throughout your codebase so you can leverage .navigationDestination properly. You don't need your entire app's view hierarchy to exist in one single model, or you may be forced to use a single .navigationDestination and struggle to pass properties or objects into your child views.

Patrimony answered 26/10, 2022 at 20:6 Comment(0)
S
8

I have same issue too.

It seems That navigationStack is broken.

Same issue with "back-half-swipe" gesture presents even in official Apple sample: https://developer.apple.com/documentation/swiftui/bringing_robust_navigation_structure_to_your_swiftui_app

I think that after doing so called "back-half-swipe" navigation path broke internally.

I appreciate if you clarify some feedback from Apple support!

Sydney answered 15/10, 2022 at 13:14 Comment(1)
I will do my best to update this question as my schedule permits. Some points to consider are the use of NavigationLinks which are not value based (being deprecated). Using this with NavigationStack can cause issues. It is also important to have only one source of truth for the navigation path source to prevent race conditions.Patrimony
I
5

Seems like it's fixed in iOS 16.1.

Built on Xcode 14.1, installed on iOS 16.0.3 first, had the issue. Then updated to iOS 16.1, tested the same app (no re-building or re-installing), issue gone. Probably a SwiftUI bug 😅

Iy answered 22/10, 2022 at 18:13 Comment(1)
Could you show a minimal code solution? I think it would be important to show if your observation was bug-free without deprecated NavigationLink, and using one source of truth for the Navigation Path. It would also be very interesting to see if Apple resolved this bug by seeing if this bug persists while also using deprecated code (which may be moot since its a bad practice)Patrimony
C
1

Really strange behavior that can be reproduced in even simpler scenarios. Looks like this "half"-gesture is messing up something in NavStack.

I would also note that in

struct CoffeeProductsView: View {
    @State var drinkProductViewModel: DrinkProductViewModel

@State does not make much sense to me and rather wants to be an @ObservedObject but it has no influence on the issue.

Chelyabinsk answered 13/10, 2022 at 19:26 Comment(2)
Thanks for flagging that - I will edit above to reflect the change to @ObservedObject! Merely a typo when translating to SOPatrimony
I received some good reading material from Apple Support - I will update promptly once I figure out the patternsPatrimony
P
0

iOS 16.0+ (tested on iOS 16.1)

Models for nav stack path (the basis for your value-based NavigationLinks):

enum ProductViews: Hashable {
    case allKombuchas([Kombucha])
    case allCoffees([Coffee])
}

enum DrinkProduct: Hashable {
    case kombucha(Kombucha)
    case coffee(Coffee)
}

Models (conformance to Identifiable is a best practice and prevents the need to use \.self in Lists or ForEach views, etc. Models not conforming to Identifiable could cause race-conditions or other issues with NavigationStack):

struct Kombucha: Hashable, Identifiable {
    let id = UUID()
    var name: String
    var price: Double
}

struct Coffee: Hashable, Identifiable {
    let id = UUID()
    var name: String
    var price: Double
}

Root view (navigation path could live in ViewModel object, or it could live as its own @State member within the View, which is still technically MVVM - please note, you can also use custom types for your NavigationPath, like an array of [MyCustomTypes], and then push and pop values onto that custom typed path):

struct ParentView: View {
    
    @StateObject var drinkProductViewModel = DrinkProductViewModel()
    
    var body: some View {
        ZStack {
            NavigationStack(path: self.$drinkProductViewModel.navPath) {
                List {
                    Section("Products") {
                        NavigationLink(value: ProductViews.allKombuchas(self.drinkProductViewModel.kombuchaProducts)) {
                            HStack {
                                Text("Kombuchas")
                                Spacer()
                                Image(systemName: "list.bullet")
                            }
                        }
                        NavigationLink(value: ProductViews.allCoffees(self.drinkProductViewModel.coffeeProducts)) {
                            HStack {
                                Text("Coffees")
                                Spacer()
                                Image(systemName: "list.bullet")
                            }
                        }
                    }
                }
                .navigationDestination(for: ProductViews.self) { productView in
                    switch productView {
                    case .allKombuchas(_):
                        KombuchaProductsView(drinkProductViewModel: self.drinkProductViewModel)
                    case .allCoffees(_):
                        CoffeeProductsView(drinkProductViewModel: self.drinkProductViewModel)
                    }
                }
            }
        }
    }
}

Child views (its important to use value-based NavigationLinks or else you can cause race-conditions or other bugs with the new Navigation API):

struct KombuchaProductsView: View {
    @State var drinkProductViewModel: DrinkProductViewModel
    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(drinkProductViewModel.kombuchaProducts) { kombucha in
                    NavigationLink(value: kombucha) {
                        HStack {
                            Text(kombucha.name)
                            Spacer()
                            Text("$\(kombucha.price)")
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                        }
                    }
                }
                .padding()
            }
        }
        .navigationDestination(for: Kombucha.self) { kombucha in
            KombuchaView(
                drinkProductViewModel: self.drinkProductViewModel,
                kombucha: kombucha
            )
        }
        .navigationTitle("Kombucha Selection")
        .onDisappear {
           print("KombuchaProductsView disappeared")
           print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (KombuchaProductsView)")
        }
    }
}

struct CoffeeProductsView: View {
    @State var drinkProductViewModel: DrinkProductViewModel
    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(drinkProductViewModel.coffeeProducts) { coffee in
                    NavigationLink(value: coffee) {
                        HStack {
                            Text(coffee.name)
                            Spacer()
                            Text("$\(coffee.price)")
                            Image(systemName: "chevron.right")
                                .foregroundColor(.gray)
                        }
                    }
                    Divider()
                }
                .padding()
            }
        }
        .navigationDestination(for: Coffee.self) { coffee in
            CoffeeView(
                drinkProductViewModel: self.drinkProductViewModel,
                coffee: coffee
            )
        }
        .navigationTitle("Coffee Selection")
        .onDisappear {
            print("CoffeeProductsView disappeared")
            print("Nav stack count: \(self.drinkProductViewModel.navPath.count) (CoffeeProductsView)")
        }
    }
}

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)")
        }
    }
}

ViewModel (for dummy purposes... again, the NavigationPath could just live in the root view, but this also shows possibilities):

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() {
        
    }
}

Finally, it's important that you consider the fact that you can use multiple enums throughout your codebase so you can leverage .navigationDestination properly. You don't need your entire app's view hierarchy to exist in one single model, or you may be forced to use a single .navigationDestination and struggle to pass properties or objects into your child views.

Patrimony answered 26/10, 2022 at 20:6 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.