iOS18 beta2: NavigationStack, Views Being Popped Automatically

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

I have been aware of an issue where views are being automatically popped within NavigationView / NavigationStack since iOS 15, and it seems to be reoccurring in iOS 18.0 beta2.

Below is the reproducible code. Additionally, in my environment, this problem does not occur iOS 18 simulator, but it does happen on an iPhone XS Max(real device) with iOS 18 beta 2.

Environment:

  • Xcode: Version 16.0 beta (16A5171c)
  • iOS: 18.0 (22A5297f)
  • iPhone: XS Max (real device)
import SwiftUI

@main
struct iOS16_4NavigationSample2App: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                NavigationLink {
                    ContentView()
                } label: {
                    Text("Content")
                }
            }
        }
    }
}

enum Kind { case none, a, b, c }
struct Value: Hashable, Identifiable {
    let id: UUID = UUID()
    var num: Int
}

@MainActor
class ContentModel: ObservableObject {
    @Published var kind: Kind = .a
    @Published var vals: [Value] = {
        return (1...5).map { Value(num: $0) }
    }()
    
    init() {}
}

struct ContentView: View {
    
    @StateObject private var model = ContentModel()
    @State private var selectedData: Value?
    @State private var isShowingSubView = false
    
    @Environment(\.dismiss) private var dismiss
    
    init() {
    }
    
    var body: some View {
        if #available(iOS 16.0, *) {
            List(selection: $selectedData) {
                ForEach(model.vals) { val in
                    NavigationLink(value: val) {
                        Text("\(val.num)")
                    }
                }
            }
            .navigationDestination(isPresented: .init(get: {
                selectedData != nil
            }, set: { newValue in
                if !newValue && selectedData != nil {
                    selectedData = nil
                }
            }), destination: {
                SubView(kind: model.kind)
            })
        }
    }
}


struct SubView: View {
    init(kind: Kind) {
        print("init(kind:)")
    }
    init() {
        print("init")
    }
    var body: some View {
        Text("Content")
    }
}

This code was shared in a different issue [iOS 16.4 NavigationStack Behavior Unstable].

I have posted FB14055676

Thanks for filing a feedback report!

Out of curiosity, have you tried using the Observable macro instead of the older ObservableObject?

https://developer.apple.com/documentation/swiftui/migrating-from-the-observable-object-protocol-to-the-observable-macro?language=objc

Out of curiosity, have you tried using the Observable macro instead of the older ObservableObject?

Thank you for your response. Using Observation, this issue no longer occurs. Since this problem does not occur in iOS 17.x, it seems to be an issue with iOS 18.0 beta 2.

Additionally, as our product code needs to support iOS 15 and later, replacing Observation—which is only available from iOS 17—with Model and View code would lead to considerable duplication and make it difficult.

I also want to share that I was able to reproduce the issue with the following code, which does not use ObservableObject or Observation.

import SwiftUI

enum Kind { case none, a, b, c }

// Selection is storing the selected values in the NavigationStack.
struct Selection: Hashable, Identifiable {
    let id = UUID()
    let num: Int
    init(num: Int) {
        self.num = num
        print("id: \(id), num: \(num)")
    }
}

// Data is corresponding to the selection.
struct Data {
    let data: Int
}

@main
struct iOS16_4NavigationSampleApp: App {
    var body: some Scene {
        WindowGroup {
            RootView()
        }
    }
}

struct RootView: View {
    var body: some View {
        if #available(iOS 16.0, *) {
            NavigationStack {
                NavigationLink {
                    ContentView()
                } label: {
                    Text("album")
                }
            }
        } else {
            EmptyView()
        }
    }
}

struct ContentView: View {
    
    @State var kind: Kind = .a
    @State var vals: [Selection] = {
        return (1...5).map { Selection(num: $0) }
    }()
    
    @State 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))")
                    }
                }
            }
            .navigationDestination(isPresented: .init(get: {
                return selection != nil
            }, set: { newValue in
                if !newValue {
                    selection = nil
                }
            }), destination: {
                SubView(kind: .a)
            })
        }
    }
    
}

//
struct SubView: View {
    init(kind: Kind) {
    }
    
    init() {
    }
    
    var body: some View {
        Text("Content")
    }
}



@hmuronaka have you tried putting the navigationDestination modifiers directly inside the NavigationStack, like this:

struct RootView: 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: {
                SubView(kind: .a)
            })
            }
        } else {
            Text("empty")
        }
    }
}

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(self.vals, selection: $selection) { val in
                NavigationLink(value: val) {
                    Text("\(String(describing: val))")
                }
            }
        }
    }
}

You'd need to pass your state variables as bindings to content view then

Thank you for your response.

I tried the suggested solution, and although it resolved the issue of the view being popped automatically, a different problem has now arisen. (The new issue: Tapping Back from a lower-level View returns to the Root View / No transition animation )

However, while this fix may work for the simplest code, I think it cannot be applied to our production code for the following three reasons:

  • This fix requires placing navigationDestination directly under NavigationStack. In our production code, we have multiple levels such as two, three, or more, each handling different data models within a large navigation view. It's not practical to describe all levels of navigationDestination directly under NavigationStack.

  • This fix introduces another problem, making it unsuitable for inclusion in our production code at this time.

  • I am unsure whether this behavior is due to a specification change in iOS 18 or a bug in the iOS 18 beta, and whether this solution is just a temporary workaround.

Regardless, thank you for your advice. I hope the behavior of NavigationStack and SwiftUI stabilizes soon.

This is similar to FB14125143 in concept. Regardless of ObservableObject or @Observed, this is an unsupported construction of the Navigation APIs. See the answer on this post for the explanation.

Regarding the get/set boolean mapping, (and I realize there is unfortunately an availability cliff between iOS 16, and iOS 17) please wherever you can afford it, don't using navigationDestination(isPresented:destination) with a get/set binding that maps to an item under the hood. Instead, use the navigationDestination(item:destination) modifier. For iOS 16, you may need to fall back to that construction, but it may result in lost frames or animations when the binding has to effectively update twice in one frame. In iOS 16, unless it's critical for deep-linking, try using view-destination links instead or represent that functionality by appending an item to the navigation path instead of setting a selection.

Thank you for the explanation at [https://developer.apple.com/forums/thread/758371].

As you understand, I am using navigationDestination(isPresented ) with get/set binding because navigationDestination(item:) is only supported from iOS 17 onwards. Our product code supports iOS 16 and later. (The product itself also supports iOS 15, so we are using NavigationView as well.)

Additionally, when I modified the code provided in the post to be closer to our product code by changing navigationDestination(for:) to navigationDestination(item:), the pop issue reoccurred (during the transition from contentView to subView).

Therefore, I believe this issue is not related to the view-destination or value-destination problem. As a workaround, I have confirmed that commenting out @Environment(.dismiss) defined in ContentView avoids the issue.

While the code in this article is very simple, our product code is enormous and has a very complex structure, making it extremely difficult to pinpoint the cause when behavior changes with or without @Environment.

The following code is automatically popped when navigating from ContentView to SubView on iOS 18 beta 2, but this does not happen on iOS 17.5.

import SwiftUI

struct Selection: Hashable, Identifiable {
    var num: Int
    var id: Int { num }
}
enum Kind: Hashable {
    case a
    case b
}


struct RootView3: View {
    @State var kind: Kind? = nil
    @State var vals: [Selection] = (1...5).map(Selection.init(num:))

    @State var selection: Selection?

    var body: some View {
        if #available(iOS 17.0, *) {
            NavigationStack {
                List(selection: $kind) {
                    NavigationLink("Album-a", value: Kind.a)
                    NavigationLink("Album-b", value: Kind.b)
                }
                .navigationDestination(item: $kind, destination: { kind in
                    ContentView3(vals: $vals, selection: $selection)
                })
//                .navigationDestination(for: Kind.self) { kind in
//                    ContentView3(vals: $vals, selection: $selection)
//                }
            }
        }
    }
}

@available(iOS 17.0, *)
struct ContentView3: 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
            SubView3(kind: .a, selection: sel)
        }
    }
}

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

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

Thanks for digging in deeper here. This looks like a regression to me. Two navigationDestination(item:destination:) view-destination should stack no problem. And the presence of the dismiss shouldn't affect it.

This will be tracked with FB14055676. Thank you again!

iOS18 beta2: NavigationStack, Views Being Popped Automatically
 
 
Q