diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 083eb2c..1ed2d82 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -3,27 +3,11 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - D82594FC28AA3E3500A36359 /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82594FB28AA3E3500A36359 /* Model.swift */; }; - D82594FE28AA3EA200A36359 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82594FD28AA3EA200A36359 /* Cell.swift */; }; - D825950128AA3F7800A36359 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950028AA3F7800A36359 /* Item.swift */; }; - D825950428AA443A00A36359 /* ItemsToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950328AA443A00A36359 /* ItemsToolbar.swift */; }; - D825950628AA45ED00A36359 /* SelectionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950528AA45ED00A36359 /* SelectionToolbar.swift */; }; - D825950928AA46C000A36359 /* StateToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950828AA46C000A36359 /* StateToolbar.swift */; }; - D841632D28AA26FC007812D8 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841632C28AA26FC007812D8 /* ExampleApp.swift */; }; - D841632F28AA26FC007812D8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841632E28AA26FC007812D8 /* ContentView.swift */; }; - D841633128AA26FE007812D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D841633028AA26FE007812D8 /* Assets.xcassets */; }; - D841633428AA26FE007812D8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D841633328AA26FE007812D8 /* Preview Assets.xcassets */; }; - D841633F28AA26FE007812D8 /* ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841633E28AA26FE007812D8 /* ExampleTests.swift */; }; - D841634928AA26FE007812D8 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841634828AA26FE007812D8 /* ExampleUITests.swift */; }; - D841634B28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */; }; - D841635A28AA2819007812D8 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841635928AA2818007812D8 /* Color.swift */; }; D841635D28AA283B007812D8 /* SelectableCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = D841635C28AA283B007812D8 /* SelectableCollectionView */; }; - D8C5D20728AACB2B001443BA /* LayoutMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C5D20628AACB2B001443BA /* LayoutMode.swift */; }; - D8C5D20928AACB75001443BA /* LayoutToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C5D20828AACB75001443BA /* LayoutToolbar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -44,29 +28,18 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - D82594FB28AA3E3500A36359 /* Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = ""; }; - D82594FD28AA3EA200A36359 /* Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = ""; }; - D825950028AA3F7800A36359 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; - D825950328AA443A00A36359 /* ItemsToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsToolbar.swift; sourceTree = ""; }; - D825950528AA45ED00A36359 /* SelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionToolbar.swift; sourceTree = ""; }; - D825950828AA46C000A36359 /* StateToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateToolbar.swift; sourceTree = ""; }; D841632928AA26FC007812D8 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; - D841632C28AA26FC007812D8 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; - D841632E28AA26FC007812D8 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - D841633028AA26FE007812D8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - D841633328AA26FE007812D8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - D841633528AA26FE007812D8 /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; D841633A28AA26FE007812D8 /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - D841633E28AA26FE007812D8 /* ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTests.swift; sourceTree = ""; }; D841634428AA26FE007812D8 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - D841634828AA26FE007812D8 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; }; - D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITestsLaunchTests.swift; sourceTree = ""; }; D841635828AA2726007812D8 /* SelectableCollectionView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SelectableCollectionView; path = ..; sourceTree = ""; }; - D841635928AA2818007812D8 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = ""; }; - D8C5D20628AACB2B001443BA /* LayoutMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMode.swift; sourceTree = ""; }; - D8C5D20828AACB75001443BA /* LayoutToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutToolbar.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + D8D376C82F2898870033AB3C /* Example */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Example; sourceTree = ""; }; + D8D376D82F28988B0033AB3C /* ExampleTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ExampleTests; sourceTree = ""; }; + D8D376DC2F28988D0033AB3C /* ExampleUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ExampleUITests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ D841632628AA26FC007812D8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -93,51 +66,13 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - D82594FA28AA3E0D00A36359 /* Extensions */ = { - isa = PBXGroup; - children = ( - D841635928AA2818007812D8 /* Color.swift */, - ); - path = Extensions; - sourceTree = ""; - }; - D82594FF28AA3F6C00A36359 /* Model */ = { - isa = PBXGroup; - children = ( - D825950028AA3F7800A36359 /* Item.swift */, - D8C5D20628AACB2B001443BA /* LayoutMode.swift */, - D82594FB28AA3E3500A36359 /* Model.swift */, - ); - path = Model; - sourceTree = ""; - }; - D825950228AA439300A36359 /* Toolbars */ = { - isa = PBXGroup; - children = ( - D825950328AA443A00A36359 /* ItemsToolbar.swift */, - D8C5D20828AACB75001443BA /* LayoutToolbar.swift */, - D825950528AA45ED00A36359 /* SelectionToolbar.swift */, - D825950828AA46C000A36359 /* StateToolbar.swift */, - ); - path = Toolbars; - sourceTree = ""; - }; - D825950728AA469600A36359 /* Views */ = { - isa = PBXGroup; - children = ( - D82594FD28AA3EA200A36359 /* Cell.swift */, - D841632E28AA26FC007812D8 /* ContentView.swift */, - ); - path = Views; - sourceTree = ""; - }; D841632028AA26FC007812D8 = { isa = PBXGroup; children = ( D841635728AA2726007812D8 /* Packages */, - D841632B28AA26FC007812D8 /* Example */, - D841633D28AA26FE007812D8 /* ExampleTests */, - D841634728AA26FE007812D8 /* ExampleUITests */, + D8D376C82F2898870033AB3C /* Example */, + D8D376D82F28988B0033AB3C /* ExampleTests */, + D8D376DC2F28988D0033AB3C /* ExampleUITests */, D841632A28AA26FC007812D8 /* Products */, D841635B28AA283B007812D8 /* Frameworks */, ); @@ -153,46 +88,6 @@ name = Products; sourceTree = ""; }; - D841632B28AA26FC007812D8 /* Example */ = { - isa = PBXGroup; - children = ( - D841633528AA26FE007812D8 /* Example.entitlements */, - D841632C28AA26FC007812D8 /* ExampleApp.swift */, - D841633028AA26FE007812D8 /* Assets.xcassets */, - D82594FA28AA3E0D00A36359 /* Extensions */, - D82594FF28AA3F6C00A36359 /* Model */, - D841633228AA26FE007812D8 /* Preview Content */, - D825950228AA439300A36359 /* Toolbars */, - D825950728AA469600A36359 /* Views */, - ); - path = Example; - sourceTree = ""; - }; - D841633228AA26FE007812D8 /* Preview Content */ = { - isa = PBXGroup; - children = ( - D841633328AA26FE007812D8 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; - D841633D28AA26FE007812D8 /* ExampleTests */ = { - isa = PBXGroup; - children = ( - D841633E28AA26FE007812D8 /* ExampleTests.swift */, - ); - path = ExampleTests; - sourceTree = ""; - }; - D841634728AA26FE007812D8 /* ExampleUITests */ = { - isa = PBXGroup; - children = ( - D841634828AA26FE007812D8 /* ExampleUITests.swift */, - D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */, - ); - path = ExampleUITests; - sourceTree = ""; - }; D841635728AA2726007812D8 /* Packages */ = { isa = PBXGroup; children = ( @@ -223,6 +118,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + D8D376C82F2898870033AB3C /* Example */, + ); name = Example; packageProductDependencies = ( D841635C28AA283B007812D8 /* SelectableCollectionView */, @@ -244,6 +142,9 @@ dependencies = ( D841633C28AA26FE007812D8 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + D8D376D82F28988B0033AB3C /* ExampleTests */, + ); name = ExampleTests; productName = ExampleTests; productReference = D841633A28AA26FE007812D8 /* ExampleTests.xctest */; @@ -262,6 +163,9 @@ dependencies = ( D841634628AA26FE007812D8 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + D8D376DC2F28988D0033AB3C /* ExampleUITests */, + ); name = ExampleUITests; productName = ExampleUITests; productReference = D841634428AA26FE007812D8 /* ExampleUITests.xctest */; @@ -315,8 +219,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - D841633428AA26FE007812D8 /* Preview Assets.xcassets in Resources */, - D841633128AA26FE007812D8 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -341,17 +243,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D82594FE28AA3EA200A36359 /* Cell.swift in Sources */, - D841632F28AA26FC007812D8 /* ContentView.swift in Sources */, - D825950628AA45ED00A36359 /* SelectionToolbar.swift in Sources */, - D841632D28AA26FC007812D8 /* ExampleApp.swift in Sources */, - D8C5D20928AACB75001443BA /* LayoutToolbar.swift in Sources */, - D8C5D20728AACB2B001443BA /* LayoutMode.swift in Sources */, - D825950428AA443A00A36359 /* ItemsToolbar.swift in Sources */, - D841635A28AA2819007812D8 /* Color.swift in Sources */, - D825950928AA46C000A36359 /* StateToolbar.swift in Sources */, - D825950128AA3F7800A36359 /* Item.swift in Sources */, - D82594FC28AA3E3500A36359 /* Model.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -359,7 +250,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D841633F28AA26FE007812D8 /* ExampleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -367,8 +257,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D841634928AA26FE007812D8 /* ExampleUITests.swift in Sources */, - D841634B28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -519,6 +407,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = uk.co.inseven.Example; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -544,6 +433,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = uk.co.inseven.Example; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -555,7 +445,6 @@ D841635228AA26FE007812D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -573,7 +462,6 @@ D841635328AA26FE007812D8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -591,7 +479,6 @@ D841635528AA26FE007812D8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; @@ -607,7 +494,6 @@ D841635628AA26FE007812D8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift new file mode 100644 index 0000000..8680865 --- /dev/null +++ b/Example/Example/Model/Creator.swift @@ -0,0 +1,95 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Combine +import SwiftUI +import SelectableCollectionView + +@Observable +class Creator: CollectionViewStreamingCollection { + + enum Operation: CaseIterable { + case add + case remove + case move + case update + + static func random() -> Self { + return allCases.randomElement()! + } + } + + private var collectionView: (any CollectionViewProxy)? = nil + public var items: [Item] = [] + private var isActive: Bool = true + + func run() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + guard let self else { + return + } + defer { self.run() } + + switch Operation.random() { + case .add: + let item = Item() + let index = Int.random(in: 0..)?) { + self.collectionView = collectionView + self.collectionView?.setItems(Array(items)) + } + +} diff --git a/Example/Example/Model/Item.swift b/Example/Example/Model/Item.swift index 9bb2b64..3d5d2f1 100644 --- a/Example/Example/Model/Item.swift +++ b/Example/Example/Model/Item.swift @@ -20,7 +20,8 @@ import SwiftUI -struct Item: Hashable, Identifiable { +class Item: Identifiable { let id = UUID() let color: Color = .random + var count: Int = 0 } diff --git a/Example/Example/Model/Model.swift b/Example/Example/Model/Model.swift index 42d9b73..f65539b 100644 --- a/Example/Example/Model/Model.swift +++ b/Example/Example/Model/Model.swift @@ -21,8 +21,7 @@ import Combine import SwiftUI -#warning("TODO: What thread is the filter running on?") -class Model: ObservableObject { +class Model: ObservableObject, @unchecked Sendable { @Environment(\.openURL) private var openURL diff --git a/Example/Example/Toolbars/LayoutToolbar.swift b/Example/Example/Toolbars/LayoutToolbar.swift index 018b7ad..bd8b00f 100644 --- a/Example/Example/Toolbars/LayoutToolbar.swift +++ b/Example/Example/Toolbars/LayoutToolbar.swift @@ -26,7 +26,7 @@ struct LayoutToolbar: CustomizableToolbarContent { var body: some CustomizableToolbarContent { - ToolbarItem(id: "mode") { + ToolbarItem(id: "mode", placement: .navigation) { Picker(selection: $mode) { ForEach(LayoutMode.allCases) { mode in Image(systemName: mode.systemImage) diff --git a/Example/Example/Toolbars/ItemsToolbar.swift b/Example/Example/Toolbars/ModeToolbar.swift similarity index 67% rename from Example/Example/Toolbars/ItemsToolbar.swift rename to Example/Example/Toolbars/ModeToolbar.swift index 2b7eb0b..fd8664d 100644 --- a/Example/Example/Toolbars/ItemsToolbar.swift +++ b/Example/Example/Toolbars/ModeToolbar.swift @@ -1,4 +1,4 @@ -// Copyright (c) 2022-2024 Jason Morley +// Copyright (c) 2022-2026 Jason Morley // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -20,30 +20,20 @@ import SwiftUI -struct ItemsToolbar: CustomizableToolbarContent { +struct ModeToolbar: CustomizableToolbarContent { - @EnvironmentObject var model: Model + @Binding var isStreaming: Bool var body: some CustomizableToolbarContent { - - ToolbarItem(id: "add") { - Button { - model.items.append(Item()) - } label: { - Label("Add", systemImage: "plus") + ToolbarItem(id: "streaming") { + Picker("Mode", selection: $isStreaming) { + Label("Streaming", systemImage: "hare") + .tag(true) + Label("Static", systemImage: "tortoise") + .tag(false) } - .help("Add item") + .pickerStyle(.inline) } - - ToolbarItem(id: "add-many") { - Button { - model.addManyItems() - } label: { - Label("Add Many", systemImage: "infinity") - } - .help("Add many items (1000)") - } - } } diff --git a/Example/Example/Toolbars/SelectionToolbar.swift b/Example/Example/Toolbars/SelectionToolbar.swift index 244cc60..fcb36b5 100644 --- a/Example/Example/Toolbars/SelectionToolbar.swift +++ b/Example/Example/Toolbars/SelectionToolbar.swift @@ -20,50 +20,60 @@ import SwiftUI -struct SelectionToolbar: CustomizableToolbarContent { +struct SelectionToolbar: ToolbarContent { @EnvironmentObject var model: Model - var body: some CustomizableToolbarContent { + var body: some ToolbarContent { - ToolbarItem(id: "clear") { - Button { - model.clearSelection() - } label: { - Label("Clear Selection", systemImage: "xmark") - } - .help("Clear selection") - .disabled(model.selection.isEmpty) - } + ToolbarItem { + Menu("Title", systemImage: "ellipsis.circle") { + Button { + model.clearSelection() + } label: { + Label("Clear Selection", systemImage: "xmark") + } + .help("Clear selection") + .disabled(model.selection.isEmpty) - ToolbarItem(id: "random") { - Button { - model.selectRandomItem() - } label: { - Label("Random Selection", systemImage: "arrow.2.squarepath") - } - .help("Select random item") - } + Button { + model.selectRandomItem() + } label: { + Label("Select Random Item", systemImage: "shuffle") + } - ToolbarItem(id: "open") { - Button { - model.open(ids: model.selection) - } label: { - Label("Open", systemImage: "globe") - } - .keyboardShortcut(.return, modifiers: []) - .help("Open selected items in default web browser") - } + Divider() + + Button { + model.open(ids: model.selection) + } label: { + Label("Open", systemImage: "globe") + } + + Divider() + + Button { + model.delete(ids: model.selection) + } label: { + Label("Delete \(model.selection.count) Items", systemImage: "trash") + } + .disabled(model.selection.isEmpty) + + Divider() + + Button { + model.items.append(Item()) + } label: { + Label("Add 1 Item", systemImage: "plus.square") + } + + Button { + model.addManyItems() + } label: { + Label("Add 1000 Items", systemImage: "plus.square.on.square") + } - ToolbarItem(id: "delete") { - Button { - model.delete(ids: model.selection) - } label: { - Label("Delete", systemImage: "trash") } - .help("Delete selected items") - .keyboardShortcut(.delete) - .disabled(model.selection.isEmpty) } } diff --git a/Example/Example/Views/Cell.swift b/Example/Example/Views/Cell.swift index a6ad13a..d4f57bc 100644 --- a/Example/Example/Views/Cell.swift +++ b/Example/Example/Views/Cell.swift @@ -55,6 +55,8 @@ struct Cell: View { Text("#\(item.color.hexCode)") Spacer() } + Text(item.count, format: .number) + .font(.title) Spacer() } .background(isPainted ? .mint : item.color.opacity(0.4)) diff --git a/Example/Example/Views/ContentView.swift b/Example/Example/Views/ContentView.swift index 9cae997..749d419 100644 --- a/Example/Example/Views/ContentView.swift +++ b/Example/Example/Views/ContentView.swift @@ -25,13 +25,16 @@ import SelectableCollectionView struct ContentView: View { + @State var isStreaming = true @StateObject var model = Model() + let creator = Creator() @MenuItemBuilder func contextMenu(_ selection: Set) -> [MenuItem] { if !selection.isEmpty { - MenuItem("Delete") { + MenuItem("Delete", systemImage: "trash") { model.remove(ids: selection) } + .disabled(isStreaming) } } @@ -42,15 +45,25 @@ struct ContentView: View { var body: some View { HStack { if let layout = model.layoutMode.layout { - SelectableCollectionView(model.filteredItems, selection: $model.selection, layout: layout) { item in - Cell(item: item, isPainted: model.isPainted) - } contextMenu: { selection in - contextMenu(selection) - } primaryAction: { selection in - primaryAction(selection) + if isStreaming { + SelectableCollectionView(creator, selection: $model.selection, layout: layout) { item in + Cell(item: item, isPainted: model.isPainted) + } contextMenu: { selection in + contextMenu(selection) + } primaryAction: { selection in + primaryAction(selection) + } + } else { + SelectableCollectionView(model.filteredItems, selection: $model.selection, layout: layout) { item in + Cell(item: item, isPainted: model.isPainted) + } contextMenu: { selection in + contextMenu(selection) + } primaryAction: { selection in + primaryAction(selection) + } } } else { - Table(model.filteredItems, selection: $model.selection) { + Table(isStreaming ? creator.items : model.filteredItems, selection: $model.selection) { TableColumn("") { item in Image(systemName: "circle.fill") .foregroundColor(item.color) @@ -65,13 +78,13 @@ struct ContentView: View { } } .searchable(text: $model.filter) - .toolbar(id: "main") { + .toolbar { LayoutToolbar(mode: $model.layoutMode) - SelectionToolbar() + ModeToolbar(isStreaming: $isStreaming) StateToolbar() - ItemsToolbar() + SelectionToolbar() } - .navigationSubtitle(model.subtitle) + .navigationSubtitle(isStreaming ? "\(creator.items.count) items" : model.subtitle) .onAppear { model.run() } diff --git a/Sources/SelectableCollectionView/Extensions/NSEdgeInsets.swift b/Sources/SelectableCollectionView/Extensions/NSEdgeInsets.swift index 6cf1804..3e1bb93 100644 --- a/Sources/SelectableCollectionView/Extensions/NSEdgeInsets.swift +++ b/Sources/SelectableCollectionView/Extensions/NSEdgeInsets.swift @@ -22,7 +22,7 @@ import Foundation -extension NSEdgeInsets: Equatable { +extension NSEdgeInsets: @retroactive Equatable { public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { return NSEdgeInsetsEqual(lhs, rhs) diff --git a/Sources/SelectableCollectionView/Layouts/GridItemCollectionViewLayout.swift b/Sources/SelectableCollectionView/Layouts/GridItemCollectionViewLayout.swift index 0bf6b58..dbf27a3 100644 --- a/Sources/SelectableCollectionView/Layouts/GridItemCollectionViewLayout.swift +++ b/Sources/SelectableCollectionView/Layouts/GridItemCollectionViewLayout.swift @@ -66,17 +66,19 @@ class GridItemCollectionViewLayout: NSCollectionViewCompositionalLayout { let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width.value), heightDimension: .estimated(10)) return [NSCollectionLayoutItem(layoutSize: itemSize)] - case .adaptive(let minimum, let maximum): + case .adaptive(let minimum, _): let width = width.value + spacing let columns = max(1.0, floor(width / (minimum + spacing))) let itemWidth = width / columns let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth - spacing), heightDimension: .estimated(10)) var items: [NSCollectionLayoutItem] = [] - for i in 0.. Void) { - self.itemType = .item(title ?? "", systemImage, role, action) + self.itemType = .item(title, systemImage, role, action) } public init(_ title: LocalizedStringKey, systemImage: String? = nil, @MenuItemBuilder items: () -> [MenuItem]) { @@ -48,7 +48,7 @@ public struct MenuItem: Identifiable { } public init(_ title: String, systemImage: String? = nil, @MenuItemBuilder items: () -> [MenuItem]) { - self.itemType = .menu(title ?? "", systemImage, items()) + self.itemType = .menu(title, systemImage, items()) } public init(_ title: String, action: @escaping () async -> Void) { diff --git a/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift new file mode 100644 index 0000000..0245778 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift @@ -0,0 +1,61 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +class AnyCollectionViewManagedCollection: CollectionViewManagedCollection { + + var supportsIncrementalUpdates: Bool { + return _supportsIncrementalUpdates() + } + + private var _supportsIncrementalUpdates: () -> Bool + private var _collectionViewDidConnect: ((any CollectionViewProxy)?) -> Void + private var _update: () -> Void + + convenience init(_ items: any RandomAccessCollection) { + self.init(collection: RandomAccessCollectionWrapper(items: items)) + } + + convenience init(_ collection: any CollectionViewStreamingCollection) { + self.init(collection: CollectionViewManagedStreamingCollection(collection)) + } + + init(collection: any CollectionViewManagedCollection) { + _supportsIncrementalUpdates = { + return collection.supportsIncrementalUpdates + } + _collectionViewDidConnect = { collectionView in + collection.collectionViewDidConnect(collectionView) + } + _update = { + collection.update() + } + } + + func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) { + _collectionViewDidConnect(collectionView) + } + + func update() { + _update() + } + +} diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift new file mode 100644 index 0000000..c5f07db --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift @@ -0,0 +1,36 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +// TODO: Document. +// Must always be called on the main thread. +// Always called on the main thread. +protocol CollectionViewManagedCollection { + + associatedtype Element: Identifiable + + var supportsIncrementalUpdates: Bool { get } + + func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) + + func update() + +} diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift new file mode 100644 index 0000000..c2df4af --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift @@ -0,0 +1,50 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +/** + * Wraps `CollectionViewStreamingCollection` and presents a `CollectionViewManagedCollection` interface. + * + * This allows us to expose just the `CollectionViewStreamingCollection` protocol which ensures developers don't have + * to / aren't able to implement methods that can mess up the internals. + * + * It feels like this might be one level of abstraction too far, but, it's internal and allows us to talk to collections + * through the same API, hopefully localizing the specifics of the mappings. + */ +class CollectionViewManagedStreamingCollection : CollectionViewManagedCollection where Element: Identifiable { + + var supportsIncrementalUpdates: Bool = true + + private var _collectionViewDidConnect: ((any CollectionViewProxy)?) -> Void + + init(_ collection: any CollectionViewStreamingCollection) { + _collectionViewDidConnect = { proxy in + collection.collectionViewDidConnect(proxy) + } + } + + func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) { + _collectionViewDidConnect(collectionView) + } + + func update() {} + +} diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift new file mode 100644 index 0000000..3e67ba7 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -0,0 +1,50 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +/** + * Proxy protocol for managing a collection view. + * + * This allows streaming collections to directly apply changes to the collection view. + * + * Implementations conforming to this protocol make no attempt at thread safety and methods must be called on the main + * thread. + */ +public protocol CollectionViewProxy { + + associatedtype Element: Identifiable + + // TODO: Shoud the indicies be unsigned? + func setItems(_ items: [Element]) + func insertItem(_ item: Element, atIndex index: Int, items: [Element]) + func updateItem(_ item: Element, atIndex index: Int, items: [Element]) + func removeItemWithId(_ id: Element.ID, atIndex: Int, items: [Element]) + + /** + * Moves the item, `item`, located at `fromIndex` before the item located to `toIndex`. If `toIndex` is equal to the + * number of items, `item` is placed at the end of the list. + * + * While these semantics might seem strange, they're designed to match the behavoiur of + * `MutableCollection.move(fromOffsets:toOffset:)`. + */ + func moveItem(_ item: Element, toIndex index: Int, items: [Element]) + +} diff --git a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift new file mode 100644 index 0000000..d2a2391 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift @@ -0,0 +1,30 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +// Always called on the main thread. +public protocol CollectionViewStreamingCollection: AnyObject { + + associatedtype Element: Identifiable + + func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) + +} diff --git a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift new file mode 100644 index 0000000..7f987cd --- /dev/null +++ b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2022-2026 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftUI + +class RandomAccessCollectionWrapper: CollectionViewManagedCollection { + + var supportsIncrementalUpdates: Bool { false } + let items: any RandomAccessCollection + var collectionView: (any CollectionViewProxy)? + + init(items: any RandomAccessCollection) { + self.items = items + } + + func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) { + self.collectionView = collectionView + } + + func update() { + self.collectionView?.setItems(Array(items)) + } + +} diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index d7789d1..31851c5 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -26,21 +26,19 @@ import SwiftUI import SelectableCollectionViewMacResources -// TODO: Rename element to ID to avoid confusion? - public protocol CollectionViewContainerDelegate: NSObject { - associatedtype Element: Hashable & Identifiable + associatedtype Element: Identifiable associatedtype CellContent: View func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - menuItemsForElements elements: Set) -> [MenuItem] + menuItemsForIds ids: Set) -> [MenuItem] func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, contentForElement element: Element) -> CellContent? func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - didUpdateSelection selection: Set) + didUpdateSelection selection: Set) func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - didDoubleClickSelection selection: Set) + didDoubleClickSelection selection: Set) func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, keyDown event: NSEvent) -> Bool @@ -48,30 +46,16 @@ public protocol CollectionViewContainerDelegate: NSObject { keyUp event: NSEvent) -> Bool } -class CustomScrollView: NSScrollView { - - override func keyDown(with event: NSEvent) { - if event.keyCode == kVK_Space { - nextResponder?.keyDown(with: event) - return - } - super.keyDown(with: event) - } - - override func keyUp(with event: NSEvent) { - if event.keyCode == kVK_Space { - nextResponder?.keyUp(with: event) - return - } - super.keyUp(with: event) - } - -} - -public class CollectionViewContainer +// TODO: Explore hositing the Element.ID -> Element mapping. +// Technically the collection view doesn't need to know about the elements at all as the cell view construction +// could be entirely opaque. Changing the collection view to use only `Hashable` ids would make it possible to +// write a `SelectableCollectionView` constructor which allows users to manage the mapping themselves, potentially +// avoiding additional work and unnecessary memory duplication / copying. +public class CollectionViewContainer : NSView, NSCollectionViewDelegate, CollectionViewInteractionDelegate, + CollectionViewProxy, NSCollectionViewDelegateFlowLayout where Delegate.Element == Element, Delegate.CellContent == Content { @@ -81,16 +65,16 @@ public class CollectionViewContainer - typealias DataSource = NSCollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + typealias DataSource = NSCollectionViewDiffableDataSource typealias Cell = ShortcutItemView private let scrollView: CustomScrollView private let collectionView: InteractiveCollectionView - private var dataSource: DataSource? = nil + private var dataSource: DataSource! = nil private var cancellables: Set = [] - var provider: ((Element) -> Content?)? = nil + private var items: [Element.ID: Element] = [:] // Synchronized on the main thread. init(layout: NSCollectionViewLayout) { @@ -105,8 +89,10 @@ public class CollectionViewContainer) { - - // Update the items. - var snapshot = Snapshot() - snapshot.appendSections([.none]) - snapshot.appendItems(items, toSection: Section.none) - dataSource!.apply(snapshot, animatingDifferences: true) - + // TODO: Take in a set of items to compare with and we can maybe do an intersection? + func updateVisibleItems() { // Update the hosted item content. for item in collectionView.visibleItems() { guard let item = item as? ShortcutItemView, @@ -174,20 +154,31 @@ public class CollectionViewContainer) { +// +// // Update the items. +// var snapshot = Snapshot() +// snapshot.appendSections([.none]) +// snapshot.appendItems(items.map({ $0.id }), toSection: Section.none) +// dataSource.apply(snapshot, animatingDifferences: true) +// +// updateVisibleItems() +// +// // Update the selection +// let indexPaths = selection.compactMap { element in +// return dataSource?.indexPath(for: element) +// } +// +// // Updating the selection at the same time as the items seems to cause some form of loop or deadlock, so we +// // break that by dispatching back to the main queue. +// DispatchQueue.main.async { +// self.collectionView.selectionIndexPaths = Set(indexPaths) +// } +// +// } + @MainActor func updateLayout(_ layout: NSCollectionViewLayout) { collectionView.animator().collectionViewLayout = layout } @@ -203,10 +194,13 @@ public class CollectionViewContainer) -> NSMenu? { - - guard let menuItems = delegate?.collectionViewContainer(self, menuItemsForElements: selectedElements), + guard let menuItems = delegate?.collectionViewContainer(self, menuItemsForIds: selectedIds), !menuItems.isEmpty else { return nil @@ -231,12 +224,18 @@ public class CollectionViewContainer { - return Set(collectionView.selectionIndexPaths.compactMap { dataSource?.itemIdentifier(for: $0) }) + var selectedIds: Set { + return Set(collectionView + .selectionIndexPaths + .compactMap { dataSource?.itemIdentifier(for: $0) }) } func updateSelection() { - delegate?.collectionViewContainer(self, didUpdateSelection: selectedElements) + // We dispatch this back onto the main loop to ensure we're not updating state in a SwiftUI render. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.collectionViewContainer(self, didUpdateSelection: selectedIds) + } } func collectionView(_ collectionView: InteractiveCollectionView, didUpdateSelection selection: Set) { @@ -244,7 +243,7 @@ public class CollectionViewContainer) { - delegate?.collectionViewContainer(self, didDoubleClickSelection: selectedElements) + delegate?.collectionViewContainer(self, didDoubleClickSelection: selectedIds) } func collectionView(_ collectionView: InteractiveCollectionView, didUpdateFocus isFirstResponder: Bool) { @@ -277,6 +276,105 @@ public class CollectionViewContainer -: NSViewRepresentable where Data.Element: Identifiable, - Data.Element: Hashable, - Data.Element.ID: Hashable { +public struct CollectionViewContainerHost +: NSViewRepresentable where E: Identifiable, + E.ID: Hashable { - public typealias ID = Data.Element.ID - public typealias Element = Data.Element + public typealias ID = E.ID + public typealias Element = E public final class Coordinator: NSObject, CollectionViewContainerDelegate { - public typealias Element = Data.Element + public typealias Element = E public typealias CellContent = Content - var parent: CollectionViewContainerHost + var parent: CollectionViewContainerHost var collectionViewLayoutHash: Int = 0 - init(_ parent: CollectionViewContainerHost) { + init(_ parent: CollectionViewContainerHost) { self.parent = parent } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - menuItemsForElements elements: Set) -> [MenuItem] { - let ids = Set(elements.map { $0.id }) + menuItemsForIds ids: Set) -> [MenuItem] { return parent.contextMenu(ids) } @@ -55,15 +52,13 @@ public struct CollectionViewContainerHost, - didUpdateSelection selection: Set) { - let ids = Set(selection.map { $0.id }) - parent.selection.wrappedValue = ids + didUpdateSelection selection: Set) { + parent.selection.wrappedValue = selection } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - didDoubleClickSelection selection: Set) { - let ids = Set(selection.map { $0.id }) - parent.primaryAction(ids) + didDoubleClickSelection selection: Set) { + parent.primaryAction(selection) } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, @@ -78,24 +73,24 @@ public struct CollectionViewContainerHost> + let collection: AnyCollectionViewManagedCollection + let selection: Binding> let layout: any Layoutable - let itemContent: (Data.Element) -> Content - let contextMenu: (Set) -> [MenuItem] - let primaryAction: (Set) -> () + let itemContent: (E) -> Content + let contextMenu: (Set) -> [MenuItem] + let primaryAction: (Set) -> () let keyDown: (NSEvent) -> Bool let keyUp: (NSEvent) -> Bool - public init(_ items: Data, - selection: Binding>, - layout: any Layoutable, - @ViewBuilder itemContent: @escaping (Data.Element) -> Content, - @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], - primaryAction: @escaping (Set) -> Void, - keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, - keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { - self.items = items + init(_ collection: AnyCollectionViewManagedCollection, + selection: Binding>, + layout: any Layoutable, + @ViewBuilder itemContent: @escaping (E) -> Content, + @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], + primaryAction: @escaping (Set) -> Void, + keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, + keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { + self.collection = collection self.selection = selection self.layout = layout self.itemContent = itemContent @@ -109,17 +104,34 @@ public struct CollectionViewContainerHost CollectionViewContainer { - let collectionView = CollectionViewContainer(layout: layout.makeLayout()) + public func makeNSView(context: Context) -> CollectionViewContainer { + let collectionView = CollectionViewContainer(layout: layout.makeLayout()) collectionView.delegate = context.coordinator + collection.collectionViewDidConnect(collectionView) + if !collection.supportsIncrementalUpdates { + collection.update() + } return collectionView } - public func updateNSView(_ collectionView: CollectionViewContainer, context: Context) { + public func updateNSView(_ collectionView: CollectionViewContainer, context: Context) { context.coordinator.parent = self - let selectedElements = items.filter { selection.wrappedValue.contains($0.id) } - collectionView.update(Array(items), selection: Set(selectedElements)) + // TODO: There needs to be a path for preparing the selection. The filtering should be done by the collection view though. +// let selectedElements = items.filter { selection.wrappedValue.contains($0.id) } +// collectionView.update(Array(items), selection: Set(selectedElements)) + + // First, ensure the visible items re-evaluate their hosted SwiftUI views. + collectionView.updateVisibleItems() + + // Next, we manually apply changes to the collection view if our collection doesn't automatically apply updates. + if !collection.supportsIncrementalUpdates { + collection.collectionViewDidConnect(collectionView) + collection.update() + } + // And finally, we apply a new layout if necessary. + // It actually looks like this might not be safe to do while also applying updates, so it's possible that we + // need to somehow gate this operation. if context.coordinator.collectionViewLayoutHash != layout.hashValue { let collectionViewLayout = layout.makeLayout() collectionView.updateLayout(collectionViewLayout) diff --git a/Sources/SelectableCollectionView/Views/CustomScrollView.swift b/Sources/SelectableCollectionView/Views/CustomScrollView.swift new file mode 100644 index 0000000..1c7b8fe --- /dev/null +++ b/Sources/SelectableCollectionView/Views/CustomScrollView.swift @@ -0,0 +1,46 @@ +// Copyright (c) 2022-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +#if os(macOS) + +import Carbon +import SwiftUI + +class CustomScrollView: NSScrollView { + + override func keyDown(with event: NSEvent) { + if event.keyCode == kVK_Space { + nextResponder?.keyDown(with: event) + return + } + super.keyDown(with: event) + } + + override func keyUp(with event: NSEvent) { + if event.keyCode == kVK_Space { + nextResponder?.keyUp(with: event) + return + } + super.keyUp(with: event) + } + +} + +#endif diff --git a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift index dad084d..7b8e5b0 100644 --- a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift +++ b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift @@ -22,30 +22,33 @@ import SwiftUI #if os(macOS) -public struct SelectableCollectionView: View where Data.Element: Identifiable, - Data.Element: Hashable, - Data.Element.ID: Hashable { +public struct SelectableCollectionView: View where Element: Identifiable, + Element.ID: Hashable { - let items: Data - let selection: Binding> + let collection: AnyCollectionViewManagedCollection + + let selection: Binding> let layout: any Layoutable - let itemContent: (Data.Element) -> Content - let contextMenu: (Set) -> [MenuItem] - let primaryAction: (Set) -> () + let itemContent: (Element) -> Content + let contextMenu: (Set) -> [MenuItem] + let primaryAction: (Set) -> () let keyDown: (NSEvent) -> Bool let keyUp: (NSEvent) -> Bool - public init(_ items: Data, - selection: Binding>, + /** + * Content and context menu builder blocks are guaranteed to be called on the main thread. + */ + public init(_ items: any RandomAccessCollection, + selection: Binding>, columns: [GridItem], spacing: CGFloat? = nil, - @ViewBuilder itemContent: @escaping (Data.Element) -> Content, - @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], - primaryAction: @escaping (Set) -> Void, + @ViewBuilder itemContent: @escaping (Element) -> Content, + @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], + primaryAction: @escaping (Set) -> Void, keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { - self.items = items + self.collection = AnyCollectionViewManagedCollection(items) self.selection = selection self.layout = GridItemLayout(columns: columns, spacing: spacing) self.itemContent = itemContent @@ -55,15 +58,39 @@ public struct SelectableCollectionView>, + /** + * Content and context menu builder blocks are guaranteed to be called on the main thread. + */ + public init(_ items: any RandomAccessCollection, + selection: Binding>, layout: any Layoutable, - @ViewBuilder itemContent: @escaping (Data.Element) -> Content, - @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], - primaryAction: @escaping (Set) -> Void, + @ViewBuilder itemContent: @escaping (Element) -> Content, + @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], + primaryAction: @escaping (Set) -> Void, keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { - self.items = items + self.collection = AnyCollectionViewManagedCollection(items) + self.selection = selection + self.layout = layout + self.itemContent = itemContent + self.contextMenu = contextMenu + self.primaryAction = primaryAction + self.keyDown = keyDown + self.keyUp = keyUp + } + + /** + * Content and context menu builder blocks are guaranteed to be called on the main thread. + */ + public init(_ collection: any CollectionViewStreamingCollection, + selection: Binding>, + layout: any Layoutable, + @ViewBuilder itemContent: @escaping (Element) -> Content, + @MenuItemBuilder contextMenu: @escaping (Set) -> [MenuItem], + primaryAction: @escaping (Set) -> Void, + keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, + keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { + self.collection = AnyCollectionViewManagedCollection(collection) self.selection = selection self.layout = layout self.itemContent = itemContent @@ -74,7 +101,7 @@ public struct SelectableCollectionView