iOS 16.4 NavigationStack Behavior Unstable

I've tested the behavior of NavigationStack in iOS 16.4 and found that it doesn't work in my already published app. Of course, my app works perfectly fine with the NavigationStack in iOS 16 to iOS 16.3.

I'm not sure about the cause, but when tapping on a NavigationLink in a very large and complex navigation hierarchy, the corresponding navigationDestination doesn't transition the screen and instead gets caught in an infinite loop. Has anyone else experienced similar issues?

I'd like to prepare a sample program to reproduce the issue, but it doesn't occur in simple view hierarchies, so I haven't been able to prepare one yet.

Although the release notes for iOS 16.4 mention changes related to the performance of the NavigationStack, I suspect that some instability has been introduced.

This behavior reminds me of the unintended automatic pop issue in the NavigationView of iOS 15.

In the iOS 16.4 environment, I am planning to revert to using NaviagtionView. Once I have prepared a sample program to reproduce the issue, I will update again.

There are bugs in NaviagtionView as well, but in the iOS 16.4 environment, I am temporarily planning to revert to using it. Once I have prepared a sample program to reproduce the issue, I will update again.

Post not yet marked as solved Up vote post of hmuronaka Down vote post of hmuronaka
2.8k views

Replies

Please do share the sample here, and/or file a feedback

Same problem here. I spent lots of time updating to NavigationStack, and now my published app is completely broken for iOS 16.4. Just beginning to dig into it.

Add a Comment

@nteissler Your comment helped me to calm down. I have encountered bugs in NavigationView and NavigationStack, such as those described in the following links, multiple times, which has made it difficult for me to remain calm.

https://developers.apple.com/forums/thread/715589

https://developers.apple.com/forums/thread/715970

https://developers.apple.com/forums/thread/693137


I am currently trimming down the production code and preparing the reproduction code. I have figured out the situations when the issue occurs and when it doesn't in the production code, but I have not yet reached the point of having a reproduction code.

In the production code, an infinite loop occurs on iOS 16.4 when referencing a StateObject in navigationDestination. Without referencing the StateObject, the infinite loop does not occur.

I have attached the reproducible code that I am currently working on below, with the relevant part marked with a 🌟.

Of course, there is a possibility that there is a bug in my code. However, I cannot understand why an infinite loop occurs depending on whether or not a StateObject is referenced. Furthermore, since the issue cannot be reproduced in the reproducible code, it may not be the root cause of the bug.

I am still working on creating the reproducible code, and I will share the details as soon as I know more.

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

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

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

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

struct ContentView: View {
    
    @StateObject var model: ContentModel = .init()
    
    @State var selection: Selection?
    @State var data: Data?
    
    var body: some View {
        list
            // Convert selection into data.
            .onChange(of: selection) { newValue in
                if let selection {
                    data = Data(data: selection.num * 10)
                } else {
                    data = nil
                }
            }
    }
    
    private var list: some View {
        List(selection: $selection) {
            ForEach(model.vals) { val in
                NavigationLink(value: val) {
                    Text("\(String(describing: val))")
                }
            }
        }
        // In production code, this navigationDestination is defined as a Custom ViewModifier.
        .navigationDestination(isPresented: .init(get: {
            return data != nil
        }, set: { newValue in
            if !newValue {
                data = nil
            }
        }), destination: {
            // 🌟 If the StateObject is referenced here, the destination will end up in an infinite loop. 
            // (This code has not yet reached the point of reproducing the issue, so it wont cause an infinite loop yet.)
            SubView(kind: model.kind)
            
            // If the StateObject is not referenced, it will transition to the SubView normally.
            // SubView()
        })
        .onChange(of: selection) { newValue in
            if newValue == nil && data != nil {
                data = nil
            }
        }
    }
    
}

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

One quick thing I noticed:

            .onChange(of: selection) { newValue in
                if let newValue { // not selection!
                    data = Data(data: newValue.num * 10)
                } else {
                    data = nil
                }
            }

You want to be sure to use newValue in the onChange block. Using selection kind capture the binding by value, and so you'll actually get the old value in the block someimtes.

I ran your code, modified slightly. I also had to add the NavigationStack, which I assume surrounds the List on level up in your code.

Let me know if you can evolve this to reproduce the bug.

import SwiftUI

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

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

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

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

struct MyContentView: View {

    @StateObject var model: ContentModel = .init()

    @State var selection: Selection?
    @State var data: Data?

    var body: some View {
        NavigationStack {
            list
            // Convert selection into data.
                .onChange(of: selection) { newValue in
                    if let newValue {
                        data = Data(data: newValue.num * 10)
                    } else {
                        data = nil
                    }
                }
        }
    }

    private var list: some View {
        List(selection: $selection) {
            ForEach(model.vals) { val in
                NavigationLink(value: val) {
                    Text("\(String(describing: val))")
                }
            }
        }
        // In production code, this navigationDestination is defined as a Custom ViewModifier.
        .navigationDestination(isPresented: .init(get: {
            return data != nil
        }, set: { newValue in
            if !newValue {
                data = nil
            }
        }), destination: {
            // 🌟 If the StateObject is referenced here, the destination will end up in an infinite loop.
            // (This code has not yet reached the point of reproducing the issue, so it wont cause an infinite loop yet.)
            SubView(kind: model.kind)

            // If the StateObject is not referenced, it will transition to the SubView normally.
            // SubView()
        })
        .onChange(of: selection) { newValue in
            if newValue == nil && data != nil {
                data = nil
            }
        }
    }

}

//
struct SubView: View {
    init(kind: Kind) {
    }

    init() {
    }

    var body: some View {
        Text("Content")
    }
}

  • Thank you for pointing that out. Due to a mistake during the creation of the reproduction code, the production code actually uses newValue.

  • I have created a reproducible code. Please check if it can be reproduced in your environment. Also, if you find a safe workaround when using Google AdMob, please let me know.

Add a Comment

I have created a reproduction code.

However, this issue cannot be reproduced with just this code. In my case, it occurs only in the iOS 16.4 environment when Google AdMob is installed via CocoaPods.

In this case, the 🌟 SubView gets caught in an infinite loop regardless of which SubView is called. If you comment out the 🌟🌟 "@Environment(.dismiss) private var dismiss" line, it seems to work properly.

Additionally, this code worked correctly on the iOS 16.0 and iOS 16.2 simulators.

I am not sure if the cause of this problem lies in SwiftUI iOS 16.4 or Google AdMob (or if it's a significant misunderstanding on my part), but I hope for a prompt resolution.

Here is the environment I tested in:

Xcode Version 14.3 (14E222b)

CocoaPods: 1.11.3, 1.12.0

AdMob: 9.14.0, 10.3.0

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

struct ContentView: View {
    
    @StateObject private var model = ContentModel()

    @State private var selectedData: Value?
    
    // 🌟🌟
    @Environment(\.dismiss) private var dismiss
    
    init() {
    }
    
    var body: some View {
        List(selection: $selectedData) {
            ForEach(model.vals) { val in
                NavigationLink(value: val) {
                    Text("1")
                }
            }
        }
        .navigationDestination(isPresented: .init(get: {
            selectedData != nil
        }, set: { val in
            if !val {
                selectedData = nil
            }
        }), destination: {
            // 🌟
            SubView(kind: model.kind)
            // SubView()
        })
    }
}

struct SubView: View {
    init(kind: Kind) {
        print("init(kind:)")
    }
    init() {
        print("init")
    }
    var body: some View {
        Text("Content")
    }
}
# Podfile
source 'https://github.com/CocoaPods/Specs.git'

platform :ios, '16.0'

target 'iOS16_4NavigationSample2' do 
  use_frameworks!
  pod 'Google-Mobile-Ads-SDK'
end
  • I don't use Google AdMob, so for sure it is not related to the problem. If I comment it out or I use instead the old API @Environment(.presentationMode) var presentationMode then the issue is not present.

Add a Comment

After modifying the ContentView as shown below, the transition to the SubView now works. (🌟 indicates the modified parts)

However, since the cause of the issue is unknown, I cannot judge whether this is an appropriate workaround or not.

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 {
        List(selection: $selectedData) {
            ForEach(model.vals) { val in
                NavigationLink(value: val) {
                    Text("\(val.num)")
                }
            }
        }
        // 🌟 
        .onChange(of: selectedData, perform: { newValue in
            // In production code, convert data here.
            isShowingSubView = newValue != nil
        })
        .navigationDestination(isPresented: $isShowingSubView, destination: {
            SubView(kind: model.kind)
            //  SubView()
        })
        .onChange(of: isShowingSubView) { newValue in
            if !newValue && selectedData != nil {
                selectedData = nil
            }
        }
    }
}

Multiline In this case, the 🌟 SubView gets caught in an infinite loop regardless of which SubView is called. If you comment out the 🌟🌟 "@Environment(.dismiss) private var dismiss" line, it seems to work properly.

I have exactly the same problem on iOS 16.4.1, the app becomes unresponsive if the navigation stack is about to present the view that uses the view that uses the @Environment(.dismiss) property.

If I comment it out or I use instead the old API @Environment(\.presentationMode) var presentationMode then the issue is not present.

I don't use Google AdMob, so for sure it is not related to the problem.

Since issue is not resolved despite of passing 6 month, we are all so happy about Apple Developers which are not qualified in System Development and yet, they are developing some features which they cannot even possibly describe and maintain. If there was an Open-Source project like Linux which is replacable to Apple ecosystem, then Apple would go down in minutes.

This has been a useful thread. I have encountered this problem when trying to move to NavigationStack when I raised my deployment target to iOS 16. This still happens with XCode 15 and iOS 17.0.3.

In my case, tapping a NavigationLink made the app unresponsive, and it would sometimes be terminated due to excessive memory use. My app uses a tab view with a navigation stack at the root of each tab.

I tried many things to narrow it down, but the only thing that helped was removing uses of @Environment(\.dismiss) var dismiss, as mentioned above.

  • Getting rid of .dismiss is also what fixed the infinite loop behavior for my project. FWIW, if I changed the navigationDestination to a sheet it worked fine, except of course I don't want it to show in a sheet.

Add a Comment