Skip to content

Commit 15b4b55

Browse files
authored
feat!: Stream incremental changes directly to the collection view (#30)
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.
1 parent 42c1cdc commit 15b4b55

23 files changed

Lines changed: 781 additions & 332 deletions

‎Example/Example.xcodeproj/project.pbxproj‎

Lines changed: 21 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,11 @@
33
archiveVersion = 1;
44
classes = {
55
};
6-
objectVersion = 56;
6+
objectVersion = 70;
77
objects = {
88

99
/* Begin PBXBuildFile section */
10-
D82594FC28AA3E3500A36359 /* Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82594FB28AA3E3500A36359 /* Model.swift */; };
11-
D82594FE28AA3EA200A36359 /* Cell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D82594FD28AA3EA200A36359 /* Cell.swift */; };
12-
D825950128AA3F7800A36359 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950028AA3F7800A36359 /* Item.swift */; };
13-
D825950428AA443A00A36359 /* ItemsToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950328AA443A00A36359 /* ItemsToolbar.swift */; };
14-
D825950628AA45ED00A36359 /* SelectionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950528AA45ED00A36359 /* SelectionToolbar.swift */; };
15-
D825950928AA46C000A36359 /* StateToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D825950828AA46C000A36359 /* StateToolbar.swift */; };
16-
D841632D28AA26FC007812D8 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841632C28AA26FC007812D8 /* ExampleApp.swift */; };
17-
D841632F28AA26FC007812D8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841632E28AA26FC007812D8 /* ContentView.swift */; };
18-
D841633128AA26FE007812D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D841633028AA26FE007812D8 /* Assets.xcassets */; };
19-
D841633428AA26FE007812D8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D841633328AA26FE007812D8 /* Preview Assets.xcassets */; };
20-
D841633F28AA26FE007812D8 /* ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841633E28AA26FE007812D8 /* ExampleTests.swift */; };
21-
D841634928AA26FE007812D8 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841634828AA26FE007812D8 /* ExampleUITests.swift */; };
22-
D841634B28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */; };
23-
D841635A28AA2819007812D8 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = D841635928AA2818007812D8 /* Color.swift */; };
2410
D841635D28AA283B007812D8 /* SelectableCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = D841635C28AA283B007812D8 /* SelectableCollectionView */; };
25-
D8C5D20728AACB2B001443BA /* LayoutMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C5D20628AACB2B001443BA /* LayoutMode.swift */; };
26-
D8C5D20928AACB75001443BA /* LayoutToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C5D20828AACB75001443BA /* LayoutToolbar.swift */; };
2711
/* End PBXBuildFile section */
2812

2913
/* Begin PBXContainerItemProxy section */
@@ -44,29 +28,18 @@
4428
/* End PBXContainerItemProxy section */
4529

4630
/* Begin PBXFileReference section */
47-
D82594FB28AA3E3500A36359 /* Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Model.swift; sourceTree = "<group>"; };
48-
D82594FD28AA3EA200A36359 /* Cell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cell.swift; sourceTree = "<group>"; };
49-
D825950028AA3F7800A36359 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
50-
D825950328AA443A00A36359 /* ItemsToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsToolbar.swift; sourceTree = "<group>"; };
51-
D825950528AA45ED00A36359 /* SelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectionToolbar.swift; sourceTree = "<group>"; };
52-
D825950828AA46C000A36359 /* StateToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateToolbar.swift; sourceTree = "<group>"; };
5331
D841632928AA26FC007812D8 /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
54-
D841632C28AA26FC007812D8 /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = "<group>"; };
55-
D841632E28AA26FC007812D8 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
56-
D841633028AA26FE007812D8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
57-
D841633328AA26FE007812D8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
58-
D841633528AA26FE007812D8 /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = "<group>"; };
5932
D841633A28AA26FE007812D8 /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
60-
D841633E28AA26FE007812D8 /* ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTests.swift; sourceTree = "<group>"; };
6133
D841634428AA26FE007812D8 /* ExampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
62-
D841634828AA26FE007812D8 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = "<group>"; };
63-
D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITestsLaunchTests.swift; sourceTree = "<group>"; };
6434
D841635828AA2726007812D8 /* SelectableCollectionView */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = SelectableCollectionView; path = ..; sourceTree = "<group>"; };
65-
D841635928AA2818007812D8 /* Color.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Color.swift; sourceTree = "<group>"; };
66-
D8C5D20628AACB2B001443BA /* LayoutMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutMode.swift; sourceTree = "<group>"; };
67-
D8C5D20828AACB75001443BA /* LayoutToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutToolbar.swift; sourceTree = "<group>"; };
6835
/* End PBXFileReference section */
6936

37+
/* Begin PBXFileSystemSynchronizedRootGroup section */
38+
D8D376C82F2898870033AB3C /* Example */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Example; sourceTree = "<group>"; };
39+
D8D376D82F28988B0033AB3C /* ExampleTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ExampleTests; sourceTree = "<group>"; };
40+
D8D376DC2F28988D0033AB3C /* ExampleUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = ExampleUITests; sourceTree = "<group>"; };
41+
/* End PBXFileSystemSynchronizedRootGroup section */
42+
7043
/* Begin PBXFrameworksBuildPhase section */
7144
D841632628AA26FC007812D8 /* Frameworks */ = {
7245
isa = PBXFrameworksBuildPhase;
@@ -93,51 +66,13 @@
9366
/* End PBXFrameworksBuildPhase section */
9467

9568
/* Begin PBXGroup section */
96-
D82594FA28AA3E0D00A36359 /* Extensions */ = {
97-
isa = PBXGroup;
98-
children = (
99-
D841635928AA2818007812D8 /* Color.swift */,
100-
);
101-
path = Extensions;
102-
sourceTree = "<group>";
103-
};
104-
D82594FF28AA3F6C00A36359 /* Model */ = {
105-
isa = PBXGroup;
106-
children = (
107-
D825950028AA3F7800A36359 /* Item.swift */,
108-
D8C5D20628AACB2B001443BA /* LayoutMode.swift */,
109-
D82594FB28AA3E3500A36359 /* Model.swift */,
110-
);
111-
path = Model;
112-
sourceTree = "<group>";
113-
};
114-
D825950228AA439300A36359 /* Toolbars */ = {
115-
isa = PBXGroup;
116-
children = (
117-
D825950328AA443A00A36359 /* ItemsToolbar.swift */,
118-
D8C5D20828AACB75001443BA /* LayoutToolbar.swift */,
119-
D825950528AA45ED00A36359 /* SelectionToolbar.swift */,
120-
D825950828AA46C000A36359 /* StateToolbar.swift */,
121-
);
122-
path = Toolbars;
123-
sourceTree = "<group>";
124-
};
125-
D825950728AA469600A36359 /* Views */ = {
126-
isa = PBXGroup;
127-
children = (
128-
D82594FD28AA3EA200A36359 /* Cell.swift */,
129-
D841632E28AA26FC007812D8 /* ContentView.swift */,
130-
);
131-
path = Views;
132-
sourceTree = "<group>";
133-
};
13469
D841632028AA26FC007812D8 = {
13570
isa = PBXGroup;
13671
children = (
13772
D841635728AA2726007812D8 /* Packages */,
138-
D841632B28AA26FC007812D8 /* Example */,
139-
D841633D28AA26FE007812D8 /* ExampleTests */,
140-
D841634728AA26FE007812D8 /* ExampleUITests */,
73+
D8D376C82F2898870033AB3C /* Example */,
74+
D8D376D82F28988B0033AB3C /* ExampleTests */,
75+
D8D376DC2F28988D0033AB3C /* ExampleUITests */,
14176
D841632A28AA26FC007812D8 /* Products */,
14277
D841635B28AA283B007812D8 /* Frameworks */,
14378
);
@@ -153,46 +88,6 @@
15388
name = Products;
15489
sourceTree = "<group>";
15590
};
156-
D841632B28AA26FC007812D8 /* Example */ = {
157-
isa = PBXGroup;
158-
children = (
159-
D841633528AA26FE007812D8 /* Example.entitlements */,
160-
D841632C28AA26FC007812D8 /* ExampleApp.swift */,
161-
D841633028AA26FE007812D8 /* Assets.xcassets */,
162-
D82594FA28AA3E0D00A36359 /* Extensions */,
163-
D82594FF28AA3F6C00A36359 /* Model */,
164-
D841633228AA26FE007812D8 /* Preview Content */,
165-
D825950228AA439300A36359 /* Toolbars */,
166-
D825950728AA469600A36359 /* Views */,
167-
);
168-
path = Example;
169-
sourceTree = "<group>";
170-
};
171-
D841633228AA26FE007812D8 /* Preview Content */ = {
172-
isa = PBXGroup;
173-
children = (
174-
D841633328AA26FE007812D8 /* Preview Assets.xcassets */,
175-
);
176-
path = "Preview Content";
177-
sourceTree = "<group>";
178-
};
179-
D841633D28AA26FE007812D8 /* ExampleTests */ = {
180-
isa = PBXGroup;
181-
children = (
182-
D841633E28AA26FE007812D8 /* ExampleTests.swift */,
183-
);
184-
path = ExampleTests;
185-
sourceTree = "<group>";
186-
};
187-
D841634728AA26FE007812D8 /* ExampleUITests */ = {
188-
isa = PBXGroup;
189-
children = (
190-
D841634828AA26FE007812D8 /* ExampleUITests.swift */,
191-
D841634A28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift */,
192-
);
193-
path = ExampleUITests;
194-
sourceTree = "<group>";
195-
};
19691
D841635728AA2726007812D8 /* Packages */ = {
19792
isa = PBXGroup;
19893
children = (
@@ -223,6 +118,9 @@
223118
);
224119
dependencies = (
225120
);
121+
fileSystemSynchronizedGroups = (
122+
D8D376C82F2898870033AB3C /* Example */,
123+
);
226124
name = Example;
227125
packageProductDependencies = (
228126
D841635C28AA283B007812D8 /* SelectableCollectionView */,
@@ -244,6 +142,9 @@
244142
dependencies = (
245143
D841633C28AA26FE007812D8 /* PBXTargetDependency */,
246144
);
145+
fileSystemSynchronizedGroups = (
146+
D8D376D82F28988B0033AB3C /* ExampleTests */,
147+
);
247148
name = ExampleTests;
248149
productName = ExampleTests;
249150
productReference = D841633A28AA26FE007812D8 /* ExampleTests.xctest */;
@@ -262,6 +163,9 @@
262163
dependencies = (
263164
D841634628AA26FE007812D8 /* PBXTargetDependency */,
264165
);
166+
fileSystemSynchronizedGroups = (
167+
D8D376DC2F28988D0033AB3C /* ExampleUITests */,
168+
);
265169
name = ExampleUITests;
266170
productName = ExampleUITests;
267171
productReference = D841634428AA26FE007812D8 /* ExampleUITests.xctest */;
@@ -315,8 +219,6 @@
315219
isa = PBXResourcesBuildPhase;
316220
buildActionMask = 2147483647;
317221
files = (
318-
D841633428AA26FE007812D8 /* Preview Assets.xcassets in Resources */,
319-
D841633128AA26FE007812D8 /* Assets.xcassets in Resources */,
320222
);
321223
runOnlyForDeploymentPostprocessing = 0;
322224
};
@@ -341,34 +243,20 @@
341243
isa = PBXSourcesBuildPhase;
342244
buildActionMask = 2147483647;
343245
files = (
344-
D82594FE28AA3EA200A36359 /* Cell.swift in Sources */,
345-
D841632F28AA26FC007812D8 /* ContentView.swift in Sources */,
346-
D825950628AA45ED00A36359 /* SelectionToolbar.swift in Sources */,
347-
D841632D28AA26FC007812D8 /* ExampleApp.swift in Sources */,
348-
D8C5D20928AACB75001443BA /* LayoutToolbar.swift in Sources */,
349-
D8C5D20728AACB2B001443BA /* LayoutMode.swift in Sources */,
350-
D825950428AA443A00A36359 /* ItemsToolbar.swift in Sources */,
351-
D841635A28AA2819007812D8 /* Color.swift in Sources */,
352-
D825950928AA46C000A36359 /* StateToolbar.swift in Sources */,
353-
D825950128AA3F7800A36359 /* Item.swift in Sources */,
354-
D82594FC28AA3E3500A36359 /* Model.swift in Sources */,
355246
);
356247
runOnlyForDeploymentPostprocessing = 0;
357248
};
358249
D841633628AA26FE007812D8 /* Sources */ = {
359250
isa = PBXSourcesBuildPhase;
360251
buildActionMask = 2147483647;
361252
files = (
362-
D841633F28AA26FE007812D8 /* ExampleTests.swift in Sources */,
363253
);
364254
runOnlyForDeploymentPostprocessing = 0;
365255
};
366256
D841634028AA26FE007812D8 /* Sources */ = {
367257
isa = PBXSourcesBuildPhase;
368258
buildActionMask = 2147483647;
369259
files = (
370-
D841634928AA26FE007812D8 /* ExampleUITests.swift in Sources */,
371-
D841634B28AA26FE007812D8 /* ExampleUITestsLaunchTests.swift in Sources */,
372260
);
373261
runOnlyForDeploymentPostprocessing = 0;
374262
};
@@ -519,6 +407,7 @@
519407
"$(inherited)",
520408
"@executable_path/../Frameworks",
521409
);
410+
MACOSX_DEPLOYMENT_TARGET = 14.0;
522411
MARKETING_VERSION = 1.0;
523412
PRODUCT_BUNDLE_IDENTIFIER = uk.co.inseven.Example;
524413
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -544,6 +433,7 @@
544433
"$(inherited)",
545434
"@executable_path/../Frameworks",
546435
);
436+
MACOSX_DEPLOYMENT_TARGET = 14.0;
547437
MARKETING_VERSION = 1.0;
548438
PRODUCT_BUNDLE_IDENTIFIER = uk.co.inseven.Example;
549439
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -555,7 +445,6 @@
555445
D841635228AA26FE007812D8 /* Debug */ = {
556446
isa = XCBuildConfiguration;
557447
buildSettings = {
558-
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
559448
BUNDLE_LOADER = "$(TEST_HOST)";
560449
CODE_SIGN_STYLE = Automatic;
561450
CURRENT_PROJECT_VERSION = 1;
@@ -573,7 +462,6 @@
573462
D841635328AA26FE007812D8 /* Release */ = {
574463
isa = XCBuildConfiguration;
575464
buildSettings = {
576-
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
577465
BUNDLE_LOADER = "$(TEST_HOST)";
578466
CODE_SIGN_STYLE = Automatic;
579467
CURRENT_PROJECT_VERSION = 1;
@@ -591,7 +479,6 @@
591479
D841635528AA26FE007812D8 /* Debug */ = {
592480
isa = XCBuildConfiguration;
593481
buildSettings = {
594-
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
595482
CODE_SIGN_STYLE = Automatic;
596483
CURRENT_PROJECT_VERSION = 1;
597484
GENERATE_INFOPLIST_FILE = YES;
@@ -607,7 +494,6 @@
607494
D841635628AA26FE007812D8 /* Release */ = {
608495
isa = XCBuildConfiguration;
609496
buildSettings = {
610-
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
611497
CODE_SIGN_STYLE = Automatic;
612498
CURRENT_PROJECT_VERSION = 1;
613499
GENERATE_INFOPLIST_FILE = YES;
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright (c) 2022-2026 Jason Morley
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in all
11+
// copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
21+
import Combine
22+
import SwiftUI
23+
import SelectableCollectionView
24+
25+
@Observable
26+
class Creator: CollectionViewStreamingCollection {
27+
28+
enum Operation: CaseIterable {
29+
case add
30+
case remove
31+
case move
32+
case update
33+
34+
static func random() -> Self {
35+
return allCases.randomElement()!
36+
}
37+
}
38+
39+
private var collectionView: (any CollectionViewProxy<Item>)? = nil
40+
public var items: [Item] = []
41+
private var isActive: Bool = true
42+
43+
func run() {
44+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
45+
guard let self else {
46+
return
47+
}
48+
defer { self.run() }
49+
50+
switch Operation.random() {
51+
case .add:
52+
let item = Item()
53+
let index = Int.random(in: 0..<items.count + 1)
54+
items.insert(item, at: index)
55+
collectionView?.insertItem(item, atIndex: index, items: Array(items))
56+
case .remove:
57+
guard !items.isEmpty else {
58+
return
59+
}
60+
let index = Int.random(in: 0..<items.count)
61+
let item = items.remove(at: index)
62+
collectionView?.removeItemWithId(item.id, atIndex: index, items: Array(items))
63+
case .move:
64+
guard !items.isEmpty else {
65+
return
66+
}
67+
let from = Int.random(in: 0..<items.count)
68+
let to = Int.random(in: 0...items.count) // Apple's move implementation _always_ treats it as inserting before this index.
69+
let item = items[from]
70+
items.move(fromOffsets: [from], toOffset: to)
71+
collectionView?.moveItem(item, toIndex: to, items: Array(items))
72+
case .update:
73+
guard !items.isEmpty else {
74+
return
75+
}
76+
let index = Int.random(in: 0..<items.count)
77+
var item = items[index]
78+
item.count += 1
79+
items[index] = item // We currently need to replace this because it's a struct.
80+
collectionView?.updateItem(item, atIndex: index, items: items)
81+
}
82+
83+
}
84+
}
85+
86+
init() {
87+
run()
88+
}
89+
90+
func collectionViewDidConnect(_ collectionView: (any CollectionViewProxy<Item>)?) {
91+
self.collectionView = collectionView
92+
self.collectionView?.setItems(Array(items))
93+
}
94+
95+
}

‎Example/Example/Model/Item.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020

2121
import SwiftUI
2222

23-
struct Item: Hashable, Identifiable {
23+
class Item: Identifiable {
2424
let id = UUID()
2525
let color: Color = .random
26+
var count: Int = 0
2627
}

0 commit comments

Comments
 (0)