Lists¶
FlatList and
SectionList are the two list components
shipped with PythonNative. Sections use frozen Section(key=..., data=..., title=...) records; section keys
are unique and independent of their positions. Both lists are virtualized: only the rows
inside (and just beyond) the viewport are mounted as native views.
On mobile, RecyclerView (Android) and UICollectionView (iOS) own physical
cell recycling. Rows remain ordinary keyed components in the application's
logical tree. Providers, error boundaries, suspense boundaries, and cancellation
therefore work across row and screen containers.
Use stable keys and immutable data. A new data snapshot advances the list's
revision; stale row requests are discarded. estimated_item_height provides an
initial estimate for variable content, and native measurement refines row
extents. Fixed heights, sections, grids, headers, footers, and pull-to-refresh
use this native path too. The browser implements the same logical-row protocol;
headless tests use a windowed scroll container.
import pythonnative as pn
items = [{"id": i, "title": f"Row {i}"} for i in range(10_000)]
@pn.component
def Big() -> pn.Node:
return pn.FlatList(
data=items,
item_height=44,
render_item=lambda item, _: pn.Text(item["title"]),
key_extractor=lambda item, _: str(item["id"]),
)
The list never holds 10,000 native views; only the window around the viewport ever exists.
Incremental data¶
Use ListData[T] for frequently edited lists. It owns stable string keys and
notifies mounted lists when items change. Keep it in a repository or create it
with use_memo on the application thread:
from dataclasses import dataclass, replace
import pythonnative as pn
@dataclass(frozen=True)
class Message:
id: str
title: str
def render_message(message: Message, index: int) -> pn.Element:
return pn.Text(message.title)
@pn.component
def Messages() -> pn.Node:
messages = pn.use_memo(
lambda: pn.ListData([Message("a", "Hello")], key=lambda message: message.id),
[],
)
def edit() -> None:
new_key = f"message-{messages.revision}"
with messages.batch():
messages.update("a", replace(messages.get("a"), title="Edited"))
messages.append(Message(new_key, "New message"))
messages.move(new_key, 0)
return pn.Column(
pn.Button("Edit messages", on_press=edit),
pn.FlatList(data=messages, render_item=render_message, style={"flex": 1}),
style={"flex": 1},
)
Omit key_extractor when passing ListData; the source owns its keys. Mutate
it on its creating thread. update(key, replacement) preserves identity;
insert(index, item), append(item), remove(key), move(key, final_index),
and clear() publish explicit edits. batch() combines notifications, including
nested batches. It doesn't roll back successful edits if its body raises.
A one-item update does constant metadata work and sends one row record, even with 100,000 items. Initial mounting and expired history require a full metadata snapshot. Structural edits shift indexed arrays, so insertion and reordering aren't constant-time operations. The bounded journal defaults to 1,024 changes; each mounted list tracks its own committed revision.
The incremental path supports ordinary single-column lists with fixed or
estimated heights. Grids and get_item_height use the sequence adapter below.
Changing rendering callbacks or list decorations also invalidates the snapshot.
Keep callbacks stable with module-level functions or use_callback.
Data snapshots¶
Lists accept a Sequence and cache keys and row metadata separately from the
scrolling window. Keep render_item, key_extractor, and height callbacks
stable when the parent rerenders, using module-level functions or
use_callback. Scrolling then does work proportional to the mounted window.
Prefer replacing data with a new sequence. If you mutate a sequence in place,
increment data_revision when passing it to FlatList or SectionList.
The revision invalidates the cached snapshot. Native prefetch requests mount
nearby rows, and keyed anchoring preserves the visible row as measurements or
data updates change extents above it.
Row heights¶
Three ways to tell the list how tall rows are, in order of preference:
item_height=44: uniform rows. Offsets are exact and cheap, and native containers can skip estimating that extent.get_item_height=lambda item, i: ...: exact per-row extents without measurement. Native containers use those extents directly.- Nothing at all: rows start at
estimated_item_height(default 44) and are corrected with their measured extent once they've been on screen. Native containers refine their layout as measurements arrive; variable-height rows use the same recycling path as fixed-height rows.
separator_height= adds a fixed gap below every row. For a drawn
divider, pass item_separator= an element or a zero-argument function
returning one; it renders after every row except the last (and, in a
SectionList, only between the items of a section, never after a
header or a section's last item):
pn.FlatList(
data=items,
item_height=44,
item_separator=lambda: pn.View(style={"height": 1, "background_color": "#E5E7EB"}),
)
Pull-to-refresh¶
Use RefreshControl as a prop on
either FlatList or ScrollView:
@pn.component
def Pullable() -> pn.Node:
refreshing, set_refreshing = pn.use_state(False)
def reload() -> None:
set_refreshing(True)
# ... fetch data ...
set_refreshing(False)
return pn.FlatList(
data=items,
item_height=44,
refresh_control=pn.RefreshControl(
refreshing=refreshing,
on_refresh=reload,
),
)
Row taps¶
Wrap the row in a Pressable inside
render_item:
def render_row(item: dict[str, Any], index: int) -> pn.Element:
return pn.Pressable(
pn.Text(item["title"]),
on_press=lambda: open_detail(item["id"]),
)
Infinite scroll¶
on_end_reached fires once when the user scrolls within
on_end_reached_threshold viewports of the end (re-arming when the
data length changes), which is the hook for pagination:
pn.FlatList(
data=items,
item_height=44,
on_end_reached=load_next_page,
on_end_reached_threshold=0.5, # half a viewport from the bottom
)
on_viewable_items_changed reports the set of visible rows whenever
it changes, as a list of frozen ViewableItem records with .index, .key, and .item; on a
SectionList the list covers items only, with flat indices.
on_scroll receives a ScrollEvent as the
list scrolls.
Imperative scrolling¶
Pass a use_ref as ref= and the list
publishes a ListController on
ref.current:
@pn.component
def JumpableList() -> pn.Node:
list_ref = pn.use_ref()
return pn.Column(
pn.Button("Jump to row 200", on_press=lambda: list_ref.current.scroll_to_index(200)),
pn.FlatList(data=items, item_height=44, ref=list_ref, style={"flex": 1}),
style={"flex": 1},
)
The controller exposes scroll_to_index(i, animated=True),
scroll_to_offset(points, animated=True), and
scroll_to_end(animated=True). To start at a given row instead, pass
initial_scroll_index=; the list scrolls there without animation once
it mounts.
Grids, headers, and empty states¶
num_columns=2chunks items into grid rows.horizontal=Truescrolls on the x-axis (extents become widths).list_header=/list_footer=render once before/after all rows.list_empty=renders whendatais empty.
Inverted lists¶
inverted=True renders the list bottom-up (or right-to-left when
horizontal), so rows appended to data appear at the visible end,
the chat-transcript layout. It is implemented in Python on top of the
same VirtualList contract: a mirror transform (scale_y: -1, or
scale_x: -1 for horizontal lists) is applied to the container and to
each row, so no native change is involved.
pn.FlatList(
data=messages,
inverted=True,
render_item=lambda m, _: Bubble(message=m),
key_extractor=lambda m, _: m["id"],
)
Section lists¶
SectionList flattens an iterable of
Section records into a single virtualized
list, dispatching to either render_section_header or render_item
depending on the row's kind.
sections = [
pn.Section(key="a", title="A", data=["Apple", "Avocado"]),
pn.Section(key="b", title="B", data=["Banana", "Blueberry"]),
]
pn.SectionList(
sections=sections,
item_height=44,
section_header_height=32,
render_section_header=lambda s, _: pn.Text(s.title),
render_item=lambda item, _i, _s: pn.Text(item),
)
Headers and items can have different extents, and variable-height
rows work exactly as in FlatList (exact via get_item_height, or
estimated and measured). inverted, item_separator, and
on_viewable_items_changed work as on FlatList.
sticky_section_headers=True keeps the current section's header pinned at the
top of the native list and lets the next header push it away. Native layout
owns this movement, including while Python is busy. Python retains the active
header in its bounded mounted window. No application on_scroll handler is
needed.
pn.SectionList(
sections=sections,
sticky_section_headers=True,
section_header_height=32,
render_section_header=lambda s, _: pn.Text(s.title, style={"background_color": "#FFF"}),
render_item=lambda item, _i, _s: pn.Text(item),
)
Give the header an opaque background so rows don't show through it.
Performance notes¶
- Use
ListDatakeys or a stablekey_extractorso rows that stay inside the window refresh in place rather than tearing down and rebuilding their subtree as the window shifts. - Provide real extents (
item_height/get_item_height) when you can. Exact extents reduce native measurement work. For variable content, choose anestimated_item_heightclose to typical row sizes. - Headers, footers, empty states, pull-to-refresh, grids, and horizontal scrolling all use native recycling on mobile. Test representative content and scrolling on your deployment targets.
- Keep row subtrees shallow. The reconciler is fast, but mounting a
hundred
Text/Image/Buttonnodes per row is wasteful work every time a row enters the window. - Move expensive computation out of
render_item(useuse_memoin the parent component, or pre-compute once before constructing the data list).