From 11089f432b6d1fc38e3f8d7ff7e902fd1ffd4f2f Mon Sep 17 00:00:00 2001 From: Wojt Janowski Date: Sun, 25 Oct 2026 22:45:50 +1100 Subject: [PATCH] Open a list or virtual list at a row with auto-scroll-to auto-scroll-to on native:list and native:virtual-list puts that row at the top when the list is created, for a relaunch, a deep link or a new record. It scrolls again only when the index changes, so re-renders don't pull the list away from where the reader scrolled. The request last applied is saved with the list, so a list restored by going back isn't moved either. Rows count as end-reached and visible-range count them. Works on iOS and Android. Amp-Thread-ID: https://ampcode.com/threads/T-01a1724b-7f88-71f2-a62f-4eae107d3dda Co-authored-by: Amp --- README.md | 36 +++++++++ resources/android/ListRenderer.kt | 80 +++++++++++++++++++ resources/android/VirtualListRenderer.kt | 6 ++ .../boost/skills/nativephp-mobile-ui/SKILL.md | 2 +- resources/ios/NativeUIListRenderer.swift | 53 ++++++++++++ .../ios/NativeUIVirtualListRenderer.swift | 10 ++- src/Elements/NativeList.php | 22 +++++ src/Elements/NativeVirtualList.php | 20 +++++ tests/CollectorElementsTest.php | 11 +++ tests/ListTest.php | 16 ++++ 10 files changed, 254 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cf1ee43..8318b31 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,42 @@ NativeList::make() ->onEndReached('loadMore'); ``` +### Opening a list at a row + +`auto-scroll-to` opens a `native:list` or `native:virtual-list` with that row at the top: a screen restored after a relaunch, a deep link to a record, or the row just added. Rows count from `0`, the way `on-visible-range` counts them, with a section's rows in their place. + +```blade + + @foreach ($posts as $post) + + @endforeach + +``` + +The list scrolls when it is created and again when the index changes, never while it stays the same, so re-renders don't pull it away from where the reader scrolled. A row that hasn't loaded yet is scrolled to when it arrives. You don't need it to keep a list in place across a pushed screen: going back already does. + +A virtual list only draws the rows inside its window, so the window has to hold the row. `scrollVirtualListTo()` on the `HasVirtualListWindow` screen moves the window there and sets `$virtualScrollTo` for the list: + +```php +public function mount(int $contact): void +{ + $this->scrollVirtualListTo($contact); +} +``` + +```blade + +``` + +Both elements take it through the fluent API too: `NativeList::make()->autoScrollTo(12)`. + ### Sticky headers A `native:sticky-header` in a list is a row that pins to the top of the list once you scroll it there, while the rows after it scroll underneath, like CSS `position: sticky`. The next sticky header pushes it off. It lays its children out like a column, so give it a background or the rows will show through. diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index f15eec2..c565d97 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -3,6 +3,7 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider @@ -13,9 +14,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.snapshotFlow import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -142,6 +145,17 @@ object ListRenderer { } } + // The row's place among the lazy items depends on whether section + // headers become items, so this follows the layout's grouped rule. + val autoScrollTo = node.props.getInt("auto_scroll_to", -1) + if (autoScrollTo >= 0) { + val grouped = node.children.any { it.type == "list_section" } && !node.props.getBool("plain") + val itemIndex = remember(node, autoScrollTo, pagerHeader) { + lazyItemIndexOfRow(node.children, autoScrollTo, horizontal, grouped, pagerHeader) ?: -1 + } + ListAutoScrollEffect(autoScrollTo, itemIndex, scrollState) + } + val keyboardController = LocalSoftwareKeyboardController.current val dismissKeyboardModifier = Modifier.pointerInput(Unit) { detectVerticalDragGestures(onDragStart = { keyboardController?.hide() }) { _, _ -> } @@ -293,6 +307,72 @@ private class ListRowHeights { } } +/** + * Scrolls [state] to [itemIndex] for the list's `auto_scroll_to` [request]: + * a jump when the list opens, an animated scroll when PHP asks for another + * row later. A request is applied once, so re-renders carrying the same one + * leave the list where the reader scrolled it. + * + * The request last applied is saved with the list's own position. A list + * restored by its screen's saved state (going back to it) already sits + * where it was left, and applying the same request again would undo that. + */ +@Composable +internal fun ListAutoScrollEffect(request: Int, itemIndex: Int, state: LazyListState) { + val applied = rememberSaveable { mutableIntStateOf(-1) } + + LaunchedEffect(request, itemIndex) { + if (request < 0 || itemIndex < 0 || request == applied.intValue) return@LaunchedEffect + + val opening = applied.intValue < 0 + applied.intValue = request + if (opening) state.scrollToItem(itemIndex) else state.animateScrollToItem(itemIndex) + } +} + +/** + * Where leaf row [row] sits among the list's lazy items, which also hold + * section headers, gaps and footers, sticky headers and a pager header. + * Mirrors how [ListRenderer] lays the items out; null when there's no such row. + */ +private fun lazyItemIndexOfRow( + children: List, + row: Int, + horizontal: Boolean, + grouped: Boolean, + pagerHeader: PagerHeader?, +): Int? { + if (row < 0) return null + if (horizontal) return row.takeIf { it < children.size } + + var item = 0 + var leaf = 0 + if (pagerHeader != null) { + item += if (pagerHeader.hasTabs) 2 else 1 + for (child in children.flatMap { if (it.type == "list_section") it.children else listOf(it) }) { + if (child.type != "sticky_header" && leaf++ == row) return item + item++ + } + return null + } + for (child in children) { + if (child.type == "list_section") { + if (child.props.getString("header", "").isNotEmpty() || grouped) item++ + for (sectionRow in child.children) { + if (leaf++ == row) return item + item++ + } + if (child.props.getString("footer", "").isNotEmpty()) item++ + } else if (child.type == "sticky_header") { + item++ + } else { + if (leaf++ == row) return item + item++ + } + } + return null +} + /** Each leaf row's position: a section's rows in its place, counted as on iOS. */ private fun leafRowIndex(children: List): Map { val map = HashMap() diff --git a/resources/android/VirtualListRenderer.kt b/resources/android/VirtualListRenderer.kt index 3078a5b..3eac9bb 100644 --- a/resources/android/VirtualListRenderer.kt +++ b/resources/android/VirtualListRenderer.kt @@ -89,6 +89,12 @@ object VirtualListRenderer { } } + // Every slot is an item, so the row's index is its item's. + val autoScrollTo = node.props.getInt("auto_scroll_to", -1) + if (autoScrollTo >= 0 && count > 0) { + ListAutoScrollEffect(autoScrollTo, autoScrollTo.coerceAtMost(count - 1), scrollState) + } + LazyColumn(modifier = modifier, state = scrollState) { items(count = count, key = { it }) { index -> val child = rowByIndex[index] diff --git a/resources/boost/skills/nativephp-mobile-ui/SKILL.md b/resources/boost/skills/nativephp-mobile-ui/SKILL.md index e1c82bd..f5fc407 100644 --- a/resources/boost/skills/nativephp-mobile-ui/SKILL.md +++ b/resources/boost/skills/nativephp-mobile-ui/SKILL.md @@ -25,7 +25,7 @@ dropped without an error. | `text` (slot is the text) | `class` (`text-lg font-bold line-through underline text-theme-on-surface`), `font` | | `button` | label as slot or `label`; `variant` primary\|secondary\|destructive\|success\|ghost; `size` sm\|md\|lg; `icon`, `icon-trailing`, `ios-icon`, `android-icon`, `disabled`, `loading`, `a11y-label` | | `outlined-text-input`, `filled-text-input`, `bare-text-input` | `native:model`, `placeholder`, `label`, `@submit`, `@change`, `keyboard` (text\|number\|decimal\|email\|phone\|url\|password), `secure`, `revealable`, `multiline`, `max-length`, `autofocus`, `leading-icon`, `trailing-icon`, `error`, `supporting` | -| `list` | `separator`, `plain`, `horizontal`, `on-refresh`, `on-end-reached`. Children: `list-item` or any element | +| `list` | `separator`, `plain`, `horizontal`, `on-refresh`, `on-end-reached`, `auto-scroll-to` (row to open at). Children: `list-item` or any element | | `list-item` | see below | | `icon` | `name` (`star.fill`, `trash`, `plus`), or per platform `ios="checkmark.circle.fill" android="check_circle"`, `:size="20"`, `class="text-theme-primary"` | | `top-bar` | `title`, `subtitle`, `back`. Put it at the top level of the view | diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index 71008ae..e7f6159 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -78,6 +78,15 @@ struct NativeUIListRenderer: View { step: max(1, node.props.getInt("visible_range_step", default: 10)), leafIndex: leafIndex) + // `auto_scroll_to` counts rows as end-reached does; the row's node id + // is what its ForEach identifies it by. + let autoScrollTo = node.props.getInt("auto_scroll_to", default: -1) + let autoScrollTarget: Int? = { + guard autoScrollTo >= 0 else { return nil } + if horizontal { return autoScrollTo < children.count ? children[autoScrollTo].id : nil } + return leafIndex.first { $0.value == autoScrollTo }?.key + }() + if horizontal { ScrollView(.horizontal, showsIndicators: showsIndicators) { LazyHStack(spacing: 0) { @@ -87,6 +96,7 @@ struct NativeUIListRenderer: View { } } } + .modifier(ListAutoScrollModifier(request: autoScrollTo, targetID: autoScrollTarget, anchor: .leading)) } else { List { if let pagerHeader { @@ -186,6 +196,7 @@ struct NativeUIListRenderer: View { .modifier(NativeUIPagerHeaderListModifier(header: pagerHeader)) // Only this list draws the header, not one in its rows. .environment(\.nativeUIPagerHeader, nil) + .modifier(ListAutoScrollModifier(request: autoScrollTo, targetID: autoScrollTarget, anchor: .top)) } } @@ -756,3 +767,45 @@ extension EnvironmentValues { set { self[NativeUIListRowMarginKey.self] = newValue } } } + +/// Scrolls a list to the row with `targetID` for its `auto_scroll_to` +/// `request`: a jump when the list opens, an animated scroll when PHP asks +/// for another row later. A request is applied once, so re-renders carrying +/// the same one leave the list where the reader scrolled it, and so does +/// coming back to the screen: NavigationStack keeps a level below the top +/// alive, with this state and the list's position. +/// +/// A row that isn't there yet (a page still loading) is scrolled to when it +/// arrives. +struct ListAutoScrollModifier: ViewModifier { + let request: Int + let targetID: Int? + let anchor: UnitPoint + + @State private var applied = -1 + + func body(content: Content) -> some View { + ScrollViewReader { proxy in + content + .onAppear { apply(proxy) } + .onChange(of: request) { _, _ in apply(proxy) } + .onChange(of: targetID) { _, _ in apply(proxy) } + } + } + + private func apply(_ proxy: ScrollViewProxy) { + guard request >= 0, request != applied, let targetID else { return } + + let opening = applied < 0 + applied = request + // Lazy rows aren't laid out inside onAppear yet, so an immediate + // scrollTo does nothing; the ScrollView renderer defers the same way. + DispatchQueue.main.async { + if opening { + proxy.scrollTo(targetID, anchor: anchor) + } else { + withAnimation(.easeOut(duration: 0.25)) { proxy.scrollTo(targetID, anchor: anchor) } + } + } + } +} diff --git a/resources/ios/NativeUIVirtualListRenderer.swift b/resources/ios/NativeUIVirtualListRenderer.swift index f821483..0a6d8ae 100644 --- a/resources/ios/NativeUIVirtualListRenderer.swift +++ b/resources/ios/NativeUIVirtualListRenderer.swift @@ -45,7 +45,8 @@ struct NativeUIVirtualListRenderer: View { overscan: overscan, cbId: cbId, nodeId: nodeId, - rowByIndex: rowByIndex + rowByIndex: rowByIndex, + autoScrollTo: node.props.getInt("auto_scroll_to", default: -1) ) // Environment-propagating, so it reaches the List inside the body // without threading another property through it. @@ -64,6 +65,7 @@ private struct VirtualListBody: View { let cbId: Int let nodeId: Int let rowByIndex: [Int: NativeUINode] + let autoScrollTo: Int @StateObject private var tracker = VisibleWindowTracker() @@ -92,6 +94,12 @@ private struct VirtualListBody: View { } } .listStyle(.plain) + // Each slot's id is its index, so the row to open at is its own id. + .modifier(ListAutoScrollModifier( + request: autoScrollTo, + targetID: autoScrollTo >= 0 && count > 0 ? min(autoScrollTo, count - 1) : nil, + anchor: .top + )) .task(id: "\(count)/\(windowFrom)/\(windowTo)/\(overscan)/\(cbId)/\(nodeId)") { tracker.update( count: count, diff --git a/src/Elements/NativeList.php b/src/Elements/NativeList.php index 565178f..3b33af6 100644 --- a/src/Elements/NativeList.php +++ b/src/Elements/NativeList.php @@ -64,6 +64,10 @@ public function applyAttributes(array $attrs): void if (isset($attrs['visible-range-step']) || isset($attrs['visibleRangeStep'])) { $this->visibleRangeStep((int) ($attrs['visible-range-step'] ?? $attrs['visibleRangeStep'])); } + $autoScrollTo = $attrs['auto-scroll-to'] ?? $attrs['autoScrollTo'] ?? null; + if ($autoScrollTo !== null && $autoScrollTo !== '') { + $this->autoScrollTo((int) $autoScrollTo); + } $this->applyA11yAttributes($attrs); } @@ -178,6 +182,24 @@ public function visibleRangeStep(int $rows): static return $this; } + /** + * Open the list with row $index at the top: a screen restored after a + * relaunch, a deep link to a record, or the row just added. Rows count + * as end-reached and visible-range count them (a section's rows in its + * place). + * + * Like ScrollView::autoScrollTo(), the list scrolls when it is created + * and again when the index changes, never while it stays the same, so + * re-renders don't pull the list away from where the reader scrolled. + * Going back to a screen keeps its list where it was without this. + */ + public function autoScrollTo(int $index): static + { + $this->listProps['auto_scroll_to'] = max(0, $index); + + return $this; + } + protected function resolveProps(CallbackRegistry $registry): array { $props = $this->listProps; diff --git a/src/Elements/NativeVirtualList.php b/src/Elements/NativeVirtualList.php index c5d736d..0d28f8e 100644 --- a/src/Elements/NativeVirtualList.php +++ b/src/Elements/NativeVirtualList.php @@ -53,6 +53,10 @@ public function applyAttributes(array $attrs): void if (isset($attrs['showsIndicators']) || isset($attrs['shows-indicators'])) { $this->showsIndicators((bool) ($attrs['showsIndicators'] ?? $attrs['shows-indicators'])); } + $autoScrollTo = $attrs['auto-scroll-to'] ?? $attrs['autoScrollTo'] ?? $attrs['auto_scroll_to'] ?? null; + if ($autoScrollTo !== null && $autoScrollTo !== '') { + $this->autoScrollTo((int) $autoScrollTo); + } $cb = $attrs['on_window_change'] ?? $attrs['onWindowChange'] ?? $attrs['on-window-change'] ?? null; if ($cb !== null) { $this->windowCallback = $cb; @@ -72,6 +76,22 @@ public function showsIndicators(bool $value = true): static return $this; } + /** + * Open the list with absolute row $index at the top. The list scrolls + * when it is created and again when the index changes, never while it + * stays the same, so re-renders don't fight the reader's scrolling. + * + * The window has to hold the row or native draws a placeholder there + * until it asks for one that does: HasVirtualListWindow's + * scrollVirtualListTo() moves the window and sets the index together. + */ + public function autoScrollTo(int $index): static + { + $this->listProps['auto_scroll_to'] = max(0, $index); + + return $this; + } + protected function resolveProps(CallbackRegistry $registry): array { $props = $this->listProps; diff --git a/tests/CollectorElementsTest.php b/tests/CollectorElementsTest.php index 18f4533..5cbdecf 100644 --- a/tests/CollectorElementsTest.php +++ b/tests/CollectorElementsTest.php @@ -287,6 +287,17 @@ expect($tree['props']['shows_indicators'])->toBeFalse(); }); +it('passes a virtual list the row to open at', function () { + NativeElementCollector::leaf('virtual_list', [ + 'count' => 5000, + 'auto-scroll-to' => 4200, + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['auto_scroll_to'])->toBe(4200); +}); + it('applies checkbox props', function () { NativeElementCollector::leaf('checkbox', [ 'value' => true, diff --git a/tests/ListTest.php b/tests/ListTest.php index d099e9f..88f44dd 100644 --- a/tests/ListTest.php +++ b/tests/ListTest.php @@ -153,3 +153,19 @@ function collectList(array $attrs): array expect($placeholder['props']['estimated_height'])->toBe(120.0); }); + +it('opens a list at a row with auto-scroll-to', function () { + $tree = collectList(['auto-scroll-to' => '12']); + + expect($tree['props']['auto_scroll_to'])->toBe(12); +}); + +it('sends no row to scroll to unless one is asked for', function () { + expect(collectList([])['props'] ?? [])->not->toHaveKey('auto_scroll_to') + ->and(collectList(['auto-scroll-to' => null])['props'] ?? [])->not->toHaveKey('auto_scroll_to'); +}); + +it('keeps row zero as a row to scroll to and clamps a negative one to it', function () { + expect(collectList(['autoScrollTo' => 0])['props']['auto_scroll_to'])->toBe(0) + ->and(NativeList::make()->autoScrollTo(-3)->toArray(new CallbackRegistry)['props']['auto_scroll_to'])->toBe(0); +});