From edce4c8101f206378eebf0797c74a519620f4027 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 10:44:09 -1000 Subject: [PATCH 01/22] feat: Stream incremental changes directly to the collection view This change introduces `CollectionViewStreamingCollection` and `CollectionViewProxy` to allow incremental updates to be directly streamed to the hosted collection view instance. This obviates the need to diff the collection when performing updates and significantly improves performance when handling large amounts of data. --- Example/Example.xcodeproj/project.pbxproj | 154 +++--------------- Example/Example/Model/Creator.swift | 55 +++++++ Example/Example/Views/ContentView.swift | 12 +- .../Extensions/NSEdgeInsets.swift | 2 +- .../AnyCollectionViewManagedCollection.swift | 61 +++++++ .../CollectionViewManagedCollection.swift | 41 +++++ ...ectionViewManagedStreamingCollection.swift | 50 ++++++ .../Model/CollectionViewProxy.swift | 40 +++++ .../CollectionViewStreamingCollection.swift | 30 ++++ .../Model/RandomAccessCollectionWrapper.swift | 41 +++++ .../Views/CollectionViewContainer.swift | 56 ++++--- .../Views/CollectionViewContainerHost.swift | 71 ++++---- .../Views/CustomScrollView.swift | 46 ++++++ .../Views/SelectableCollectionView.swift | 65 +++++--- .../Views/ShortcutItemView.swift | 3 +- 15 files changed, 516 insertions(+), 211 deletions(-) create mode 100644 Example/Example/Model/Creator.swift create mode 100644 Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift create mode 100644 Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift create mode 100644 Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift create mode 100644 Sources/SelectableCollectionView/Model/CollectionViewProxy.swift create mode 100644 Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift create mode 100644 Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift create mode 100644 Sources/SelectableCollectionView/Views/CustomScrollView.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 083eb2c..6fac369 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; }; @@ -555,7 +443,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 +460,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 +477,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 +492,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..7349a21 --- /dev/null +++ b/Example/Example/Model/Creator.swift @@ -0,0 +1,55 @@ +// 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. + +import Combine +import SwiftUI +import SelectableCollectionView + +// TODO: Test removal. +// TODO: Make this a model so we can reuse it in other SwiftUI; good performance test too. +class Creator: CollectionViewStreamingCollection { + + var supportsIncrementalUpdates: Bool { true } + + private var collectionView: (any CollectionViewProxy)? = nil + private var items: [Item] = [] + + func run() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in + guard let self else { + return + } + let item = Item() + let index = Int.random(in: 0..)?) { + self.collectionView = collectionView + } + +} diff --git a/Example/Example/Views/ContentView.swift b/Example/Example/Views/ContentView.swift index 9cae997..b339d98 100644 --- a/Example/Example/Views/ContentView.swift +++ b/Example/Example/Views/ContentView.swift @@ -23,9 +23,12 @@ import SwiftUI import SelectableCollectionView + + struct ContentView: View { @StateObject var model = Model() + let creator = Creator() @MenuItemBuilder func contextMenu(_ selection: Set) -> [MenuItem] { if !selection.isEmpty { @@ -42,13 +45,20 @@ struct ContentView: View { var body: some View { HStack { if let layout = model.layoutMode.layout { - SelectableCollectionView(model.filteredItems, selection: $model.selection, layout: layout) { item in + 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) } +// 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) { TableColumn("") { item in 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/Model/AnyCollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift new file mode 100644 index 0000000..2049750 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift @@ -0,0 +1,61 @@ +// 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. + +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..896ed9a --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift @@ -0,0 +1,41 @@ +// 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. + +import SwiftUI + +// CollectionViewCollectionDelegate +// Must always be called on the main thread. + + +// TODO: Rename to CollectionViewCollection of similar. +// TODO: Would it make sense to have a start or stop to still make this a SwiftUI managed lifecycle? +// Always called on the main thread. +// TODO: This perhaps doesn't need to be a protocol itself since it's only implemented in one place? +protocol CollectionViewManagedCollection { + + associatedtype Element: Identifiable & Hashable + + 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..0d4ee27 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift @@ -0,0 +1,50 @@ +// 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. + +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 & Hashable { + + 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..33c25e7 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -0,0 +1,40 @@ +// 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. + +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 & Hashable + + func updateItems(_ items: [Element]) // TODO: Set items? + func insertItem(_ item: Element, atIndex index: Int, items: [Element]) + func updateItem(_ item: Element, atIndex index: Int, items: [Element]) + func removeItemWithIdentifier(_ identifier: Element.ID, atIndex index: Int, items: [Element]) + +} diff --git a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift new file mode 100644 index 0000000..e369eac --- /dev/null +++ b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift @@ -0,0 +1,30 @@ +// 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. + +import SwiftUI + +// Always called on the main thread. +public protocol CollectionViewStreamingCollection: AnyObject { + + associatedtype Element: Identifiable & Hashable + + 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..97f0ca0 --- /dev/null +++ b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift @@ -0,0 +1,41 @@ +// 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. + +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?.updateItems(Array(items)) + } + +} diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index d7789d1..04d1c23 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -48,30 +48,13 @@ 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 : NSView, NSCollectionViewDelegate, CollectionViewInteractionDelegate, + CollectionViewProxy, NSCollectionViewDelegateFlowLayout where Delegate.Element == Element, Delegate.CellContent == Content { @@ -87,7 +70,7 @@ public class CollectionViewContainer = [] var provider: ((Element) -> Content?)? = nil @@ -155,7 +138,7 @@ public class CollectionViewContainer) { + @MainActor private func update(_ items: [Element], selection: Set) { // Update the items. var snapshot = Snapshot() @@ -277,6 +260,39 @@ public class CollectionViewContainer -: NSViewRepresentable where Data.Element: Identifiable, - Data.Element: Hashable, - Data.Element.ID: Hashable { +public struct CollectionViewContainerHost +: NSViewRepresentable where E: Identifiable, + E: Hashable, + 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 } @@ -57,7 +56,9 @@ public struct CollectionViewContainerHost, didUpdateSelection selection: Set) { let ids = Set(selection.map { $0.id }) - parent.selection.wrappedValue = ids + DispatchQueue.main.async { [weak self] in // TODO: Do this internally? + self?.parent.selection.wrappedValue = ids // TODO: FIX THIS CALLBACK! + } } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, @@ -78,24 +79,24 @@ public struct CollectionViewContainerHost> + let items: 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(_ items: 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.items = items // TODO: Rename to collection?? self.selection = selection self.layout = layout self.itemContent = itemContent @@ -109,17 +110,25 @@ 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 + items.collectionViewDidConnect(collectionView) + if !items.supportsIncrementalUpdates { + items.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)) + if !items.supportsIncrementalUpdates { + items.collectionViewDidConnect(collectionView) + items.update() + } 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..6bc93ee 100644 --- a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift +++ b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift @@ -22,30 +22,32 @@ import SwiftUI #if os(macOS) -public struct SelectableCollectionView: View where Data.Element: Identifiable, - Data.Element: Hashable, - Data.Element.ID: Hashable { +// TODO: Rename to wrapper???? +public struct SelectableCollectionView: View where Element: Identifiable, + Element: Hashable, + 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>, + 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 +57,34 @@ public struct SelectableCollectionView>, + 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 + } + + // Streaming Collection? + 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 +95,7 @@ public struct SelectableCollectionView Date: Tue, 27 Jan 2026 10:51:48 -1000 Subject: [PATCH 02/22] Remove unnecessary comment --- Sources/SelectableCollectionView/Views/ShortcutItemView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/SelectableCollectionView/Views/ShortcutItemView.swift b/Sources/SelectableCollectionView/Views/ShortcutItemView.swift index bc5951f..21a77a4 100644 --- a/Sources/SelectableCollectionView/Views/ShortcutItemView.swift +++ b/Sources/SelectableCollectionView/Views/ShortcutItemView.swift @@ -24,8 +24,6 @@ import SwiftUI import SelectableCollectionViewMacResources -// TODO: RENAME RENAME RENAME -// TODO: Can we type this internally? class ShortcutItemView: NSCollectionViewItem { static let identifier = NSUserInterfaceItemIdentifier(rawValue: "CollectionViewItem") From 5b84a240186c25991069ecde97c5a7386810b2ee Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 11:04:18 -1000 Subject: [PATCH 03/22] Make it possible to toggle streaming mode --- Example/Example/Model/Creator.swift | 2 +- Example/Example/Model/Model.swift | 4 +-- .../Example/Toolbars/StreamingToolbar.swift | 34 +++++++++++++++++++ Example/Example/Views/ContentView.swift | 32 +++++++++-------- 4 files changed, 54 insertions(+), 18 deletions(-) create mode 100644 Example/Example/Toolbars/StreamingToolbar.swift diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 7349a21..dca1d3b 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.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 diff --git a/Example/Example/Model/Model.swift b/Example/Example/Model/Model.swift index 42d9b73..f79b0c6 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 @@ -33,6 +32,7 @@ class Model: ObservableObject { @Published var isPainted = false @Published var layoutMode: LayoutMode = .column @Published var subtitle: String = "" + @Published var isStreaming: Bool = true private var cancellables: Set = [] private var backgroundQueue = DispatchQueue(label: "backgroundQueue") diff --git a/Example/Example/Toolbars/StreamingToolbar.swift b/Example/Example/Toolbars/StreamingToolbar.swift new file mode 100644 index 0000000..c3f42c4 --- /dev/null +++ b/Example/Example/Toolbars/StreamingToolbar.swift @@ -0,0 +1,34 @@ +// 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. + +import SwiftUI + +struct StreamingToolbar: CustomizableToolbarContent { + + @Binding var isStreaming: Bool + + var body: some CustomizableToolbarContent { + ToolbarItem(id: "streaming") { + Toggle("Stream", systemImage: "playpause", isOn: $isStreaming) + + } + } + +} diff --git a/Example/Example/Views/ContentView.swift b/Example/Example/Views/ContentView.swift index b339d98..cb79e76 100644 --- a/Example/Example/Views/ContentView.swift +++ b/Example/Example/Views/ContentView.swift @@ -23,8 +23,6 @@ import SwiftUI import SelectableCollectionView - - struct ContentView: View { @StateObject var model = Model() @@ -45,20 +43,23 @@ struct ContentView: View { var body: some View { HStack { if let layout = model.layoutMode.layout { - 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) + if model.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) + } } -// 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) { TableColumn("") { item in @@ -76,6 +77,7 @@ struct ContentView: View { } .searchable(text: $model.filter) .toolbar(id: "main") { + StreamingToolbar(isStreaming: $model.isStreaming) LayoutToolbar(mode: $model.layoutMode) SelectionToolbar() StateToolbar() From 0c5383962928a5ff8fef970c2d3452733a59c538 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 11:38:43 -1000 Subject: [PATCH 04/22] Send update when the proxy connects --- Example/Example/Model/Creator.swift | 1 + .../Views/CollectionViewContainer.swift | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index dca1d3b..bce9902 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -50,6 +50,7 @@ class Creator: CollectionViewStreamingCollection { func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) { self.collectionView = collectionView + self.collectionView?.updateItems(Array(items)) } } diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index 04d1c23..f03afc8 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -48,8 +48,6 @@ public protocol CollectionViewContainerDelegate: NSObject { keyUp event: NSEvent) -> Bool } - - public class CollectionViewContainer : NSView, NSCollectionViewDelegate, From 3c887700c8a0e6b36e67b3fc815838590280d566 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 11:43:10 -1000 Subject: [PATCH 05/22] Rename `updateItems` to `setItems` --- Example/Example/Model/Creator.swift | 4 +--- .../SelectableCollectionView/Model/CollectionViewProxy.swift | 2 +- .../Model/RandomAccessCollectionWrapper.swift | 2 +- .../Views/CollectionViewContainer.swift | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index bce9902..d6d5154 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -26,8 +26,6 @@ import SelectableCollectionView // TODO: Make this a model so we can reuse it in other SwiftUI; good performance test too. class Creator: CollectionViewStreamingCollection { - var supportsIncrementalUpdates: Bool { true } - private var collectionView: (any CollectionViewProxy)? = nil private var items: [Item] = [] @@ -50,7 +48,7 @@ class Creator: CollectionViewStreamingCollection { func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) { self.collectionView = collectionView - self.collectionView?.updateItems(Array(items)) + self.collectionView?.setItems(Array(items)) } } diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index 33c25e7..5ba0745 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -32,7 +32,7 @@ public protocol CollectionViewProxy { associatedtype Element: Identifiable & Hashable - func updateItems(_ items: [Element]) // TODO: Set items? + func setItems(_ items: [Element]) func insertItem(_ item: Element, atIndex index: Int, items: [Element]) func updateItem(_ item: Element, atIndex index: Int, items: [Element]) func removeItemWithIdentifier(_ identifier: Element.ID, atIndex index: Int, items: [Element]) diff --git a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift index 97f0ca0..341cb18 100644 --- a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift +++ b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift @@ -35,7 +35,7 @@ class RandomAccessCollectionWrapper: Collectio } func update() { - self.collectionView?.updateItems(Array(items)) + self.collectionView?.setItems(Array(items)) } } diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index f03afc8..dfe06d9 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -258,7 +258,7 @@ public class CollectionViewContainer Date: Tue, 27 Jan 2026 11:48:35 -1000 Subject: [PATCH 06/22] Update the copyright and simplify the comments --- Example/Example/Toolbars/StreamingToolbar.swift | 2 +- .../Model/AnyCollectionViewManagedCollection.swift | 2 +- .../Model/CollectionViewManagedCollection.swift | 9 ++------- .../Model/CollectionViewManagedStreamingCollection.swift | 2 +- .../Model/CollectionViewProxy.swift | 2 +- 5 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Example/Example/Toolbars/StreamingToolbar.swift b/Example/Example/Toolbars/StreamingToolbar.swift index c3f42c4..8d53b44 100644 --- a/Example/Example/Toolbars/StreamingToolbar.swift +++ b/Example/Example/Toolbars/StreamingToolbar.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 diff --git a/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift index 2049750..46bb0c5 100644 --- a/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift +++ b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.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 diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift index 896ed9a..0a72b1a 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.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,14 +20,9 @@ import SwiftUI -// CollectionViewCollectionDelegate +// TODO: Document. // Must always be called on the main thread. - - -// TODO: Rename to CollectionViewCollection of similar. -// TODO: Would it make sense to have a start or stop to still make this a SwiftUI managed lifecycle? // Always called on the main thread. -// TODO: This perhaps doesn't need to be a protocol itself since it's only implemented in one place? protocol CollectionViewManagedCollection { associatedtype Element: Identifiable & Hashable diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift index 0d4ee27..d7a7ad4 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.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 diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index 5ba0745..66654df 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.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 From 901aa35ebbc269ed4dfcdcab333a2c6adb9d48f6 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 11:49:41 -1000 Subject: [PATCH 07/22] Update copyright again --- .../Model/CollectionViewStreamingCollection.swift | 2 +- .../Model/RandomAccessCollectionWrapper.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift index e369eac..60a7cc6 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.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 diff --git a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift index 341cb18..dca5df6 100644 --- a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift +++ b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.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 From b0faef832a2be8fce599ad8cb46bca62b8c8bfe5 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 12:05:03 -1000 Subject: [PATCH 08/22] Support removing items --- Example/Example/Model/Creator.swift | 22 ++++++++++++++----- .../Menus/MenuItem.swift | 4 ++-- .../Model/CollectionViewProxy.swift | 4 +++- .../Views/CollectionViewContainer.swift | 8 +++++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index d6d5154..b4cc4c8 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -34,11 +34,23 @@ class Creator: CollectionViewStreamingCollection { guard let self else { return } - let item = Item() - let index = Int.random(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/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index 66654df..aac5556 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -20,6 +20,8 @@ import SwiftUI +// TODO: Does Element need to be identifiable?? + /** * Proxy protocol for managing a collection view. * @@ -35,6 +37,6 @@ public protocol CollectionViewProxy { func setItems(_ items: [Element]) func insertItem(_ item: Element, atIndex index: Int, items: [Element]) func updateItem(_ item: Element, atIndex index: Int, items: [Element]) - func removeItemWithIdentifier(_ identifier: Element.ID, atIndex index: Int, items: [Element]) + func removeItem(_ item: Element, atIndex index: Int, items: [Element]) } diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index dfe06d9..c276190 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -285,10 +285,14 @@ public class CollectionViewContainer Date: Tue, 27 Jan 2026 12:05:45 -1000 Subject: [PATCH 09/22] Remove logging --- Example/Example/Model/Creator.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index b4cc4c8..919a03e 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -37,13 +37,11 @@ class Creator: CollectionViewStreamingCollection { defer { self.run() } if Bool.random() { - print("Add...") let item = Item() let index = Int.random(in: 0.. Date: Tue, 27 Jan 2026 16:54:07 -1000 Subject: [PATCH 10/22] Support moving items --- Example/Example.xcodeproj/project.pbxproj | 2 + Example/Example/Model/Creator.swift | 34 +++++-- Example/Example/Views/ContentView.swift | 7 +- .../Model/CollectionViewProxy.swift | 10 +++ .../Views/CollectionViewContainer.swift | 88 +++++++++++++++---- 5 files changed, 116 insertions(+), 25 deletions(-) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 6fac369..1ed2d82 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -407,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)"; @@ -432,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)"; diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 919a03e..5d38047 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -22,33 +22,53 @@ import Combine import SwiftUI import SelectableCollectionView -// TODO: Test removal. -// TODO: Make this a model so we can reuse it in other SwiftUI; good performance test too. +@Observable class Creator: CollectionViewStreamingCollection { + enum Operation: CaseIterable { + case add + case remove + case move + + static func random() -> Self { + return allCases.randomElement()! + } + } + private var collectionView: (any CollectionViewProxy)? = nil - private var items: [Item] = [] + public var items: [Item] = [] func run() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self else { return } defer { self.run() } - if Bool.random() { + switch Operation.random() { + case .add: let item = Item() let index = Int.random(in: 0..) -> [MenuItem] { if !selection.isEmpty { - MenuItem("Delete") { + MenuItem("Delete", systemImage: "trash") { model.remove(ids: selection) } + .disabled(model.isStreaming) } } @@ -61,7 +62,7 @@ struct ContentView: View { } } } else { - Table(model.filteredItems, selection: $model.selection) { + Table(model.isStreaming ? creator.items : model.filteredItems, selection: $model.selection) { TableColumn("") { item in Image(systemName: "circle.fill") .foregroundColor(item.color) @@ -83,7 +84,7 @@ struct ContentView: View { StateToolbar() ItemsToolbar() } - .navigationSubtitle(model.subtitle) + .navigationSubtitle(model.isStreaming ? "\(creator.items.count) items" : model.subtitle) .onAppear { model.run() } diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index aac5556..ea169d6 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -34,9 +34,19 @@ public protocol CollectionViewProxy { associatedtype Element: Identifiable & Hashable + // 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 removeItem(_ item: Element, atIndex index: 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/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index c276190..c97505f 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -136,14 +136,8 @@ 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? + private func updateVisibleItems() { // Update the hosted item content. for item in collectionView.visibleItems() { guard let item = item as? ShortcutItemView, @@ -155,6 +149,17 @@ public class CollectionViewContainer) { + + // Update the items. + var snapshot = Snapshot() + snapshot.appendSections([.none]) + snapshot.appendItems(items, toSection: Section.none) + dataSource.apply(snapshot, animatingDifferences: true) + + updateVisibleItems() // Update the selection let indexPaths = selection.compactMap { element in @@ -184,10 +189,13 @@ public class CollectionViewContainer) { @@ -260,41 +271,88 @@ public class CollectionViewContainer Date: Tue, 27 Jan 2026 17:44:45 -1000 Subject: [PATCH 11/22] Submenu and fix cell updates --- Example/Example/Model/Creator.swift | 2 + Example/Example/Model/Model.swift | 1 - Example/Example/Toolbars/ItemsToolbar.swift | 49 ----------- Example/Example/Toolbars/LayoutToolbar.swift | 2 +- ...reamingToolbar.swift => ModeToolbar.swift} | 11 ++- .../Example/Toolbars/SelectionToolbar.swift | 82 +++++++++++-------- Example/Example/Views/ContentView.swift | 16 ++-- .../Views/CollectionViewContainer.swift | 14 +--- .../Views/CollectionViewContainerHost.swift | 3 + 9 files changed, 71 insertions(+), 109 deletions(-) delete mode 100644 Example/Example/Toolbars/ItemsToolbar.swift rename Example/Example/Toolbars/{StreamingToolbar.swift => ModeToolbar.swift} (79%) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 5d38047..5c011f5 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -25,6 +25,7 @@ import SelectableCollectionView @Observable class Creator: CollectionViewStreamingCollection { + // TODO: Exercise the update code! enum Operation: CaseIterable { case add case remove @@ -37,6 +38,7 @@ class Creator: CollectionViewStreamingCollection { 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 diff --git a/Example/Example/Model/Model.swift b/Example/Example/Model/Model.swift index f79b0c6..f65539b 100644 --- a/Example/Example/Model/Model.swift +++ b/Example/Example/Model/Model.swift @@ -32,7 +32,6 @@ class Model: ObservableObject, @unchecked Sendable { @Published var isPainted = false @Published var layoutMode: LayoutMode = .column @Published var subtitle: String = "" - @Published var isStreaming: Bool = true private var cancellables: Set = [] private var backgroundQueue = DispatchQueue(label: "backgroundQueue") diff --git a/Example/Example/Toolbars/ItemsToolbar.swift b/Example/Example/Toolbars/ItemsToolbar.swift deleted file mode 100644 index 2b7eb0b..0000000 --- a/Example/Example/Toolbars/ItemsToolbar.swift +++ /dev/null @@ -1,49 +0,0 @@ -// 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. - -import SwiftUI - -struct ItemsToolbar: CustomizableToolbarContent { - - @EnvironmentObject var model: Model - - var body: some CustomizableToolbarContent { - - ToolbarItem(id: "add") { - Button { - model.items.append(Item()) - } label: { - Label("Add", systemImage: "plus") - } - .help("Add item") - } - - ToolbarItem(id: "add-many") { - Button { - model.addManyItems() - } label: { - Label("Add Many", systemImage: "infinity") - } - .help("Add many items (1000)") - } - - } - -} 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/StreamingToolbar.swift b/Example/Example/Toolbars/ModeToolbar.swift similarity index 79% rename from Example/Example/Toolbars/StreamingToolbar.swift rename to Example/Example/Toolbars/ModeToolbar.swift index 8d53b44..fd8664d 100644 --- a/Example/Example/Toolbars/StreamingToolbar.swift +++ b/Example/Example/Toolbars/ModeToolbar.swift @@ -20,14 +20,19 @@ import SwiftUI -struct StreamingToolbar: CustomizableToolbarContent { +struct ModeToolbar: CustomizableToolbarContent { @Binding var isStreaming: Bool var body: some CustomizableToolbarContent { ToolbarItem(id: "streaming") { - Toggle("Stream", systemImage: "playpause", isOn: $isStreaming) - + Picker("Mode", selection: $isStreaming) { + Label("Streaming", systemImage: "hare") + .tag(true) + Label("Static", systemImage: "tortoise") + .tag(false) + } + .pickerStyle(.inline) } } 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/ContentView.swift b/Example/Example/Views/ContentView.swift index 2634a86..749d419 100644 --- a/Example/Example/Views/ContentView.swift +++ b/Example/Example/Views/ContentView.swift @@ -25,6 +25,7 @@ import SelectableCollectionView struct ContentView: View { + @State var isStreaming = true @StateObject var model = Model() let creator = Creator() @@ -33,7 +34,7 @@ struct ContentView: View { MenuItem("Delete", systemImage: "trash") { model.remove(ids: selection) } - .disabled(model.isStreaming) + .disabled(isStreaming) } } @@ -44,7 +45,7 @@ struct ContentView: View { var body: some View { HStack { if let layout = model.layoutMode.layout { - if model.isStreaming { + if isStreaming { SelectableCollectionView(creator, selection: $model.selection, layout: layout) { item in Cell(item: item, isPainted: model.isPainted) } contextMenu: { selection in @@ -62,7 +63,7 @@ struct ContentView: View { } } } else { - Table(model.isStreaming ? creator.items : model.filteredItems, selection: $model.selection) { + Table(isStreaming ? creator.items : model.filteredItems, selection: $model.selection) { TableColumn("") { item in Image(systemName: "circle.fill") .foregroundColor(item.color) @@ -77,14 +78,13 @@ struct ContentView: View { } } .searchable(text: $model.filter) - .toolbar(id: "main") { - StreamingToolbar(isStreaming: $model.isStreaming) + .toolbar { LayoutToolbar(mode: $model.layoutMode) - SelectionToolbar() + ModeToolbar(isStreaming: $isStreaming) StateToolbar() - ItemsToolbar() + SelectionToolbar() } - .navigationSubtitle(model.isStreaming ? "\(creator.items.count) items" : model.subtitle) + .navigationSubtitle(isStreaming ? "\(creator.items.count) items" : model.subtitle) .onAppear { model.run() } diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index c97505f..7209a38 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -137,7 +137,7 @@ public class CollectionViewContainer // 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)) + + collectionView.updateVisibleItems() + if !items.supportsIncrementalUpdates { items.collectionViewDidConnect(collectionView) items.update() From 813cce292ce4c32ad67303b71c8a66ed60736039 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Tue, 27 Jan 2026 19:18:14 -1000 Subject: [PATCH 12/22] Add comments --- .../Views/CollectionViewContainerHost.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift index 9b01689..dd498c6 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift @@ -126,12 +126,18 @@ public struct CollectionViewContainerHost // 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 !items.supportsIncrementalUpdates { items.collectionViewDidConnect(collectionView) items.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) From 83c4c299355645a2c823e68600891dcf41c6be4f Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 10:11:59 -1000 Subject: [PATCH 13/22] Address compiler warnings --- .../Layouts/GridItemCollectionViewLayout.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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.. Date: Wed, 28 Jan 2026 11:25:32 -1000 Subject: [PATCH 14/22] Start using identifiers internally --- Example/Example/Model/Creator.swift | 2 +- .../Model/CollectionViewProxy.swift | 20 ++- .../Views/CollectionViewContainer.swift | 137 +++++++++++------- .../Views/CollectionViewContainerHost.swift | 15 +- 4 files changed, 111 insertions(+), 63 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 5c011f5..28c1b8d 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -59,7 +59,7 @@ class Creator: CollectionViewStreamingCollection { } let index = Int.random(in: 0.. { func setItems(_ items: [Element]) func insertItem(_ item: Element, atIndex index: Int, items: [Element]) func updateItem(_ item: Element, atIndex index: Int, items: [Element]) - func removeItem(_ item: Element, atIndex index: Int, items: [Element]) +// func removeItem(_ item: Element, atIndex index: Int, items: [Element]) + func removeItemWithId(_ id: Element.ID, atIndex: Int, items: [Element]) + + /** + * Remove the item with identifier `identifier` from the collection view. + * + * Items are referenced by identifier + */ +// func removeItemWithIdentifier(_ identifier: Element.ID, atIndex index: Int, items: [Element]) + // TODO: We need to double check how equality works for these elements. + + // So the interesting thing here is that Folders _only_ inserts the IDs. So I think the correct answer is to make + // the table view _only_ use IDs. This is complex though. Right now the current implementation is broken. Which + // might explain the over-animation in builds when things change since we're replacing everything in the table view + // rather than simply reloading the cell contents. + // If I explicitly use identifiable and only insert the ids into the table view, then that probably forces clients + // to use it more correctly as it'll force them to think about identifiers which are unique across the iteration of + // the items. But it requires us to cache the items. I guess we're effectively already doing that already. Though + // building that list might suck---perhaps it's necessary to do it on a separate serial queue? /** * Moves the item, `item`, located at `fromIndex` before the item located to `toIndex`. If `toIndex` is equal to the diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index 7209a38..fab40a9 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 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,7 +46,12 @@ public protocol CollectionViewContainerDelegate: NSObject { keyUp event: NSEvent) -> Bool } -public class CollectionViewContainer +// TODO: Can I hoist the id -> element mapping out of the CollectionViewContainer. Does it make sense to?? +// It could probably be injected, though it would require the CollectionViewProxy to not be the collection view, but +// instead a tracking proxy if necessary. + +// TODO: Consider whether `Element` needs to be AnyClass? +public class CollectionViewContainer : NSView, NSCollectionViewDelegate, CollectionViewInteractionDelegate, @@ -62,8 +65,8 @@ public class CollectionViewContainer - typealias DataSource = NSCollectionViewDiffableDataSource + typealias Snapshot = NSDiffableDataSourceSnapshot + typealias DataSource = NSCollectionViewDiffableDataSource typealias Cell = ShortcutItemView private let scrollView: CustomScrollView @@ -71,6 +74,8 @@ public class CollectionViewContainer = [] + private var items: [Element.ID: Element] = [:] // TODO: Ensure this is threadsafe. + var provider: ((Element) -> Content?)? = nil init(layout: NSCollectionViewLayout) { @@ -86,8 +91,10 @@ public class CollectionViewContainer) { - - // Update the items. - var snapshot = Snapshot() - snapshot.appendSections([.none]) - snapshot.appendItems(items, 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 private func update(_ items: [Element], selection: Set) { +// +// // 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 @@ -211,8 +218,7 @@ 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 @@ -220,14 +226,19 @@ public class CollectionViewContainer { - return Set(collectionView.selectionIndexPaths.compactMap { dataSource?.itemIdentifier(for: $0) }) + var selectedIds: Set { + // TODO: We shouldn't need to map to `Element` for this, but there's a bunch of knock-on effects that need work. + return Set(collectionView + .selectionIndexPaths + .compactMap { dataSource?.itemIdentifier(for: $0) } + /*.compactMap({ items[$0] })*/) } func updateSelection() { // We dispatch this back onto the main loop to ensure we're not updating state in a SwiftUI render. - DispatchQueue.main.async { - self.delegate?.collectionViewContainer(self, didUpdateSelection: self.selectedElements) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.delegate?.collectionViewContainer(self, didUpdateSelection: selectedIds) } } @@ -236,7 +247,8 @@ public class CollectionViewContainer) { - delegate?.collectionViewContainer(self, didDoubleClickSelection: selectedElements) + // TODO: Do I need to dispatch this? + delegate?.collectionViewContainer(self, didDoubleClickSelection: selectedIds) } func collectionView(_ collectionView: InteractiveCollectionView, didUpdateFocus isFirstResponder: Bool) { @@ -272,10 +284,15 @@ public class CollectionViewContainer } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - menuItemsForElements elements: Set) -> [MenuItem] { - let ids = Set(elements.map { $0.id }) + menuItemsForIds ids: Set) -> [MenuItem] { return parent.contextMenu(ids) } @@ -54,17 +53,13 @@ public struct CollectionViewContainerHost } public func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, - didUpdateSelection selection: Set) { - let ids = Set(selection.map { $0.id }) - DispatchQueue.main.async { [weak self] in // TODO: Do this internally? - self?.parent.selection.wrappedValue = ids // TODO: FIX THIS CALLBACK! - } + 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, From 01dbdea584b09975a02fbe5fbbe08f7cf774191e Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 11:32:46 -1000 Subject: [PATCH 15/22] Remove the `Hashable` constraint on `Element` This was unnecessary and allowing me to do bad things in the collection view. --- .../Model/CollectionViewProxy.swift | 20 +------------------ .../Views/CollectionViewContainer.swift | 6 ++---- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index 7e4cf5b..dfc38c4 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -32,32 +32,14 @@ import SwiftUI */ public protocol CollectionViewProxy { - associatedtype Element: Identifiable & Hashable + 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 removeItem(_ item: Element, atIndex index: Int, items: [Element]) func removeItemWithId(_ id: Element.ID, atIndex: Int, items: [Element]) - /** - * Remove the item with identifier `identifier` from the collection view. - * - * Items are referenced by identifier - */ -// func removeItemWithIdentifier(_ identifier: Element.ID, atIndex index: Int, items: [Element]) - // TODO: We need to double check how equality works for these elements. - - // So the interesting thing here is that Folders _only_ inserts the IDs. So I think the correct answer is to make - // the table view _only_ use IDs. This is complex though. Right now the current implementation is broken. Which - // might explain the over-animation in builds when things change since we're replacing everything in the table view - // rather than simply reloading the cell contents. - // If I explicitly use identifiable and only insert the ids into the table view, then that probably forces clients - // to use it more correctly as it'll force them to think about identifiers which are unique across the iteration of - // the items. But it requires us to cache the items. I guess we're effectively already doing that already. Though - // building that list might suck---perhaps it's necessary to do it on a separate serial queue? - /** * 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. diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index fab40a9..bd27689 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -28,7 +28,7 @@ import SelectableCollectionViewMacResources public protocol CollectionViewContainerDelegate: NSObject { - associatedtype Element: Hashable & Identifiable + associatedtype Element: Identifiable associatedtype CellContent: View func collectionViewContainer(_ collectionViewContainer: CollectionViewContainer, @@ -51,7 +51,7 @@ public protocol CollectionViewContainerDelegate: NSObject { // instead a tracking proxy if necessary. // TODO: Consider whether `Element` needs to be AnyClass? -public class CollectionViewContainer +public class CollectionViewContainer : NSView, NSCollectionViewDelegate, CollectionViewInteractionDelegate, @@ -76,8 +76,6 @@ public class CollectionViewContainer Content?)? = nil - init(layout: NSCollectionViewLayout) { scrollView = CustomScrollView() From 6f786772f4d8dbc1103bcc348bd138f932dbd228 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 11:46:23 -1000 Subject: [PATCH 16/22] Test update behavior (and fix update bugs) --- Example/Example/Model/Creator.swift | 10 ++++++++++ Example/Example/Model/Item.swift | 2 ++ Example/Example/Views/Cell.swift | 2 ++ .../Views/CollectionViewContainer.swift | 2 +- 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 28c1b8d..040c624 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -30,6 +30,7 @@ class Creator: CollectionViewStreamingCollection { case add case remove case move + case update static func random() -> Self { return allCases.randomElement()! @@ -69,6 +70,15 @@ class Creator: CollectionViewStreamingCollection { let item = items[from] items.move(fromOffsets: [from], toOffset: to) collectionView?.moveItem(item, toIndex: to, items: Array(items)) + case .update: + guard !items.isEmpty else { + return + } + let index = Int.random(in: 0.. Date: Wed, 28 Jan 2026 11:52:57 -1000 Subject: [PATCH 17/22] Small tidy up --- Example/Example/Model/Creator.swift | 1 - .../Views/CollectionViewContainerHost.swift | 18 +++++++++--------- .../Views/SelectableCollectionView.swift | 1 + 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Example/Example/Model/Creator.swift b/Example/Example/Model/Creator.swift index 040c624..8680865 100644 --- a/Example/Example/Model/Creator.swift +++ b/Example/Example/Model/Creator.swift @@ -25,7 +25,6 @@ import SelectableCollectionView @Observable class Creator: CollectionViewStreamingCollection { - // TODO: Exercise the update code! enum Operation: CaseIterable { case add case remove diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift index 8fa6597..994c8cc 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift @@ -74,7 +74,7 @@ public struct CollectionViewContainerHost } - let items: AnyCollectionViewManagedCollection + let collection: AnyCollectionViewManagedCollection let selection: Binding> let layout: any Layoutable let itemContent: (E) -> Content @@ -83,7 +83,7 @@ public struct CollectionViewContainerHost let keyDown: (NSEvent) -> Bool let keyUp: (NSEvent) -> Bool - init(_ items: AnyCollectionViewManagedCollection, + init(_ collection: AnyCollectionViewManagedCollection, selection: Binding>, layout: any Layoutable, @ViewBuilder itemContent: @escaping (E) -> Content, @@ -91,7 +91,7 @@ public struct CollectionViewContainerHost primaryAction: @escaping (Set) -> Void, keyDown: @escaping (NSEvent) -> Bool = { _ in return false }, keyUp: @escaping (NSEvent) -> Bool = { _ in return false }) { - self.items = items // TODO: Rename to collection?? + self.collection = collection self.selection = selection self.layout = layout self.itemContent = itemContent @@ -108,9 +108,9 @@ public struct CollectionViewContainerHost public func makeNSView(context: Context) -> CollectionViewContainer { let collectionView = CollectionViewContainer(layout: layout.makeLayout()) collectionView.delegate = context.coordinator - items.collectionViewDidConnect(collectionView) - if !items.supportsIncrementalUpdates { - items.update() + collection.collectionViewDidConnect(collectionView) + if !collection.supportsIncrementalUpdates { + collection.update() } return collectionView } @@ -125,9 +125,9 @@ public struct CollectionViewContainerHost collectionView.updateVisibleItems() // Next, we manually apply changes to the collection view if our collection doesn't automatically apply updates. - if !items.supportsIncrementalUpdates { - items.collectionViewDidConnect(collectionView) - items.update() + if !collection.supportsIncrementalUpdates { + collection.collectionViewDidConnect(collectionView) + collection.update() } // And finally, we apply a new layout if necessary. diff --git a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift index 6bc93ee..d53939e 100644 --- a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift +++ b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift @@ -23,6 +23,7 @@ import SwiftUI #if os(macOS) // TODO: Rename to wrapper???? +// TODO: Document the main thread guarantee for cell creation / element access. public struct SelectableCollectionView: View where Element: Identifiable, Element: Hashable, From 971c814c17f1be0385287b0ede6cbad2d0af6b57 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 11:55:51 -1000 Subject: [PATCH 18/22] Small documentation improvement. --- .../Views/SelectableCollectionView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift index d53939e..de6c7d9 100644 --- a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift +++ b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift @@ -22,8 +22,6 @@ import SwiftUI #if os(macOS) -// TODO: Rename to wrapper???? -// TODO: Document the main thread guarantee for cell creation / element access. public struct SelectableCollectionView: View where Element: Identifiable, Element: Hashable, @@ -39,6 +37,9 @@ public struct SelectableCollectionView Bool let keyUp: (NSEvent) -> Bool + /** + * Content and context menu builder blocks are guaranteed to be called on the main thread. + */ public init(_ items: any RandomAccessCollection, selection: Binding>, columns: [GridItem], @@ -58,6 +59,9 @@ public struct SelectableCollectionView, selection: Binding>, layout: any Layoutable, @@ -76,7 +80,9 @@ public struct SelectableCollectionView, selection: Binding>, layout: any Layoutable, From cd1034a429d6f824001a5ef7341d049f4daeed94 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 12:10:07 -1000 Subject: [PATCH 19/22] Remove more `Hashable` constraints --- Example/Example/Model/Item.swift | 3 +-- .../Model/AnyCollectionViewManagedCollection.swift | 2 +- .../Model/CollectionViewManagedCollection.swift | 2 +- .../CollectionViewManagedStreamingCollection.swift | 2 +- .../Model/CollectionViewProxy.swift | 2 -- .../Model/CollectionViewStreamingCollection.swift | 2 +- .../Model/RandomAccessCollectionWrapper.swift | 2 +- .../Views/CollectionViewContainer.swift | 12 ++++++------ .../Views/CollectionViewContainerHost.swift | 1 - .../Views/SelectableCollectionView.swift | 1 - .../Views/ShortcutItemView.swift | 1 + 11 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Example/Example/Model/Item.swift b/Example/Example/Model/Item.swift index b7ac223..3d5d2f1 100644 --- a/Example/Example/Model/Item.swift +++ b/Example/Example/Model/Item.swift @@ -20,8 +20,7 @@ import SwiftUI -// TODO: Consider making this a class. -struct Item: Hashable, Identifiable { +class Item: Identifiable { let id = UUID() let color: Color = .random var count: Int = 0 diff --git a/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift index 46bb0c5..0245778 100644 --- a/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift +++ b/Sources/SelectableCollectionView/Model/AnyCollectionViewManagedCollection.swift @@ -20,7 +20,7 @@ import SwiftUI -class AnyCollectionViewManagedCollection: CollectionViewManagedCollection { +class AnyCollectionViewManagedCollection: CollectionViewManagedCollection { var supportsIncrementalUpdates: Bool { return _supportsIncrementalUpdates() diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift index 0a72b1a..c5f07db 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedCollection.swift @@ -25,7 +25,7 @@ import SwiftUI // Always called on the main thread. protocol CollectionViewManagedCollection { - associatedtype Element: Identifiable & Hashable + associatedtype Element: Identifiable var supportsIncrementalUpdates: Bool { get } diff --git a/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift index d7a7ad4..c2df4af 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewManagedStreamingCollection.swift @@ -29,7 +29,7 @@ import SwiftUI * 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 & Hashable { +class CollectionViewManagedStreamingCollection : CollectionViewManagedCollection where Element: Identifiable { var supportsIncrementalUpdates: Bool = true diff --git a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift index dfc38c4..3e67ba7 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewProxy.swift @@ -20,8 +20,6 @@ import SwiftUI -// TODO: Does Element need to be identifiable?? - /** * Proxy protocol for managing a collection view. * diff --git a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift index 60a7cc6..d2a2391 100644 --- a/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift +++ b/Sources/SelectableCollectionView/Model/CollectionViewStreamingCollection.swift @@ -23,7 +23,7 @@ import SwiftUI // Always called on the main thread. public protocol CollectionViewStreamingCollection: AnyObject { - associatedtype Element: Identifiable & Hashable + associatedtype Element: Identifiable func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy)?) diff --git a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift index dca5df6..7f987cd 100644 --- a/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift +++ b/Sources/SelectableCollectionView/Model/RandomAccessCollectionWrapper.swift @@ -20,7 +20,7 @@ import SwiftUI -class RandomAccessCollectionWrapper: CollectionViewManagedCollection { +class RandomAccessCollectionWrapper: CollectionViewManagedCollection { var supportsIncrementalUpdates: Bool { false } let items: any RandomAccessCollection diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index 9a4b6f7..7e7447a 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -46,11 +46,11 @@ public protocol CollectionViewContainerDelegate: NSObject { keyUp event: NSEvent) -> Bool } -// TODO: Can I hoist the id -> element mapping out of the CollectionViewContainer. Does it make sense to?? -// It could probably be injected, though it would require the CollectionViewProxy to not be the collection view, but -// instead a tracking proxy if necessary. - -// TODO: Consider whether `Element` needs to be AnyClass? +// 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, @@ -74,7 +74,7 @@ public class CollectionViewContainer = [] - private var items: [Element.ID: Element] = [:] // TODO: Ensure this is threadsafe. + private var items: [Element.ID: Element] = [:] // Synchronized on the main thread. init(layout: NSCollectionViewLayout) { diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift index 994c8cc..285f0b6 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainerHost.swift @@ -24,7 +24,6 @@ import SwiftUI public struct CollectionViewContainerHost : NSViewRepresentable where E: Identifiable, - E: Hashable, E.ID: Hashable { public typealias ID = E.ID diff --git a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift index de6c7d9..7b8e5b0 100644 --- a/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift +++ b/Sources/SelectableCollectionView/Views/SelectableCollectionView.swift @@ -24,7 +24,6 @@ import SwiftUI public struct SelectableCollectionView: View where Element: Identifiable, - Element: Hashable, Element.ID: Hashable { let collection: AnyCollectionViewManagedCollection diff --git a/Sources/SelectableCollectionView/Views/ShortcutItemView.swift b/Sources/SelectableCollectionView/Views/ShortcutItemView.swift index 21a77a4..5e3a7be 100644 --- a/Sources/SelectableCollectionView/Views/ShortcutItemView.swift +++ b/Sources/SelectableCollectionView/Views/ShortcutItemView.swift @@ -24,6 +24,7 @@ import SwiftUI import SelectableCollectionViewMacResources +// TODO: Explore whether it's possible to make this generic on the cell view type. class ShortcutItemView: NSCollectionViewItem { static let identifier = NSUserInterfaceItemIdentifier(rawValue: "CollectionViewItem") From 6039c638c6700343f5b1137731563d0d7426d459 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 12:11:08 -1000 Subject: [PATCH 20/22] Remove todo --- .../SelectableCollectionView/Views/CollectionViewContainer.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index 7e7447a..f203ab7 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -245,7 +245,6 @@ public class CollectionViewContainer) { - // TODO: Do I need to dispatch this? delegate?.collectionViewContainer(self, didDoubleClickSelection: selectedIds) } From 1202b7c3401d3aad98f4046f39bdf44c6967a756 Mon Sep 17 00:00:00 2001 From: Jason Morley Date: Wed, 28 Jan 2026 12:12:47 -1000 Subject: [PATCH 21/22] Guard against no-op moves --- .../Views/CollectionViewContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index f203ab7..644933f 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -356,7 +356,7 @@ public class CollectionViewContainer Date: Wed, 28 Jan 2026 12:14:01 -1000 Subject: [PATCH 22/22] Remove lingering todo --- .../Views/CollectionViewContainer.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift index 644933f..31851c5 100644 --- a/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift +++ b/Sources/SelectableCollectionView/Views/CollectionViewContainer.swift @@ -225,11 +225,9 @@ public class CollectionViewContainer { - // TODO: We shouldn't need to map to `Element` for this, but there's a bunch of knock-on effects that need work. return Set(collectionView .selectionIndexPaths - .compactMap { dataSource?.itemIdentifier(for: $0) } - /*.compactMap({ items[$0] })*/) + .compactMap { dataSource?.itemIdentifier(for: $0) }) } func updateSelection() {