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.
ScrollView
Section titled “ScrollView”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.
Imperative jumps and restoration
Section titled “Imperative jumps and restoration”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
Section titled “FlatList”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.
VirtualizedList and SectionList
Section titled “VirtualizedList and SectionList”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.
Tabs over one list: keyed sections
Section titled “Tabs over one list: keyed sections”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
activeSectionis 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. listSectionbelongs on the cell root, likestickyHeader: a row component sets it on its own root element (for example from asectionprop). Keys must be unique across sections, and the rows of one section must be contiguous.
Paging, scroll phases and end of list
Section titled “Paging, scroll phases and end of list”$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.
List behaviour you can rely on
Section titled “List behaviour you can rely on”- Explicit scroll targets use the per-cell pixel extents and the list’s real
viewport; an
initialScrollIndexapplied 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.
Pull to refresh
Section titled “Pull to refresh”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.