diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/GeometryReader.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/GeometryReader.swift new file mode 100644 index 0000000..0af31a2 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/GeometryReader.swift @@ -0,0 +1,70 @@ +// +// GeometryReader.swift +// AndroidSwiftUICore +// +// Size feedback from layout back into Swift — the one place the data flows +// backwards. Swift resolves a tree *before* Compose lays it out, so the size +// can't be known on the first pass: the interpreter measures, reports the size +// through a callback, and the resulting store update re-evaluates with the real +// numbers. Rendering a GeometryReader therefore settles over two passes. +// +// The reported size comes from the *constraints the parent offers*, never from +// the content, so measuring can't feed back into itself. The store also drops +// a report that matches what it already holds, so a steady layout stops. +// + +import Foundation + +/// The geometry of the space a `GeometryReader` was offered. +public struct GeometryProxy: Sendable { + public let size: CGSize +} + +/// Holds the last size the interpreter reported for one GeometryReader, +/// persisted at its identity path so it survives re-evaluation. +internal final class GeometrySizeStore { + + private(set) var size = CGSize(width: 0, height: 0) + var onChange: (() -> Void)? + + /// Records a `","` report, re-evaluating only on a change. + func update(from payload: String) { + let parts = payload.split(separator: ",").compactMap { Double($0) } + guard parts.count == 2 else { return } + guard parts[0] != size.width || parts[1] != size.height else { return } + size = CGSize(width: parts[0], height: parts[1]) + onChange?() + } +} + +public struct GeometryReader: View { + + internal let content: (GeometryProxy) -> Content + + public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) { + self.content = content + } + + public typealias Body = Never +} + +extension GeometryReader: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + let store = context.storage.persistentObject(at: context.path + ".geometry") { + GeometrySizeStore() + } + store.onChange = context.storage.onChange + let id = context.callbacks.register(.string { [store] payload in + store.update(from: payload) + }) + // first pass resolves against .zero; the report brings the real size + let proxy = GeometryProxy(size: store.size) + return RenderNode( + type: "GeometryReader", + id: context.path, + props: ["onSize": .int(Int(id))], + children: Evaluator.resolveChildren(content(proxy), context.descending("content")) + ) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 5615a63..de9a585 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -520,3 +520,69 @@ struct AnimationTests { #expect(anim?.args["token"] == .string("3")) } } + +@Suite("Geometry") +struct GeometryTests { + + /// Finds a GeometryReader's size-report callback anywhere in a tree. + private func sizeCallback(_ node: RenderNode) -> Int64? { + if node.type == "GeometryReader", case .int(let id)? = node.props["onSize"] { + return Int64(id) + } + for child in node.children { + if let found = sizeCallback(child) { return found } + } + return nil + } + + @Test("A GeometryReader resolves against zero until a size is reported") + func geometrySettlesOverTwoPasses() { + struct Screen: View { + var body: some View { + GeometryReader { geometry in + Text("w=\(Int(geometry.size.width)) h=\(Int(geometry.size.height))") + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.type == "GeometryReader") + // first pass: layout hasn't happened, so the proxy reads zero + #expect(firstTextString(node) == "w=0 h=0") + + guard let report = sizeCallback(node) else { + Issue.record("missing size callback"); return + } + host.callbacks.invokeString(report, "320.0,240.0") + node = host.evaluate() + // second pass: the reported size reaches the content + #expect(firstTextString(node) == "w=320 h=240") + } + + @Test("The size store re-evaluates on a change and ignores everything else") + func geometryStoreSettles() { + // Driving the store directly is the precise way to pin the settling + // rule: without it a steady layout would re-evaluate forever. + let store = GeometrySizeStore() + var changes = 0 + store.onChange = { changes += 1 } + + store.update(from: "100.0,50.0") + #expect(changes == 1) + #expect(store.size.width == 100) + #expect(store.size.height == 50) + + store.update(from: "100.0,50.0") + #expect(changes == 1) // same size — layout has settled + + store.update(from: "180.0,50.0") + #expect(changes == 2) // a real change reports again + #expect(store.size.width == 180) + + store.update(from: "garbage") + store.update(from: "") + store.update(from: "1.0") // too few components + #expect(changes == 2) // malformed reports never re-evaluate + #expect(store.size.width == 180) + } +} diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index 5411360..3ab3ccc 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -36,6 +36,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())), CatalogEntry(id: "controlstyle", title: "Control Styles", screen: AnyCatalogScreen(ControlStylePlayground())), CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())), + CatalogEntry(id: "geometry", title: "GeometryReader", screen: AnyCatalogScreen(GeometryPlayground())), CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())), CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())), CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())), diff --git a/Demo/App.swiftpm/Sources/GeometryPlaygrounds.swift b/Demo/App.swiftpm/Sources/GeometryPlaygrounds.swift new file mode 100644 index 0000000..1bca714 --- /dev/null +++ b/Demo/App.swiftpm/Sources/GeometryPlaygrounds.swift @@ -0,0 +1,36 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct GeometryPlayground: View { + + @State private var tall = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("A GeometryReader reports the space its parent offers.") + + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 10) { + Text("Offered: \(Int(geometry.size.width)) × \(Int(geometry.size.height))") + // bars sized from the measurement — proof the number is live + Text("half width") + Rectangle() + .fill(.blue) + .frame(width: geometry.size.width / 2, height: 22) + Text("one quarter") + Rectangle() + .fill(.green) + .frame(width: geometry.size.width / 4, height: 22) + } + } + .frame(height: tall ? 320 : 200) + + Button(tall ? "Shrink the reader" : "Grow the reader") { tall.toggle() } + } + .padding() + .navigationTitle("GeometryReader") + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index b26e428..8b9f713 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -331,6 +332,8 @@ private fun RenderResolved(node: ViewNode) { } } + "GeometryReader" -> RenderGeometryReader(node) + "LazyVStack" -> RenderLazyStack(node, vertical = true) "LazyHStack" -> RenderLazyStack(node, vertical = false) @@ -546,6 +549,24 @@ private fun RenderToggle(node: ViewNode) { } } +// Reports the size the PARENT offers, never the content's own size — that is +// what keeps measurement from feeding back into itself. Swift ignores a report +// equal to what it already holds, so a settled layout reports once and stops. +@Composable +private fun RenderGeometryReader(node: ViewNode) { + val onSize = node.long("onSize") + BoxWithConstraints(modifier = node.composeModifiers().fillMaxWidth()) { + val width = maxWidth.value.toDouble() + // an unbounded height (inside a scrolling parent) isn't a real offer; + // report 0 so the content can fall back rather than see infinity + val height = if (constraints.hasBoundedHeight) maxHeight.value.toDouble() else 0.0 + LaunchedEffect(width, height) { + onSize?.let { SwiftBridge.sink.invokeString(it, "$width,$height") } + } + RenderChildren(node) + } +} + private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"