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

NativeList preserves the original, narrower string-row API for existing applications:

use Pam\Native\UI\NativeList;
return NativeList::make($recentSearches)
->rowHeight(48)
->prefetch(12)
->onEndReached(fn () => $this->loadMore(), threshold: 0.6);

Every item must be a string. Row height and prefetch are clamped to at least one, and the threshold to 0.0–1.0. Prefer FlatList for new string-row screens because it additionally supports horizontal layout, columns, inversion, initial index, clipping, indicators, scroll events, and bounded prefetch up to 32 rows. Use VirtualizedList for custom or heterogeneous cells.

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.

Since PAM Native 1.5.2, single-column cells without an authored height are content-sized, like React Native FlatList cells: variable rows (chat bubbles, comments) get their real height and never overlap. estimatedRowHeight (alias rowHeight) remains the prefetch estimate and the extent of empty cells or cells whose content has no definite intrinsic height (for example percentage-sized media). An authored cell height stays 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>

Multi-column rows and horizontal lists without rowHeight are content-sized too (the tallest cell sets the row height, 1.6.0+).

Since 1.25.1 a margin on a cell’s root element counts toward the cell, as in React Native/Yoga: the row is the root’s margin box (marginTop + content + marginBottom; left and right margins inset the root inside its column) and the root is offset by its margins. This holds for grid rows (the tallest margin box sets the row), fullSpan cells and horizontal lists. An authored height (width horizontally) stays the root’s own size, with the margins added around it. Earlier versions dropped root margins from the row, so margins had to be written as padding.

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.

Headers, sticky headers and full-span rows

Section titled “Headers, sticky headers and full-span rows”

Mark a direct child of a ScrollView, VirtualizedList or VirtualGrid with stickyHeader="true" (Element::stickyHeader()), the equivalent of stickyHeaderIndices. It pins to the top once scrolled past and is pushed away by the next sticky sibling. Like React Native’s sticky header wrapper, it pins and is pushed by its margin box, so a pinned header keeps its top margin above it (1.25.1+).

<VirtualizedList estimatedRowHeight="72" columns="3">
<View fullSpan="true"><ProfileHeader /></View>
<View stickyHeader="true" fullSpan="true"><TabBar :selected="$tab" /></View>
<MediaTile p-for="$item in $media" :key="$item['id']" :item="$item" />
</VirtualizedList>

Since 1.14.1 the pinned header is a real, interactive view (FlashList parity): presses, pressed state and accessibility reach it, a touch inside it never reaches the row scrolling underneath, rows keep recycling while it is pinned, and its native views move into the pinned holder and back, so their state (a horizontal tab rail offset, a focused input) is kept.

fullSpan="true" (Element::fullSpan()) makes a child span every column of a multi-column list; use it for ListHeaderComponent/ListFooterComponent equivalents placed first or last.

Since PAM Native 1.27.0 a VirtualizedList can keep several tabs alive at once, like a React Native screen that renders a header, a sticky tab rail and the active tab’s rows in one FlatList. Give each tab’s rows a section and the list the active one:

<VirtualizedList :activeSection="$activeTab">
<Column><!-- profile header --></Column>
<Row stickyHeader="true"><!-- tab rail --></Row>
<Row p-for="$row in $mediaRows" p-key="$row['key']" listSection="media">…</Row>
<Row p-for="$row in $fileRows" p-key="$row['key']" listSection="files">…</Row>
</VirtualizedList>

In PHP builders, use Element::listSection('media') and VirtualizedList::activeSection('media').

  • Rows without a section (header, rail, footer) are always shown.
  • Rows of the other sections stay in the tree but hidden: they keep their layouts, their native views and their decoded images. Switching back is a native visibility swap: no PHP rebuild of the rows, no remount, no decode.
  • While the rail is pinned every section keeps its own scroll position; the first visit starts with the rail at the top. With the header still visible the switch keeps the current offset.
  • Changing activeSection is a property-only update. Render only the tabs the user visited and keep their row arrays unchanged between renders so PHP re-renders nothing but the rail.
  • listSection belongs on the cell root, like stickyHeader: a row component sets it on its own root element (for example from a section prop). Keys must be unique across sections, and the rows of one section must be contiguous.
$reels = VirtualizedList::make(...$pages)
->pagingEnabled() // one item per page
->onEndReached(fn () => $this->loadMore(), threshold: 0.5);
<VirtualizedList pagingEnabled="true" on:momentumScrollEnd="pageChanged" on:endReached="loadMore">
<ReelPage p-for="$reel in $reels" :key="$reel['id']" :reel="$reel" />
</VirtualizedList>

on:scrollBeginDrag, on:scrollEndDrag (release velocity) and on:momentumScrollEnd (with the settled page) deliver ScrollPhaseEvents without per-frame traffic; see Gestures and animation. ScrollView also accepts on:endReached.

  • Explicit scroll targets use the per-cell pixel extents and the list’s real viewport; an initialScrollIndex applied in the same commit never overrides an explicit scroll request.
  • A list resting at its end stays there when cells are re-measured or the viewport shrinks (a chat timeline when the keyboard opens).
  • Prepending a page mounts no off-screen views and keeps the scroll anchor.
  • A commit that changes every visible cell (new props, promotion to a host view, re-keying) never draws a blank viewport.
  • Scrolled-out cell views are pooled by kind and property set; image views are cleared before reuse, so a recycled row never shows the previous row’s pixels.
  • Key every cell (:key/p-key): identity, component state and native views follow the item. See Performance and memoization.
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 most field-tested recycled-list path; iOS implements the same contract (content-sized cells, sticky headers, pooling) since 1.9.0 and is pending device validation.