diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 3b96dc6128..1d9f7d44a5 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -64,3 +64,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.HoverExample' product = 'HoverExample' version = '0.1.0' + +[apps.ForEachExample] +identifier = 'dev.swiftcrossui.ForEachExample' +product = 'ForEachExample' +version = '0.1.0' diff --git a/Examples/Package.resolved b/Examples/Package.resolved index 3842945e2a..bc8254d972 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6977ba851e440a7fbdfc7cb46441e32853dc2ba48ba34fe702e6784699d08682", + "originHash" : "f29a33ba90b5b5615d0de581d82e49b8fa747057114f7c3fd44c8916099b361c", "pins" : [ { "identity" : "aexml", @@ -167,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "9bf03ff58ce34478e66aaee630e491823326fd06", - "version" : "1.1.3" + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" } }, { diff --git a/Examples/Package.swift b/Examples/Package.swift index 1735fc7675..7bae86806a 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -76,6 +76,10 @@ let package = Package( .executableTarget( name: "HoverExample", dependencies: exampleDependencies - ) + ), + .executableTarget( + name: "ForEachExample", + dependencies: exampleDependencies + ) ] ) diff --git a/Examples/Sources/ForEachExample/ForEachApp.swift b/Examples/Sources/ForEachExample/ForEachApp.swift new file mode 100644 index 0000000000..42e104ee6c --- /dev/null +++ b/Examples/Sources/ForEachExample/ForEachApp.swift @@ -0,0 +1,105 @@ +import DefaultBackend +import Foundation +import SwiftCrossUI + +#if canImport(SwiftBundlerRuntime) + import SwiftBundlerRuntime +#endif + +@main +@HotReloadable +struct ForEachApp: App { + @State var items = { + var items = [Item]() + for i in 0..<20 { + items.append(.init("\(i)")) + } + return items + }() + @State var biggestValue = 19 + @State var insertionPosition = 10 + + var body: some Scene { + WindowGroup("ForEach") { + #hotReloadable { + ScrollView { + VStack { + Button("Append") { + biggestValue += 1 + items.append(.init("\(biggestValue)")) + } + + #if !os(tvOS) + Button( + "Insert in front of current item at position \(insertionPosition)" + ) { + biggestValue += 1 + items.insert(.init("\(biggestValue)"), at: insertionPosition) + } + + Slider($insertionPosition, minimum: 0, maximum: items.count - 1) + .onChange(of: items.count) { + guard insertionPosition > items.count - 1 else { + return + } + insertionPosition = max(items.count - 1, 0) + } + #endif + + ForEach(items) { item in + ItemRow( + item: item, isFirst: Optional(item.id) == items.first?.id, + isLast: Optional(item.id) == items.last?.id + ) { + items.removeAll(where: { $0.id == item.id }) + } moveUp: { + guard + let ownIndex = items.firstIndex(where: { $0.id == item.id }), + ownIndex != items.startIndex + else { return } + items.swapAt(ownIndex, ownIndex - 1) + } moveDown: { + guard + let ownIndex = items.firstIndex(where: { $0.id == item.id }), + ownIndex != items.endIndex + else { return } + items.swapAt(ownIndex, ownIndex + 1) + } + } + } + .padding(10) + } + } + } + .defaultSize(width: 400, height: 800) + } +} + +struct ItemRow: View { + @State var item: Item + let isFirst: Bool + let isLast: Bool + var remove: () -> Void + var moveUp: () -> Void + var moveDown: () -> Void + + var body: some View { + HStack { + Text(item.value) + Button("Delete") { remove() } + Button("⌃") { moveUp() } + .disabled(isFirst) + Button("⌄") { moveDown() } + .disabled(isLast) + } + } +} + +class Item: Identifiable, SwiftCrossUI.ObservableObject { + let id = UUID() + @SwiftCrossUI.Published var value: String + + init(_ value: String) { + self.value = value + } +} diff --git a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift index fa4b984eff..87ce8efc7e 100644 --- a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift +++ b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift @@ -38,7 +38,7 @@ struct GreetingGeneratorApp: App { .padding(.top, 20) ScrollView { - ForEach(greetings.reversed()[1...]) { greeting in + ForEach(items: greetings.reversed()[1...]) { greeting in Text(greeting) } } diff --git a/Examples/Sources/StressTestExample/StressTestApp.swift b/Examples/Sources/StressTestExample/StressTestApp.swift index 974c2a5bdd..7eab0de879 100644 --- a/Examples/Sources/StressTestExample/StressTestApp.swift +++ b/Examples/Sources/StressTestExample/StressTestApp.swift @@ -43,12 +43,11 @@ struct StressTestApp: App { for _ in 0..<1000 { values.append(Self.options.randomElement()!) } - self.values[tab!] = values } if let values = values[tab!] { ScrollView { - ForEach(values) { value in + ForEach(values, id: \.self) { value in Text(value) } }.frame(minWidth: 300) diff --git a/Examples/Sources/WebViewExample/WebViewApp.swift b/Examples/Sources/WebViewExample/WebViewApp.swift index fb11609c57..8de0c6f77a 100644 --- a/Examples/Sources/WebViewExample/WebViewApp.swift +++ b/Examples/Sources/WebViewExample/WebViewApp.swift @@ -29,10 +29,12 @@ struct WebViewApp: App { } .padding() - WebView($url) - .onChange(of: url) { - urlInput = url.absoluteString - } + #if !os(tvOS) + WebView($url) + .onChange(of: url) { + urlInput = url.absoluteString + } + #endif } } } diff --git a/Package.resolved b/Package.resolved index 173c2d3001..464f684aed 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3b87bbc3d0f0110380f592dc86a1c8c65c20f5a326f484bdbe2f6ef5e357840d", + "originHash" : "812b37087572e0699cecaadb15b7b5b42abb5278782a7809de1c1e72330a6d38", "pins" : [ { "identity" : "jpeg", @@ -28,6 +28,15 @@ "version" : "1.4.1" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", + "version" : "1.2.1" + } + }, { "identity" : "swift-cwinrt", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d854305ca1..8022a25270 100644 --- a/Package.swift +++ b/Package.swift @@ -110,6 +110,10 @@ let package = Package( url: "https://github.com/stackotter/swift-winui", revision: "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" ), + .package( + url: "https://github.com/apple/swift-collections.git", + exact: "1.2.1" + ), // .package( // url: "https://github.com/stackotter/TermKit", // revision: "163afa64f1257a0c026cc83ed8bc47a5f8fc9704" @@ -129,6 +133,7 @@ let package = Package( dependencies: [ "HotReloadingMacrosPlugin", .product(name: "ImageFormats", package: "swift-image-formats"), + .product(name: "OrderedCollections", package: "swift-collections") ], exclude: [ "Builders/ViewBuilder.swift.gyb", diff --git a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift index f549a94b74..349cfc3134 100644 --- a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift @@ -17,8 +17,8 @@ public struct MenuItemsBuilder { first.items } - public static func buildPartialBlock( - first: ForEach + public static func buildPartialBlock( + first: ForEach ) -> [MenuItem] { first.elements.map(first.child).flatMap { $0 } } @@ -51,9 +51,9 @@ public struct MenuItemsBuilder { accumulated + buildPartialBlock(first: next) } - public static func buildPartialBlock( + public static func buildPartialBlock( accumulated: [MenuItem], - next: ForEach + next: ForEach ) -> [MenuItem] { accumulated + buildPartialBlock(first: next) } diff --git a/Sources/SwiftCrossUI/Views/Button.swift b/Sources/SwiftCrossUI/Views/Button.swift index 739d1ba407..343a23f88b 100644 --- a/Sources/SwiftCrossUI/Views/Button.swift +++ b/Sources/SwiftCrossUI/Views/Button.swift @@ -21,8 +21,7 @@ public struct Button: Sendable { } } -extension Button: View { -} +extension Button: View {} extension Button: ElementaryView { public func asWidget(backend: Backend) -> Backend.Widget { diff --git a/Sources/SwiftCrossUI/Views/ForEach.swift b/Sources/SwiftCrossUI/Views/ForEach.swift index 81ad975c1a..2a03041bce 100644 --- a/Sources/SwiftCrossUI/Views/ForEach.swift +++ b/Sources/SwiftCrossUI/Views/ForEach.swift @@ -1,76 +1,47 @@ +import OrderedCollections + /// A view that displays a variable amount of children. -public struct ForEach where Items.Index == Int { +public struct ForEach { /// A variable-length collection of elements to display. var elements: Items /// A method to display the elements as views. var child: (Items.Element) -> Child + /// The path to the property used as Identifier + var idKeyPath: KeyPath? } -extension ForEach where Child == [MenuItem] { - /// Creates a view that creates child views on demand based on a collection of data. - @_disfavoredOverload +extension ForEach: TypeSafeView, View where Child: View { + typealias Children = ForEachViewChildren public init( _ elements: Items, - @MenuItemsBuilder _ child: @escaping (Items.Element) -> [MenuItem] + id keyPath: KeyPath, + @ViewBuilder _ child: @escaping (Items.Element) -> Child ) { self.elements = elements self.child = child + self.idKeyPath = keyPath } -} - -extension ForEach where Items == [Int] { - /// Creates a view that creates child views on demand based on a given ClosedRange - @_disfavoredOverload - public init( - _ range: ClosedRange, - child: @escaping (Int) -> Child - ) { - self.elements = Array(range) - self.child = child - } - - /// Creates a view that creates child views on demand based on a given Range - @_disfavoredOverload - public init( - _ range: Range, - child: @escaping (Int) -> Child - ) { - self.elements = Array(range) - self.child = child - } -} - -extension ForEach: TypeSafeView, View where Child: View { - typealias Children = ForEachViewChildren public var body: EmptyView { return EmptyView() } - /// Creates a view that creates child views on demand based on a collection of data. - public init( - _ elements: Items, - @ViewBuilder _ child: @escaping (Items.Element) -> Child - ) { - self.elements = elements - self.child = child - } - func children( backend: Backend, snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, environment: EnvironmentValues - ) -> ForEachViewChildren { - return ForEachViewChildren( + ) -> Children { + return Children( from: self, backend: backend, + idKeyPath: idKeyPath, snapshots: snapshots, environment: environment ) } func asWidget( - _ children: ForEachViewChildren, + _ children: Children, backend: Backend ) -> Backend.Widget { return backend.createContainer() @@ -78,7 +49,7 @@ extension ForEach: TypeSafeView, View where Child: View { func update( _ widget: Backend.Widget, - children: ForEachViewChildren, + children: Children, proposedSize: SIMD2, environment: EnvironmentValues, backend: Backend, @@ -112,6 +83,174 @@ extension ForEach: TypeSafeView, View where Child: View { children.queuedChanges = [] } + // Use the previous update Method when no keyPath is set on a + // [Hashable] Collection to optionally keep the old behaviour. + guard let idKeyPath else { + return deprecatedUpdate( + widget, + children: children, + proposedSize: proposedSize, + environment: environment, + backend: backend, + dryRun: dryRun + ) + } + + var layoutableChildren: [LayoutSystem.LayoutableChild] = [] + + let oldNodes = children.nodes + let oldMap = children.nodeIdentifierMap + let oldIdentifiers = children.identifiers + let identifiersStart = oldIdentifiers.startIndex + + children.nodes = [] + children.nodeIdentifierMap = [:] + children.identifiers = [] + + // Once this is true, every node that existed in the previous update and + // still exists in the new one is reinserted to ensure that items are + // rendered in the correct order. + var requiresOngoingReinsertion = false + + // Forces node recreation when enabled (expensive on large Collections). + // Use only when idKeyPath yields non-unique values. Prefer Identifiable + // or guaranteed unique, constant identifiers for optimal performance. + // Node caching and diffing require unique, stable IDs. + var ongoingNodeReusingDisabled = false + + // Avoid reallocation + var inserted = false + + for element in elements { + let childContent = child(element) + let node: AnyViewGraphNode + + // Track duplicates: inserted=false if ID exists. + // Disables node reuse if any duplicate gets found. + (inserted, _) = children.identifiers.append(element[keyPath: idKeyPath]) + ongoingNodeReusingDisabled = ongoingNodeReusingDisabled || !inserted + + if !ongoingNodeReusingDisabled { + if let oldNode = oldMap[element[keyPath: idKeyPath]] { + node = oldNode + + // Detects reordering or mid-collection insertion: + // Checks if there is a preceding item that was not + // preceding in the previous update. + requiresOngoingReinsertion = + requiresOngoingReinsertion + || { + guard + let ownOldIndex = oldIdentifiers.firstIndex( + of: element[keyPath: idKeyPath]) + else { return false } + + let subset = oldIdentifiers[identifiersStart..( + _ widget: Backend.Widget, + children: Children, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + func addChild(_ child: Backend.Widget) { + if dryRun { + children.queuedChanges.append(.addChild(AnyWidget(child))) + } else { + backend.addChild(child, to: widget) + } + } + + func removeChild(_ child: Backend.Widget) { + if dryRun { + children.queuedChanges.append(.removeChild(AnyWidget(child))) + } else { + backend.removeChild(child, from: widget) + } + } + + let elementsStartIndex = elements.startIndex + // TODO: The way we're reusing nodes for technically different elements means that if // Child has state of its own then it could get pretty confused thinking that its state // changed whereas it was actually just moved to a new slot in the array. Probably not @@ -121,7 +260,7 @@ extension ForEach: TypeSafeView, View where Child: View { guard i < elements.count else { break } - let index = elements.startIndex.advanced(by: i) + let index = elements.index(elementsStartIndex, offsetBy: i) let childContent = child(elements[index]) if children.isFirstUpdate { addChild(node.widget.into()) @@ -144,9 +283,9 @@ extension ForEach: TypeSafeView, View where Child: View { let nodeCount = children.nodes.count let remainingElementCount = elements.count - nodeCount if remainingElementCount > 0 { - let startIndex = elements.startIndex.advanced(by: nodeCount) + let startIndex = elements.index(elementsStartIndex, offsetBy: nodeCount) for i in 0..: ViewGraphNodeChildren where Items.Index == Int { +>: ViewGraphNodeChildren { /// The nodes for all current children of the ``ForEach`` view. var nodes: [AnyViewGraphNode] = [] + + /// The nodes for all current children of the ``ForEach`` view, queriable by their identifier. + var nodeIdentifierMap: [ID: AnyViewGraphNode] + + /// The identifiers of all current children ``ForEach`` view in the order they are displayed. + /// Can be used for checking if an element was moved or an element was inserted in front of it. + var identifiers: OrderedSet + /// Changes queued during `dryRun` updates. var queuedChanges: [Change] = [] @@ -222,15 +370,37 @@ class ForEachViewChildren< /// Gets a variable length view's children as view graph node children. init( - from view: ForEach, + from view: ForEach, backend: Backend, + idKeyPath: KeyPath?, snapshots: [ViewGraphSnapshotter.NodeSnapshot]?, environment: EnvironmentValues ) { - nodes = view.elements - .map(view.child) - .enumerated() - .map { (index, child) in + guard let idKeyPath else { + nodes = view.elements + .map(view.child) + .enumerated() + .map { (index, child) in + let snapshot = index < snapshots?.count ?? 0 ? snapshots?[index] : nil + return ViewGraphNode( + for: child, + backend: backend, + snapshot: snapshot, + environment: environment + ) + } + .map(AnyViewGraphNode.init(_:)) + identifiers = [] + nodeIdentifierMap = [:] + return + } + var nodeIdentifierMap = [ID: AnyViewGraphNode]() + var identifiers = OrderedSet() + var viewNodes = [AnyViewGraphNode]() + + for (index, element) in view.elements.enumerated() { + let child = view.child(element) + let viewGraphNode = { let snapshot = index < snapshots?.count ?? 0 ? snapshots?[index] : nil return ViewGraphNode( for: child, @@ -238,7 +408,129 @@ class ForEachViewChildren< snapshot: snapshot, environment: environment ) - } - .map(AnyViewGraphNode.init(_:)) + }() + + let anyViewGraphNode = AnyViewGraphNode(viewGraphNode) + viewNodes.append(anyViewGraphNode) + + identifiers.append(element[keyPath: idKeyPath]) + nodeIdentifierMap[element[keyPath: idKeyPath]] = anyViewGraphNode + } + nodes = viewNodes + self.identifiers = identifiers + self.nodeIdentifierMap = nodeIdentifierMap + } +} + +// MARK: - Alternative Initializers +extension ForEach where Items.Element: Hashable, ID == Items.Element { + /// Creates a view that creates child views on demand based on a collection of data. + @available( + *, + deprecated, + message: "Use ForEach with id argument on non-Identifiable Elements instead." + ) + @_disfavoredOverload + public init( + items elements: Items, + _ child: @escaping (Items.Element) -> Child + ) { + self.elements = elements + self.child = child + self.idKeyPath = nil + } +} + +extension ForEach where Child == [MenuItem], Items.Element: Hashable, ID == Items.Element { + /// Creates a view that creates child views on demand based on a collection of data. + @available( + *, + deprecated, + message: "Use ForEach with id argument on non-Identifiable Elements instead." + ) + @_disfavoredOverload + public init( + menuItems elements: Items, + @MenuItemsBuilder _ child: @escaping (Items.Element) -> [MenuItem] + ) { + self.elements = elements + self.child = child + self.idKeyPath = nil + } +} + +extension ForEach where Child == [MenuItem] { + /// Creates a view that creates child views on demand based on a collection of data. + @_disfavoredOverload + public init( + menuItems elements: Items, + id keyPath: KeyPath, + @MenuItemsBuilder _ child: @escaping (Items.Element) -> [MenuItem] + ) { + self.elements = elements + self.child = child + self.idKeyPath = keyPath + } +} + +extension ForEach where Items == [Int], ID == Items.Element { + /// Creates a view that creates child views on demand based on a given ClosedRange + @_disfavoredOverload + public init( + _ range: ClosedRange, + child: @escaping (Int) -> Child + ) { + self.elements = Array(range) + self.child = child + self.idKeyPath = \.self + } + + /// Creates a view that creates child views on demand based on a given Range + @_disfavoredOverload + public init( + _ range: Range, + child: @escaping (Int) -> Child + ) { + self.elements = Array(range) + self.child = child + self.idKeyPath = \.self + } +} + +extension ForEach where Items == [Int] { + /// Creates a view that creates child views on demand based on a given ClosedRange + @_disfavoredOverload + public init( + _ range: ClosedRange, + id keyPath: KeyPath, + child: @escaping (Int) -> Child + ) { + self.elements = Array(range) + self.child = child + self.idKeyPath = keyPath + } + + /// Creates a view that creates child views on demand based on a given Range + @_disfavoredOverload + public init( + _ range: Range, + id keyPath: KeyPath, + child: @escaping (Int) -> Child + ) { + self.elements = Array(range) + self.child = child + self.idKeyPath = keyPath + } +} + +extension ForEach where Items.Element: Identifiable, ID == Items.Element.ID { + /// Creates a view that creates child views on demand based on a collection of identifiable data. + public init( + _ elements: Items, + child: @escaping (Items.Element) -> Child + ) { + self.elements = elements + self.child = child + self.idKeyPath = \.id } }