diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index 0a9d200..0342cf2 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -57,6 +57,8 @@ object ListRenderer { fun Render(node: NativeUINode, modifier: Modifier) { val horizontal = node.props.getBool("horizontal") val separator = node.props.getBool("separator") + val rowColor = node.props.getColor("row_color", 0) + val rowBackground = if (rowColor != 0) Color(rowColor) else null val onRefreshCb = node.props.getCallbackId("on_refresh") val onEndReachedCb = node.props.getCallbackId("on_end_reached") val endReachedBuffer = node.props.getInt("end_reached_buffer", 3).coerceAtLeast(1) @@ -133,9 +135,10 @@ object ListRenderer { SectionRow( isFirst = i == 0, isLast = i == child.children.size - 1, + background = rowBackground, ) { ListRow(row) } } else { - ListRow(row) + ListRow(row, rowBackground) if (separator && i < child.children.size - 1) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -147,7 +150,7 @@ object ListRenderer { } } else { item(key = child.id) { - ListRow(child) + ListRow(child, rowBackground) if (separator && index < node.children.size - 1) { HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -192,7 +195,7 @@ object ListRenderer { * destructive trailing action when no structured actions are present. */ @Composable -private fun ListRow(child: NativeUINode) { +private fun ListRow(child: NativeUINode, background: Color? = null) { val legacyDeleteCb = child.props.getCallbackId("on_swipe_delete") val leading = decodeSwipeActions(child.props.getString("leading_actions_json", "")) val trailing = decodeSwipeActions(child.props.getString("trailing_actions_json", "")) @@ -208,8 +211,18 @@ private fun ListRow(child: NativeUINode) { trailing = effectiveTrailing, onAction = { cb -> NativeElementBridge.sendPressEvent(cb, child.id) } ) { - NodeView(node = child) + RowContent(child, background) } + } else { + RowContent(child, background) + } +} + +/** The row's node, on the list's row colour when it declares one. */ +@Composable +private fun RowContent(child: NativeUINode, background: Color?) { + if (background != null) { + Box(Modifier.fillMaxWidth().background(background)) { NodeView(node = child) } } else { NodeView(node = child) } @@ -258,7 +271,7 @@ private fun SectionGap() { * look SwiftUI's `.insetGrouped` gives for free. */ @Composable -private fun SectionRow(isFirst: Boolean, isLast: Boolean, content: @Composable () -> Unit) { +private fun SectionRow(isFirst: Boolean, isLast: Boolean, background: Color? = null, content: @Composable () -> Unit) { val radius = 12.dp val shape = RoundedCornerShape( topStart = if (isFirst) radius else 0.dp, @@ -271,7 +284,7 @@ private fun SectionRow(isFirst: Boolean, isLast: Boolean, content: @Composable ( Modifier .fillMaxWidth() .clip(shape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .background(background ?: MaterialTheme.colorScheme.surfaceVariant) ) { content() if (!isLast) { diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 647f034..7412b8d 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -32,6 +32,7 @@ struct NativeUIListRenderer: View { let horizontal = node.props.getBool("horizontal") let showsIndicators = node.props.getBool("shows_indicators", default: true) let separator = node.props.getBool("separator") + let rowColor = node.props.getColor("row_color", default: 0) let onRefreshCb = node.props.getCallbackId("on_refresh") let onEndReachedCb = node.props.getCallbackId("on_end_reached") let endReachedBuffer = max(1, node.props.getInt("end_reached_buffer", default: 3)) @@ -66,7 +67,7 @@ struct NativeUIListRenderer: View { let footer = child.props.getString("footer", default: "") Section { ForEach(Array(child.children.enumerated()), id: \.element.id) { index, row in - rowView(row, separator: separator, isLastInSection: index == child.children.count - 1) + rowView(row, separator: separator, rowColor: rowColor, grouped: grouped, isLastInSection: index == child.children.count - 1) .onAppear { fireEndReached(rowId: row.id, leafIndex: leafIndex, leafCount: leafCount, buffer: endReachedBuffer, @@ -79,7 +80,7 @@ struct NativeUIListRenderer: View { if !footer.isEmpty { Text(footer).nuiScaledFont(size: 13) } } } else { - rowView(child, separator: separator) + rowView(child, separator: separator, rowColor: rowColor, grouped: grouped) .onAppear { fireEndReached(rowId: child.id, leafIndex: leafIndex, leafCount: leafCount, buffer: endReachedBuffer, @@ -106,7 +107,7 @@ struct NativeUIListRenderer: View { /// One list row: the node plus its leading/trailing swipe actions and /// row-level styling. Shared by both flat rows and section children. @ViewBuilder - private func rowView(_ child: NativeUINode, separator: Bool, isLastInSection: Bool = false) -> some View { + 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. @@ -117,11 +118,12 @@ struct NativeUIListRenderer: View { .equatable() .frame(maxWidth: .infinity, alignment: .leading) .listRowInsets(EdgeInsets()) - // Rows carry SwiftUI's opaque system fill, which paints over the - // list's own background — so hiding the scroll background via - // ListBackgroundModifier still left a white sheet on a themed - // screen. Clear the row and let its content decide. - .listRowBackground(Color.clear) + // Plain rows are cleared: SwiftUI's opaque system fill paints + // over the list's own background, leaving a white sheet on a + // themed screen. Grouped rows keep a card — the system grouped + // cell colour, which is what draws the rounded inset-grouped + // sections — and `row_color` themes either. + .listRowBackground(Self.rowBackground(rowColor: rowColor, grouped: grouped)) // Drive dividers from the bottom edge only; always hide the top // edge. The top separator renders solely on a section's first row, // so hiding it removes the stray full-width line that otherwise @@ -152,6 +154,13 @@ 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 + } + /// Fire the end-reached callback when a row within the configured number /// of leaf rows appears. Works across sections via the precomputed leaf index. private func fireEndReached(rowId: Int, leafIndex: [Int: Int], leafCount: Int, diff --git a/src/Elements/NativeList.php b/src/Elements/NativeList.php index ecac078..c7ace41 100644 --- a/src/Elements/NativeList.php +++ b/src/Elements/NativeList.php @@ -4,9 +4,12 @@ use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Native\Mobile\UI\Concerns\ResolvesColorValues; class NativeList extends Element { + use ResolvesColorValues; + protected string $type = 'list'; protected array $listProps = []; @@ -40,6 +43,10 @@ public function applyAttributes(array $attrs): void if (! empty($attrs['transparent'])) { $this->transparent(); } + $rowColor = $attrs['row-color'] ?? $attrs['rowColor'] ?? null; + if (is_string($rowColor) && $rowColor !== '') { + $this->rowColor($rowColor); + } if (isset($attrs['on-refresh']) || isset($attrs['onRefresh'])) { $this->onRefresh($attrs['on-refresh'] ?? $attrs['onRefresh']); } @@ -103,6 +110,19 @@ public function transparent(bool $value = true): static return $this; } + /** + * Fill every row with one colour: the white cards of an inset-grouped + * settings list (iOS Settings) or opaque rows on a plain one. Rows are + * otherwise clear, so the screen background shows through them. Takes + * a hex value or a theme token (`theme('surface')`). + */ + public function rowColor(string $color): static + { + $this->listProps['row_color'] = $this->resolveColorValue($color); + + return $this; + } + public function onRefresh(string $method): static { $this->refreshCallback = $method; diff --git a/tests/ListTest.php b/tests/ListTest.php index 37097d4..a78f423 100644 --- a/tests/ListTest.php +++ b/tests/ListTest.php @@ -62,6 +62,21 @@ function collectList(array $attrs): array ->and($tree['props']['separator'])->toBeTrue(); }); +it('paints rows with a row colour', function () { + $tree = collectList(['row-color' => '#FFFFFF']); + + expect($tree['props']['row_color'])->toBe('#FFFFFF'); +}); + +it('accepts the camelCase rowColor spelling and the fluent builder', function () { + expect(collectList(['rowColor' => '#FFF8EE'])['props']['row_color'])->toBe('#FFF8EE') + ->and(NativeList::make()->rowColor('#123456')->toArray(new CallbackRegistry)['props']['row_color'])->toBe('#123456'); +}); + +it('leaves rows clear without a row colour', function () { + expect(collectList(['row-color' => ''])['props'] ?? [])->not->toHaveKey('row_color'); +}); + it('exposes transparent on the fluent builder', function () { $list = NativeList::make()->transparent();