I converted an existing app to use NavigationSplitView, NavigationStack, NavigationLink(value:), and .navigationDestination(for:). I found that this causes a hang with an infinite loop when I tap on many of the NavigationLink(value:). This happens in may different places in the app. Over the past few months I have tried to figure out what was happening without any luck. I ended up creating a simple test app called NavigationLinkIssue that demonstrates one of the places where I have the hang/infinite loop.
The loop can be seen by doing this:
- Run
- Tap on Push Second View
- Tap on Push New/Different Instantiation of First View
The problem seem to be related to the report Binding. If I don’t make report a binding the hang/infinite loop goes away. If I change report to an ObservedObject the hang/infinite loop goes away.
I contacted Apple Developer Support, and had a few replies, but in the end they replied:
To clarify the issue you are experiencing is not a bug, it is expected. To elaborate, In Case1: This is expected because the ContentView creates an instance of FirstView with a Binding to the report object, which is then passed to SecondView. When the NavigationLink in SecondView is clicked, it navigates back to FirstView, which creates a new instance of FirstView with a new Binding to report. This causes the ContentView to update and create a new instance of SecondView, which then navigates back to FirstView, and so on, resulting in an infinite loop.
I have spent a lot of time looking at this and I still don’t understand why a Binding should cause the loop, especially if and ObservedObject does not. It still seems like a bug to me. Can anybody explain why this is causing a loop? The full code is below.
import SwiftUI
import OSLog
final class NavigationModel: ObservableObject {
@Published var path = NavigationPath() {
didSet {
print("navigationModel.path.count: \(path.count)")
}
}
}
struct Report: Hashable {
var title: String = "Report Title"
var photo: UIImage? = UIImage(systemName: "z.circle")
}
private let logger = Logger()
struct ViewType: Identifiable, Hashable {
let id: String
}
private var viewTypes = [
ViewType(id: "First View"),
]
struct ContentView: View {
@StateObject private var navigationModel = NavigationModel()
@State var selection: Set<String> = [viewTypes[0].id]
@State var report = Report(title: "ContentView Report")
var body: some View {
NavigationSplitView {
List(viewTypes, selection: $selection) { viewType in
Text("\(viewType.id)")
}
} detail: {
if let viewTitle = selection.first {
switch viewTitle {
case "First View":
FirstView(report: $report)
default:
Text("Invalid View")
}
}
}
.environmentObject(navigationModel)
}
}
// FirstView
struct FirstView: View {
@EnvironmentObject var navigationModel: NavigationModel
@Binding var report: Report
private enum NavigationLinkValueFirstView {
case secondView
}
var body: some View {
NavigationStack(path: $navigationModel.path) {
NavigationLink(value: NavigationLinkValueFirstView.secondView) {
let _ = logger.debug("FirstView NavigationLink")
Text("Push Second View")
}
.navigationDestination(for: NavigationLinkValueFirstView.self) { value in
let _ = logger.debug("FirstView navigationDestination")
SecondView(report: $report)
}
}
.navigationTitle("First View")
}
}
// SecondView
struct SecondView: View {
@State var showTheView = false
@Binding var report: Report
private enum NavigationLinkValueSecondView {
case firstView
}
var body: some View {
NavigationLink(value: NavigationLinkValueSecondView.firstView) {
let _ = logger.debug("SecondView NavigationLink")
Text("Push New/Different Instantiation of First View")
}
.navigationDestination(for: NavigationLinkValueSecondView.self) { value in
FirstView(report: $report)
let _ = logger.debug("SecondView navigationDestination")
}
.navigationTitle("Second View")
}
}
You're pushing multiple stacks that share the same path. So one stack is trying to push another stack which is trying to push the other stack....
It's a happy coincidence that ObservableObject
doesn't infinite loop, I'm surprised it does not.
2 general rules will help when using the navigation system:
- move
navigationDestination
modifiers as high up in the view hierarchy. This is more efficient for the Navigation system to read up front than with potentially every view update. - Don't push stacks onto stacks, and try to avoid entire
NavigationStack
s coming and going from the columns of aNavigationSplitView
.NSV
will "adopt" the stack and integrate its state with the state of the whole split view.
I ran this example on macOS and it looked as expected
import SwiftUI
import OSLog
final class NavigationModel: ObservableObject {
@Published var path = NavigationPath() {
didSet {
print("navigationModel.path.count: \(path.count)")
}
}
}
struct Report: Hashable {
var title: String = "Report Title"
}
private let logger = Logger()
struct ViewType: Identifiable, Hashable {
let id: String
}
private var viewTypes = [
ViewType(id: "First View"),
]
struct MyContentView: View {
@StateObject private var navigationModel = NavigationModel()
@State var selection: Set<String> = [viewTypes[0].id]
@State var report = Report(title: "ContentView Report")
var body: some View {
NavigationSplitView {
List(viewTypes, selection: $selection) { viewType in
Text("\(viewType.id)")
}
} detail: {
NavigationStack(path: $navigationModel.path) {
Group {
if let viewTitle = selection.first {
switch viewTitle {
case "First View":
FirstView(report: $report)
default:
Text("Invalid View")
}
}
}
.navigationDestination(for: NavigationLinkValueFirstView.self) { value in
let _ = logger.debug("FirstView navigationDestination")
SecondView(report: $report)
}
.navigationDestination(for: NavigationLinkValueSecondView.self) { value in
FirstView(report: $report)
let _ = logger.debug("SecondView navigationDestination")
}
}
}
.environmentObject(navigationModel)
}
}
// FirstView
private enum NavigationLinkValueFirstView {
case secondView
}
struct FirstView: View {
@EnvironmentObject var navigationModel: NavigationModel
@Binding var report: Report
var body: some View {
NavigationLink(value: NavigationLinkValueFirstView.secondView) {
let _ = logger.debug("FirstView NavigationLink")
Text("Push Second View")
}
.navigationTitle("First View")
}
}
// SecondView
private enum NavigationLinkValueSecondView {
case firstView
}
struct SecondView: View {
@State var showTheView = false
@Binding var report: Report
var body: some View {
NavigationLink(value: NavigationLinkValueSecondView.firstView) {
let _ = logger.debug("SecondView NavigationLink")
Text("Push New/Different Instantiation of First View")
}
.navigationTitle("Second View")
}
}