swiftmacosswiftuiswiftui-listdisclosuregroup

How do I validate and modify a proposed selection in a list?


Context

In a Mac (nb: NOT iOS) app, I have a complex OutlineView that is powered by SwiftUI's List and various embedded DisclosureGroups. Here's what it looks like:

enter image description here

Question

I need control over which rows of this OutlineView can be simultaneously selected. For example, a user can select multiple items at the same level of the tree, but not a parent item and its child or grandchildren at the same time.

This was easy to achieve in AppKit with NSOutlineView, which called a delegate method (outlineView:selectionIndexesForProposedSelection:) allowing me to intercept the proposed selection and modify it if needed.

What is the idiomatic way to do that in SwiftUI?

Code

Because:

  1. Each level of the OutlineView is a different model object that cannot inherit from an abstract base class,

  2. SwiftUI cannot work with protocols instead of concrete types, and,

  3. I need control over expansion states for each item in the list, which OutlineGroup conveniently does not provide,

I cannot use a single List with the children: parameter. Instead, I have nested DisclosureGroup items. Here, I've simplified to get rid of custom views, expansion state tracking, etc:

struct MainWindowNavigator: View
{
    @State var clients: [Client] = ...

    //  property observers (willSet/didSet) don't work with property wrappers. So what's the right way to validate and modify this?
    //  This is also just UUIDs; not an IndexSet of selected rows, so it's not possible to tell *what* they are in willSet/didSet.
    @State var selectedUUIDs: Set<UUID> = []

    var body: some View
    {
        List(clients, selection: $selectedUUIDS) { client in
            
            DisclosureGroup
            {
                ForEach(client.projects) { project in
                    
                    DisclosureGroup {
                        Text("blah")
                    } label: {
                        Text(project.name)
                    }
                }
                
            } label: {
                Text(client.name)
            }
        }
    }
}

Solution

  • Use a type that includes which level the item is on as the selection type. e.g.

    struct HierachicalID: Hashable {
        let id: UUID
        let level: Int
    }
    

    Add the corresponding tags.

        @State var selectedUUIDs: Set<HierachicalID> = []
        
        var body: some View {
            List(clients, selection: $selectedUUIDs) { client in
                DisclosureGroup
                {
                    ForEach(client.projects) { project in
                        DisclosureGroup {
                            Text("blah")
                                .tag(HierachicalID(id: project.id, level: 2))
                        } label: {
                            Text(project.name)
                        }
                        .tag(HierachicalID(id: project.id, level: 1))
                    }
                    
                } label: {
                    Text(client.name)
                }
                .tag(HierachicalID(id: client.id, level: 0))
            }
    

    Then you can use onChange to observe changes:

    .onChange(of: selectedUUIDs) { oldValue, newValue in
        if Set(newValue.map(\.level)).count > 1 {
            selectedUUIDs = oldValue
        }
    }
    

    It is also possible to use .selectionDisabled to disable selections depending on the current selection. e.g. for the clients, you can write

    .selectionDisabled(selectedUUIDs.contains { $0.level != 0 })
    

    But this would require you to give the user a way to deselect everything, since the selection now cannot change between levels directly.


    If you want even more control than this, you should probably go back to using an NSOutlineView. Currently, the functionalities provided by SwiftUI for macOS is nowhere near enough that you can write a proper macOS app in pure SwiftUI. You are going to need a NSViewRepresentable sooner or later.