Search code examples
macosswiftuiswiftui-list

Preserving List selection when you have selectable items that are contained within a VStack


I want to have a List that gives the user the ability to select items (and therefore use up/down keyboard navigation on the Mac to navigate the list). Due to the complexity of views, I am using children elements within the List that contain selectable items. For simplification purposes, this is a minimum example of what I’m trying to do.

struct ContentView: View {
    @State private var selectedItem: Obj?
    
    @State var demoData: [Obj] = [.init("Bardi"), .init("John")]
    
    var body: some View {
        List(selection: $selectedItem) {
            VStack {
                ForEach(demoData, id: \.self) { item in
                    Item(item: item)
                }
            }
        }
    }
}

struct Obj: Hashable {
    var id: UUID = .init()
    var name: String = ""
    
    init(_ name: String) {
        self.name = name
    }
}

struct Item: View {
    var item: Obj
    
    var body: some View {
        VStack {
            Text(item.name)
        }
    }
}

The problem is the addition of the VStack breaks list selection. If I remove it, it works fine and as expected. I assume the VStack is preventing the List from properly detecting the individual items within the ForEach as selectable.

Is there any way to work around this? It seems a simple ask: I want a list with children subviews that categorise list items.


Solution

  • Only list rows can be selected. By wrapping your items in a VStack, the entire VStack becomes a single list row. You can add a tag with an Obj value to it, to be able to select the whole VStack, but you cannot select the things inside the VStack.

    For example, one contains a list of tasks for the day as well as a TextField to add another task.

    In that case you can make the TextField a separate list row on its own.

    List(selection: $selectedItem) {
        ForEach(data, id: \.self) { item in
            Item(item: item)
        }
        TextField("New Item", text: $newItemName)
    }
    

    The list row with the TextField will not be selectable, because it has no tag. Note that the ForEach adds a tag to its views, with the value of that item's id.

    You also said,

    I have different custom sections...

    Great, then use Sections! You even mentioned it yourself.

    Presumably your VStacks just contains the selectable items in the middle, then some unselectable views at the bottom (like the text field), and/or some unselectable views at the top. You can turn those unselectable views into the Section's header and footer! Your VStacks are basically reinventing Sections.

    Here is an example of a list with 2 sections:

    List(selection: $selectedItem) {
        // in reality you'd use a ForEach to "loop through" the sections you have, of course
        Section {
            ForEach(group1Data, id: \.self) { item in
                Item(item: item)
            }
        } footer: {
            TextField("New Item", text: $newGroup1Item)
        }
        Section {
            ForEach(group2Data, id: \.self) { item in
                Item(item: item)
            }
        } footer: {
            TextField("New Item", text: $newGroup2Item)
        }
    }