Skip to content

Scroll and lists

Use Scroll for one composed child and a list component for large collections. Native lists recycle platform rows and keep scrolling away from the PHP render path.

use Pam\Native\ScrollKeyboardDismissMode;
use Pam\Native\UI\Column;
use Pam\Native\UI\Scroll;
$content = Scroll::make(
Column::make(...$sections),
)
->showsIndicator(false)
->fillViewport()
->nestedScrollEnabled()
->keyboardDismissMode(ScrollKeyboardDismissMode::OnDrag)
->onScroll(fn ($event) => $this->rememberOffset($event));

ScrollView::make($content) is the compatibility alias. Horizontal paging can combine horizontal(), pagingEnabled(), snapToInterval() and a bounded deceleration rate.

In a .pam.php template, declarative ScrollView accepts one or many direct children. PAM Native 0.5.46 inserts a native Row for horizontal content and a native Column for vertical content:

<ScrollView horizontal="true" showsHorizontalScrollIndicator="false">
<Pressable
p-for="$story in $stories"
:key="$story->id"
width="66"
>
<Image :source="$story->avatar" width="66" height="66" />
<Text>{{ $story->name }}</Text>
</Pressable>
</ScrollView>

This keeps compact items at their authored width even when only one branch is currently visible, while loops and conditions can safely produce multiple items. The imperative Scroll::make($content) API remains explicit and still accepts exactly one content element.

PAM Native 0.5.29 adds tokenized scroll requests. A changed request number executes exactly once on the native UI thread. PAM Native 0.5.60 also accepts a persisted logical offset on that request. A testId has first priority, a non-negative offset is second, and omitting both targets jumps to the content end.

<ScrollView
anchorToEnd="true"
maintainVisibleContentPosition="true"
:scrollTargetTestId="$scrollTarget"
:scrollTargetOffset="$scrollTargetOffset"
:scrollRequest="$scrollRequest"
>
<Column>
<Row testId="first-unread">
<Text>Unread messages</Text>
</Row>
</Column>
</ScrollView>
public string $scrollTarget = '';
public float $scrollTargetOffset = -1.0;
public int $scrollRequest = 0;
public function jumpToUnread(): void
{
$this->scrollTarget = 'first-unread';
$this->scrollTargetOffset = -1.0;
$this->scrollRequest++;
}
public function restoreReadingPosition(float $offset): void
{
$this->scrollTarget = '';
$this->scrollTargetOffset = max(0.0, $offset);
$this->scrollRequest++;
}
public function jumpToLatest(): void
{
$this->scrollTarget = '';
$this->scrollTargetOffset = -1.0;
$this->scrollRequest++;
}

The fluent PHP equivalent is $scroll->scrollRequest($request, $targetTestId, $targetOffset). Keep the request value in component state. The offset is expressed in logical points, not physical pixels. Because it is part of a one-shot request, it restores state without making the scroll continuously controlled or bouncing against the user’s gesture.

anchorToEnd() controls initial placement and following new content while the viewport is already near the end. maintainVisibleContentPosition() preserves the visible region when older content is prepended. Scroll requests are explicit user or application commands and take precedence when dispatched.

FlatList is optimized for native text rows:

use Pam\Native\UI\FlatList;
$list = FlatList::make(array_map(
fn ($contact) => $contact->displayName,
$contacts,
))
->rowHeight(56)
->prefetch(8)
->initialScrollIndex($restoredIndex)
->removeClippedSubviews()
->onEndReached(fn () => $this->loadNextPage(), threshold: 0.35);
use Pam\Native\UI\SectionList;
use Pam\Native\UI\Text;
use Pam\Native\UI\VirtualizedList;
$cards = VirtualizedList::make(
...array_map(
fn ($item) => ContactCard::make($item)->key("contact:{$item->id}"),
$contacts,
),
)
->estimatedRowHeight(72)
->prefetch(10)
->onEndReached(fn () => $this->loadMore());
$sections = SectionList::make([
['title' => 'Favorites', 'items' => ['Ada', 'Linus']],
['title' => 'Recent', 'items' => ['Margaret', 'James']],
])->rowHeight(52);

Use stable keys for composed rows. VirtualGrid applies the same recycled list contract to multi-column responsive cells.

For rich cells, estimatedRowHeight is a prefetch estimate and the fallback when a cell has no authored main-axis size. Explicit cell heights remain authoritative:

<VirtualizedList estimatedRowHeight="604" prefetch="4">
<Column
p-for="$post in $posts"
:key="$post['id']"
:height="$post['rowHeight']"
>
<Image
:source="$post['image']"
widthPercent="100"
:height="$post['mediaHeight']"
/>
</Column>
</VirtualizedList>

PAM computes these extents in Rust and gives each recycled Android holder its exact native size. Scrolling does not synchronously measure PHP components on the UI thread. Horizontal rich lists use an explicit cell width the same way, while rowHeight remains a compatibility alias.

The list viewport and each recycled holder are hard native paint boundaries. Media cannot draw over headers, neighboring rows, or tab bars while scrolling. Inside the cell, component-level overflow still controls descendant clipping.

use Pam\Native\RefreshIndicatorSize;
use Pam\Native\UI\RefreshControl;
$feed = RefreshControl::make($cards, $this->state->refreshing)
->colors(0xFF2563EB, 0xFF7C3AED)
->progressBackgroundColor(0xFFFFFFFF)
->size(RefreshIndicatorSize::Default)
->onRefresh(fn () => $this->refresh());

Avoid rendering an unbounded collection into a Column inside Scroll. Choose a recycled list whenever item count can grow with remote or user data. Android has the mature recycled-list path; confirm the current iOS parity record before relying on identical large-list behavior.