Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 `"<width>,<height>"` 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<Content: View>: 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"))
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
36 changes: 36 additions & 0 deletions Demo/App.swiftpm/Sources/GeometryPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
21 changes: 21 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -331,6 +332,8 @@ private fun RenderResolved(node: ViewNode) {
}
}

"GeometryReader" -> RenderGeometryReader(node)

"LazyVStack" -> RenderLazyStack(node, vertical = true)
"LazyHStack" -> RenderLazyStack(node, vertical = false)

Expand Down Expand Up @@ -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"

Expand Down
Loading