From 1c6145ccbb41de24ed75cbed151ac2e536613264 Mon Sep 17 00:00:00 2001 From: Wojt Janowski Date: Mon, 26 Oct 2026 05:31:03 +1100 Subject: [PATCH] Paint a virtual list's background on iOS like a list's A virtual list with a bg-* class drew the iOS system background instead: pure black in dark mode, white in light mode, under a themed screen. Its List never got ListBackgroundModifier, which native:list uses to hide the system scroll background and paint the node's colour. Rows outside the window also drew a hard-coded systemGray6 block, so a fast scroll flashed a grey sheet. The virtual list now uses the same ListBackgroundModifier as native:list, so a colour, a gradient or transparent behave the same on both. Rows and placeholders are clear, as in a plain native:list, so the list's background shows through while rows load. virtual-list also accepts transparent, like list. Android is unchanged: LazyColumn draws no background of its own, and its placeholders already use the theme's surface-variant. Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a17444-1016-755c-a4c4-8bfd1563e21c --- resources/ios/NativeUIListRenderer.swift | 5 ++++- resources/ios/NativeUIVirtualListRenderer.swift | 17 ++++++++++++++--- src/Elements/NativeVirtualList.php | 16 ++++++++++++++++ tests/CollectorElementsTest.php | 10 ++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 71008ae..474d98c 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -687,7 +687,10 @@ private struct GroupedOrPlainListStyle: ViewModifier { /// - `transparent` — the list sits directly on the screen's background /// (a gradient, an image, the background layer). `bg-transparent` /// can't express this: it packs to the same 0 as "no colour". -private struct ListBackgroundModifier: ViewModifier { +/// +/// Shared with `NativeUIVirtualListRenderer`, so both lists follow one set +/// of background rules. +struct ListBackgroundModifier: ViewModifier { let node: NativeUINode @Environment(\.colorScheme) private var colorScheme diff --git a/resources/ios/NativeUIVirtualListRenderer.swift b/resources/ios/NativeUIVirtualListRenderer.swift index f821483..1a36cb2 100644 --- a/resources/ios/NativeUIVirtualListRenderer.swift +++ b/resources/ios/NativeUIVirtualListRenderer.swift @@ -3,10 +3,14 @@ import Combine /// Windowed list. The native side renders `count` logical slots in a /// lazy `List`; PHP only ships the rows inside `window_from..window_to`. -/// Slots outside the window display a fixed-height skeleton so SwiftUI's +/// Slots outside the window display a fixed-height placeholder so SwiftUI's /// lazy machinery can compute total scroll extent without allocating a /// real subtree per row. /// +/// The background follows `native:list`'s rules (`ListBackgroundModifier`): +/// a `bg-*` colour, a gradient or `transparent` replaces the system +/// background, and rows are clear so it shows through them. +/// /// As the visible range moves, the tracker debounces onAppear/onDisappear /// events and (with hysteresis) fires `on_window_change(from, to)` to /// PHP only when the visible range is approaching the edge of the data @@ -47,6 +51,7 @@ struct NativeUIVirtualListRenderer: View { nodeId: nodeId, rowByIndex: rowByIndex ) + .modifier(ListBackgroundModifier(node: node)) // Environment-propagating, so it reaches the List inside the body // without threading another property through it. .scrollIndicators(node.props.getBool("shows_indicators", default: true) ? .automatic : .hidden) @@ -79,13 +84,19 @@ private struct VirtualListBody: View { if let child = rowByIndex[index] { NodeView(node: child).equatable() } else { - // Placeholder skeleton — meaningless to VoiceOver. - Color(.systemGray6) + // Clear, like `native:list`'s placeholder rows: the + // list's own background shows through, so a fast + // scroll past the window never flashes a fill that + // isn't the screen's. Meaningless to VoiceOver. + Color.clear .frame(height: estimatedRowHeight) .accessibilityHidden(true) } } .listRowInsets(EdgeInsets()) + // Clear, as in a plain `native:list`: SwiftUI's opaque + // system row fill would paint over the list's background. + .listRowBackground(Color.clear) .listRowSeparator(.hidden) .onAppear { tracker.markVisible(index) } .onDisappear { tracker.markHidden(index) } diff --git a/src/Elements/NativeVirtualList.php b/src/Elements/NativeVirtualList.php index c5d736d..aff3ab8 100644 --- a/src/Elements/NativeVirtualList.php +++ b/src/Elements/NativeVirtualList.php @@ -53,6 +53,9 @@ public function applyAttributes(array $attrs): void if (isset($attrs['showsIndicators']) || isset($attrs['shows-indicators'])) { $this->showsIndicators((bool) ($attrs['showsIndicators'] ?? $attrs['shows-indicators'])); } + if (! empty($attrs['transparent'])) { + $this->transparent(); + } $cb = $attrs['on_window_change'] ?? $attrs['onWindowChange'] ?? $attrs['on-window-change'] ?? null; if ($cb !== null) { $this->windowCallback = $cb; @@ -72,6 +75,19 @@ public function showsIndicators(bool $value = true): static return $this; } + /** + * Let the screen behind the list show through, as on `native:list`: + * SwiftUI's `List` paints the system background, and `bg-transparent` + * packs to the same value as "no colour". iOS-only in effect: + * Compose's LazyColumn draws no background of its own. + */ + public function transparent(bool $value = true): static + { + $this->listProps['transparent'] = $value; + + return $this; + } + protected function resolveProps(CallbackRegistry $registry): array { $props = $this->listProps; diff --git a/tests/CollectorElementsTest.php b/tests/CollectorElementsTest.php index 18f4533..070ba6a 100644 --- a/tests/CollectorElementsTest.php +++ b/tests/CollectorElementsTest.php @@ -287,6 +287,16 @@ expect($tree['props']['shows_indicators'])->toBeFalse(); }); +it('marks a virtual list transparent like a list', function () { + NativeElementCollector::leaf('virtual_list', ['count' => 100]); + expect(NativeElementCollector::collect()->toArray(new CallbackRegistry)['props'])->not->toHaveKey('transparent'); + + NativeElementCollector::reset(); + NativeElementCollector::leaf('virtual_list', ['count' => 100, 'transparent' => true]); + expect(NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']['transparent'])->toBeTrue() + ->and(NativeVirtualList::make()->transparent()->toArray(new CallbackRegistry)['props']['transparent'])->toBeTrue(); +}); + it('applies checkbox props', function () { NativeElementCollector::leaf('checkbox', [ 'value' => true,