Skip to content
Merged
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
238 changes: 170 additions & 68 deletions resources/ios/NativeUIListRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ struct NativeUIListRenderer: View {
// end-reached fires near the true bottom rather than when a section
// container first appears. Computed once per render.
let (leafIndex, leafCount) = Self.leafRowIndex(children)
let rowStyle = ListRowStyle(separator: separator, rowColor: rowColor, grouped: grouped, minHeight: minRowHeight)

if horizontal {
ScrollView(.horizontal, showsIndicators: showsIndicators) {
Expand All @@ -78,38 +79,50 @@ struct NativeUIListRenderer: View {
.listRowSeparator(.hidden)
Section {
ForEach(Self.leafRows(children)) { row in
rowView(row, separator: separator, rowColor: rowColor, grouped: grouped)
.onAppear(perform: Self.endReachedAction(at: leafIndex[row.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId))
Self.row(row, style: rowStyle,
endReachedCb: Self.endReachedCallback(at: leafIndex[row.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb),
listId: nodeId)
}
} header: {
if pagerHeader.hasTabs {
NativeUIPagerHeaderStrip(header: pagerHeader)
.listRowInsets(EdgeInsets())
}
}
} else {
} else if hasSections {
ForEach(children) { child in
if child.type == "list_section" {
let header = child.props.getString("header", default: "")
let footer = child.props.getString("footer", default: "")
Section {
ForEach(Array(child.children.enumerated()), id: \.element.id) { index, row in
rowView(row, separator: separator, rowColor: rowColor, grouped: grouped, isLastInSection: index == child.children.count - 1)
.onAppear(perform: Self.endReachedAction(at: leafIndex[row.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId))
Self.row(row, style: rowStyle, isLastInSection: index == child.children.count - 1,
endReachedCb: Self.endReachedCallback(at: leafIndex[row.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb),
listId: nodeId)
}
} header: {
if !header.isEmpty { Text(header).nuiScaledFont(size: 13) }
} footer: {
if !footer.isEmpty { Text(footer).nuiScaledFont(size: 13) }
}
} else {
rowView(child, separator: separator, rowColor: rowColor, grouped: grouped)
.onAppear(perform: Self.endReachedAction(at: leafIndex[child.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId))
Self.row(child, style: rowStyle,
endReachedCb: Self.endReachedCallback(at: leafIndex[child.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb),
listId: nodeId)
}
}
} else {
// Without sections each child is one row, and the ForEach
// says so by building exactly one view for it.
ForEach(children) { child in
Self.row(child, style: rowStyle,
endReachedCb: Self.endReachedCallback(at: leafIndex[child.id], leafCount: leafCount,
buffer: endReachedBuffer, cb: onEndReachedCb),
listId: nodeId)
}
}
}
.modifier(GroupedOrPlainListStyle(grouped: grouped))
Expand All @@ -136,26 +149,123 @@ struct NativeUIListRenderer: View {
children.flatMap { $0.type == "list_section" ? $0.children : [$0] }
}

/// One list row: the node plus its leading/trailing swipe actions and
/// row-level styling. Shared by both flat rows and section children.
/// One list row: the node, then what the List reads off it, outside the
/// node's EquatableView, where the List can see it (see ListRow).
@ViewBuilder
private func rowView(_ child: NativeUINode, separator: Bool, rowColor: Int = 0, grouped: Bool = false, isLastInSection: Bool = false) -> some View {
// Legacy single-action API.
let legacyDeleteCb = child.props.getCallbackId("on_swipe_delete")
// New multi-action API.
let leading = decodeActions(child.props.getString("leading_actions_json", default: ""))
let trailing = decodeActions(child.props.getString("trailing_actions_json", default: ""))
// The swipe closures capture only this, not the row or `self` (see endReachedAction).
let rowId = child.id

NodeView(node: child)
private static func row(_ child: NativeUINode, style: ListRowStyle, isLastInSection: Bool = false,
endReachedCb: Int, listId: Int) -> some View {
let row = ListRow(node: child, minHeight: style.minHeight, endReachedCb: endReachedCb, listId: listId)
let traits = ListRowTraits(separator: style.separator && !isLastInSection, rowColor: style.rowColor, grouped: style.grouped)
if let swipeActions = ListRowSwipeActions(node: child) {
row
.equatable()
.modifier(traits)
.modifier(swipeActions)
} else {
row
.equatable()
.modifier(traits)
}
}

/// The list's end-reached callback for a row within the configured number
/// of leaf rows of the end, or 0. Works across sections via the
/// precomputed leaf index.
private static func endReachedCallback(at index: Int?, leafCount: Int, buffer: Int, cb: Int) -> Int {
guard cb != 0, let index, index >= leafCount - buffer else { return 0 }

return cb
}

/// Flatten the list's direct children into an ordered map of leaf-row id
/// → global index (sections contribute their children in order), plus the
/// total leaf count. Used only for end-reached detection.
private static func leafRowIndex(_ children: [NativeUINode]) -> ([Int: Int], Int) {
var map: [Int: Int] = [:]
var counter = 0
for child in children {
if child.type == "list_section" {
for row in child.children {
map[row.id] = counter
counter += 1
}
} else {
map[child.id] = counter
counter += 1
}
}
return (map, counter)
}
}

/// How every row of a list is styled, from the list's own props.
private struct ListRowStyle {
let separator: Bool
let rowColor: Int
let grouped: Bool
let minHeight: CGFloat
}

/// The part of a list row SwiftUI can skip: the row's node, built from plain
/// values and compared with `==`.
///
/// SwiftUI's List runs the ForEach's content for every row each time the list
/// draws. A row of modifiers carrying closures (swipe actions, onAppear) never
/// compared equal to last frame's, so each frame updated every row SwiftUI had
/// made, whether one row had changed or none of the ones on screen had.
/// mobile-air's frame diff keeps the node object of a subtree that didn't
/// change, so an unchanged row now compares equal and SwiftUI skips it.
///
/// It goes in an EquatableView, which costs less than leaving SwiftUI to find
/// `==` itself. The List doesn't see insets, backgrounds, separators or swipe
/// actions set inside one, so those go on outside it (ListRowTraits,
/// ListRowSwipeActions).
private struct ListRow: View, Equatable {
let node: NativeUINode
/// The list's minimum row height, passed in so it's part of `==`.
let minHeight: CGFloat
/// The list's end-reached callback when this row is near enough the end to fire it, otherwise 0.
let endReachedCb: Int
/// The list's node id, which the end-reached event is sent for.
let listId: Int

var body: some View {
NodeView(node: node)
.equatable()
// A node fills the height it is offered with its content pinned
// to the top, so a one-line row in a cell taller than itself (the
// list's minimum row height, 52pt on iOS 26) sat high. Keep the
// row at its own height and centre it in that minimum instead.
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, minHeight: minRowHeight, alignment: .leading)
.frame(maxWidth: .infinity, minHeight: minHeight, alignment: .leading)
.onAppear(perform: Self.endReachedAction(cb: endReachedCb, listId: listId))
}

/// What a row does when it appears: fire the end-reached callback if the
/// list gave this row one.
///
/// Static and built from plain values on purpose. SwiftUI keeps a row's
/// closures until the row draws again, which for a row scrolled past is
/// never. A closure that called an instance method captured `self`, and
/// with it this list's whole node tree, so every row above the viewport
/// kept the entire list from the frame it last drew in. A long list held
/// many copies of itself and the app ran out of memory.
private static func endReachedAction(cb: Int, listId: Int) -> () -> Void {
guard cb != 0 else { return {} }

return { NativeElementBridge.sendPressEvent(cb, nodeId: listId) }
}
}

/// What the List reads off every row, outside the row's EquatableView, where
/// it can see it.
private struct ListRowTraits: ViewModifier {
let separator: Bool
let rowColor: Int
let grouped: Bool

func body(content: Content) -> some View {
content
.listRowInsets(EdgeInsets())
// Plain rows are cleared: SwiftUI's opaque system fill paints
// over the list's own background, leaving a white sheet on a
Expand All @@ -170,8 +280,44 @@ struct NativeUIListRenderer: View {
// Also hide the bottom edge on a section's last row so the group
// ends clean (SwiftUI's grouped default), rather than drawing a
// divider under the final row.
.listRowSeparator(separator && !isLastInSection ? .visible : .hidden, edges: .bottom)
.listRowSeparator(separator ? .visible : .hidden, edges: .bottom)
.listRowSeparator(.hidden, edges: .top)
}

/// A row's fill: the list's `row_color` when set, else the system card
/// colour in the grouped style and nothing in the plain one.
private static func rowBackground(rowColor: Int, grouped: Bool) -> Color {
if rowColor != 0 { return Color(argb: rowColor) }
return grouped ? Color(uiColor: .secondarySystemGroupedBackground) : .clear
}
}

/// A row's leading/trailing swipe actions, for a row that has any.
///
/// Kept as the props' JSON and decoded when drawn, so a frame that doesn't
/// change them compares equal and builds nothing again.
private struct ListRowSwipeActions: ViewModifier, Equatable {
/// The swipe closures capture only this, not the row (see ListRow.endReachedAction).
let rowId: Int
/// New multi-action API.
let leadingJson: String
let trailingJson: String
/// Legacy single-action API.
let legacyDeleteCb: Int

init?(node: NativeUINode) {
rowId = node.id
leadingJson = node.props.getString("leading_actions_json", default: "")
trailingJson = node.props.getString("trailing_actions_json", default: "")
legacyDeleteCb = node.props.getCallbackId("on_swipe_delete")
if leadingJson.isEmpty && trailingJson.isEmpty && legacyDeleteCb == 0 { return nil }
}

func body(content: Content) -> some View {
let leading = decodeActions(leadingJson)
let trailing = decodeActions(trailingJson)

content
.swipeActions(edge: .leading, allowsFullSwipe: false) {
ForEach(leading) { action in
Self.actionButton(spec: action, nodeId: rowId)
Expand All @@ -193,50 +339,6 @@ struct NativeUIListRenderer: View {
}
}

/// A row's fill: the list's `row_color` when set, else the system card
/// colour in the grouped style and nothing in the plain one.
private static func rowBackground(rowColor: Int, grouped: Bool) -> Color {
if rowColor != 0 { return Color(argb: rowColor) }
return grouped ? Color(uiColor: .secondarySystemGroupedBackground) : .clear
}

/// What a row does when it appears: fire the end-reached callback if it's
/// within the configured number of leaf rows of the end. Works across
/// sections via the precomputed leaf index.
///
/// Static and built from plain values on purpose. SwiftUI keeps a row's
/// closures until the row draws again, which for a row scrolled past is
/// never. A closure that called an instance method captured `self`, and
/// with it this list's whole node tree, so every row above the viewport
/// kept the entire list from the frame it last drew in. A long list held
/// many copies of itself and the app ran out of memory.
private static func endReachedAction(at index: Int?, leafCount: Int, buffer: Int,
cb: Int, nodeId: Int) -> () -> Void {
guard cb != 0, let index, index >= leafCount - buffer else { return {} }

return { NativeElementBridge.sendPressEvent(cb, nodeId: nodeId) }
}

/// Flatten the list's direct children into an ordered map of leaf-row id
/// → global index (sections contribute their children in order), plus the
/// total leaf count. Used only for end-reached detection.
private static func leafRowIndex(_ children: [NativeUINode]) -> ([Int: Int], Int) {
var map: [Int: Int] = [:]
var counter = 0
for child in children {
if child.type == "list_section" {
for row in child.children {
map[row.id] = counter
counter += 1
}
} else {
map[child.id] = counter
counter += 1
}
}
return (map, counter)
}

/// Build a SwiftUI Button for one swipe action spec.
/// Destructive role gets the red treatment automatically; otherwise
/// the configured tint (if any) wins.
Expand Down
Loading