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,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<Content: View>(
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..<count).map { .string(provider._elementKey(at: $0)) })
return RenderNode(type: type, id: context.path, props: props, count: count)
}

/// A vertically stacked layout that only resolves the elements in view.
public struct LazyVStack<Content: View>: 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<Content: View>: 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
71 changes: 71 additions & 0 deletions AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
36 changes: 36 additions & 0 deletions Demo/App.swiftpm/Sources/LazyStackPlaygrounds.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 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")
}
}
68 changes: 64 additions & 4 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1554,6 +1566,54 @@ private fun ViewNode.stringArray(key: String): List<String> {
}


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) {
Expand Down
Loading