diff --git a/.gitignore b/.gitignore index be272b02386..482321a3a54 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ DerivedData/ .swiftpm vcpkg_installed/ *.trace +notes.json \ No newline at end of file diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 3b96dc61288..1d9f7d44a56 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 cb59bcc5d55..cd3bc8c9046 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 1735fc76759..7bae86806ab 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 00000000000..597688003bb --- /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 4434769400a..3b94bfa74c0 100644 --- a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift +++ b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift @@ -39,7 +39,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 974c2a5bdd2..7eab0de8799 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 a1510c7dff6..20db0f8c090 100644 --- a/Examples/Sources/WebViewExample/WebViewApp.swift +++ b/Examples/Sources/WebViewExample/WebViewApp.swift @@ -37,10 +37,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 b91c2091dba..f140038fa54 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9735f7da95f3aac9ace06a19f5c4e1a822b5a72397c47e857a072c23df62791b", + "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 61564c6d949..7da07affdc8 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") @@ -150,6 +154,7 @@ let package = Package( "HotReloadingMacrosPlugin", .product(name: "ImageFormats", package: "swift-image-formats"), .product(name: "Logging", package: "swift-log"), + .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 fc23e4f1641..a3ac5ac659b 100644 --- a/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift +++ b/Sources/SwiftCrossUI/Builders/MenuItemsBuilder.swift @@ -4,7 +4,7 @@ public struct MenuItemsBuilder { public static func buildBlock() -> [MenuItem] { [] } - + public static func buildPartialBlock(first: Button) -> [MenuItem] { [.button(first)] } @@ -21,8 +21,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 } } @@ -55,9 +55,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/Environment/Actions/OpenURLAction.swift b/Sources/SwiftCrossUI/Environment/Actions/OpenURLAction.swift index 3efeab506b1..e7709fb1a1f 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/OpenURLAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/OpenURLAction.swift @@ -11,10 +11,12 @@ public struct OpenURLAction { do { try backend.openExternalURL(url) } catch { - logger.warning("failed to open external url", metadata: [ - "url": "\(url)", - "error": "\(error)", - ]) + logger.warning( + "failed to open external url", + metadata: [ + "url": "\(url)", + "error": "\(error)", + ]) } } } diff --git a/Sources/SwiftCrossUI/Environment/Actions/RevealFileAction.swift b/Sources/SwiftCrossUI/Environment/Actions/RevealFileAction.swift index 5f0efbbf8eb..d80b783cac0 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/RevealFileAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/RevealFileAction.swift @@ -15,10 +15,12 @@ public struct RevealFileAction { do { try backend.revealFile(file) } catch { - logger.warning("failed to reveal file", metadata: [ - "url": "\(file)", - "error": "\(error)", - ]) + logger.warning( + "failed to reveal file", + metadata: [ + "url": "\(file)", + "error": "\(error)", + ]) } } } diff --git a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift index 3e9a8a61ad7..12a9caaaf84 100644 --- a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift +++ b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift @@ -59,7 +59,8 @@ public enum LayoutSystem { var tag: String? public init( - computeLayout: @escaping @MainActor (ProposedViewSize, EnvironmentValues) -> + computeLayout: + @escaping @MainActor (ProposedViewSize, EnvironmentValues) -> ViewLayoutResult, commit: @escaping @MainActor () -> ViewLayoutResult, tag: String? = nil diff --git a/Sources/SwiftCrossUI/Views/Button.swift b/Sources/SwiftCrossUI/Views/Button.swift index dc86fdc6945..140846f44ef 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/EitherView.swift b/Sources/SwiftCrossUI/Views/EitherView.swift index 685ca682469..ec8e482780a 100644 --- a/Sources/SwiftCrossUI/Views/EitherView.swift +++ b/Sources/SwiftCrossUI/Views/EitherView.swift @@ -59,7 +59,7 @@ extension EitherView: TypeSafeView { switch storage { case .a(let a): switch children.node { - case let .a(nodeA): + case .a(let nodeA): result = nodeA.computeLayout( with: a, proposedSize: proposedSize, @@ -82,7 +82,7 @@ extension EitherView: TypeSafeView { } case .b(let b): switch children.node { - case let .b(nodeB): + case .b(let nodeB): result = nodeB.computeLayout( with: b, proposedSize: proposedSize, diff --git a/Sources/SwiftCrossUI/Views/ForEach.swift b/Sources/SwiftCrossUI/Views/ForEach.swift index b050be06585..48116a0315e 100644 --- a/Sources/SwiftCrossUI/Views/ForEach.swift +++ b/Sources/SwiftCrossUI/Views/ForEach.swift @@ -1,78 +1,48 @@ import Foundation +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() @@ -80,7 +50,7 @@ extension ForEach: TypeSafeView, View where Child: View { func computeLayout( _ widget: Backend.Widget, - children: ForEachViewChildren, + children: Children, proposedSize: ProposedViewSize, environment: EnvironmentValues, backend: Backend @@ -93,6 +63,168 @@ extension ForEach: TypeSafeView, View where Child: View { children.queuedChanges.append(.removeChild(AnyWidget(child))) } + // 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 + ) + } + + 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 + + for element in elements { + // Avoid reallocation + var inserted = false + + 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: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + @inline(__always) + func addChild(_ child: Backend.Widget) { + children.queuedChanges.append(.addChild(AnyWidget(child))) + } + + @inline(__always) + func removeChild(_ child: Backend.Widget) { + children.queuedChanges.append(.removeChild(AnyWidget(child))) + } + + 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 @@ -102,7 +234,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()) @@ -125,9 +257,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..( _ widget: Backend.Widget, - children: ForEachViewChildren, + children: Children, layout: ViewLayoutResult, environment: EnvironmentValues, backend: Backend @@ -217,10 +349,19 @@ extension ForEach: TypeSafeView, View where Child: View { /// when elements are added/removed. class ForEachViewChildren< Items: Collection, + ID: Hashable, Child: View ->: 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] = [] @@ -243,15 +384,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, @@ -259,7 +422,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/ScrollView.swift b/Sources/SwiftCrossUI/Views/ScrollView.swift index 828f12a2b4b..a731397621d 100644 --- a/Sources/SwiftCrossUI/Views/ScrollView.swift +++ b/Sources/SwiftCrossUI/Views/ScrollView.swift @@ -121,14 +121,16 @@ public struct ScrollView: TypeSafeView, View { // Compute the outer size. var outerSize = finalChildResult.size if axes.contains(.horizontal) { - outerSize.width = proposedSize.width + outerSize.width = + proposedSize.width ?? (finalChildResult.size.width + verticalScrollBarWidth) } else { outerSize.width += verticalScrollBarWidth } if axes.contains(.vertical) { - outerSize.height = proposedSize.height + outerSize.height = + proposedSize.height ?? (finalChildResult.size.height + horizontalScrollBarHeight) } else { outerSize.height += horizontalScrollBarHeight