Skip to content

Commit 8a2596e

Browse files
authored
Merge pull request #56 from PureSwift/feature/geometry-reader
Add GeometryReader
2 parents 114fa9a + 4ff56a6 commit 8a2596e

5 files changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// GeometryReader.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Size feedback from layout back into Swift — the one place the data flows
6+
// backwards. Swift resolves a tree *before* Compose lays it out, so the size
7+
// can't be known on the first pass: the interpreter measures, reports the size
8+
// through a callback, and the resulting store update re-evaluates with the real
9+
// numbers. Rendering a GeometryReader therefore settles over two passes.
10+
//
11+
// The reported size comes from the *constraints the parent offers*, never from
12+
// the content, so measuring can't feed back into itself. The store also drops
13+
// a report that matches what it already holds, so a steady layout stops.
14+
//
15+
16+
import Foundation
17+
18+
/// The geometry of the space a `GeometryReader` was offered.
19+
public struct GeometryProxy: Sendable {
20+
public let size: CGSize
21+
}
22+
23+
/// Holds the last size the interpreter reported for one GeometryReader,
24+
/// persisted at its identity path so it survives re-evaluation.
25+
internal final class GeometrySizeStore {
26+
27+
private(set) var size = CGSize(width: 0, height: 0)
28+
var onChange: (() -> Void)?
29+
30+
/// Records a `"<width>,<height>"` report, re-evaluating only on a change.
31+
func update(from payload: String) {
32+
let parts = payload.split(separator: ",").compactMap { Double($0) }
33+
guard parts.count == 2 else { return }
34+
guard parts[0] != size.width || parts[1] != size.height else { return }
35+
size = CGSize(width: parts[0], height: parts[1])
36+
onChange?()
37+
}
38+
}
39+
40+
public struct GeometryReader<Content: View>: View {
41+
42+
internal let content: (GeometryProxy) -> Content
43+
44+
public init(@ViewBuilder content: @escaping (GeometryProxy) -> Content) {
45+
self.content = content
46+
}
47+
48+
public typealias Body = Never
49+
}
50+
51+
extension GeometryReader: PrimitiveView {
52+
53+
public func _render(in context: ResolveContext) -> RenderNode {
54+
let store = context.storage.persistentObject(at: context.path + ".geometry") {
55+
GeometrySizeStore()
56+
}
57+
store.onChange = context.storage.onChange
58+
let id = context.callbacks.register(.string { [store] payload in
59+
store.update(from: payload)
60+
})
61+
// first pass resolves against .zero; the report brings the real size
62+
let proxy = GeometryProxy(size: store.size)
63+
return RenderNode(
64+
type: "GeometryReader",
65+
id: context.path,
66+
props: ["onSize": .int(Int(id))],
67+
children: Evaluator.resolveChildren(content(proxy), context.descending("content"))
68+
)
69+
}
70+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,69 @@ struct AnimationTests {
520520
#expect(anim?.args["token"] == .string("3"))
521521
}
522522
}
523+
524+
@Suite("Geometry")
525+
struct GeometryTests {
526+
527+
/// Finds a GeometryReader's size-report callback anywhere in a tree.
528+
private func sizeCallback(_ node: RenderNode) -> Int64? {
529+
if node.type == "GeometryReader", case .int(let id)? = node.props["onSize"] {
530+
return Int64(id)
531+
}
532+
for child in node.children {
533+
if let found = sizeCallback(child) { return found }
534+
}
535+
return nil
536+
}
537+
538+
@Test("A GeometryReader resolves against zero until a size is reported")
539+
func geometrySettlesOverTwoPasses() {
540+
struct Screen: View {
541+
var body: some View {
542+
GeometryReader { geometry in
543+
Text("w=\(Int(geometry.size.width)) h=\(Int(geometry.size.height))")
544+
}
545+
}
546+
}
547+
let host = ViewHost(Screen())
548+
var node = host.evaluate()
549+
#expect(node.type == "GeometryReader")
550+
// first pass: layout hasn't happened, so the proxy reads zero
551+
#expect(firstTextString(node) == "w=0 h=0")
552+
553+
guard let report = sizeCallback(node) else {
554+
Issue.record("missing size callback"); return
555+
}
556+
host.callbacks.invokeString(report, "320.0,240.0")
557+
node = host.evaluate()
558+
// second pass: the reported size reaches the content
559+
#expect(firstTextString(node) == "w=320 h=240")
560+
}
561+
562+
@Test("The size store re-evaluates on a change and ignores everything else")
563+
func geometryStoreSettles() {
564+
// Driving the store directly is the precise way to pin the settling
565+
// rule: without it a steady layout would re-evaluate forever.
566+
let store = GeometrySizeStore()
567+
var changes = 0
568+
store.onChange = { changes += 1 }
569+
570+
store.update(from: "100.0,50.0")
571+
#expect(changes == 1)
572+
#expect(store.size.width == 100)
573+
#expect(store.size.height == 50)
574+
575+
store.update(from: "100.0,50.0")
576+
#expect(changes == 1) // same size — layout has settled
577+
578+
store.update(from: "180.0,50.0")
579+
#expect(changes == 2) // a real change reports again
580+
#expect(store.size.width == 180)
581+
582+
store.update(from: "garbage")
583+
store.update(from: "")
584+
store.update(from: "1.0") // too few components
585+
#expect(changes == 2) // malformed reports never re-evaluate
586+
#expect(store.size.width == 180)
587+
}
588+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ struct CatalogEntry: Identifiable {
3636
CatalogEntry(id: "morecontrols", title: "More Controls", screen: AnyCatalogScreen(MoreControlsPlayground())),
3737
CatalogEntry(id: "controlstyle", title: "Control Styles", screen: AnyCatalogScreen(ControlStylePlayground())),
3838
CatalogEntry(id: "stack", title: "Stacks", screen: AnyCatalogScreen(StackPlayground())),
39+
CatalogEntry(id: "geometry", title: "GeometryReader", screen: AnyCatalogScreen(GeometryPlayground())),
3940
CatalogEntry(id: "frame", title: "Frames", screen: AnyCatalogScreen(FramePlayground())),
4041
CatalogEntry(id: "spacer", title: "Spacer & Divider", screen: AnyCatalogScreen(SpacerDividerPlayground())),
4142
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct GeometryPlayground: View {
8+
9+
@State private var tall = false
10+
11+
var body: some View {
12+
VStack(alignment: .leading, spacing: 16) {
13+
Text("A GeometryReader reports the space its parent offers.")
14+
15+
GeometryReader { geometry in
16+
VStack(alignment: .leading, spacing: 10) {
17+
Text("Offered: \(Int(geometry.size.width)) × \(Int(geometry.size.height))")
18+
// bars sized from the measurement — proof the number is live
19+
Text("half width")
20+
Rectangle()
21+
.fill(.blue)
22+
.frame(width: geometry.size.width / 2, height: 22)
23+
Text("one quarter")
24+
Rectangle()
25+
.fill(.green)
26+
.frame(width: geometry.size.width / 4, height: 22)
27+
}
28+
}
29+
.frame(height: tall ? 320 : 200)
30+
31+
Button(tall ? "Shrink the reader" : "Grow the reader") { tall.toggle() }
32+
}
33+
.padding()
34+
.navigationTitle("GeometryReader")
35+
}
36+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.compose.foundation.horizontalScroll
1313
import androidx.compose.foundation.verticalScroll
1414
import androidx.compose.foundation.layout.Arrangement
1515
import androidx.compose.foundation.layout.Box
16+
import androidx.compose.foundation.layout.BoxWithConstraints
1617
import androidx.compose.foundation.layout.Column
1718
import androidx.compose.foundation.layout.ColumnScope
1819
import androidx.compose.foundation.layout.Row
@@ -331,6 +332,8 @@ private fun RenderResolved(node: ViewNode) {
331332
}
332333
}
333334

335+
"GeometryReader" -> RenderGeometryReader(node)
336+
334337
"LazyVStack" -> RenderLazyStack(node, vertical = true)
335338
"LazyHStack" -> RenderLazyStack(node, vertical = false)
336339

@@ -546,6 +549,24 @@ private fun RenderToggle(node: ViewNode) {
546549
}
547550
}
548551

552+
// Reports the size the PARENT offers, never the content's own size — that is
553+
// what keeps measurement from feeding back into itself. Swift ignores a report
554+
// equal to what it already holds, so a settled layout reports once and stops.
555+
@Composable
556+
private fun RenderGeometryReader(node: ViewNode) {
557+
val onSize = node.long("onSize")
558+
BoxWithConstraints(modifier = node.composeModifiers().fillMaxWidth()) {
559+
val width = maxWidth.value.toDouble()
560+
// an unbounded height (inside a scrolling parent) isn't a real offer;
561+
// report 0 so the content can fall back rather than see infinity
562+
val height = if (constraints.hasBoundedHeight) maxHeight.value.toDouble() else 0.0
563+
LaunchedEffect(width, height) {
564+
onSize?.let { SwiftBridge.sink.invokeString(it, "$width,$height") }
565+
}
566+
RenderChildren(node)
567+
}
568+
}
569+
549570
private fun ViewNode.isPresentation(): Boolean =
550571
type == "Sheet" || type == "Alert" || type == "ConfirmationDialog" || type == "ToolbarItem"
551572

0 commit comments

Comments
 (0)