Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<native:list :auto-scroll-to="$openAt">
@foreach ($posts as $post)
<native:list-item native:key="post-{{ $post->id }}" :headline="$post->title" />
@endforeach
</native:list>
```

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
<native:virtual-list
:count="$total"
:from="$virtualWindowFrom"
:to="$virtualWindowTo"
:auto-scroll-to="$virtualScrollTo"
item="rows.contact"
on-window-change="setVirtualWindow"
/>
```

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.
Expand Down
80 changes: 80 additions & 0 deletions resources/android/ListRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() }) { _, _ -> }
Expand Down Expand Up @@ -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<NativeUINode>,
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<NativeUINode>): Map<Any, Int> {
val map = HashMap<Any, Int>()
Expand Down
6 changes: 6 additions & 0 deletions resources/android/VirtualListRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion resources/boost/skills/nativephp-mobile-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
53 changes: 53 additions & 0 deletions resources/ios/NativeUIListRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -87,6 +96,7 @@ struct NativeUIListRenderer: View {
}
}
}
.modifier(ListAutoScrollModifier(request: autoScrollTo, targetID: autoScrollTarget, anchor: .leading))
} else {
List {
if let pagerHeader {
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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) }
}
}
}
}
10 changes: 9 additions & 1 deletion resources/ios/NativeUIVirtualListRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()

Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions src/Elements/NativeList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/Elements/NativeVirtualList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions tests/CollectorElementsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions tests/ListTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading