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..597688003b --- /dev/null +++ b/Examples/Sources/ForEachExample/ForEachApp.swift @@ -0,0 +1,92 @@ +import DefaultBackend +import Foundation +import SwiftCrossUI + +#if canImport(SwiftBundlerRuntime) + import SwiftBundlerRuntime +#endif + +@main +@HotReloadable +struct ForEachApp: App { + @State var items = (0..<20).map { Item("\($0)") } + @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) { + let upperLimit = max(items.count - 1, 0) + insertionPosition = min(insertionPosition, upperLimit) + } + #endif + + ForEach(Array(items.enumerated()), id: \.element.id) { (index, item) in + ItemRow( + item: item, + isFirst: index == 0, + isLast: index == items.count - 1 + ) { + items.remove(at: index) + } moveUp: { + guard index != items.startIndex else { return } + items.swapAt(index, index - 1) + } moveDown: { + guard index != items.endIndex else { return } + items.swapAt(index, index + 1) + } + } + } + .padding(10) + } + } + } + .defaultSize(width: 400, height: 800) + } +} + +struct ItemRow: View { + 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) + } + } +} + +struct Item: Identifiable { + let id = UUID() + 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..1713aa4ee4 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(greetings.reversed()[1...], id: \.self) { 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..57df59d976 100644 --- a/Examples/Sources/WebViewExample/WebViewApp.swift +++ b/Examples/Sources/WebViewExample/WebViewApp.swift @@ -29,10 +29,14 @@ struct WebViewApp: App { } .padding() - WebView($url) - .onChange(of: url) { - urlInput = url.absoluteString - } + #if !os(tvOS) + WebView($url) + .onChange(of: url) { + urlInput = url.absoluteString + } + #else + Text("WebView isn't supported on tvOS") + #endif } } } diff --git a/Package.resolved b/Package.resolved index b260ee897f..6ad24f1016 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "77caf3e84e88f2ff183c89c647d8007a7ba7b50bfb1fb7216b22f7dfda4a2dc0", + "originHash" : "ddeec630632b0145da44c9ac6a3914e4fe12d2fdc9527164d01281076d0c497e", "pins" : [ { "identity" : "jpeg", @@ -31,7 +31,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", + "location" : "https://github.com/apple/swift-argument-parser", "state" : { "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", "version" : "1.6.2" @@ -46,6 +46,15 @@ "version" : "0.2.0" } }, + { + "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 6d49268f6f..c796803d53 100644 --- a/Package.swift +++ b/Package.swift @@ -122,6 +122,10 @@ let package = Package( url: "https://github.com/stackotter/swift-winui", revision: "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" ), + .package( + url: "https://github.com/apple/swift-collections.git", + .upToNextMinor(from: "1.2.1") + ), .package( url: "https://github.com/stackotter/swift-benchmark", .upToNextMinor(from: "0.2.0") @@ -145,6 +149,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/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index ccc6cb9f72..30a193e9e7 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -1,5 +1,5 @@ -import SwiftCrossUI import Foundation +import SwiftCrossUI public final class DummyBackend: AppBackend { public class Window { @@ -64,7 +64,7 @@ public final class DummyBackend: AppBackend { public var maximumValue: Double = 100 public var decimalPlaces = 1 public var changeHandler: ((Double) -> Void)? - + override public var naturalSize: SIMD2 { SIMD2(20, 10) } @@ -233,7 +233,8 @@ public final class DummyBackend: AppBackend { window.minimumSize = minimumSize } - public func setResizeHandler(ofWindow window: Window, to action: @escaping (SIMD2) -> Void) { + public func setResizeHandler(ofWindow window: Window, to action: @escaping (SIMD2) -> Void) + { window.resizeHandler = action } @@ -253,11 +254,15 @@ public final class DummyBackend: AppBackend { public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) {} - public func computeWindowEnvironment(window: Window, rootEnvironment: EnvironmentValues) -> EnvironmentValues { + public func computeWindowEnvironment(window: Window, rootEnvironment: EnvironmentValues) + -> EnvironmentValues + { rootEnvironment } - public func setWindowEnvironmentChangeHandler(of window: Window, to action: @escaping () -> Void) {} + public func setWindowEnvironmentChangeHandler( + of window: Window, to action: @escaping () -> Void + ) {} public func show(widget: Widget) {} @@ -317,7 +322,10 @@ public final class DummyBackend: AppBackend { public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} - public func setScrollBarPresence(ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, hasHorizontalScrollBar: Bool) { + public func setScrollBarPresence( + ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, + hasHorizontalScrollBar: Bool + ) { let scrollContainer = scrollView as! ScrollContainer scrollContainer.hasVerticalScrollBar = hasVerticalScrollBar scrollContainer.hasHorizontalScrollBar = hasHorizontalScrollBar @@ -335,13 +343,17 @@ public final class DummyBackend: AppBackend { .zero } - public func setItems(ofSelectableListView listView: Widget, to items: [Widget], withRowHeights rowHeights: [Int]) { + public func setItems( + ofSelectableListView listView: Widget, to items: [Widget], withRowHeights rowHeights: [Int] + ) { let selectableListView = listView as! SelectableListView selectableListView.items = items selectableListView.rowHeights = rowHeights } - public func setSelectionHandler(forSelectableListView listView: Widget, to action: @escaping (Int) -> Void) { + public func setSelectionHandler( + forSelectableListView listView: Widget, to action: @escaping (Int) -> Void + ) { (listView as! SelectableListView).selectionHandler = action } @@ -364,13 +376,18 @@ public final class DummyBackend: AppBackend { (splitView as! SplitView).sidebarWidth } - public func setSidebarWidthBounds(ofSplitView splitView: Widget, minimum minimumWidth: Int, maximum maximumWidth: Int) { + public func setSidebarWidthBounds( + ofSplitView splitView: Widget, minimum minimumWidth: Int, maximum maximumWidth: Int + ) { let splitView = splitView as! SplitView splitView.minimumSidebarWidth = minimumWidth splitView.maximumSidebarWidth = maximumWidth } - public func size(of text: String, whenDisplayedIn widget: Widget, proposedFrame: SIMD2?, environment: EnvironmentValues) -> SIMD2 { + public func size( + of text: String, whenDisplayedIn widget: Widget, proposedFrame: SIMD2?, + environment: EnvironmentValues + ) -> SIMD2 { let resolvedFont = environment.resolvedFont let lineHeight = Int(resolvedFont.lineHeight) let characterHeight = Int(resolvedFont.pointSize) @@ -395,7 +412,8 @@ public final class DummyBackend: AppBackend { TextView() } - public func updateTextView(_ textView: Widget, content: String, environment: EnvironmentValues) { + public func updateTextView(_ textView: Widget, content: String, environment: EnvironmentValues) + { let textView = textView as! TextView textView.content = content textView.color = environment.suggestedForegroundColor @@ -406,7 +424,10 @@ public final class DummyBackend: AppBackend { ImageView() } - public func updateImageView(_ imageView: Widget, rgbaData: [UInt8], width: Int, height: Int, targetWidth: Int, targetHeight: Int, dataHasChanged: Bool, environment: EnvironmentValues) { + public func updateImageView( + _ imageView: Widget, rgbaData: [UInt8], width: Int, height: Int, targetWidth: Int, + targetHeight: Int, dataHasChanged: Bool, environment: EnvironmentValues + ) { let imageView = imageView as! ImageView imageView.rgbaData = rgbaData imageView.pixelWidth = width @@ -421,11 +442,15 @@ public final class DummyBackend: AppBackend { (table as! Table).rowCount = rows } - public func setColumnLabels(ofTable table: Widget, to labels: [String], environment: EnvironmentValues) { + public func setColumnLabels( + ofTable table: Widget, to labels: [String], environment: EnvironmentValues + ) { (table as! Table).columnLabels = labels } - public func setCells(ofTable table: Widget, to cells: [Widget], withRowHeights rowHeights: [Int]) { + public func setCells( + ofTable table: Widget, to cells: [Widget], withRowHeights rowHeights: [Int] + ) { let table = table as! Table table.cells = cells table.rowHeights = rowHeights @@ -435,13 +460,18 @@ public final class DummyBackend: AppBackend { Button() } - public func updateButton(_ button: Widget, label: String, environment: EnvironmentValues, action: @escaping () -> Void) { + public func updateButton( + _ button: Widget, label: String, environment: EnvironmentValues, + action: @escaping () -> Void + ) { let button = button as! Button button.label = label button.action = action } - public func updateButton(_ button: Widget, label: String, menu: Menu, environment: EnvironmentValues) { + public func updateButton( + _ button: Widget, label: String, menu: Menu, environment: EnvironmentValues + ) { let button = button as! Button button.label = label button.menu = menu @@ -452,7 +482,10 @@ public final class DummyBackend: AppBackend { ToggleButton() } - public func updateToggle(_ toggle: Widget, label: String, environment: EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateToggle( + _ toggle: Widget, label: String, environment: EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { let toggle = toggle as! ToggleButton toggle.label = label toggle.toggleHandler = onChange @@ -467,7 +500,10 @@ public final class DummyBackend: AppBackend { ToggleSwitch() } - public func updateSwitch(_ switchWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateSwitch( + _ switchWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { (switchWidget as! ToggleSwitch).toggleHandler = onChange } @@ -479,7 +515,10 @@ public final class DummyBackend: AppBackend { Checkbox() } - public func updateCheckbox(_ checkboxWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateCheckbox( + _ checkboxWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { (checkboxWidget as! Checkbox).toggleHandler = onChange } @@ -491,7 +530,10 @@ public final class DummyBackend: AppBackend { Slider() } - public func updateSlider(_ slider: Widget, minimum: Double, maximum: Double, decimalPlaces: Int, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Double) -> Void) { + public func updateSlider( + _ slider: Widget, minimum: Double, maximum: Double, decimalPlaces: Int, + environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Double) -> Void + ) { let slider = slider as! Slider slider.minimumValue = minimum slider.maximumValue = maximum @@ -507,7 +549,10 @@ public final class DummyBackend: AppBackend { TextField() } - public func updateTextField(_ textField: Widget, placeholder: String, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (String) -> Void, onSubmit: @escaping () -> Void) { + public func updateTextField( + _ textField: Widget, placeholder: String, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (String) -> Void, onSubmit: @escaping () -> Void + ) { let textField = textField as! TextField textField.placeholder = placeholder textField.font = environment.resolvedFont @@ -524,142 +569,142 @@ public final class DummyBackend: AppBackend { } // public func createTextEditor() -> Widget { - + // } // public func updateTextEditor(_ textEditor: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (String) -> Void) { - + // } // public func setContent(ofTextEditor textEditor: Widget, to content: String) { - + // } // public func getContent(ofTextEditor textEditor: Widget) -> String { - + // } // public func createPicker() -> Widget { - + // } // public func updatePicker(_ picker: Widget, options: [String], environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Int?) -> Void) { - + // } // public func setSelectedOption(ofPicker picker: Widget, to selectedOption: Int?) { - + // } // public func createProgressSpinner() -> Widget { - + // } // public func createProgressBar() -> Widget { - + // } // public func updateProgressBar(_ widget: Widget, progressFraction: Double?, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func createPopoverMenu() -> Menu { - + // } // public func updatePopoverMenu(_ menu: Menu, content: SwiftCrossUI.ResolvedMenu, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func showPopoverMenu(_ menu: Menu, at position: SIMD2, relativeTo widget: Widget, closeHandler handleClose: @escaping () -> Void) { - + // } // public func createAlert() -> Alert { - + // } // public func updateAlert(_ alert: Alert, title: String, actionLabels: [String], environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func showAlert(_ alert: Alert, window: Window?, responseHandler handleResponse: @escaping (Int) -> Void) { - + // } // public func dismissAlert(_ alert: Alert, window: Window?) { - + // } // public func createSheet(content: Widget) -> Sheet { - + // } // public func updateSheet(_ sheet: Sheet, window: Window, environment: SwiftCrossUI.EnvironmentValues, size: SIMD2, onDismiss: @escaping () -> Void, cornerRadius: Double?, detents: [SwiftCrossUI.PresentationDetent], dragIndicatorVisibility: SwiftCrossUI.Visibility, backgroundColor: SwiftCrossUI.Color?, interactiveDismissDisabled: Bool) { - + // } // public func presentSheet(_ sheet: Sheet, window: Window, parentSheet: Sheet?) { - + // } // public func dismissSheet(_ sheet: Sheet, window: Window, parentSheet: Sheet?) { - + // } // public func size(ofSheet sheet: Sheet) -> SIMD2 { - + // } // public func showOpenDialog(fileDialogOptions: SwiftCrossUI.FileDialogOptions, openDialogOptions: SwiftCrossUI.OpenDialogOptions, window: Window?, resultHandler handleResult: @escaping (SwiftCrossUI.DialogResult<[URL]>) -> Void) { - + // } // public func showSaveDialog(fileDialogOptions: SwiftCrossUI.FileDialogOptions, saveDialogOptions: SwiftCrossUI.SaveDialogOptions, window: Window?, resultHandler handleResult: @escaping (SwiftCrossUI.DialogResult) -> Void) { - + // } // public func createTapGestureTarget(wrapping child: Widget, gesture: SwiftCrossUI.TapGesture) -> Widget { - + // } // public func updateTapGestureTarget(_ tapGestureTarget: Widget, gesture: SwiftCrossUI.TapGesture, environment: SwiftCrossUI.EnvironmentValues, action: @escaping () -> Void) { - + // } // public func createHoverTarget(wrapping child: Widget) -> Widget { - + // } // public func updateHoverTarget(_ hoverTarget: Widget, environment: SwiftCrossUI.EnvironmentValues, action: @escaping (Bool) -> Void) { - + // } // public func createPathWidget() -> Widget { - + // } // public func createPath() -> Path { - + // } // public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, bounds: SwiftCrossUI.Path.Rect, pointsChanged: Bool, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func renderPath(_ path: Path, container: Widget, strokeColor: SwiftCrossUI.Color, fillColor: SwiftCrossUI.Color, overrideStrokeStyle: SwiftCrossUI.StrokeStyle?) { - + // } // public func createWebView() -> Widget { - + // } // public func updateWebView(_ webView: Widget, environment: SwiftCrossUI.EnvironmentValues, onNavigate: @escaping (URL) -> Void) { - + // } // public func navigateWebView(_ webView: Widget, to url: URL) { - + // } } 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 } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift index 3b5738a615..0b6d460f85 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -30,7 +30,7 @@ extension View { /// Sets the visibility of the enclosing sheet presentation's drag indicator. /// Drag indicators are only supported on platforms that support sheet /// resizing, and sheet resizing is generally only support on mobile. - /// + /// /// - Supported platforms: iOS & Mac Catalyst 15+ (ignored on unsupported platforms) /// /// - Parameter visibility: The visibility to use for the drag indicator of diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 00d87b182c..bb6c85deeb 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -6,14 +6,14 @@ extension View { /// /// `onDismiss` isn't called when the sheet gets dismissed programmatically /// (i.e. by setting `isPresented` to `false`). - /// + /// /// `onDismiss` gets called *after* the sheet has been dismissed by the /// underlying UI framework, and *before* `isPresented` gets set to false. /// /// - Parameters: /// - isPresented: A binding controlling whether the sheet is presented. /// - onDismiss: An action to perform when the sheet is dismissed - /// by the user. + /// by the user. public func sheet( isPresented: Binding, onDismiss: (() -> Void)? = nil, diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index ef085c3f53..9370394b7a 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -16,7 +16,7 @@ extension UIKitBackend { // Fetch the child controller before adding the child to the view // hierarchy. Otherwise, if the child doesn't have its own controller, we'd // get back a reference to the sheet controller and attempt to add it as a - // child of itself. + // child of itself. if let childController = content.controller { sheet.addChild(childController) } @@ -52,7 +52,8 @@ extension UIKitBackend { sheet.onDismiss = onDismiss setPresentationDetents(of: sheet, to: detents) setPresentationCornerRadius(of: sheet, to: cornerRadius) - setPresentationDragIndicatorVisibility(of: sheet, to: dragIndicatorVisibility, detents: detents) + setPresentationDragIndicatorVisibility( + of: sheet, to: dragIndicatorVisibility, detents: detents) let defaultColor: UIColor? #if targetEnvironment(macCatalyst)