iOS18 beta2 NavigationStack: Tapping Back from a lower-level View returns to the Root View / No transition animation

This is a report of an issue that appears to be a regression regarding NavigationStack.

While investigating another issue [iOS18 beta2: NavigationStack, Views Being Popped Automatically] , I encountered this separate issue and wanted to share it.

In a NavigationStack with three levels: RootView - ContentView - SubView, tapping the Back button from the SubView returned to the RootView instead of the ContentView.

This issue is similar to one that I previously posted regarding iOS16.0 beta. https://developer.apple.com/forums/thread/715970

Additionally, there is no transition animation when moving from ContentView to SubView.

The reproduction code is as follows:

import SwiftUI

struct RootView2: View {
    @State var kind: Kind = .a
    @State var vals: [Selection] = {
        return (1...5).map { Selection(num: $0) }
    }()
    
    @State var selection: Selection?
 
    var body: some View {
        if #available(iOS 16.0, *) {
            NavigationStack {
                NavigationLink {
                    ContentView2(vals: $vals, selection: $selection)
                } label: {
                    Text("album")
                }
                .navigationDestination(isPresented: .init(get: {
                    return selection != nil
                }, set: { newValue in
                    if !newValue {
                        selection = nil
                    }
                }), destination: {
                    if let selection {
                        SubView2(kind: .a, selection: selection)
                    }
                })
                
            }
        } else {
            EmptyView()
        }
    }
}

struct ContentView2: View {
    
    @Binding var vals: [Selection]
    @Binding var selection: Selection?
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        list
            .onChange(of: self.selection) { newValue in
                print("changed: \(String(describing: newValue?.num))")
            }
    }
    
    @ViewBuilder
    private var list: some View {
        if #available(iOS 16.0, *) {
            List(selection: $selection) {
                ForEach(self.vals) { val in
                    NavigationLink(value: val) {
                        Text("\(String(describing: val))")
                    }
                }
            }
        }
    }
    
}

//
struct SubView2: View {
    let kind: Kind
    let selection: Selection
    
    var body: some View {
        Text("Content. \(kind): \(selection)")
    }
}

This problem may not be a regression but rather an issue that has not been fixed since iOS16.

Have you raised a Feedback report? If not, please do so at https://www.apple.com/feedback/ then post the FB number here.

I have posted FB14125143, and FB11599516 around the time of iOS 16 beta.

This example shows undefined behavior. A NavigationStack's path is comprised of: 0 or more value-destination links followed by 0 or more view-destination links. A value-destination link is one that pushes a value onto either the navigation path, or into a List's selection. e.g. NavigationLink("Foo", value: 42). A view-destination link is one that pushes a view onto the navigation path, e.g. NavigationLink("Bar") { MyDestinationView() }. Because view-destination links have a weaker notion of identity than value destination links, they can't precede them on the path.

The way this example uses a get-set binding is prone to animation issues as well, since it may take multiple graph updates for that binding to toggle to true, animations can be lost.

You can rewrite this like so:

struct Selection: Hashable, Identifiable {
    var num: Int
    var id: Int { num }
}
enum Kind: Hashable {
    case a
    case b
}
struct RootView2: View {
    @State var kind: Kind = .a
    @State var vals: [Selection] = (1...5).map(Selection.init(num:))

    @State var selection: Selection?

    var body: some View {
        NavigationStack {
            NavigationLink("Album", value: Kind.a)
                .navigationDestination(for: Kind.self) { kind in
                    ContentView2(vals: $vals, selection: $selection)
                }
        }
    }
}

struct ContentView2: View {

    @Binding var vals: [Selection]
    @Binding var selection: Selection?
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        List(selection: $selection) {
            ForEach(self.vals) { val in
                NavigationLink(value: val) {
                    Text("\(String(describing: val))")
                }
            }
        }
        .navigationDestination(item: $selection) { sel in
            SubView2(kind: .a, selection: sel)
        }
    }
}

struct SubView2: View {
    let kind: Kind
    let selection: Selection

    var body: some View {
        Text("Content. \(kind): \(selection)")
    }
}

One important difference above is that instead of navigationDestination(isPresented:destination:), I've used navigationDestination(item:destination), these have identical semantics, but you should always prefer the item-bound modifier if your model isn't strictly a boolean type. I've also moved that navigationDestination(item:destination) into ContentView2 since that modifier presents off of the view it is attached to as a view-destination link. These semantics are described in various WWDC sessions, but admittedly it would be nice to be codified in text on the developer docs site. There is also decent external discussion of some of these semantics here: https://hachyderm.io/@teissler/112476994716479303

Thank you for your multiple feedbacks this year and prior.

Thank you for the detailed explanation.

I have a question.

This example shows undefined behavior.

How can SwiftUI developers know that this behavior is undefined? I couldn't find it in the NavigationLink documentation.


And thank you for the code rewrite. The sample code worked as expected. In our production code, I will avoid mixing value-destination and view-destination and will use value-destination exclusively.

I also understood that using get-set binding for navigationDestination(isPresented:) can cause animation issues. To add, I am using this method to support iOS versions prior to iOS16, where navigationDestination(item:destination) is not available, and it works for versions before iOS18.

However, after slightly modifying the rewritten code to match our production code, I encountered a pop issue again. Specifically, when I changed .navigationDestination(for:) to .navigationDestination(item:) within RootView, it automatically pops during the first transition from ContentView to SubView. This issue occurs when using value-destination.

In any case, the issue in this article has been resolved, so I will continue the discussion in the other post: iOS18 beta2: NavigationStack, Views Being Popped Automatically.

Specifically, when I changed .navigationDestination(for:) to .navigationDestination(item:) within RootView, it automatically pops during the first transition from ContentView to SubView. This issue occurs when using value-destination.

When used in a NavigationStack, navigationDestination(for:destination:) pushes a value-destination. navigationDestination(item:destination:) when used in a stack pushes a view-destination.

In a NavigationStack context, the sole differentiator is whether or not the destination is represented explicitly, from the client's point of view, in the path passed to NavigationStack (or if it would be if the client weren't using the default argument for path:.

Thus, if you are presenting view-destination A with navigationDestination(item:destination:) from RootView, and then append a value 1 to the path, view A will be popped off.

iOS18 beta2 NavigationStack: Tapping Back from a lower-level View returns to the Root View / No transition animation
 
 
Q