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);
+});