Skip to content

Commit d133364

Browse files
authored
Merge pull request #52 from PureSwift/feature/lazy-stacks
Make LazyVStack and LazyHStack actually lazy
2 parents ba13d06 + 935e725 commit d133364

6 files changed

Lines changed: 305 additions & 6 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//
2+
// LazyStacks.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Genuinely lazy stacks. Unlike `List`, which takes its data directly, a lazy
6+
// stack takes a ViewBuilder — so the laziness has to come from the `ForEach`
7+
// inside it. When the content is exactly one `ForEach`, its elements are
8+
// exposed through an item provider and resolved on demand; anything else
9+
// (mixed or static content) falls back to resolving children eagerly, which
10+
// is correct, just not lazy.
11+
//
12+
13+
/// Type-erased access to a `ForEach`'s elements, so a lazy container can
14+
/// resolve one element at a time instead of flattening them all.
15+
internal protocol _LazyElementProvider {
16+
var _elementCount: Int { get }
17+
func _elementKey(at index: Int) -> String
18+
func _resolveElement(at index: Int, in context: ResolveContext) -> RenderNode
19+
}
20+
21+
extension ForEach: _LazyElementProvider {
22+
23+
internal var _elementCount: Int { data.count }
24+
25+
private func element(at index: Int) -> Data.Element {
26+
data[data.index(data.startIndex, offsetBy: index)]
27+
}
28+
29+
internal func _elementKey(at index: Int) -> String {
30+
identityString(id(element(at: index)))
31+
}
32+
33+
internal func _resolveElement(at index: Int, in context: ResolveContext) -> RenderNode {
34+
let element = self.element(at: index)
35+
// keyed by identity, matching the eager path, so element @State survives
36+
// scrolling and reordering
37+
let key = "#\(identityString(id(element)))"
38+
return Evaluator.resolve(content(element), context.descending(key))
39+
}
40+
}
41+
42+
/// Builds a lazy stack node, using the item-provider path when the content is a
43+
/// single `ForEach` and falling back to eager children otherwise.
44+
internal func _lazyStackNode<Content: View>(
45+
type: String,
46+
content: Content,
47+
props: [String: PropValue],
48+
context: ResolveContext
49+
) -> RenderNode {
50+
var props = props
51+
guard let provider = content as? _LazyElementProvider else {
52+
return RenderNode(
53+
type: type,
54+
id: context.path,
55+
props: props,
56+
children: Evaluator.resolveChildren(content, context.descending("content"))
57+
)
58+
}
59+
let storage = context.storage
60+
let callbacks = context.callbacks
61+
let environment = context.environment
62+
let basePath = context.path + "/content"
63+
let count = provider._elementCount
64+
65+
let providerID = callbacks.register(.item { index in
66+
let elementContext = ResolveContext(
67+
storage: storage,
68+
callbacks: callbacks,
69+
environment: environment,
70+
path: basePath
71+
)
72+
return provider._resolveElement(at: index, in: elementContext)
73+
})
74+
props["itemProvider"] = .int(Int(providerID))
75+
props["keys"] = .array((0..<count).map { .string(provider._elementKey(at: $0)) })
76+
return RenderNode(type: type, id: context.path, props: props, count: count)
77+
}
78+
79+
/// A vertically stacked layout that only resolves the elements in view.
80+
public struct LazyVStack<Content: View>: View {
81+
82+
internal let alignment: HorizontalAlignment
83+
internal let spacing: Double?
84+
internal let content: Content
85+
86+
public init(
87+
alignment: HorizontalAlignment = .center,
88+
spacing: Double? = nil,
89+
@ViewBuilder content: () -> Content
90+
) {
91+
self.alignment = alignment
92+
self.spacing = spacing
93+
self.content = content()
94+
}
95+
96+
public typealias Body = Never
97+
}
98+
99+
extension LazyVStack: PrimitiveView {
100+
public func _render(in context: ResolveContext) -> RenderNode {
101+
var props: [String: PropValue] = ["alignment": .string(alignment.rawValue)]
102+
if let spacing { props["spacing"] = .double(spacing) }
103+
return _lazyStackNode(type: "LazyVStack", content: content, props: props, context: context)
104+
}
105+
}
106+
107+
/// A horizontally stacked layout that only resolves the elements in view.
108+
public struct LazyHStack<Content: View>: View {
109+
110+
internal let alignment: VerticalAlignment
111+
internal let spacing: Double?
112+
internal let content: Content
113+
114+
public init(
115+
alignment: VerticalAlignment = .center,
116+
spacing: Double? = nil,
117+
@ViewBuilder content: () -> Content
118+
) {
119+
self.alignment = alignment
120+
self.spacing = spacing
121+
self.content = content()
122+
}
123+
124+
public typealias Body = Never
125+
}
126+
127+
extension LazyHStack: PrimitiveView {
128+
public func _render(in context: ResolveContext) -> RenderNode {
129+
var props: [String: PropValue] = ["alignment": .string(alignment.rawValue)]
130+
if let spacing { props["spacing"] = .double(spacing) }
131+
return _lazyStackNode(type: "LazyHStack", content: content, props: props, context: context)
132+
}
133+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,6 @@ extension SecureField: PrimitiveView {
196196

197197
/// Eager stand-ins: laziness is an optimization the lazy container path (R7)
198198
/// provides; semantics match the eager stacks.
199-
public typealias LazyVStack = VStack
200-
public typealias LazyHStack = HStack
201199

202200
/// An angle in degrees or radians.
203201
public struct Angle: Equatable, Sendable {

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,74 @@ struct LazyTests {
103103
#expect(node.props["trackCount"] == nil)
104104
}
105105
}
106+
107+
@Suite("Lazy stacks")
108+
struct LazyStackTests {
109+
110+
@Test("LazyVStack over a ForEach resolves only the elements asked for")
111+
func lazyVStackIsLazy() {
112+
// a counter proves elements aren't built until requested
113+
final class Probe: @unchecked Sendable { var built = 0 }
114+
let probe = Probe()
115+
struct Row: View {
116+
let index: Int
117+
let probe: Probe
118+
var body: some View {
119+
probe.built += 1
120+
return Text("Row \(index)")
121+
}
122+
}
123+
let host = ViewHost(
124+
LazyVStack {
125+
ForEach(0..<10_000, id: \.self) { index in Row(index: index, probe: probe) }
126+
}
127+
)
128+
let node = host.evaluate()
129+
#expect(node.type == "LazyVStack")
130+
#expect(node.count == 10_000)
131+
#expect(probe.built == 0) // nothing resolved up front
132+
133+
guard case .int(let provider)? = node.props["itemProvider"] else {
134+
Issue.record("missing item provider"); return
135+
}
136+
let row = host.callbacks.item(Int64(provider), 42)
137+
#expect(firstTextString(row!) == "Row 42")
138+
#expect(probe.built == 1) // exactly the one requested
139+
}
140+
141+
@Test("A lazy stack keys its elements by identity, not position")
142+
func lazyVStackKeys() {
143+
let node = ViewHost(
144+
LazyVStack {
145+
ForEach(["a", "b", "c"], id: \.self) { Text($0) }
146+
}
147+
).evaluate()
148+
guard case .array(let keys)? = node.props["keys"] else {
149+
Issue.record("missing keys"); return
150+
}
151+
#expect(keys == [.string("a"), .string("b"), .string("c")])
152+
}
153+
154+
@Test("Content that isn't a single ForEach still renders, eagerly")
155+
func lazyVStackFallback() {
156+
let node = ViewHost(
157+
LazyVStack {
158+
Text("header")
159+
Text("footer")
160+
}
161+
).evaluate()
162+
#expect(node.type == "LazyVStack")
163+
#expect(node.props["itemProvider"] == nil) // no lazy path
164+
#expect(node.children.count == 2) // but the content is intact
165+
}
166+
167+
@Test("LazyHStack carries the same provider shape")
168+
func lazyHStack() {
169+
let node = ViewHost(
170+
LazyHStack { ForEach(0..<50, id: \.self) { Text("\($0)") } }
171+
).evaluate()
172+
#expect(node.type == "LazyHStack")
173+
#expect(node.count == 50)
174+
#expect(node.props["itemProvider"] != nil)
175+
}
176+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ struct CatalogEntry: Identifiable {
4343
CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())),
4444
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
4545
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
46+
CatalogEntry(id: "lazystack", title: "Lazy Stacks", screen: AnyCatalogScreen(LazyStackPlayground())),
4647
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
4748
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
4849
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
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 LazyStackPlayground: View {
8+
var body: some View {
9+
VStack(alignment: .leading, spacing: 12) {
10+
Text("LazyHStack — 500 items")
11+
LazyHStack(spacing: 8) {
12+
ForEach(0..<500, id: \.self) { index in
13+
Text("\(index)")
14+
.foregroundColor(.white)
15+
.padding()
16+
.background(Color.blue)
17+
.cornerRadius(6)
18+
}
19+
}
20+
.frame(height: 64)
21+
22+
Text("LazyVStack — 10,000 rows")
23+
// The ScrollView hands scrolling to the lazy stack, which only
24+
// materializes the rows on screen.
25+
ScrollView {
26+
LazyVStack(alignment: .leading, spacing: 10) {
27+
ForEach(0..<10_000, id: \.self) { index in
28+
Text("Row \(index)")
29+
}
30+
}
31+
}
32+
}
33+
.padding()
34+
.navigationTitle("Lazy Stacks")
35+
}
36+
}

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

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import androidx.compose.foundation.layout.Column as LayoutColumn
5454
import androidx.compose.foundation.layout.fillMaxSize
5555
import androidx.compose.foundation.layout.height
5656
import androidx.compose.foundation.lazy.LazyColumn
57+
import androidx.compose.foundation.lazy.LazyRow
5758
import androidx.compose.foundation.lazy.grid.GridCells
5859
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
5960
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -306,14 +307,25 @@ private fun RenderResolved(node: ViewNode) {
306307
"Divider" -> HorizontalDivider(modifier = node.composeModifiers())
307308

308309
"ScrollView" -> {
309-
val state = rememberScrollState()
310-
if (node.string("axis") == "horizontal") {
311-
Row(modifier = node.composeModifiers().horizontalScroll(state)) { RenderChildren(node) }
310+
// A lazy stack scrolls itself, and nesting it inside a scrolling
311+
// parent would measure it with unbounded height (a Compose
312+
// crash), so hand the scrolling over to it.
313+
val lone = node.children.singleOrNull { !it.isPresentation() }
314+
if (lone != null && lone.isLazyStack()) {
315+
RenderChild(lone)
312316
} else {
313-
Column(modifier = node.composeModifiers().verticalScroll(state)) { RenderChildren(node) }
317+
val state = rememberScrollState()
318+
if (node.string("axis") == "horizontal") {
319+
Row(modifier = node.composeModifiers().horizontalScroll(state)) { RenderChildren(node) }
320+
} else {
321+
Column(modifier = node.composeModifiers().verticalScroll(state)) { RenderChildren(node) }
322+
}
314323
}
315324
}
316325

326+
"LazyVStack" -> RenderLazyStack(node, vertical = true)
327+
"LazyHStack" -> RenderLazyStack(node, vertical = false)
328+
317329
// A Color greedily fills the space offered it (SwiftUI semantics);
318330
// fillMaxWidth is applied after the chain so an explicit frame width
319331
// still constrains it.
@@ -1554,6 +1566,54 @@ private fun ViewNode.stringArray(key: String): List<String> {
15541566
}
15551567

15561568

1569+
private fun ViewNode.isLazyStack(): Boolean = type == "LazyVStack" || type == "LazyHStack"
1570+
1571+
// A lazy stack fetches only the elements Compose asks for, one JNI call each.
1572+
// Content that wasn't a single ForEach carries no provider and renders eagerly.
1573+
@Composable
1574+
private fun RenderLazyStack(node: ViewNode, vertical: Boolean) {
1575+
val provider = node.long("itemProvider")
1576+
if (provider == null) {
1577+
if (vertical) Column(modifier = node.composeModifiers()) { RenderChildren(node) }
1578+
else Row(modifier = node.composeModifiers()) { RenderChildren(node) }
1579+
return
1580+
}
1581+
val count = node.count ?: 0
1582+
val keys = node.stringArray("keys")
1583+
val spacing = (node.double("spacing") ?: 0.0).dp
1584+
if (vertical) {
1585+
LazyColumn(
1586+
modifier = node.composeModifiers().fillMaxWidth(),
1587+
horizontalAlignment = when (node.string("alignment")) {
1588+
"leading" -> Alignment.Start
1589+
"trailing" -> Alignment.End
1590+
else -> Alignment.CenterHorizontally
1591+
},
1592+
verticalArrangement = Arrangement.spacedBy(spacing),
1593+
) {
1594+
items(count = count, key = { keys.getOrNull(it) ?: it }) { index ->
1595+
val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) }
1596+
element?.let { RenderChild(it) }
1597+
}
1598+
}
1599+
} else {
1600+
LazyRow(
1601+
modifier = node.composeModifiers(),
1602+
verticalAlignment = when (node.string("alignment")) {
1603+
"top" -> Alignment.Top
1604+
"bottom" -> Alignment.Bottom
1605+
else -> Alignment.CenterVertically
1606+
},
1607+
horizontalArrangement = Arrangement.spacedBy(spacing),
1608+
) {
1609+
items(count = count, key = { keys.getOrNull(it) ?: it }) { index ->
1610+
val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) }
1611+
element?.let { RenderChild(it) }
1612+
}
1613+
}
1614+
}
1615+
}
1616+
15571617
@OptIn(ExperimentalMaterial3Api::class)
15581618
@Composable
15591619
private fun RenderList(node: ViewNode) {

0 commit comments

Comments
 (0)