onChange stops working after triggering navigationDestination(isPresented:destination:) within NavigationLink

In the code below, changes to the destination value aren't captured by ViewA if:

  1. ViewB is navigated to by changing the destination property of ViewModel.
  2. ViewA is embedded in a NavigationLink or another navigationDestination.

For example, the return button in ViewB doesn't work when accessed from ContentWithinNavigationLink, but it works from ContentDirect.

I've also noticed that the code works if I move the presentingViewB property to the ViewModel class. However, in real code, that significantly increases the complexity of ViewModel, as I have more than ten destinations to handle. Each bound with different shapes of data. I'd prefer to store an enum value in ViewModel, listen to it in ViewA, and then convert it to boolean flags and properties in ViewA—just for iOS 16 compatibility. For iOS 17 and above, the enum works perfectly with navigationDestination(item:destination:).

class ViewModel: ObservableObject {
    @Published var destination = ""
    
    func gotoB() {
        destination = "ViewB"
    }
    
    func reset() {
        destination = ""
    }
}

struct ContentWithinNavigationLink: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                ViewA()
            } label: {
                Text("Goto A")
            }
        }
    }
}

struct ContentDirect: View {
    var body: some View {
        NavigationStack {
            ViewA()
        }
    }
}

struct ViewA: View {
    
    @State private var presentingViewB = false
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        Button {
            viewModel.gotoB()
        } label: {
            Text("goto ViewB")
        }
        .navigationDestination(isPresented: $presentingViewB) {
            ViewB().environmentObject(viewModel)
        }
        .onChange(of: viewModel.destination) { newValue in
            if newValue == "ViewB" {
                presentingViewB = true
            } else {
                presentingViewB = false
            }
        }
    }
}
    
struct ViewB: View {
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        Button {
            viewModel.reset()
        } label: {
            Text("Return")
        }
        .navigationBarBackButtonHidden()
    }
}

The reason of using code like

Button {
            viewModel.gotoB()
        } label: {
            Text("goto ViewB")
        }

in ViewA is because in real code the Button is actually several subviews where the navigation-triggering code is deeply nested.

Please IGNORE the post above 👆, as I've realised the issue is just about onChange and viewA's position in the stack.

The following code snippet is revised to demonstrate the issue, or my confusion.

Only logs in View B's onChange closure are printed if you use the view ViewAInTheMiddle, then go to View B and tap the button to toggle that flag. Both logs in View A and View B's onChange closure are printed if you use the view ViewAAsRoot. However, logs in View A's onReceive closure are always printed regardless.

I know I might be misusing onChange in this scenario, but could someone please point out where the misuse is? Thanks.

class ViewModel: ObservableObject {
    @Published var flag = false
}

struct ViewAInTheMiddle: View {
    var body: some View {
        NavigationStack {
            NavigationLink {
                ViewA()
            } label: {
                Text("Goto viewA")
            }
        }
    }
}

struct ViewAAsRoot: View {
    var body: some View {
        NavigationStack {
            ViewA()
        }
    }
}

struct ViewA: View {
    
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        NavigationLink(destination: {
            ViewB(viewModel: viewModel)
        }, label: {
            Text("goto ViewB")
        })
        .onReceive(viewModel.$flag) { newValue in
            print("ViewA receiving - flag: \(newValue)")
        }
        .onChange(of: viewModel.flag) { newValue in
            print("ViewA - flag: \(newValue)")
        }
    }
}

struct ViewB: View {
    @ObservedObject var viewModel: ViewModel
    var body: some View {
        Button {
            viewModel.flag.toggle()
        } label: {
            Text("Toggle the flag")
        }
        .onChange(of: viewModel.flag) { newValue in
            print("ViewB - flag: \(newValue)")
        }
    }
}

I adjusted the code to the following form to better reflect the problem of onChange in a multi-layer nested navigation hierarchy.

class ViewModel: ObservableObject {
    @Published var count = 0
}

struct Root: View {
    @StateObject private var viewModel = ViewModel()
    var body: some View {
        NavigationStack {
            SubView(level: 1)
                .navigationDestination(for: Int.self) { value in
                    SubView(level: value)
                }
        }
        .environmentObject(viewModel)
    }
}

struct SubView: View {
    let level: Int
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        VStack {
            let _ = print("level \(level) Update: \(viewModel.count)")
            Text("Level: \(level)")
            Text("Count: \(viewModel.count)")
            Button("Count++") {
                viewModel.count += 1
            }
            NavigationLink(value: level + 1) {
                Text("Goto New Level")
            }
        }
        .task(id: viewModel.count) {
            print("Level \(level) onTaskID: \(viewModel.count)")
        }
        .onChange(of: viewModel.count){ _ in
            print("Level \(level) onChange: \(viewModel.count)")
        }
        .onReceive(viewModel.$count){ _ in
            print("Level \(level) onReceive: \(viewModel.count)")
        }
    }
}

Clicking "Goto New Level" will add a level, and clicking "Count++" will increase the count value by one When the navigation exceeds 2 levels, only the bottom and top onChange closures will respond No matter how many levels of navigation there are, only the top task(id:) will respond No matter how many levels of navigation there are, onReceive at each level will respond Not sure if this is intentional or a bug, I will submit a feedback to Apple later

onChange stops working after triggering navigationDestination(isPresented:destination:) within NavigationLink
 
 
Q