diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 6b958e9..fc26366 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -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) { @@ -78,9 +79,10 @@ 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 { @@ -88,16 +90,17 @@ struct NativeUIListRenderer: View { .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) } @@ -105,11 +108,21 @@ struct NativeUIListRenderer: View { 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)) @@ -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 @@ -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) @@ -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.