Skip to content

Commit ce028e5

Browse files
authored
Merge pull request #24 from PureSwift/feature/lazy-containers
Lazy List, grids, and the async render pipeline
2 parents 33aa263 + 5fba084 commit ce028e5

14 files changed

Lines changed: 520 additions & 8 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/CallbackRegistry.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ public final class CallbackRegistry {
1616
case double((Double) -> Void)
1717
case int((Int) -> Void)
1818
case string((String) -> Void)
19+
/// A lazy row provider: given an index, returns that row's subtree.
20+
/// Must be a pure read — it runs during Compose composition.
21+
case item((Int) -> RenderNode)
1922
}
2023

2124
private var current: [Int64: Callback] = [:]
@@ -68,4 +71,10 @@ public final class CallbackRegistry {
6871
public func invokeString(_ id: Int64, _ value: String) {
6972
if case .string(let action)? = callback(for: id) { action(value) }
7073
}
74+
75+
/// Resolves a lazy row on demand.
76+
public func item(_ id: Int64, _ index: Int) -> RenderNode? {
77+
if case .item(let provider)? = callback(for: id) { return provider(index) }
78+
return nil
79+
}
7180
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ public struct ResolveContext {
1717
public var environment: EnvironmentStorage
1818
/// Collects the `navigationTitle` of the screen currently being resolved.
1919
public var titleSink: TitleSink?
20+
/// Carries a `refreshable` action to the List it wraps.
21+
public var refreshSink: RefreshSink?
2022
public var path: String
2123
public var depth: Int
2224

@@ -25,13 +27,15 @@ public struct ResolveContext {
2527
callbacks: CallbackRegistry,
2628
environment: EnvironmentStorage = EnvironmentStorage(),
2729
titleSink: TitleSink? = nil,
30+
refreshSink: RefreshSink? = nil,
2831
path: String = "",
2932
depth: Int = 0
3033
) {
3134
self.storage = storage
3235
self.callbacks = callbacks
3336
self.environment = environment
3437
self.titleSink = titleSink
38+
self.refreshSink = refreshSink
3539
self.path = path
3640
self.depth = depth
3741
}
@@ -91,6 +95,12 @@ public final class TitleSink {
9195
public init() {}
9296
}
9397

98+
/// Carries a pending `refreshable` action to the enclosing List.
99+
public final class RefreshSink {
100+
public var action: (@Sendable () async -> Void)?
101+
public init() {}
102+
}
103+
94104
public enum Evaluator {
95105

96106
/// Guards against a self-referential `body`.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//
2+
// Grids.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Grids resolve their cells eagerly (bounded in practice) into a node carrying
6+
// the track spec; the interpreter lays them out in a Compose lazy grid.
7+
//
8+
9+
/// A grid track description.
10+
public struct GridItem: Sendable {
11+
12+
public enum Size: Sendable {
13+
case fixedTrack(Double)
14+
case flexibleTrack(minimum: Double, maximum: Double)
15+
case adaptiveTrack(minimum: Double, maximum: Double)
16+
}
17+
18+
public var size: Size
19+
20+
public init(_ size: Size = .flexible()) {
21+
self.size = size
22+
}
23+
}
24+
25+
public extension GridItem.Size {
26+
static func fixed(_ value: Double) -> GridItem.Size { .fixedTrack(value) }
27+
static func flexible(minimum: Double = 10, maximum: Double = .infinity) -> GridItem.Size {
28+
.flexibleTrack(minimum: minimum, maximum: maximum)
29+
}
30+
static func adaptive(minimum: Double, maximum: Double = .infinity) -> GridItem.Size {
31+
.adaptiveTrack(minimum: minimum, maximum: maximum)
32+
}
33+
}
34+
35+
/// A grid growing vertically, laid out across `columns` tracks.
36+
public struct LazyVGrid<Content: View>: View {
37+
38+
internal let columns: [GridItem]
39+
internal let spacing: Double?
40+
internal let content: Content
41+
42+
public init(columns: [GridItem], spacing: Double? = nil, @ViewBuilder content: () -> Content) {
43+
self.columns = columns
44+
self.spacing = spacing
45+
self.content = content()
46+
}
47+
48+
public typealias Body = Never
49+
}
50+
51+
extension LazyVGrid: PrimitiveView {
52+
public func _render(in context: ResolveContext) -> RenderNode {
53+
gridNode(type: "LazyVGrid", tracks: columns, spacing: spacing, content: content, context: context)
54+
}
55+
}
56+
57+
/// A grid growing horizontally, laid out down `rows` tracks.
58+
public struct LazyHGrid<Content: View>: View {
59+
60+
internal let rows: [GridItem]
61+
internal let spacing: Double?
62+
internal let content: Content
63+
64+
public init(rows: [GridItem], spacing: Double? = nil, @ViewBuilder content: () -> Content) {
65+
self.rows = rows
66+
self.spacing = spacing
67+
self.content = content()
68+
}
69+
70+
public typealias Body = Never
71+
}
72+
73+
extension LazyHGrid: PrimitiveView {
74+
public func _render(in context: ResolveContext) -> RenderNode {
75+
gridNode(type: "LazyHGrid", tracks: rows, spacing: spacing, content: content, context: context)
76+
}
77+
}
78+
79+
private func gridNode<Content: View>(
80+
type: String,
81+
tracks: [GridItem],
82+
spacing: Double?,
83+
content: Content,
84+
context: ResolveContext
85+
) -> RenderNode {
86+
var props: [String: PropValue] = [:]
87+
// a single adaptive track fits as many columns as possible; otherwise it's
88+
// a fixed track count — the common flexible-columns usage
89+
if tracks.count == 1, case .adaptiveTrack(let minimum, _) = tracks[0].size {
90+
props["adaptiveMin"] = .double(minimum)
91+
} else {
92+
props["trackCount"] = .int(max(tracks.count, 1))
93+
}
94+
if let spacing { props["spacing"] = .double(spacing) }
95+
return RenderNode(
96+
type: type,
97+
id: context.path,
98+
props: props,
99+
children: Evaluator.resolveChildren(content, context.descending("content"))
100+
)
101+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//
2+
// List.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A lazy list: rows are evaluated on demand through an item provider, so a
6+
// large list contributes one node plus the rows Compose actually asks for.
7+
// This is the only unbounded-tree case, and the reason the schema carries
8+
// `count` and `itemProviderID`.
9+
//
10+
11+
public struct List<Data: RandomAccessCollection, ID: Hashable, RowContent: View>: View {
12+
13+
internal let data: Data
14+
internal let id: (Data.Element) -> ID
15+
internal let row: (Data.Element) -> RowContent
16+
17+
public init(_ data: Data, id: @escaping (Data.Element) -> ID, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) {
18+
self.data = data
19+
self.id = id
20+
self.row = rowContent
21+
}
22+
23+
public typealias Body = Never
24+
}
25+
26+
public extension List where Data.Element: Identifiable, ID == Data.Element.ID {
27+
init(_ data: Data, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) {
28+
self.init(data, id: { $0.id }, rowContent: rowContent)
29+
}
30+
}
31+
32+
extension List: PrimitiveView {
33+
34+
public func _render(in context: ResolveContext) -> RenderNode {
35+
// snapshot the data and row builder; the provider resolves each row on
36+
// demand, keyed by element id so row @State survives scroll and reorder
37+
let elements = Array(data)
38+
let rowBuilder = row
39+
let keyFor = id
40+
let listPath = context.path
41+
let storage = context.storage
42+
let callbacks = context.callbacks
43+
let environment = context.environment
44+
45+
let providerID = callbacks.register(.item { index in
46+
let element = elements[index]
47+
let key = "#\(identityString(keyFor(element)))"
48+
var rowContext = ResolveContext(
49+
storage: storage,
50+
callbacks: callbacks,
51+
environment: environment,
52+
path: listPath + "/" + key
53+
)
54+
return Evaluator.resolve(rowBuilder(element), rowContext)
55+
})
56+
57+
let keys = elements.map { PropValue.string(identityString(keyFor($0))) }
58+
var props: [String: PropValue] = [
59+
"keys": .array(keys),
60+
"itemProvider": .int(Int(providerID)),
61+
]
62+
if let onRefresh = context.refreshSink?.action {
63+
let refreshID = callbacks.register(.void {
64+
Task { await onRefresh() }
65+
})
66+
props["onRefresh"] = .int(Int(refreshID))
67+
}
68+
return RenderNode(type: "List", id: context.path, props: props, count: elements.count)
69+
}
70+
}
71+
72+
// MARK: - Refreshable
73+
74+
public struct _RefreshableView<Content: View>: View {
75+
internal let action: @Sendable () async -> Void
76+
internal let content: Content
77+
public typealias Body = Never
78+
}
79+
80+
extension _RefreshableView: _ResolutionEffectView {
81+
public func _applyEffect(_ context: inout ResolveContext) -> any View {
82+
// attach the refresh action to the List the content resolves to
83+
if context.refreshSink == nil { context.refreshSink = RefreshSink() }
84+
context.refreshSink?.action = action
85+
return content
86+
}
87+
}
88+
89+
public extension View {
90+
func refreshable(action: @escaping @Sendable () async -> Void) -> _RefreshableView<Self> {
91+
_RefreshableView(action: action, content: self)
92+
}
93+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//
2+
// LazyTests.swift
3+
// AndroidSwiftUICoreTests
4+
//
5+
6+
import Testing
7+
@testable import AndroidSwiftUICore
8+
9+
struct Item: Identifiable { let id: Int }
10+
11+
@Suite("Lazy containers")
12+
struct LazyTests {
13+
14+
@Test("List emits count and a provider, not inline rows")
15+
func listIsLazy() {
16+
struct Screen: View {
17+
var body: some View {
18+
List((1...1000).map(Item.init)) { Text("Row \($0.id)") }
19+
}
20+
}
21+
let node = ViewHost(Screen()).evaluate()
22+
#expect(node.type == "List")
23+
#expect(node.count == 1000)
24+
#expect(node.children.isEmpty) // rows are not materialized up front
25+
#expect(node.props["itemProvider"] != nil)
26+
}
27+
28+
@Test("The item provider resolves a row subtree on demand")
29+
func providerResolvesRow() {
30+
struct Screen: View {
31+
var body: some View {
32+
List((1...100).map(Item.init)) { Text("Row \($0.id)") }
33+
}
34+
}
35+
let host = ViewHost(Screen())
36+
let node = host.evaluate()
37+
guard case .int(let providerID)? = node.props["itemProvider"] else {
38+
Issue.record("no provider"); return
39+
}
40+
// rows 0 and 42 resolve independently, only when asked
41+
let row0 = host.callbacks.item(Int64(providerID), 0)
42+
let row42 = host.callbacks.item(Int64(providerID), 42)
43+
#expect(row0?.props["text"] == .string("Row 1"))
44+
#expect(row42?.props["text"] == .string("Row 43"))
45+
}
46+
47+
@Test("Row identity path is keyed by element id")
48+
func rowKeyedByIdentity() {
49+
struct Screen: View {
50+
let items: [Item]
51+
var body: some View { List(items) { Text("Row \($0.id)") } }
52+
}
53+
func rowID(_ items: [Item], index: Int) -> String {
54+
let host = ViewHost(Screen(items: items))
55+
let node = host.evaluate()
56+
guard case .int(let p)? = node.props["itemProvider"] else { return "" }
57+
return host.callbacks.item(Int64(p), index)?.id ?? ""
58+
}
59+
// element id 5 keeps its identity path whether it's at index 0 or 2
60+
let atIndex0 = rowID([Item(id: 5), Item(id: 6)], index: 0)
61+
let atIndex2 = rowID([Item(id: 3), Item(id: 4), Item(id: 5)], index: 2)
62+
#expect(atIndex0 == atIndex2)
63+
}
64+
65+
@Test("refreshable attaches a refresh callback to the list")
66+
func refreshable() {
67+
struct Screen: View {
68+
var body: some View {
69+
List((1...3).map(Item.init)) { Text("Row \($0.id)") }
70+
.refreshable { }
71+
}
72+
}
73+
let node = ViewHost(Screen()).evaluate()
74+
#expect(node.props["onRefresh"] != nil)
75+
}
76+
77+
@Test("LazyVGrid carries its track spec and resolves cells")
78+
func vgridFixed() {
79+
struct Screen: View {
80+
var body: some View {
81+
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) {
82+
ForEach(1...6, id: \.self) { Text("\($0)") }
83+
}
84+
}
85+
}
86+
let node = ViewHost(Screen()).evaluate()
87+
#expect(node.type == "LazyVGrid")
88+
#expect(node.props["trackCount"] == .int(3))
89+
#expect(node.children.count == 6)
90+
}
91+
92+
@Test("Adaptive grid carries a minimum column width")
93+
func vgridAdaptive() {
94+
struct Screen: View {
95+
var body: some View {
96+
LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) {
97+
ForEach(1...4, id: \.self) { Text("\($0)") }
98+
}
99+
}
100+
}
101+
let node = ViewHost(Screen()).evaluate()
102+
#expect(node.props["adaptiveMin"] == .double(80))
103+
#expect(node.props["trackCount"] == nil)
104+
}
105+
}

Demo/App.swiftpm/Sources/ContentView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ struct ContentView: View {
1616
NavigationLink("Text", destination: TextScreen())
1717
NavigationLink("Buttons", destination: ButtonScreen())
1818
NavigationLink("Stacks", destination: StacksScreen())
19+
NavigationLink("List", destination: ListScreen())
20+
NavigationLink("Grid", destination: GridScreen())
1921
NavigationLink("State", destination: StateScreen())
2022
NavigationLink("Controls", destination: ControlsScreen())
2123
NavigationLink("Modifiers", destination: ModifierScreen())

0 commit comments

Comments
 (0)