In a Mac (nb: NOT iOS) app, I have a complex OutlineView that is powered by SwiftUI's List
and various embedded DisclosureGroup
s. Here's what it looks like:
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?
Because:
Each level of the OutlineView is a different model object that cannot inherit from an abstract base class,
SwiftUI cannot work with protocols instead of concrete types, and,
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)
}
}
}
}
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 tag
s.
@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.