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
Expand Up @@ -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] = [:]
Expand Down Expand Up @@ -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
}
}
10 changes: 10 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,13 +27,15 @@ public struct ResolveContext {
callbacks: CallbackRegistry,
environment: EnvironmentStorage = EnvironmentStorage(),
titleSink: TitleSink? = nil,
refreshSink: RefreshSink? = nil,
path: String = "",
depth: Int = 0
) {
self.storage = storage
self.callbacks = callbacks
self.environment = environment
self.titleSink = titleSink
self.refreshSink = refreshSink
self.path = path
self.depth = depth
}
Expand Down Expand Up @@ -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`.
Expand Down
101 changes: 101 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Grids.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>: 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<Content: View>: 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<Content: View>(
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"))
)
}
93 changes: 93 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/List.swift
Original file line number Diff line number Diff line change
@@ -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<Data: RandomAccessCollection, ID: Hashable, RowContent: View>: 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<Content: View>: 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<Self> {
_RefreshableView(action: action, content: self)
}
}
105 changes: 105 additions & 0 deletions AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/LazyTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions Demo/App.swiftpm/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading