Reputation: 712
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.
Upvotes: 9
Views: 2106
Reputation: 712
iOS 16.0+ (tested on iOS 16.1)
Models for nav stack path (the basis for your value-based NavigationLink
s):
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 List
s 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 NavigationLink
s 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.
Upvotes: 0
Reputation: 128
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 😅
Upvotes: 5
Reputation: 81
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!
Upvotes: 8
Reputation: 5115
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.
Upvotes: 1