NavigationSplitView and .navigationDestination

I've run into two issues using NavigationSplitView with .navigationDestination modifiers.

First, programmatic navigation using .navigationDestination(isPresented: destination) does not seem to work when used in the content column of a NavigationSplitView It does, however, work from the sidebar / first column.

Second, it appears that we need to add redundant .navigationDestination modifiers on iPhone and iPad to handle how NavigationSplitView is adapted when used on iPhone. Specially, when it is collapsed into a NavigationStack.

Also, when an iPad UI is collapsed to a NavigationStack via Stage Manager, then expanded again, .navigationDestination modifiers in the content column seem to get lost, preventing the content list from working going forward. This somewhat explains why dynamically adding .navigationDestinations via the first column push isn't sufficient. However it results in a broken UI.

Is there one location we can place .navigationDestination modifiers to work both on iPad and iPhone? For example, technically, there is a .navigationDestination in the content column of the NavigationSplitView once one of the top level options are selected. However it's not at the top level. And the .navigationDestination isn't there by default. It's only "pushed" there at a later time.

This makes sense, if you take into account the underlying adaptation implementation of NavigationSplitView under the hood, but the documentation doesn't seem to account for how the view hierarchy is adopted by the system.

See this minimal reproduction...


import SwiftUI

enum SubSection: Hashable {
    case first
    case second
    case third
    
    var title: String {
        switch self {
        case .first:
            return "First"
        case .second:
            return "Second"
        case .third:
            return "Third"
        }
    }
}

struct ContentView: View {
    @State var showDetail: Bool = false
    @State var showList: Bool = false
    var body: some View {
        let _ = Self._printChanges()
        NavigationSplitView {
            List {
                NavigationLink(value: "A") {
                    Text("Section A")
                }
                // This "pushes" to the content column
                Button {
                    showList.toggle()
                } label: {
                    HStack {
                        Text("Section B")
                        Spacer()
                        Image(systemName: showList ? "checkmark.circle.fill" : "checkmark.circle")
                    }
                }
            }
            .navigationDestination(for: String.self, destination: { value in
                ListView(sectionID: value, showDetail: $showDetail)
            })
            .navigationDestination(isPresented: $showList) {
                ListView(sectionID: "B", showDetail: $showDetail)
            }
            .listStyle(.sidebar)
            .navigationTitle("Sidebar")
        } content: {
            Text("Default")
                // Destinations for iPad
                // We cannot use the destinations present in the ListView?
                // Must destinations be in the content column to push to
                // the detail column?
                .navigationDestination(for: SubSection.self, destination: {  value in
                    Text("Detail: \(value.title)")
                })
                .navigationDestination(isPresented: $showDetail, destination: {
                    Text("Detail: \(showDetail ? "true" : "false")")
                })
        } detail: {
            Text("Detail")
        }
        .onChange(of: showDetail) { newValue in
            print("Show Detail: \(newValue)")
        }
    }
}

struct ListView: View {
    var sectionID: String
    @Binding var showDetail: Bool
    
    var body: some View {
        List {
            // This does *not* "push" to the detail column!
            Button {
                showDetail.toggle()
            } label: {
                HStack {
                    Text("Toggle isPresented")
                    Spacer()
                    Image(systemName: showDetail ? "checkmark.circle.fill" : "checkmark.circle")
                }
            }
            NavigationLink(value: SubSection.first) {
                Text("\(SubSection.first.title)")
            }
            NavigationLink(value: SubSection.second) {
                Text("\(SubSection.second.title)")
            }
            NavigationLink(value: SubSection.third) {
                Text("\(SubSection.third.title)")
            }
        }
        .listStyle(.insetGrouped)
        .navigationTitle("Section: \(sectionID)")
        // Redundent destinations for iPhone as destinations in
        // the content column are not yet realized in the collaped
        // StackView representation of the NavigationSplitView?
        .navigationDestination(for: SubSection.self, destination: {  value in
            Text("Detail: \(value.title)")
        })
        .navigationDestination(isPresented: $showDetail, destination: {
            Text("Detail: \(showDetail ? "true" : "false")")
        })
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

From my comment below...

  • Also, if you remove the .navigationDestinations from the ListView, run on iPad, navigate to a detail view via the content list, then collapse the UI with Stage Manager, you get a yellow alert icon. There is no .navigationDestinations to fall back to. Apparently, the redundant .navigationDestination`s represent an analog to how we can return a different view controllers for collapsed / expanded states via delegate methods on UISplitViewController?

Add a Comment