Skip to content

Commit e9b94a8

Browse files
committed
Fixed reordering, insertion and removal in ForEach where Element: Identifiable
No non-identifiable support in this commit
1 parent 734b5df commit e9b94a8

File tree

7 files changed

+222
-53
lines changed

7 files changed

+222
-53
lines changed

Examples/Bundler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,8 @@ version = '0.1.0'
6464
identifier = 'dev.swiftcrossui.HoverExample'
6565
product = 'HoverExample'
6666
version = '0.1.0'
67+
68+
[apps.ForEachExample]
69+
identifier = 'dev.swiftcrossui.ForEachExample'
70+
product = 'ForEachExample'
71+
version = '0.1.0'

Examples/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Examples/Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ let package = Package(
7575
),
7676
.executableTarget(
7777
name: "HoverExample",
78+
dependencies: exampleDependencies
79+
),
80+
.executableTarget(
81+
name: "ForEachExample",
7882
dependencies: exampleDependencies
7983
)
8084
]
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import DefaultBackend
2+
import Foundation
3+
import SwiftCrossUI
4+
5+
#if canImport(SwiftBundlerRuntime)
6+
import SwiftBundlerRuntime
7+
#endif
8+
9+
@main
10+
@HotReloadable
11+
struct ForEachApp: App {
12+
@State var items = {
13+
var items = [Item]()
14+
for i in 0..<20 {
15+
items.append(.init("\(i)"))
16+
}
17+
return items
18+
}()
19+
@State var biggestValue = 19
20+
@State var insertionPosition = 10
21+
22+
var body: some Scene {
23+
WindowGroup("ForEach") {
24+
#hotReloadable {
25+
ScrollView {
26+
VStack {
27+
Text("Items")
28+
29+
Button("Append") {
30+
biggestValue += 1
31+
items.append(.init("\(biggestValue)"))
32+
}
33+
34+
Button("Insert in front of current item at position \(insertionPosition)") {
35+
biggestValue += 1
36+
items.insert(.init("\(biggestValue)"), at: insertionPosition)
37+
}
38+
Slider($insertionPosition, minimum: 0, maximum: items.count - 1)
39+
.onChange(of: items.count) {
40+
guard insertionPosition > items.count - 1 else {
41+
return
42+
}
43+
insertionPosition = max(items.count - 1, 0)
44+
}
45+
46+
ForEach(items) { item in
47+
ItemRow(
48+
item: item, isFirst: Optional(item.id) == items.first?.id,
49+
isLast: Optional(item.id) == items.last?.id
50+
) {
51+
items.removeAll(where: { $0.id == item.id })
52+
} moveUp: {
53+
guard
54+
let ownIndex = items.firstIndex(where: { $0.id == item.id }),
55+
ownIndex != items.startIndex
56+
else { return }
57+
items.swapAt(ownIndex, ownIndex - 1)
58+
} moveDown: {
59+
guard
60+
let ownIndex = items.firstIndex(where: { $0.id == item.id }),
61+
ownIndex != items.endIndex
62+
else { return }
63+
items.swapAt(ownIndex, ownIndex + 1)
64+
}
65+
}
66+
}
67+
.padding(10)
68+
}
69+
}
70+
}
71+
.defaultSize(width: 400, height: 800)
72+
}
73+
}
74+
75+
struct ItemRow: View {
76+
@State var item: Item
77+
let isFirst: Bool
78+
let isLast: Bool
79+
var remove: () -> Void
80+
var moveUp: () -> Void
81+
var moveDown: () -> Void
82+
83+
var body: some View {
84+
HStack {
85+
Text(item.value)
86+
Button("Delete") { remove() }
87+
Button("") { moveUp() }
88+
.disabled(isFirst)
89+
Button("") { moveDown() }
90+
.disabled(isLast)
91+
}
92+
}
93+
}
94+
95+
class Item: Identifiable, SwiftCrossUI.ObservableObject {
96+
let id = UUID()
97+
@SwiftCrossUI.Published var value: String
98+
99+
init(_ value: String) {
100+
self.value = value
101+
}
102+
}

Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ let package = Package(
110110
url: "https://github.com/stackotter/swift-winui",
111111
revision: "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86"
112112
),
113+
.package(
114+
url: "https://github.com/apple/swift-collections.git",
115+
.upToNextMajor(from: "1.3.0")
116+
),
113117
// .package(
114118
// url: "https://github.com/stackotter/TermKit",
115119
// revision: "163afa64f1257a0c026cc83ed8bc47a5f8fc9704"
@@ -129,6 +133,7 @@ let package = Package(
129133
dependencies: [
130134
"HotReloadingMacrosPlugin",
131135
.product(name: "ImageFormats", package: "swift-image-formats"),
136+
.product(name: "OrderedCollections", package: "swift-collections")
132137
],
133138
exclude: [
134139
"Builders/ViewBuilder.swift.gyb",

Sources/SwiftCrossUI/Views/ForEach.swift

Lines changed: 93 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import OrderedCollections
2+
13
/// A view that displays a variable amount of children.
24
public struct ForEach<Items: Collection, Child> where Items.Index == Int {
35
/// A variable-length collection of elements to display.
@@ -40,7 +42,7 @@ extension ForEach where Items == [Int] {
4042
}
4143
}
4244

43-
extension ForEach: TypeSafeView, View where Child: View {
45+
extension ForEach: TypeSafeView, View where Child: View, Items.Element: Identifiable {
4446
typealias Children = ForEachViewChildren<Items, Child>
4547

4648
public var body: EmptyView {
@@ -112,20 +114,69 @@ extension ForEach: TypeSafeView, View where Child: View {
112114
children.queuedChanges = []
113115
}
114116

115-
// TODO: The way we're reusing nodes for technically different elements means that if
116-
// Child has state of its own then it could get pretty confused thinking that its state
117-
// changed whereas it was actually just moved to a new slot in the array. Probably not
118-
// a huge issue, but definitely something to keep an eye on.
119117
var layoutableChildren: [LayoutSystem.LayoutableChild] = []
120-
for (i, node) in children.nodes.enumerated() {
121-
guard i < elements.count else {
122-
break
118+
119+
let oldNodes = children.nodes
120+
let oldMap = children.nodeIdentifierMap
121+
let oldIdentifiers = children.identifiers
122+
let identifiersStart = oldIdentifiers.startIndex
123+
124+
children.nodes = []
125+
children.nodeIdentifierMap = [:]
126+
children.identifiers = []
127+
128+
// Once this is true, every node that existed in the previous update and
129+
// still exists in the new one is reinserted to ensure that items are
130+
// rendered in the correct order.
131+
var requiresOngoingReinsertion = false
132+
133+
for (i, element) in elements.enumerated() {
134+
let childContent = child(element)
135+
let node: AnyViewGraphNode<Child>
136+
137+
if let oldNode = oldMap[element.id] {
138+
node = oldNode
139+
140+
// Checks if there is a preceding item that was not preceding in
141+
// the previous update. If such an item exists, it means that
142+
// the order of the collection has changed or that an item was
143+
// inserted somewhere in the middle, rather than simply appended.
144+
requiresOngoingReinsertion =
145+
requiresOngoingReinsertion
146+
|| {
147+
guard
148+
let ownOldIndex = oldIdentifiers.firstIndex(of: element.id)
149+
else { return false }
150+
151+
let subset = oldIdentifiers[identifiersStart..<ownOldIndex]
152+
return !children.identifiers.subtracting(subset).isEmpty
153+
}()
154+
155+
if requiresOngoingReinsertion {
156+
removeChild(oldNode.widget.into())
157+
addChild(oldNode.widget.into())
158+
}
159+
} else {
160+
// New Items need ongoing reinsertion to get
161+
// displayed at the correct location.
162+
requiresOngoingReinsertion = true
163+
node = AnyViewGraphNode(
164+
for: childContent,
165+
backend: backend,
166+
environment: environment
167+
)
168+
addChild(node.widget.into())
123169
}
124-
let index = elements.startIndex.advanced(by: i)
125-
let childContent = child(elements[index])
170+
171+
children.nodeIdentifierMap[element.id] = node
172+
children.identifiers.append(element.id)
173+
174+
children.nodes.append(node)
175+
126176
if children.isFirstUpdate {
127177
addChild(node.widget.into())
128178
}
179+
129180
layoutableChildren.append(
130181
LayoutSystem.LayoutableChild(
131182
update: { proposedSize, environment, dryRun in
@@ -139,40 +190,13 @@ extension ForEach: TypeSafeView, View where Child: View {
139190
)
140191
)
141192
}
193+
142194
children.isFirstUpdate = false
143195

144-
let nodeCount = children.nodes.count
145-
let remainingElementCount = elements.count - nodeCount
146-
if remainingElementCount > 0 {
147-
let startIndex = elements.startIndex.advanced(by: nodeCount)
148-
for i in 0..<remainingElementCount {
149-
let childContent = child(elements[startIndex.advanced(by: i)])
150-
let node = AnyViewGraphNode(
151-
for: childContent,
152-
backend: backend,
153-
environment: environment
154-
)
155-
children.nodes.append(node)
156-
addChild(node.widget.into())
157-
layoutableChildren.append(
158-
LayoutSystem.LayoutableChild(
159-
update: { proposedSize, environment, dryRun in
160-
node.update(
161-
with: childContent,
162-
proposedSize: proposedSize,
163-
environment: environment,
164-
dryRun: dryRun
165-
)
166-
}
167-
)
168-
)
169-
}
170-
} else if remainingElementCount < 0 {
171-
let unused = -remainingElementCount
172-
for i in (nodeCount - unused)..<nodeCount {
173-
removeChild(children.nodes[i].widget.into())
174-
}
175-
children.nodes.removeLast(unused)
196+
for removed in oldMap.filter({
197+
!children.identifiers.contains($0.key)
198+
}).values {
199+
removeChild(removed.widget.into())
176200
}
177201

178202
return LayoutSystem.updateStackLayout(
@@ -199,9 +223,17 @@ extension ForEach: TypeSafeView, View where Child: View {
199223
class ForEachViewChildren<
200224
Items: Collection,
201225
Child: View
202-
>: ViewGraphNodeChildren where Items.Index == Int {
226+
>: ViewGraphNodeChildren where Items.Index == Int, Items.Element: Identifiable {
203227
/// The nodes for all current children of the ``ForEach`` view.
204228
var nodes: [AnyViewGraphNode<Child>] = []
229+
230+
/// The nodes for all current children of the ``ForEach`` view, queriable by their identifier.
231+
var nodeIdentifierMap: [Items.Element.ID: AnyViewGraphNode<Child>]
232+
233+
/// The identifiers of all current children ``ForEach`` view in the order they are displayed.
234+
/// Can be used for checking if an element was moved or an element was inserted in front of it.
235+
var identifiers: OrderedSet<Items.Element.ID>
236+
205237
/// Changes queued during `dryRun` updates.
206238
var queuedChanges: [Change] = []
207239

@@ -227,18 +259,30 @@ class ForEachViewChildren<
227259
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
228260
environment: EnvironmentValues
229261
) {
230-
nodes = view.elements
231-
.map(view.child)
232-
.enumerated()
233-
.map { (index, child) in
262+
var nodeIdentifierMap = [Items.Element.ID: AnyViewGraphNode<Child>]()
263+
var identifiers = OrderedSet<Items.Element.ID>()
264+
var viewNodes = [AnyViewGraphNode<Child>]()
265+
266+
for (index, element) in view.elements.enumerated() {
267+
let child = view.child(element)
268+
let viewGraphNode = {
234269
let snapshot = index < snapshots?.count ?? 0 ? snapshots?[index] : nil
235270
return ViewGraphNode(
236271
for: child,
237272
backend: backend,
238273
snapshot: snapshot,
239274
environment: environment
240275
)
241-
}
242-
.map(AnyViewGraphNode.init(_:))
276+
}()
277+
278+
let anyViewGraphNode = AnyViewGraphNode(viewGraphNode)
279+
viewNodes.append(anyViewGraphNode)
280+
281+
identifiers.append(element.id)
282+
nodeIdentifierMap[element.id] = anyViewGraphNode
283+
}
284+
nodes = viewNodes
285+
self.identifiers = identifiers
286+
self.nodeIdentifierMap = nodeIdentifierMap
243287
}
244288
}

0 commit comments

Comments
 (0)