diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/CallbackRegistry.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/CallbackRegistry.swift index 6117fde..b2059e3 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/CallbackRegistry.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/CallbackRegistry.swift @@ -16,6 +16,9 @@ public final class CallbackRegistry { case double((Double) -> Void) case int((Int) -> Void) case string((String) -> Void) + /// A lazy row provider: given an index, returns that row's subtree. + /// Must be a pure read — it runs during Compose composition. + case item((Int) -> RenderNode) } private var current: [Int64: Callback] = [:] @@ -68,4 +71,10 @@ public final class CallbackRegistry { public func invokeString(_ id: Int64, _ value: String) { if case .string(let action)? = callback(for: id) { action(value) } } + + /// Resolves a lazy row on demand. + public func item(_ id: Int64, _ index: Int) -> RenderNode? { + if case .item(let provider)? = callback(for: id) { return provider(index) } + return nil + } } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index 08a3a51..7ecce2c 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -17,6 +17,8 @@ public struct ResolveContext { public var environment: EnvironmentStorage /// Collects the `navigationTitle` of the screen currently being resolved. public var titleSink: TitleSink? + /// Carries a `refreshable` action to the List it wraps. + public var refreshSink: RefreshSink? public var path: String public var depth: Int @@ -25,6 +27,7 @@ public struct ResolveContext { callbacks: CallbackRegistry, environment: EnvironmentStorage = EnvironmentStorage(), titleSink: TitleSink? = nil, + refreshSink: RefreshSink? = nil, path: String = "", depth: Int = 0 ) { @@ -32,6 +35,7 @@ public struct ResolveContext { self.callbacks = callbacks self.environment = environment self.titleSink = titleSink + self.refreshSink = refreshSink self.path = path self.depth = depth } @@ -91,6 +95,12 @@ public final class TitleSink { public init() {} } +/// Carries a pending `refreshable` action to the enclosing List. +public final class RefreshSink { + public var action: (@Sendable () async -> Void)? + public init() {} +} + public enum Evaluator { /// Guards against a self-referential `body`. diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Grids.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Grids.swift new file mode 100644 index 0000000..951888e --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Grids.swift @@ -0,0 +1,101 @@ +// +// Grids.swift +// AndroidSwiftUICore +// +// Grids resolve their cells eagerly (bounded in practice) into a node carrying +// the track spec; the interpreter lays them out in a Compose lazy grid. +// + +/// A grid track description. +public struct GridItem: Sendable { + + public enum Size: Sendable { + case fixedTrack(Double) + case flexibleTrack(minimum: Double, maximum: Double) + case adaptiveTrack(minimum: Double, maximum: Double) + } + + public var size: Size + + public init(_ size: Size = .flexible()) { + self.size = size + } +} + +public extension GridItem.Size { + static func fixed(_ value: Double) -> GridItem.Size { .fixedTrack(value) } + static func flexible(minimum: Double = 10, maximum: Double = .infinity) -> GridItem.Size { + .flexibleTrack(minimum: minimum, maximum: maximum) + } + static func adaptive(minimum: Double, maximum: Double = .infinity) -> GridItem.Size { + .adaptiveTrack(minimum: minimum, maximum: maximum) + } +} + +/// A grid growing vertically, laid out across `columns` tracks. +public struct LazyVGrid: View { + + internal let columns: [GridItem] + internal let spacing: Double? + internal let content: Content + + public init(columns: [GridItem], spacing: Double? = nil, @ViewBuilder content: () -> Content) { + self.columns = columns + self.spacing = spacing + self.content = content() + } + + public typealias Body = Never +} + +extension LazyVGrid: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + gridNode(type: "LazyVGrid", tracks: columns, spacing: spacing, content: content, context: context) + } +} + +/// A grid growing horizontally, laid out down `rows` tracks. +public struct LazyHGrid: View { + + internal let rows: [GridItem] + internal let spacing: Double? + internal let content: Content + + public init(rows: [GridItem], spacing: Double? = nil, @ViewBuilder content: () -> Content) { + self.rows = rows + self.spacing = spacing + self.content = content() + } + + public typealias Body = Never +} + +extension LazyHGrid: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + gridNode(type: "LazyHGrid", tracks: rows, spacing: spacing, content: content, context: context) + } +} + +private func gridNode( + type: String, + tracks: [GridItem], + spacing: Double?, + content: Content, + context: ResolveContext +) -> RenderNode { + var props: [String: PropValue] = [:] + // a single adaptive track fits as many columns as possible; otherwise it's + // a fixed track count — the common flexible-columns usage + if tracks.count == 1, case .adaptiveTrack(let minimum, _) = tracks[0].size { + props["adaptiveMin"] = .double(minimum) + } else { + props["trackCount"] = .int(max(tracks.count, 1)) + } + if let spacing { props["spacing"] = .double(spacing) } + return RenderNode( + type: type, + id: context.path, + props: props, + children: Evaluator.resolveChildren(content, context.descending("content")) + ) +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/List.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/List.swift new file mode 100644 index 0000000..a6d9b66 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/List.swift @@ -0,0 +1,93 @@ +// +// List.swift +// AndroidSwiftUICore +// +// A lazy list: rows are evaluated on demand through an item provider, so a +// large list contributes one node plus the rows Compose actually asks for. +// This is the only unbounded-tree case, and the reason the schema carries +// `count` and `itemProviderID`. +// + +public struct List: View { + + internal let data: Data + internal let id: (Data.Element) -> ID + internal let row: (Data.Element) -> RowContent + + public init(_ data: Data, id: @escaping (Data.Element) -> ID, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) { + self.data = data + self.id = id + self.row = rowContent + } + + public typealias Body = Never +} + +public extension List where Data.Element: Identifiable, ID == Data.Element.ID { + init(_ data: Data, @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent) { + self.init(data, id: { $0.id }, rowContent: rowContent) + } +} + +extension List: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + // snapshot the data and row builder; the provider resolves each row on + // demand, keyed by element id so row @State survives scroll and reorder + let elements = Array(data) + let rowBuilder = row + let keyFor = id + let listPath = context.path + let storage = context.storage + let callbacks = context.callbacks + let environment = context.environment + + let providerID = callbacks.register(.item { index in + let element = elements[index] + let key = "#\(identityString(keyFor(element)))" + var rowContext = ResolveContext( + storage: storage, + callbacks: callbacks, + environment: environment, + path: listPath + "/" + key + ) + return Evaluator.resolve(rowBuilder(element), rowContext) + }) + + let keys = elements.map { PropValue.string(identityString(keyFor($0))) } + var props: [String: PropValue] = [ + "keys": .array(keys), + "itemProvider": .int(Int(providerID)), + ] + if let onRefresh = context.refreshSink?.action { + let refreshID = callbacks.register(.void { + Task { await onRefresh() } + }) + props["onRefresh"] = .int(Int(refreshID)) + } + return RenderNode(type: "List", id: context.path, props: props, count: elements.count) + } +} + +// MARK: - Refreshable + +public struct _RefreshableView: View { + internal let action: @Sendable () async -> Void + internal let content: Content + public typealias Body = Never +} + +extension _RefreshableView: _ResolutionEffectView { + public func _applyEffect(_ context: inout ResolveContext) -> any View { + // attach the refresh action to the List the content resolves to + if context.refreshSink == nil { context.refreshSink = RefreshSink() } + context.refreshSink?.action = action + return content + } +} + +public extension View { + func refreshable(action: @escaping @Sendable () async -> Void) -> _RefreshableView { + _RefreshableView(action: action, content: self) + } +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift new file mode 100644 index 0000000..c101720 --- /dev/null +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift @@ -0,0 +1,105 @@ +// +// LazyTests.swift +// AndroidSwiftUICoreTests +// + +import Testing +@testable import AndroidSwiftUICore + +struct Item: Identifiable { let id: Int } + +@Suite("Lazy containers") +struct LazyTests { + + @Test("List emits count and a provider, not inline rows") + func listIsLazy() { + struct Screen: View { + var body: some View { + List((1...1000).map(Item.init)) { Text("Row \($0.id)") } + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.type == "List") + #expect(node.count == 1000) + #expect(node.children.isEmpty) // rows are not materialized up front + #expect(node.props["itemProvider"] != nil) + } + + @Test("The item provider resolves a row subtree on demand") + func providerResolvesRow() { + struct Screen: View { + var body: some View { + List((1...100).map(Item.init)) { Text("Row \($0.id)") } + } + } + let host = ViewHost(Screen()) + let node = host.evaluate() + guard case .int(let providerID)? = node.props["itemProvider"] else { + Issue.record("no provider"); return + } + // rows 0 and 42 resolve independently, only when asked + let row0 = host.callbacks.item(Int64(providerID), 0) + let row42 = host.callbacks.item(Int64(providerID), 42) + #expect(row0?.props["text"] == .string("Row 1")) + #expect(row42?.props["text"] == .string("Row 43")) + } + + @Test("Row identity path is keyed by element id") + func rowKeyedByIdentity() { + struct Screen: View { + let items: [Item] + var body: some View { List(items) { Text("Row \($0.id)") } } + } + func rowID(_ items: [Item], index: Int) -> String { + let host = ViewHost(Screen(items: items)) + let node = host.evaluate() + guard case .int(let p)? = node.props["itemProvider"] else { return "" } + return host.callbacks.item(Int64(p), index)?.id ?? "" + } + // element id 5 keeps its identity path whether it's at index 0 or 2 + let atIndex0 = rowID([Item(id: 5), Item(id: 6)], index: 0) + let atIndex2 = rowID([Item(id: 3), Item(id: 4), Item(id: 5)], index: 2) + #expect(atIndex0 == atIndex2) + } + + @Test("refreshable attaches a refresh callback to the list") + func refreshable() { + struct Screen: View { + var body: some View { + List((1...3).map(Item.init)) { Text("Row \($0.id)") } + .refreshable { } + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.props["onRefresh"] != nil) + } + + @Test("LazyVGrid carries its track spec and resolves cells") + func vgridFixed() { + struct Screen: View { + var body: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) { + ForEach(1...6, id: \.self) { Text("\($0)") } + } + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.type == "LazyVGrid") + #expect(node.props["trackCount"] == .int(3)) + #expect(node.children.count == 6) + } + + @Test("Adaptive grid carries a minimum column width") + func vgridAdaptive() { + struct Screen: View { + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) { + ForEach(1...4, id: \.self) { Text("\($0)") } + } + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.props["adaptiveMin"] == .double(80)) + #expect(node.props["trackCount"] == nil) + } +} diff --git a/Demo/App.swiftpm/Sources/ContentView.swift b/Demo/App.swiftpm/Sources/ContentView.swift index 366956e..c3f81bf 100644 --- a/Demo/App.swiftpm/Sources/ContentView.swift +++ b/Demo/App.swiftpm/Sources/ContentView.swift @@ -16,6 +16,8 @@ struct ContentView: View { NavigationLink("Text", destination: TextScreen()) NavigationLink("Buttons", destination: ButtonScreen()) NavigationLink("Stacks", destination: StacksScreen()) + NavigationLink("List", destination: ListScreen()) + NavigationLink("Grid", destination: GridScreen()) NavigationLink("State", destination: StateScreen()) NavigationLink("Controls", destination: ControlsScreen()) NavigationLink("Modifiers", destination: ModifierScreen()) diff --git a/Demo/App.swiftpm/Sources/GridScreen.swift b/Demo/App.swiftpm/Sources/GridScreen.swift new file mode 100644 index 0000000..9dd00cc --- /dev/null +++ b/Demo/App.swiftpm/Sources/GridScreen.swift @@ -0,0 +1,41 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Lazy grids. +struct GridScreen: View { + + var body: some View { + VStack(spacing: 16) { + Text("Three fixed columns") + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]) { + ForEach(1...9, id: \.self) { number in + Text("\(number)") + .padding() + .background(Color.blue) + } + } + Divider() + Text("Adaptive columns") + LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) { + ForEach(1...8, id: \.self) { number in + Text("Item \(number)") + .padding() + .background(Color.orange) + } + } + Divider() + Text("Two rows, horizontal") + LazyHGrid(rows: [GridItem(.flexible()), GridItem(.flexible())]) { + ForEach(1...8, id: \.self) { number in + Text("Cell \(number)") + .padding() + .background(Color.green) + } + } + .frame(height: 160) + } + } +} diff --git a/Demo/App.swiftpm/Sources/ListScreen.swift b/Demo/App.swiftpm/Sources/ListScreen.swift new file mode 100644 index 0000000..b46c9d9 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ListScreen.swift @@ -0,0 +1,36 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// A lazy list with pull to refresh. Rows are evaluated on demand, so the +/// large-list variant scrolls without materializing every row up front. +struct ListScreen: View { + + @State + private var items = (1...30).map(ListItem.init) + + var body: some View { + List(items) { item in + Text(item.title) + } + .refreshable { + await addItem() + } + } + + private func addItem() async { + try? await Task.sleep(for: .seconds(1)) + items.insert(ListItem(id: items.count + 1), at: 0) + } +} + +struct ListItem: Identifiable { + + let id: Int + + var title: String { + "Row \(id)" + } +} 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 a261fc4..95213e1 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -21,6 +21,11 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally 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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator @@ -33,6 +38,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -187,6 +193,12 @@ fun Render(node: ViewNode) { "TabView" -> RenderTabView(node) + "List" -> RenderList(node) + + "LazyVGrid" -> RenderGrid(node, vertical = true) + + "LazyHGrid" -> RenderGrid(node, vertical = false) + "EmptyView" -> Unit "Composable" -> ComposableRegistry.Render(node) @@ -509,3 +521,63 @@ private fun ViewNode.stringArray(key: String): List { val arr = props[key] as? kotlinx.serialization.json.JsonArray ?: return emptyList() return arr.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content } } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderList(node: ViewNode) { + val provider = node.long("itemProvider") ?: return + val count = node.count ?: 0 + val keys = node.stringArray("keys") + val onRefresh = node.long("onRefresh") + val list: @Composable () -> Unit = { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(count = count, key = { keys.getOrNull(it) ?: it }) { index -> + // one JNI call per newly-visible row; re-fetched when the tree + // (hence the provider id) changes + val row = remember(provider, index) { SwiftBridge.sink.itemNode(provider, index) } + row?.let { Render(it) } + } + } + } + if (onRefresh != null) { + var refreshing by remember { mutableStateOf(false) } + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + SwiftBridge.sink.invokeVoid(onRefresh) + }, + ) { list() } + // clear the indicator once the tree updates (new content arrived) + LaunchedEffect(node.count) { refreshing = false } + } else { + list() + } +} + +@Composable +private fun RenderGrid(node: ViewNode, vertical: Boolean) { + val spacing = (node.double("spacing") ?: 8.0).dp + val cells = node.double("adaptiveMin")?.let { GridCells.Adaptive(it.dp) } + ?: GridCells.Fixed(node.long("trackCount")?.toInt() ?: 1) + if (vertical) { + LazyVerticalGrid( + columns = cells, + verticalArrangement = Arrangement.spacedBy(spacing), + horizontalArrangement = Arrangement.spacedBy(spacing), + modifier = node.composeModifiers(), + ) { + items(node.children.size) { Render(node.children[it]) } + } + } else { + LazyHorizontalGrid( + rows = cells, + verticalArrangement = Arrangement.spacedBy(spacing), + horizontalArrangement = Arrangement.spacedBy(spacing), + modifier = node.composeModifiers(), + ) { + items(node.children.size) { Render(node.children[it]) } + } + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt index f495264..96b0161 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/TreeStore.kt @@ -33,6 +33,8 @@ interface CallbackSink { fun invokeDouble(id: Long, value: Double) fun invokeInt(id: Long, value: Int) fun invokeString(id: Long, value: String) + /// Resolves a lazy row on demand; the logging default has no rows. + fun itemNode(id: Long, index: Int): ViewNode? = null } /// Global event sink used by the interpreter. Defaults to a logger. @@ -46,5 +48,6 @@ object SwiftBridge { override fun invokeDouble(id: Long, value: Double) = println("SwiftBridge.invokeDouble($id, $value)") override fun invokeInt(id: Long, value: Int) = println("SwiftBridge.invokeInt($id, $value)") override fun invokeString(id: Long, value: String) = println("SwiftBridge.invokeString($id, $value)") + override fun itemNode(id: Long, index: Int): ViewNode? = null } } diff --git a/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt b/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt index 5bad982..06ab576 100644 --- a/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt +++ b/Demo/swiftui/src/jvmShared/kotlin/com/pureswift/swiftui/SwiftCallbackSink.kt @@ -16,4 +16,7 @@ class SwiftCallbackSink : CallbackSink { external override fun invokeInt(id: Long, value: Int) external override fun invokeString(id: Long, value: String) + + // A lazy row query: returns the materialized row subtree, or null. + external override fun itemNode(id: Long, index: Int): ViewNode? } diff --git a/Sources/AndroidSwiftUI/SwiftUIHostView.swift b/Sources/AndroidSwiftUI/SwiftUIHostView.swift index 8ff8730..383dcd6 100644 --- a/Sources/AndroidSwiftUI/SwiftUIHostView.swift +++ b/Sources/AndroidSwiftUI/SwiftUIHostView.swift @@ -6,6 +6,7 @@ // import AndroidKit +import JavaLang import AndroidSwiftUIBridge import AndroidSwiftUICore @@ -36,7 +37,13 @@ public enum AndroidSwiftUIApp { assertionFailure("host view has no tree store") return } - let runtime = BridgeRuntime(root: root, store: store) + // marshal re-renders onto the main looper: JNI object creation and + // Compose state writes must run there, not on a Swift Task's thread + let handler = AndroidOS.Handler(try! JavaClass().getMainLooper()) + let runtime = BridgeRuntime(root: root, store: store) { block in + let runnable = Runnable { block() } + _ = handler.post(runnable.as(JavaLang.Runnable.self)) + } Self.runtime = runtime runtime.start() activity.setRootView(host) diff --git a/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift b/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift index 13c30c4..f6f060e 100644 --- a/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift +++ b/Sources/AndroidSwiftUIBridge/BridgeRuntime.swift @@ -16,17 +16,23 @@ public final class BridgeRuntime { let host: ViewHost let store: TreeStore + /// Runs a block on the platform main thread. JNI object creation and Compose + /// state writes must happen there — a native background thread (e.g. a Swift + /// `Task` driving an async state write) has neither the app class loader nor + /// the right to touch Compose state. + let scheduler: (@escaping () -> Void) -> Void + private var needsRender = false - public init(root: any View, store: TreeStore) { + public init( + root: any View, + store: TreeStore, + scheduler: @escaping (@escaping () -> Void) -> Void = { $0() } + ) { self.host = ViewHost(root) self.store = store - // UI events arrive on the platform main thread (Compose dispatch), and - // state writes inside them re-evaluate synchronously here. Event - // callbacks run outside composition, so assigning the store's Compose - // state from them is the standard, safe path. Cross-thread writes get - // a scheduler when async state arrives (R5). + self.scheduler = scheduler self.host.onStateChange = { [weak self] in - self?.push() + self?.setNeedsRender() } } @@ -37,6 +43,18 @@ public final class BridgeRuntime { push() } + /// Coalesces state changes into one scheduled render on the main thread. + /// Never evaluates synchronously inside a callback (avoids reentrancy). + private func setNeedsRender() { + guard !needsRender else { return } + needsRender = true + scheduler { [weak self] in + guard let self else { return } + self.needsRender = false + self.push() + } + } + func push() { let tree = host.evaluate() store.update(Materializer.materialize(tree)) @@ -49,4 +67,11 @@ public final class BridgeRuntime { public func invokeDouble(_ id: Int64, _ value: Double) { host.callbacks.invokeDouble(id, value) } public func invokeInt(_ id: Int64, _ value: Int) { host.callbacks.invokeInt(id, value) } public func invokeString(_ id: Int64, _ value: String) { host.callbacks.invokeString(id, value) } + + /// Resolves a lazy row and materializes it for the interpreter. Runs during + /// Compose composition — a pure read. + public func itemNode(_ id: Int64, _ index: Int) -> ViewNodeObject? { + guard let node = host.callbacks.item(id, index) else { return nil } + return Materializer.materialize(node) + } } diff --git a/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift b/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift index 682a55d..7463d2e 100644 --- a/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift +++ b/Sources/AndroidSwiftUIBridge/SwiftCallbackSink.swift @@ -41,4 +41,9 @@ extension SwiftCallbackSink { func invokeString(_ id: Int64, _ value: String) { BridgeRuntime.current?.invokeString(id, value) } + + @JavaMethod + func itemNode(_ id: Int64, _ index: Int32) -> ViewNodeObject? { + BridgeRuntime.current?.itemNode(id, Int(index)) + } }