From 5a2e23f0cb0b22d37ec5f36c80ca5e226bcffe80 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 1/6] Drop the eager lazy stack aliases --- .../Sources/AndroidSwiftUICore/Primitives/Views.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift index 174b6ee..57ea731 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift @@ -196,8 +196,6 @@ extension SecureField: PrimitiveView { /// Eager stand-ins: laziness is an optimization the lazy container path (R7) /// provides; semantics match the eager stacks. -public typealias LazyVStack = VStack -public typealias LazyHStack = HStack /// An angle in degrees or radians. public struct Angle: Equatable, Sendable { From 36380802429e47493a4061f618faadd9d73666da Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 2/6] Add lazy vertical and horizontal stacks --- .../Primitives/LazyStacks.swift | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/LazyStacks.swift diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/LazyStacks.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/LazyStacks.swift new file mode 100644 index 0000000..9fd5167 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/LazyStacks.swift @@ -0,0 +1,133 @@ +// +// LazyStacks.swift +// AndroidSwiftUICore +// +// Genuinely lazy stacks. Unlike `List`, which takes its data directly, a lazy +// stack takes a ViewBuilder — so the laziness has to come from the `ForEach` +// inside it. When the content is exactly one `ForEach`, its elements are +// exposed through an item provider and resolved on demand; anything else +// (mixed or static content) falls back to resolving children eagerly, which +// is correct, just not lazy. +// + +/// Type-erased access to a `ForEach`'s elements, so a lazy container can +/// resolve one element at a time instead of flattening them all. +internal protocol _LazyElementProvider { + var _elementCount: Int { get } + func _elementKey(at index: Int) -> String + func _resolveElement(at index: Int, in context: ResolveContext) -> RenderNode +} + +extension ForEach: _LazyElementProvider { + + internal var _elementCount: Int { data.count } + + private func element(at index: Int) -> Data.Element { + data[data.index(data.startIndex, offsetBy: index)] + } + + internal func _elementKey(at index: Int) -> String { + identityString(id(element(at: index))) + } + + internal func _resolveElement(at index: Int, in context: ResolveContext) -> RenderNode { + let element = self.element(at: index) + // keyed by identity, matching the eager path, so element @State survives + // scrolling and reordering + let key = "#\(identityString(id(element)))" + return Evaluator.resolve(content(element), context.descending(key)) + } +} + +/// Builds a lazy stack node, using the item-provider path when the content is a +/// single `ForEach` and falling back to eager children otherwise. +internal func _lazyStackNode( + type: String, + content: Content, + props: [String: PropValue], + context: ResolveContext +) -> RenderNode { + var props = props + guard let provider = content as? _LazyElementProvider else { + return RenderNode( + type: type, + id: context.path, + props: props, + children: Evaluator.resolveChildren(content, context.descending("content")) + ) + } + let storage = context.storage + let callbacks = context.callbacks + let environment = context.environment + let basePath = context.path + "/content" + let count = provider._elementCount + + let providerID = callbacks.register(.item { index in + let elementContext = ResolveContext( + storage: storage, + callbacks: callbacks, + environment: environment, + path: basePath + ) + return provider._resolveElement(at: index, in: elementContext) + }) + props["itemProvider"] = .int(Int(providerID)) + props["keys"] = .array((0..: View { + + internal let alignment: HorizontalAlignment + internal let spacing: Double? + internal let content: Content + + public init( + alignment: HorizontalAlignment = .center, + spacing: Double? = nil, + @ViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public typealias Body = Never +} + +extension LazyVStack: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + var props: [String: PropValue] = ["alignment": .string(alignment.rawValue)] + if let spacing { props["spacing"] = .double(spacing) } + return _lazyStackNode(type: "LazyVStack", content: content, props: props, context: context) + } +} + +/// A horizontally stacked layout that only resolves the elements in view. +public struct LazyHStack: View { + + internal let alignment: VerticalAlignment + internal let spacing: Double? + internal let content: Content + + public init( + alignment: VerticalAlignment = .center, + spacing: Double? = nil, + @ViewBuilder content: () -> Content + ) { + self.alignment = alignment + self.spacing = spacing + self.content = content() + } + + public typealias Body = Never +} + +extension LazyHStack: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + var props: [String: PropValue] = ["alignment": .string(alignment.rawValue)] + if let spacing { props["spacing"] = .double(spacing) } + return _lazyStackNode(type: "LazyHStack", content: content, props: props, context: context) + } +} From 45bf8180a9b0ef59e891b636b09f8fd539d55385 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 3/6] Render lazy stacks and yield scrolling to them --- .../kotlin/com/pureswift/swiftui/Render.kt | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) 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 366f378..d377533 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -54,6 +54,7 @@ import androidx.compose.foundation.layout.Column as LayoutColumn import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -306,14 +307,25 @@ private fun RenderResolved(node: ViewNode) { "Divider" -> HorizontalDivider(modifier = node.composeModifiers()) "ScrollView" -> { - val state = rememberScrollState() - if (node.string("axis") == "horizontal") { - Row(modifier = node.composeModifiers().horizontalScroll(state)) { RenderChildren(node) } + // A lazy stack scrolls itself, and nesting it inside a scrolling + // parent would measure it with unbounded height (a Compose + // crash), so hand the scrolling over to it. + val lone = node.children.singleOrNull { !it.isPresentation() } + if (lone != null && lone.isLazyStack()) { + RenderChild(lone) } else { - Column(modifier = node.composeModifiers().verticalScroll(state)) { RenderChildren(node) } + val state = rememberScrollState() + if (node.string("axis") == "horizontal") { + Row(modifier = node.composeModifiers().horizontalScroll(state)) { RenderChildren(node) } + } else { + Column(modifier = node.composeModifiers().verticalScroll(state)) { RenderChildren(node) } + } } } + "LazyVStack" -> RenderLazyStack(node, vertical = true) + "LazyHStack" -> RenderLazyStack(node, vertical = false) + // A Color greedily fills the space offered it (SwiftUI semantics); // fillMaxWidth is applied after the chain so an explicit frame width // still constrains it. @@ -1554,6 +1566,54 @@ private fun ViewNode.stringArray(key: String): List { } +private fun ViewNode.isLazyStack(): Boolean = type == "LazyVStack" || type == "LazyHStack" + +// A lazy stack fetches only the elements Compose asks for, one JNI call each. +// Content that wasn't a single ForEach carries no provider and renders eagerly. +@Composable +private fun RenderLazyStack(node: ViewNode, vertical: Boolean) { + val provider = node.long("itemProvider") + if (provider == null) { + if (vertical) Column(modifier = node.composeModifiers()) { RenderChildren(node) } + else Row(modifier = node.composeModifiers()) { RenderChildren(node) } + return + } + val count = node.count ?: 0 + val keys = node.stringArray("keys") + val spacing = (node.double("spacing") ?: 0.0).dp + if (vertical) { + LazyColumn( + modifier = node.composeModifiers().fillMaxWidth(), + horizontalAlignment = when (node.string("alignment")) { + "leading" -> Alignment.Start + "trailing" -> Alignment.End + else -> Alignment.CenterHorizontally + }, + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> + val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + element?.let { RenderChild(it) } + } + } + } else { + LazyRow( + modifier = node.composeModifiers(), + verticalAlignment = when (node.string("alignment")) { + "top" -> Alignment.Top + "bottom" -> Alignment.Bottom + else -> Alignment.CenterVertically + }, + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> + val element = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + element?.let { RenderChild(it) } + } + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RenderList(node: ViewNode) { From 46dee7c1ebad3e91732d4a86260435b03f0eac67 Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 4/6] Test that lazy stacks resolve only what is asked for --- .../AndroidSwiftUICoreTests/LazyTests.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift index c101720..a320423 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift @@ -103,3 +103,74 @@ struct LazyTests { #expect(node.props["trackCount"] == nil) } } + +@Suite("Lazy stacks") +struct LazyStackTests { + + @Test("LazyVStack over a ForEach resolves only the elements asked for") + func lazyVStackIsLazy() { + // a counter proves elements aren't built until requested + final class Probe: @unchecked Sendable { var built = 0 } + let probe = Probe() + struct Row: View { + let index: Int + let probe: Probe + var body: some View { + probe.built += 1 + return Text("Row \(index)") + } + } + let host = ViewHost( + LazyVStack { + ForEach(0..<10_000, id: \.self) { index in Row(index: index, probe: probe) } + } + ) + let node = host.evaluate() + #expect(node.type == "LazyVStack") + #expect(node.count == 10_000) + #expect(probe.built == 0) // nothing resolved up front + + guard case .int(let provider)? = node.props["itemProvider"] else { + Issue.record("missing item provider"); return + } + let row = host.callbacks.item(Int64(provider), 42) + #expect(firstTextString(row!) == "Row 42") + #expect(probe.built == 1) // exactly the one requested + } + + @Test("A lazy stack keys its elements by identity, not position") + func lazyVStackKeys() { + let node = ViewHost( + LazyVStack { + ForEach(["a", "b", "c"], id: \.self) { Text($0) } + } + ).evaluate() + guard case .array(let keys)? = node.props["keys"] else { + Issue.record("missing keys"); return + } + #expect(keys == [.string("a"), .string("b"), .string("c")]) + } + + @Test("Content that isn't a single ForEach still renders, eagerly") + func lazyVStackFallback() { + let node = ViewHost( + LazyVStack { + Text("header") + Text("footer") + } + ).evaluate() + #expect(node.type == "LazyVStack") + #expect(node.props["itemProvider"] == nil) // no lazy path + #expect(node.children.count == 2) // but the content is intact + } + + @Test("LazyHStack carries the same provider shape") + func lazyHStack() { + let node = ViewHost( + LazyHStack { ForEach(0..<50, id: \.self) { Text("\($0)") } } + ).evaluate() + #expect(node.type == "LazyHStack") + #expect(node.count == 50) + #expect(node.props["itemProvider"] != nil) + } +} From e2d754aa55dc95c6f3f14b1edd35109ca89fc35c Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 5/6] Add a lazy stack playground with ten thousand rows --- .../Sources/LazyStackPlaygrounds.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Demo/App.swiftpm/Sources/LazyStackPlaygrounds.swift diff --git a/Demo/App.swiftpm/Sources/LazyStackPlaygrounds.swift b/Demo/App.swiftpm/Sources/LazyStackPlaygrounds.swift new file mode 100644 index 0000000..494987e --- /dev/null +++ b/Demo/App.swiftpm/Sources/LazyStackPlaygrounds.swift @@ -0,0 +1,36 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct LazyStackPlayground: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("LazyHStack — 500 items") + LazyHStack(spacing: 8) { + ForEach(0..<500, id: \.self) { index in + Text("\(index)") + .foregroundColor(.white) + .padding() + .background(Color.blue) + .cornerRadius(6) + } + } + .frame(height: 64) + + Text("LazyVStack — 10,000 rows") + // The ScrollView hands scrolling to the lazy stack, which only + // materializes the rows on screen. + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(0..<10_000, id: \.self) { index in + Text("Row \(index)") + } + } + } + } + .padding() + .navigationTitle("Lazy Stacks") + } +} From 935e725c75460a59ebe3bd617c5d3b3440cbd8cb Mon Sep 17 00:00:00 2001 From: Alsey Coleman Miller Date: Thu, 23 Jul 2026 19:45:44 -0400 Subject: [PATCH 6/6] List the lazy stack playground in the catalog --- Demo/App.swiftpm/Sources/Catalog.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index fe81102..2c50be0 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -43,6 +43,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())), CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())), CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())), + CatalogEntry(id: "lazystack", title: "Lazy Stacks", screen: AnyCatalogScreen(LazyStackPlayground())), CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())), CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())), CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),