Driving NavigationSplitView with something other than List?

Is it possible to drive NavigationSplitView navigation with a view in sidebar (left column) that is not a List? All examples that I have seen from this year only contain List in sidebar.

I ask this because I would like to have a more complex layout in sidebar (or first view on iOS) that contains a mix of elements, some of them non-interactive and not targeting navigation. Here’s what I would like to do:

import SwiftUI

struct Thing: Identifiable, Hashable {
    let id: UUID
    let name: String
}

struct ContentView: View {
    let things: [Thing]
    @State private var selectedThingId: UUID?
    
    var body: some View {
        NavigationSplitView {
            ScrollView(.vertical) {
                VStack {
                    ForEach(things) { thing in
                        Button("Thing: \(thing.name) \( selectedThingId == thing.id ? "selected" : "" )") {
                            selectedThingId = thing.id
                        }
                    }
                   SomeOtherViewHere()
                   Button("Navigate to something else") { selectedThingId = someSpecificId }
                }
            }
        } detail: {
            // ZStack is workaround for known SDK bug
            ZStack {
                if let selectedThingId {
                    Text("There is a thing ID: \(selectedThingId)")
                } else {
                    Text("There is no thing.")
                }
            }
        }
    }
}

This actually works as expected on iPadOS and macOS, but not iOS (iPhone). Tapping changes the selection as I see in the button label, but does not push anything to navigation stack, I remain stuck at home screen.

Also filed as FB10332749.

@Jaanus,

Have you tried composing your NavigationSplitView with a NavigationStack as your detail? You can then control push / pop behavior with a bound NavigationPath type. You mentioned your button doesn't push anything on the navigation stack, but I don't see a NavigationStack in your view's body.

All of my views are List driven but worth a shot. If you haven't already seen this video, check it out: https://developer.apple.com/wwdc22/10054

I don’t need NavigationStack as my detail. What I meant is that NavigationSplitView behaves as stack on iPhone, yet it doesn’t seem to have the kind of programmatic control for navigation that NavigationStack has. If I had NavigationStack as my content/detail view, it wouldn’t help with controlling navigation on the first level (sidebar).

I have seen the video. Everything about NavigationStack there looks great, but it doesn’t help me. All NavigationSplitView examples there are with a simple list in sidebar. It appears there is some magic going on with binding the List selection to NavigationSplitView state. I can’t figure out how it should behave with non-List or if it’s even possible.

I'm having the same issue using a LazyVStack in my sidebar. As of beta 1, it appears that NavigationSplitView doesn't work on iPhone.

Update: Unless I'm missing something, it appears that beta 2 does not work either.

Thanks for posting this. It looks as though the new view is not even appearing (i.e. off screen) and it seems to be broken on iPad too when the app is running at iPhone size in, for example, slide over.

Since List is very versatile, I'm not sure why it cannot be used here. For example, the following works fine in iOS and iPadOS (by persisting the selection):

import SwiftUI

class Q708440NavigationModel: ObservableObject {
    @Published var selectedThingId: Thing?
}

struct Thing: Identifiable, Hashable {
    let id: UUID = UUID()
    let name: String
}

class ThingData: ObservableObject {
    @Published var things: [Thing] = [
        Thing(name: "One"),
        Thing(name: "Two"),
        Thing(name: "Three")
    ]
}

struct ThingDetail: View {
    let thing: Thing
    @EnvironmentObject var navigationModel: Q708440NavigationModel

    var body: some View {
        Text("Thing: \(thing.name)")
    }
}

struct SomeOtherViewHere: View {
    var body: some View {
        Text("Some other view")
    }
}

@main
struct Q708440App: App {
    @StateObject var navigationModel =  Q708440NavigationModel()
    @StateObject var thingData = ThingData()
    
    var body: some Scene {
        WindowGroup {
            Q708440(things: $thingData.things)
                .environmentObject(navigationModel)
        }
    }
}


struct Q708440: View {
    @Binding var things: [Thing]
    @EnvironmentObject var navigationModel: Q708440NavigationModel

    var body: some View {
        NavigationSplitView {
            List(selection: $navigationModel.selectedThingId) {
               
                Section("Data driven selection") {
                    ForEach(things) { thing in
                        Button("Thing: \(thing.name) \( navigationModel.selectedThingId?.id == thing.id ? "selected" : "" )") {
                            navigationModel.selectedThingId = thing
                        }
                    }
                }

                Section("Some other view") {
                    SomeOtherViewHere()
                }

                Section("Non-Interactive") {
                        Text("Selection-driven, three-column NavigationSplitView sometimes fails to push when collapsed to a single column. (93673059)")
                }

                Section("Another selection") {
                    Button("Navigate to thing 2") { navigationModel.selectedThingId = things[1] }
                }
                
                Section("Using NavtigationLink") {
                    NavigationLink("Goto to thing 1", value: things[0])
                }
            }
        } detail: {
            ThingDetail(thing: navigationModel.selectedThingId ?? Thing(name: "Select"))
        }
    }
}

Same thing here...

Same here.

I ran into the same problem not using List. Additionally, when I do use List, my detail view will be updated with the data I pass to it but it does not initiate the other variables in it. For example, .task doesn't get triggered to run and @State variables are not set to their default values. It works properly on iPhone but not on iPad. Very frustrating.

NavigationSplitView just doesn't work without a list. What a waste of a Saturday morning though. I think I'm going to stick with NavigationView for now and hope there's better support in the future.

There seems no way to work with NavigationSplitView other than List at first column for now, but I think adding a empty List with selection parameter can solve this problem almostly.

Here's my solution:

var categories : [Category]

// can be replaced to NavigationPath
@State var path : Category? = nil
@State var subpath : Item? = nil

var row = [
    GridItem(),
    GridItem(),
    GridItem()
]

var body: some View {
    NavigationSplitView {
        LazyVGrid(columns: row) {
            ForEach(categories) { category in
                Button(category.title) { path = category }
                // can use NavigationLink with value parameter, .onTapGesture, etc.
            }
        }

        // coordinates with the NavigationSplitView via selection parameter.
        List(selection: $path) {}
    } content: {
        // MiddleSidebarView(category: path, selection: $subpath)
    } detail: {
        // ...
    }
}

You can resize or hide List, but always have to be active while navigation feature is needed.

Any Solution?

Could we use NavigationStack in sidebar like this? It seems to work for iPhone, iPad and Mac:

import SwiftUI

struct Thing: Identifiable, Hashable {
    let id = UUID()
    let name: String
}


struct ContentView: View {
    let things: [Thing] = [
        Thing(name: "One"),
        Thing(name: "Two"),
        Thing(name: "Three")
    ]
    @EnvironmentObject var navigation: NavigationModel
    
    var body: some View {

        NavigationSplitView {
            NavigationStack {
                sidebar
            }
            .navigationDestination(for: Thing.ID.self) { id in
                DetailView(selectedThingId: id)
            }
        } detail: {
        }
    }
    
    var sidebar: some View {
        ScrollView(.vertical) {
            LazyVStack {
                
                ForEach(things) { thing in
                    NavigationLink("Thing: \(thing.name) \( navigation.selectedThingId == thing.id ? "selected" : "" )",
                                   value: thing.id)
                }
                
                SomeOtherViewHere()
                
                NavigationLink("Navigate to something else", value: things[1].id)
            }
        }
    }
    
}

struct DetailView: View {
    let selectedThingId: Thing.ID?
    var body: some View {
        if let selectedThingId {
            Text("There is a thing ID: \(selectedThingId)")
        } else {
            Text("There is no thing.")
        }
    }
}

struct SomeOtherViewHere: View {
    var body: some View {
        Text("Some other view")
    }
}

Driving NavigationSplitView with something other than List?
 
 
Q