From 72fcdc9138a8ccc169a2360ef5f7e5d61c0b9d80 Mon Sep 17 00:00:00 2001 From: woogus Date: Thu, 9 Feb 2023 03:12:16 +0900 Subject: [PATCH] [#8] Added MultiSelect --- .../ContentView.swift | 4 +- Sources/OutlineView/OutlineView.swift | 213 ++++++++++++++++-- .../OutlineView/OutlineViewController.swift | 14 +- Sources/OutlineView/OutlineViewDelegate.swift | 40 ++-- 4 files changed, 235 insertions(+), 36 deletions(-) diff --git a/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ContentView.swift b/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ContentView.swift index f52d4f3..8828516 100644 --- a/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ContentView.swift +++ b/Examples/OutlineViewDraggingExample/OutlineViewDraggingExample/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme @StateObject var dataSource = sampleDataSource() - @State var selection: FileItem? + @State var selections: Set = [] @State var separatorColor: Color = Color(NSColor.separatorColor) @State var separatorEnabled = false @@ -33,7 +33,7 @@ struct ContentView: View { var outlineView: some View { OutlineView( dataSource.rootData, - selection: $selection, + selections: $selections, children: dataSource.childrenOfItem, separatorInsets: { fileItem in NSEdgeInsets( diff --git a/Sources/OutlineView/OutlineView.swift b/Sources/OutlineView/OutlineView.swift index 35de294..743f829 100644 --- a/Sources/OutlineView/OutlineView.swift +++ b/Sources/OutlineView/OutlineView.swift @@ -17,15 +17,15 @@ enum ChildSource { @available(macOS 10.15, *) public struct OutlineView: NSViewControllerRepresentable -where Drop.DataElement == Data.Element { +where Drop.DataElement == Data.Element, Data.Element: Hashable { public typealias NSViewControllerType = OutlineViewController let data: Data let childSource: ChildSource - @Binding var selection: Data.Element? + @Binding var selections: Set var content: (Data.Element) -> NSView var separatorInsets: ((Data.Element) -> NSEdgeInsets)? - + var allowsMultipleSelection = false /// Outline view style is unavailable on macOS 10.15 and below. /// Stored as `Any` to make the property available on all platforms. private var _styleStorage: Any? @@ -55,7 +55,7 @@ where Drop.DataElement == Data.Element { data: data, childrenSource: childSource, content: content, - selectionChanged: { selection = $0 }, + selectionChanged: { selections = $0 }, separatorInsets: separatorInsets) controller.setIndentation(to: indentation) if #available(macOS 11.0, *) { @@ -69,12 +69,13 @@ where Drop.DataElement == Data.Element { context: Context ) { outlineController.updateData(newValue: data) - outlineController.changeSelectedItem(to: selection) + outlineController.changeSelectedItem(to: selections) outlineController.setRowSeparator(visibility: separatorVisibility) outlineController.setRowSeparator(color: separatorColor) outlineController.setDragSourceWriter(dragDataSource) outlineController.setDropReceiver(dropReceiver) outlineController.setAcceptedDragTypes(acceptedDropTypes) + outlineController.setAllowsMultipleSelection(allowsMultipleSelection) } } @@ -184,9 +185,31 @@ public extension OutlineView { ) { self.data = data self.childSource = .keyPath(children) - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } + self.separatorVisibility = .hidden + self.content = content + } + + init( + _ data: Data, + children: KeyPath, + selections: Binding>, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self.childSource = .keyPath(children) + self._selections = selections self.separatorVisibility = .hidden self.content = content + self.allowsMultipleSelection = true } /// Creates an `OutlineView` from a collection of root data elements and @@ -225,11 +248,33 @@ public extension OutlineView { content: @escaping (Data.Element) -> NSView ) { self.data = data - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } self.childSource = .provider(children) self.separatorVisibility = .hidden self.content = content } + + init( + _ data: Data, + selections: Binding>, + children: @escaping (Data.Element) -> Data?, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self._selections = selections + self.childSource = .provider(children) + self.separatorVisibility = .hidden + self.content = content + self.allowsMultipleSelection = true + } } // MARK: Initializers for macOS 10.15 and higher with NoDropReceiver. @@ -273,9 +318,31 @@ public extension OutlineView where Drop == NoDropReceiver { ) { self.data = data self.childSource = .keyPath(children) - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } + self.separatorVisibility = .hidden + self.content = content + } + + init( + _ data: Data, + children: KeyPath, + selections: Binding>, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self.childSource = .keyPath(children) + self._selections = selections self.separatorVisibility = .hidden self.content = content + self.allowsMultipleSelection = true } /// Creates an `OutlineView` from a collection of root data elements and @@ -314,11 +381,33 @@ public extension OutlineView where Drop == NoDropReceiver { content: @escaping (Data.Element) -> NSView ) { self.data = data - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } self.childSource = .provider(children) self.separatorVisibility = .hidden self.content = content } + + init( + _ data: Data, + selections: Binding>, + children: @escaping (Data.Element) -> Data?, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self._selections = selections + self.childSource = .provider(children) + self.separatorVisibility = .hidden + self.content = content + self.allowsMultipleSelection = true + } } // MARK: Initializers for macOS 11 and higher. @@ -367,11 +456,36 @@ public extension OutlineView { ) { self.data = data self.childSource = .keyPath(children) - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } self.separatorInsets = separatorInsets self.separatorVisibility = separatorInsets == nil ? .hidden : .visible self.content = content } + + @available(macOS 11.0, *) + init( + _ data: Data, + children: KeyPath, + selections: Binding>, + separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self.childSource = .keyPath(children) + self._selections = selections + self.separatorInsets = separatorInsets + self.separatorVisibility = separatorInsets == nil ? .hidden : .visible + self.content = content + self.allowsMultipleSelection = true + } /// Creates an `OutlineView` from a collection of root data elements and /// a closure that provides children to each element. @@ -414,12 +528,37 @@ public extension OutlineView { content: @escaping (Data.Element) -> NSView ) { self.data = data - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } self.childSource = .provider(children) self.separatorInsets = separatorInsets self.separatorVisibility = separatorInsets == nil ? .hidden : .visible self.content = content } + + @available(macOS 11.0, *) + init( + _ data: Data, + selections: Binding>, + children: @escaping (Data.Element) -> Data?, + separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self._selections = selections + self.childSource = .provider(children) + self.separatorInsets = separatorInsets + self.separatorVisibility = separatorInsets == nil ? .hidden : .visible + self.content = content + self.allowsMultipleSelection = true + } } // MARK: Initializers for macOS 11 and higher with NoDropReceiver. @@ -467,11 +606,35 @@ public extension OutlineView where Drop == NoDropReceiver { ) { self.data = data self.childSource = .keyPath(children) - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } self.separatorInsets = separatorInsets self.separatorVisibility = separatorInsets == nil ? .hidden : .visible self.content = content } + + init( + _ data: Data, + children: KeyPath, + selections: Binding>, + separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self.childSource = .keyPath(children) + self._selections = selections + self.separatorInsets = separatorInsets + self.separatorVisibility = separatorInsets == nil ? .hidden : .visible + self.content = content + self.allowsMultipleSelection = true + } /// Creates an `OutlineView` from a collection of root data elements and /// a closure that provides children to each element. @@ -513,10 +676,34 @@ public extension OutlineView where Drop == NoDropReceiver { content: @escaping (Data.Element) -> NSView ) { self.data = data - self._selection = selection + self._selections = .init { + if let sel = selection.wrappedValue { + return Set([sel]) + } + + return Set() + } set: { newValue in + selection.wrappedValue = newValue.first + } + self.childSource = .provider(children) + self.separatorInsets = separatorInsets + self.separatorVisibility = separatorInsets == nil ? .hidden : .visible + self.content = content + } + + init( + _ data: Data, + selections: Binding>, + children: @escaping (Data.Element) -> Data?, + separatorInsets: ((Data.Element) -> NSEdgeInsets)? = nil, + content: @escaping (Data.Element) -> NSView + ) { + self.data = data + self._selections = selections self.childSource = .provider(children) self.separatorInsets = separatorInsets self.separatorVisibility = separatorInsets == nil ? .hidden : .visible self.content = content + self.allowsMultipleSelection = true } } diff --git a/Sources/OutlineView/OutlineViewController.swift b/Sources/OutlineView/OutlineViewController.swift index d8a9dee..7ace423 100644 --- a/Sources/OutlineView/OutlineViewController.swift +++ b/Sources/OutlineView/OutlineViewController.swift @@ -2,7 +2,7 @@ import Cocoa @available(macOS 10.15, *) public class OutlineViewController: NSViewController -where Drop.DataElement == Data.Element { +where Drop.DataElement == Data.Element, Data.Element: Hashable { let outlineView = NSOutlineView() let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 400, height: 400)) @@ -16,7 +16,7 @@ where Drop.DataElement == Data.Element { data: Data, childrenSource: ChildSource, content: @escaping (Data.Element) -> NSView, - selectionChanged: @escaping (Data.Element?) -> Void, + selectionChanged: @escaping (Set) -> Void, separatorInsets: ((Data.Element) -> NSEdgeInsets)? ) { scrollView.documentView = outlineView @@ -28,7 +28,7 @@ where Drop.DataElement == Data.Element { outlineView.headerView = nil outlineView.usesAutomaticRowHeights = true outlineView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle - + let onlyColumn = NSTableColumn() onlyColumn.resizingMask = .autoresizingMask outlineView.addTableColumn(onlyColumn) @@ -96,9 +96,9 @@ extension OutlineViewController { dataSource.rebuildIDTree(rootItems: newState, outlineView: outlineView) } - func changeSelectedItem(to item: Data.Element?) { + func changeSelectedItem(to items: Set) { delegate.changeSelectedItem( - to: item.map { OutlineViewItem(value: $0, children: childrenSource) }, + to: items.map { OutlineViewItem(value: $0, children: childrenSource) }, in: outlineView) } @@ -145,4 +145,8 @@ extension OutlineViewController { outlineView.registerForDraggedTypes(acceptedTypes) } } + + func setAllowsMultipleSelection(_ allowsMultipleSelection: Bool) { + outlineView.allowsMultipleSelection = allowsMultipleSelection + } } diff --git a/Sources/OutlineView/OutlineViewDelegate.swift b/Sources/OutlineView/OutlineViewDelegate.swift index 4f4e5b8..81fa51b 100644 --- a/Sources/OutlineView/OutlineViewDelegate.swift +++ b/Sources/OutlineView/OutlineViewDelegate.swift @@ -2,11 +2,11 @@ import Cocoa @available(macOS 10.15, *) class OutlineViewDelegate: NSObject, NSOutlineViewDelegate -where Data.Element: Identifiable { +where Data.Element: Identifiable & Hashable { let content: (Data.Element) -> NSView - let selectionChanged: (Data.Element?) -> Void + let selectionChanged: (Set) -> Void let separatorInsets: ((Data.Element) -> NSEdgeInsets)? - var selectedItem: OutlineViewItem? + var selectedItems: [OutlineViewItem] = [] func typedItem(_ item: Any) -> OutlineViewItem { item as! OutlineViewItem @@ -14,7 +14,7 @@ where Data.Element: Identifiable { init( content: @escaping (Data.Element) -> NSView, - selectionChanged: @escaping (Data.Element?) -> Void, + selectionChanged: @escaping (Set) -> Void, separatorInsets: ((Data.Element) -> NSEdgeInsets)? ) { self.content = content @@ -102,27 +102,36 @@ where Data.Element: Identifiable { func outlineViewItemDidExpand(_ notification: Notification) { let outlineView = notification.object as! NSOutlineView if outlineView.selectedRow == -1 { - selectRow(for: selectedItem, in: outlineView) + selectRow(for: selectedItems, in: outlineView) } } func outlineViewSelectionDidChange(_ notification: Notification) { let outlineView = notification.object as! NSOutlineView - if outlineView.selectedRow != -1 { - let newSelection = outlineView.item(atRow: outlineView.selectedRow).map(typedItem) - if selectedItem?.id != newSelection?.id { - selectedItem = newSelection - selectionChanged(selectedItem?.value) + let selectedRowIndexes = outlineView.selectedRowIndexes + + if !selectedRowIndexes.isEmpty { + let newSelections = selectedRowIndexes.compactMap { + outlineView.item(atRow: $0).map(typedItem) + } + + if selectedItems.count == newSelections.count { + return + } + + if selectedItems.allSatisfy({ newSelections.contains($0) }) { + selectedItems = newSelections + selectionChanged(Set(selectedItems.map(\.value))) } } } func selectRow( - for item: OutlineViewItem?, + for items: [OutlineViewItem], in outlineView: NSOutlineView ) { // Returns -1 if row is not found. - let index = outlineView.row(forItem: selectedItem) + let index = outlineView.row(forItem: selectedItems) if index != -1 { outlineView.selectRowIndexes(IndexSet([index]), byExtendingSelection: false) } else { @@ -131,11 +140,10 @@ where Data.Element: Identifiable { } func changeSelectedItem( - to item: OutlineViewItem?, + to items: [OutlineViewItem], in outlineView: NSOutlineView ) { - guard selectedItem?.id != item?.id else { return } - selectedItem = item - selectRow(for: selectedItem, in: outlineView) + selectedItems = items + selectRow(for: selectedItems, in: outlineView) } }