Docs

Lists

A list (virtual_list(..) in Rust, List from nsh in TSX) renders long data on a native scroll view while only creating the rows near the screen. Which rows exist, where they go, the columns, sticky headers and which items are visible are all computed natively; your code only fills cells with content when the native side asks for one. A list of a million items costs about as much as a list of thirty.

guide-lists.tsx
guide-lists.tsxLive
import { effect, List, mount, signal, Text, View, type YNode } from "nsh";
import { windowSize } from "nsh";
import { focusRing, hoverFill, size, tint, ui, wide } from "./palette";

/** In a wide window the lists sit in one centered region, 760 points across. */
const REGION = 760;

// Grid tiles, tinted with the status and info colours.
const tints = ["down", "up", "info", "warn", "faint", "violet"] as const;

type Post = { id: number; title: string };

/** Bumped when the selected tab is tapped again: its list scrolls to the top. */
const toTop = signal(0);

/** An endless feed: loads more near the end, pull to refresh, scroll to an item. */
function Feed() {
  let next = 0;
  const make = (n: number): Post[] => Array.from({ length: n }, () => ({ id: next, title: `Post ${++next}` }));
  const posts = signal(make(40));
  const refreshing = signal(false);
  const visible = signal("");
  let list!: YNode;
  effect(() => {
    if (toTop() > 0) list.scrollToOffset({ offset: 0 });
  });
  return (
    <List
      ref={list}
      each={posts()}
      keyExtractor={(p) => String(p.id)}
      estimatedItemSize={wide() ? size.row : 56}
      style={wide() ? { ...ui().card, flex: 1, margin: 20, marginTop: 12, borderRadius: 12, overflow: "hidden" } : { flex: 1 }}
      contentStyle={wide() ? {} : { padding: 12, gap: 8 }}
      onLoadMore={() => posts.update((p) => [...p, ...make(20)])}
      loadMoreThreshold={0.5}
      refreshing={refreshing()}
      onRefresh={() => {
        refreshing.set(true);
        setTimeout(() => {
          next = 0;
          posts.set(make(40));
          refreshing.set(false);
        }, 800);
      }}
      viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
      onViewableItemsChange={({ viewableItems }) =>
        visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "")
      }
      header={
        <View
          style={
            wide()
              ? { flexDirection: "row", alignItems: "center", gap: 12, height: size.rowSm, paddingHorizontal: 12, borderBottomWidth: 1, borderBottomColor: ui().line, backgroundColor: ui().inset.backgroundColor }
              : { flexDirection: "row", alignItems: "center", gap: 12, paddingBottom: 4 }
          }
        >
          <Text style={{ flex: 1, color: ui().muted, fontSize: wide() ? 13 : undefined }}>
            {posts().length} posts, visible {visible()}
          </Text>
          <Text onPress={() => list.scrollToIndex({ index: 30, viewPosition: 0.5 })} style={{ color: ui().accent, fontSize: wide() ? 13 : undefined }} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            #30
          </Text>
          <Text onPress={() => list.scrollToOffset({ offset: 0 })} style={{ color: ui().accent, fontSize: wide() ? 13 : undefined }} hoverStyle={hoverFill()} focusStyle={focusRing()} activeStyle={{ opacity: 0.7 }}>
            Top
          </Text>
        </View>
      }
    >
      {(post) => (
        <View
          style={
            wide()
              ? { height: size.row, paddingHorizontal: 12, justifyContent: "center", borderBottomWidth: 1, borderBottomColor: ui().line }
              : { ...ui().row, padding: 16, borderRadius: 12 }
          }
        >
          <Text style={{ fontSize: wide() ? size.label : 16, color: ui().text }}>{post().title}</Text>
        </View>
      )}
    </List>
  );
}

/** Three columns of different heights. */
function Grid() {
  const items = Array.from({ length: 300 }, (_, i) => ({ id: i, height: 60 + (i % 4) * 18 }));
  return (
    <List
      each={items}
      numColumns={wide() ? 6 : 3}
      estimatedItemSize={80}
      style={{ flex: 1 }}
      contentStyle={wide() ? { padding: 20, paddingTop: 12, gap: 8 } : { padding: 12, gap: 8 }}
    >
      {(item) => (
        <View style={{ height: item().height, borderRadius: 10, backgroundColor: tint(ui()[tints[item().id % tints.length]], 0.16), alignItems: "center", justifyContent: "center" }}>
          <Text style={{ fontWeight: "600", color: ui().text }}>{item().id}</Text>
        </View>
      )}
    </List>
  );
}

/** Sticky letter headers: one list of header and name rows. */
function Contacts() {
  const names = ["Ada", "Alan", "Anita", "Barbara", "Bjarne", "Brian", "Claude", "Dennis", "Donald", "Edsger", "Frances", "Grace", "Guido", "Hedy", "Ken", "Linus", "Margaret", "Niklaus", "Radia", "Tim"];
  type Row = { header: string } | { name: string };
  const rows: Row[] = [];
  const headers: number[] = [];
  for (const letter of [...new Set(names.map((n) => n[0]))]) {
    headers.push(rows.length);
    rows.push({ header: letter });
    for (const name of names.filter((n) => n[0] === letter)) rows.push({ name });
  }
  return (
    <List
      each={rows}
      stickyHeaderIndices={headers}
      estimatedItemSize={wide() ? size.rowSm : 44}
      style={
        wide()
          ? { ...ui().card, flex: 1, margin: 20, marginTop: 12, borderRadius: 12, overflow: "hidden" }
          : { flex: 1, backgroundColor: ui().card.backgroundColor }
      }
    >
      {(row) => (
        <If
          when={"header" in row()}
          else={
            <View
              style={
                wide()
                  ? { height: size.rowSm, paddingHorizontal: 12, justifyContent: "center", borderBottomWidth: 1, borderBottomColor: ui().line }
                  : { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: ui().line }
              }
            >
              <Text style={{ fontSize: wide() ? size.label : 16, color: ui().text }}>{(row() as { name: string }).name}</Text>
            </View>
          }
        >
          <View style={{ backgroundColor: ui().inset.backgroundColor, paddingHorizontal: wide() ? 12 : 16, paddingVertical: wide() ? 4 : 6 }}>
            <Text style={{ fontWeight: "700", fontSize: wide() ? 12 : undefined, color: ui().muted }}>{(row() as { header: string }).header}</Text>
          </View>
        </If>
      )}
    </List>
  );
}

type Tab = "feed" | "grid" | "contacts";

function App() {
  const tab = signal<Tab>("feed");
  const button = (id: Tab, label: string) => (
    <Text
      onPress={() => (tab() === id ? toTop.update((n) => n + 1) : tab.set(id))}
      style={{
        flex: 1,
        textAlign: "center",
        paddingVertical: wide() ? 4 : 6,
        fontSize: wide() ? 13 : undefined,
        borderRadius: 8,
        overflow: "hidden",
        fontWeight: "600",
        color: tab() === id ? ui().text : ui().muted,
        backgroundColor: tab() === id ? ui().raised.backgroundColor : "transparent",
        backgroundImage: tab() === id ? ui().raised.backgroundImage : "none",
        boxShadow: tab() === id ? ui().raised.boxShadow : "none",
      }}
      hoverStyle={{ opacity: 0.88 }}
      focusStyle={focusRing()}
      activeStyle={{ opacity: 0.7 }}
    >
      {label}
    </Text>
  );
  return (
    <View
      style={
        wide()
          ? { flex: 1, backgroundColor: ui().page, paddingTop: 24, paddingHorizontal: Math.max(0, (windowSize().width - REGION) / 2) }
          : { flex: 1, backgroundColor: ui().page, paddingTop: 24 }
      }
    >
      <View
        style={
          wide()
            ? { ...ui().inset, flexDirection: "row", alignSelf: "flex-start", width: 320, marginHorizontal: 20, padding: 3, borderRadius: 10 }
            : { ...ui().inset, flexDirection: "row", marginHorizontal: 12, padding: 3, borderRadius: 10 }
        }
      >
        {button("feed", "Feed")}
        {button("grid", "Grid")}
        {button("contacts", "Sticky")}
      </View>
      {() => {
        switch (tab()) {
          case "feed":
            return <Feed />;
          case "grid":
            return <Grid />;
          case "contacts":
            return <Contacts />;
        }
      }}
    </View>
  );
}

mount(App);
use std::rc::Rc;

use nsh_core::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let titles: Rc<Vec<String>> = Rc::new((1..=10_000).map(|i| format!("Item {i}")).collect());
    virtual_list(titles.len(), move |at| {
        let titles = titles.clone();
        view()
            .p_4()
            .border_b_1()
            .border_b_color(rgb(0xeaeef2))
            .child(text(move || titles[at.index()].clone()).text_size(px(16.0)))
    })
    .estimated_item_size(px(52.0))
    .flex_1()
    .bg(white())
}

In Rust, virtual_list(count, row) takes the number of rows (a value, a signal or a closure) and a function that builds a row from its position (at.index()); the rows read your data by that index. In TSX, each takes the items and the row function gets the item. Like a scroll view, the list is the viewport and needs a size from its parent (.flex_1(), flex: 1 or a height).

#How recycling works

The row function is not called once per item. It is called once per cell, and cells are reused: when a row scrolls far off screen, its cell is given another row, and its position (at.index() in Rust, item() and index() in TSX) changes. Everything in the row that reads it updates in place, like any other signal.

Two rules follow from that:

Read the position where you use it. A value copied out at the top of the row function belongs to whichever row the cell showed first.

// Wrong: `title` stays the first row's title when the cell is reused.
virtual_list(count, move |at| {
    let title = titles[at.index()].clone();
    text(title)
})

// Right: the text follows the cell's current row.
virtual_list(count, move |at| {
    let titles = titles.clone();
    text(move || titles[at.index()].clone())
})

Keep per-item state in your data, not in the row. A signal created inside the row belongs to the cell and travels with it to the next item. Store “is this row expanded / selected / liked” in the items or in a set keyed by id:

let liked = signal(HashSet::<u32>::new());
let toggle = move |id: u32| {
    liked.update(|s| {
        if !s.remove(&id) {
            s.insert(id);
        }
    })
};

virtual_list(move || posts.with(Vec::len), move |at| {
    let post = computed(move || posts.with(|p| p[at.index()].clone()));
    view()
        .flex_row()
        .p_4()
        .child(text(move || post.with(|p| p.title.clone())).flex_1())
        .child(
            text(move || if liked.with(|s| s.contains(&post.with(|p| p.id))) { "♥" } else { "♡" })
                .on_press(move || toggle(post.with(|p| p.id))),
        )
})
.estimated_item_size(px(64.0))
.flex_1()

Rows can have different heights. Until a row has been measured, the list assumes the estimated item size (default 50 points); pass a value close to your typical row so the scroll indicator and jumps to far items are accurate from the start.

#Changing the data

In Rust, give the count as a signal or a closure (move || posts.with(Vec::len)): the list grows and shrinks in place. Rows added or removed at the end need nothing more; when rows come and go elsewhere, give their identities with .keys(move || posts.with(|p| p.iter().map(|p| p.id).collect::<Vec<_>>())), and the list keeps the common start and end and treats what is between as removed and inserted.

In TSX, pass a new array to change the items: posts.set([...posts(), ...more]). The list compares the old and the new array by item identity (===), keeps the common start and end, and treats what is between as removed and inserted.

In both, measurements stay with their rows, and when rows are inserted above the rows on screen, the visible rows stay where they are instead of jumping.

  • In TSX, update immutably: a new array, with the unchanged item objects reused. Mutating the array in place (push) is not seen.
  • A changed item should be a new object ({ ...post, title }), so its row shows the change.
  • Prepending (new messages, a refreshed feed) keeps the user’s place.

keyExtractor names items in viewability tokens (below); by default the key is item.key, then item.id, then the index. It does not affect how changes are matched in TSX, which is always by identity.

#Props

A list takes every scrolling prop of the scroll view that makes sense for a list (pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, decelerationRate, disableIntervalMomentum, bounces, scrollable, contentInset, contentOffset, keyboardShouldPersistTaps, keyboardDismissMode, scrollEventThrottle, onScroll, onScrollDragStart, onScrollDragEnd, onMomentumStart, onMomentumEnd, onScrollEnd, scrollValue, pullValue; in Rust the same methods as a scroll view’s), described in Scrolling. Lists keep a scale of 1. Its own props:

PropRustTypeDefaultDescription
eachvirtual_list(count, ..)readonly T[]The items; in Rust, the number of rows.
childrenvirtual_list(.., row)(item: () => T, index: () => number) => JSX.ElementRenders a cell; called once per cell, see recycling.
estimatedItemSize.estimated_item_size(px(..))number50The size of rows not measured yet, in points.
horizontal.horizontal()booleanfalseScrolls sideways.
viewport.viewport(ListViewport::..)"own" | "parent""own""own": the list scrolls in a scroll view of its own. "parent": it lies at its full length in the scroll view around it, which scrolls it with the rest of the page (see lists in a page). Chosen when the list is made.
numColumns.columns(n)number1Items per row (vertical lists).
inverted.inverted()booleanfalseItem 0 at the bottom (chat); starts at the end and keeps its distance from the end.
maintainVisibleContentPosition.follow_end(px(..)){ autoscrollToBottomThreshold?: number }A list that is not inverted stays at the end while it is within that many points of it, as content grows (see below).
stickyHeaderIndices.sticky_header_indices(..)number[]Items that stick to the top while scrolled past (one column, not inverted).
stickyHeaderOffset.sticky_header_offset(px(..)), .bind_sticky_header_offset(&node)number | AnimatedNode0How far below the top the sticky items stick, as CSS position: sticky’s top; the content scrolls on under the space above them. An animated value moves them every frame.
contentStyle.content_padding(..), .content_padding_x(..), …, .row_gap(..), .column_gap(..){ padding*, gap, rowGap, columnGap }Padding and gaps of the content, numbers only.
contentClassstringThe same as classes (p-*, gap-*); contentStyle wins over them.
header.header(..)component or elementBefore the items.
footer.footer(..)component or elementAfter the items.
empty.empty(..)component or elementShown instead of the items while there are none.
separator.separator(|| ..)component or elementBetween items (one column).
initialScrollIndex.initial_scroll_index(i)numberScrolls to this item before the first frame.
onLoadMore.on_load_more(..)() => voidThe end is near (see below).
loadMoreThreshold.load_more_threshold(..)number1How close to the end, in viewports, onLoadMore fires.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidThe start is near, after a scroll (see below).
loadMoreAtStartThreshold.load_more_at_start_threshold(..)number1How close to the start, in viewports, onLoadMoreAtStart fires.
onViewableItemsChange.on_viewable_items_change(..)(info) => voidItems became or stopped being viewable.
viewabilityConfig.viewability(..), .minimum_view_time(..)ViewabilityConfigWhen items count as viewable.
keyExtractor.keys(..)(item, index) => stringThe key in viewability tokens; in Rust, the rows’ identities.
refreshing / onRefresh.refreshing(..) / .on_refresh(..)boolean / () => voidPull to refresh.
refreshIndicator.refresh_indicator(..){ height: number }The app’s own pull-to-refresh indicator, in place of the platform’s spinner (see Scrolling).
scrollbar.scrollbar(..)booleantrueThe scroll indicator shows. On the desktop and in desktop browsers a horizontal view keeps it: it is the mouse’s scroll bar.
ref.element_ref(&list)YNodescrollToIndex, scrollToOffset, scrollToEnd, scrollTo.

The header, footer and empty slots take an element (in TSX also a component: header={Header}). They are cells of their own, never recycled, and they are reactive like any other element.

#Columns and grids

numColumns (.columns(n)) lays items out in rows of that many columns; content padding goes around the content, with gaps between rows and columns. Rows of a grid may have different heights: each row is as tall as its tallest item.

let count = photos.len();
virtual_list(count, move |at| {
    let photos = photos.clone();
    image(move || photos[at.index()].thumb.clone()).aspect_square().rounded(px(8.0))
})
.columns(3)
.estimated_item_size(px(120.0))
.content_padding(px(12.0))
.row_gap(px(8.0))
.column_gap(px(8.0))
.flex_1()

#Sticky headers

stickyHeaderIndices lists items that stick to the top of the viewport while the content scrolls past them, until the next sticky item pushes them out. Sections are one list of header and row items; the headers’ indices stick. The pinning is done natively on every scroll frame.

#[derive(Clone, PartialEq)]
enum Row {
    Header(String),
    Name(String),
}

let mut rows = Vec::new();
let mut headers = Vec::new();
for (letter, names) in groups {
    headers.push(rows.len());
    rows.push(Row::Header(letter));
    rows.extend(names.into_iter().map(Row::Name));
}
let count = rows.len();
let rows = Rc::new(rows);

virtual_list(count, move |at| {
    let rows = rows.clone();
    let row = computed(move || rows[at.index()].clone());
    let is_header = computed(move || matches!(row.get(), Row::Header(_)));
    let label = move || match row.get() {
        Row::Header(s) | Row::Name(s) => s,
    };
    when(is_header, move || {
        text(label).p(px(6.0)).bg(rgb(0xeaeef2)).font_weight(FontWeight::BOLD)
    })
    .otherwise(move || text(label).p_3())
})
.sticky_header_indices(headers)
.estimated_item_size(px(44.0))
.flex_1()

Because a cell may show a header now and a name later, the row decides what to render with when / <If>, which switches when the row changes. Sticky headers need one column and a list that is not inverted.

stickyHeaderOffset (.sticky_header_offset(..) in Rust) makes the headers stick that many points below the top instead, as CSS position: sticky’s top does: under a translucent bar 56 points tall, stickyHeaderOffset={56} (.sticky_header_offset(px(56.0))) keeps each header below the bar while the rows scroll on under it, and the next header pushes it out from there. A negative offset leaves part of a header out of view.

The offset can be an animated value (.bind_sticky_header_offset(&node) in Rust): the headers follow it every frame on the UI thread, so they stay below a bar that moves. With a bar that hides as the content scrolls down and comes back as it scrolls up (diffClamp), the headers stick to its bottom edge wherever it is:

let offset = animated_value(0.0);
let hidden = offset.diff_clamp(0.0, 56.0);
let shown = hidden.mul(-1.0).add(56.0);

view().flex_1().overflow_hidden().child(
    virtual_list(rows.len(), row)
        .scroll_value(&offset)
        .sticky_header_indices(headers)
        .bind_sticky_header_offset(&shown),
).child(view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&hidden.mul(-1.0)))

#Chat: inverted lists

inverted lays the list out from the end: item 0 is at the bottom, the list starts scrolled to the end, and it keeps its distance from the end as items change. Keep the newest message first; prepend to add one, append to load older ones with onLoadMore (the “end” of an inverted list is the top). A chat opened at an old message loads the newer ones with onLoadMoreAtStart; with it, the list keeps its distance from the bottom while it is there and keeps the messages on screen in place when scrolled up.

#[derive(Clone, PartialEq)]
struct Message {
    id: u32,
    text: String,
    mine: bool,
}

pub fn chat() -> impl IntoElement {
    let next = stored(0_u32);
    let message = move |text: String, mine: bool| Message {
        id: next.with(|n| {
            *n += 1;
            *n
        }),
        text,
        mine,
    };
    // Newest first.
    let messages = signal(
        (0..30)
            .map(|i| message(format!("Message {}", 30 - i), i % 3 == 0))
            .collect::<Vec<_>>(),
    );
    let draft = signal(String::new());
    let send = move || {
        let text = draft.get_untracked();
        if text.trim().is_empty() {
            return;
        }
        let sent = message(text, true);
        messages.update(|m| m.insert(0, sent));
        draft.set(String::new());
    };
    let load_older = move || {
        let older: Vec<_> = (0..20).map(|i| message(format!("Older {i}"), false)).collect();
        messages.update(|m| m.extend(older));
    };
    let list = virtual_list(
        move || messages.with(Vec::len),
        move |at| {
            let mine = computed(move || messages.with(|m| m[at.index()].mine));
            view()
                .style_when(mine, |s| s.self_end().bg(rgb(0x0969da)))
                .style_when(move || !mine.get(), |s| s.self_start().bg(rgb(0xeaeef2)))
                .max_w(relative(0.75))
                .px_3()
                .py_2()
                .rounded(px(14.0))
                .child(
                    text(move || messages.with(|m| m[at.index()].text.clone()))
                        .text_color(move || if mine.get() { rgb(0xffffff) } else { rgb(0x1f2328) }),
                )
        },
    )
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .inverted()
    .estimated_item_size(px(44.0))
    .content_padding(px(12.0))
    .row_gap(px(6.0))
    .keyboard_dismiss_mode(KeyboardDismissMode::Interactive)
    .on_load_more(load_older)
    .load_more_threshold(0.5)
    .flex_1();
    view()
        .flex_1()
        .pb_keyboard()
        .child(list)
        .child(
            view().flex_row().gap_2().p_2().child(
                input()
                    .value(move || draft.get())
                    .on_change(move |text: &str| draft.set(text.to_owned()))
                    .on_submit(move |_: &str| send())
                    .multiline(true)
                    .submit_behavior(SubmitBehavior::Submit)
                    .return_key_type(ReturnKeyType::Send)
                    .placeholder("Message")
                    .flex_1()
                    .max_h(px(120.0))
                    .border_1()
                    .border_color(rgb(0xd0d7de))
                    .rounded(px(18.0))
                    .px_3()
                    .py_2(),
            ),
        )
}

scroll_to_end(..) / scrollToEnd() on an inverted list scrolls to its start: the newest message.

#Following the end of a list that is not inverted

A list in reading order (oldest first) can follow its end too: with .follow_end(px(40.0)) in Rust or maintainVisibleContentPosition={{ autoscrollToBottomThreshold: 40 }} in TSX, while the user is at the end (or within 40 points of it), the list stays at the end as the content grows: a reply streaming into the last item, a new message appended, the keyboard shrinking the list. Scrolled further up, the visible content stays where it is and the new content grows below it. Items inserted above the visible ones never move them, with or without the option.

virtual_list(move || messages.with(Vec::len), move |at| bubble(messages, at))
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .follow_end(px(40.0))
    .flex_1()

#Horizontal lists

horizontal turns the list sideways; cells are laid out at the list’s height. Combined with snapping it makes a carousel of any length:

let count = cards.len();
virtual_list(count, move |at| {
    let cards = cards.clone();
    view()
        .w(px(220.0))
        .h(px(140.0))
        .rounded(px(14.0))
        .bg(move || cards[at.index()].color)
})
.horizontal()
.estimated_item_size(px(232.0))
.snap_interval(px(232.0))
.deceleration(0.99)
.scrollbar(false)
.content_padding_x(px(16.0))
.column_gap(px(12.0))
.h(px(140.0))

Right to left, horizontal lists start at the right and run leftwards.

#Lists in a page: viewport=“parent”

A page often scrolls as one: a header, a few cards, then a long list, and maybe another list below it. Give the list viewport="parent" (.viewport(ListViewport::Parent) in Rust) and put it in a Scroll with the rest of the page: the list lies in the page at its full length, and the page’s scroll view scrolls it with everything else. It still creates only the rows near the page’s viewport and recycles their cells, so one page can hold several long lists.

scroll()
    .flex_1()
    .child(header())
    .child(
        virtual_list(count, row)
            .viewport(ListViewport::Parent)
            .estimated_item_size(px(56.0))
            .row_gap(px(6.0)),
    )
    .child(footer())
  • The list is as long as its rows: its height (its width, sideways) comes from its content, as a view’s does, and its padding, border and margins work as on a view.
  • Its rows follow the nearest scroll view around it along its axis, or the screen it is on.
  • The page keeps what you read in place, as a browser does: rows that change size wholly above the top of the viewport move the page by as much; while the list’s start is in view, rows that grow push what is below them down.
  • scrollToIndex, scrollToEnd and initialScrollIndex scroll the page to the list’s rows, within the page’s content; onLoadMore fires as the list’s end comes near the viewport, and onViewableItemsChange reports the rows in the page’s viewport.
  • stickyHeaderIndices and stickyHeaderOffset stick rows to the top of the page: a section header stays at the top while its rows scroll under it, until the next one pushes it out, and the last one leaves with the end of its list.
  • How the page scrolls is its scroll view’s: set onScroll and the other scroll events, scrollValue, refreshing / onRefresh, paging and snapping, contentInset, scrollbar, scrollable and keyboardDismissMode on the Scroll. Inverted lists and maintainVisibleContentPosition are features of a list with a viewport of its own (viewport="own", the default).
  • The viewport is chosen when the list is made: to switch, render a new list.

#Loading more: onLoadMore

onLoadMore (.on_load_more(..)) fires when the viewport comes within loadMoreThreshold viewports of the end (1 by default: one screen ahead). Append the next page; the list fires again when the new end comes near.

let posts = signal(Vec::<Post>::new());
let page = stored(0_u32);
let loading = stored(false);

let load_more = move || {
    if loading.replace(true) {
        return;
    }
    let next = page.with(|p| {
        *p += 1;
        *p
    });
    spawn(async move {
        if let Ok(response) = fetch(format!("https://api.example.com/posts?page={next}")).await {
            let more = parse_posts(&response.text());
            posts.update(|p| p.extend(more));
        }
        loading.replace(false);
    });
};
load_more();

virtual_list(move || posts.with(Vec::len), move |at| post_row(posts, at))
    .estimated_item_size(px(72.0))
    .on_load_more(load_more)
    .load_more_threshold(0.5)
    .footer(text("Loading…").p_4().text_center().text_color(rgb(0x8c959f)))
    .flex_1()

Guard against overlapping loads as above: the end may be reported again while a page is on its way.

#Loading at the start: onLoadMoreAtStart

A list can also open in the middle of its data: a chat opened at an old message (a search result, a reply’s quote) shows a page around it, with newer messages still to load. onLoadMoreAtStart (.on_load_more_at_start(..)) fires when the viewport comes within loadMoreAtStartThreshold viewports of the start (1 by default), the side of item 0: the top of a list, the bottom of an inverted one. Prepend the page: the rows on screen keep their place while it arrives before them (an inverted list too, once scrolled away from its bottom), and the list fires again on the next approach, or at once when the page left it still near the start.

It fires once the user scrolls there or a scroll command takes the list there (initialScrollIndex included), so a list that opens at its newest item asks for nothing, and one opened with initialScrollIndex near its start asks for the page before it right away.

let messages = signal(page_around(target)); // newest first, ending at an old message
let loading = stored(false);

let load_newer = move |_: &()| {
    if loading.replace(true) {
        return;
    }
    let newest = messages.with(|m| m[0].id);
    spawn(async move {
        if let Ok(response) = fetch(format!("https://api.example.com/messages?after={newest}")).await {
            let newer = parse_messages(&response.text());
            messages.update(|m| {
                m.splice(0..0, newer);
            });
        }
        loading.replace(false);
    });
};

virtual_list(move || messages.with(Vec::len), move |at| message_row(messages, at))
    .inverted(true)
    .on_load_more(load_older)
    .on_load_more_at_start(load_newer)
    .load_more_at_start_threshold(0.5)
    .flex_1()

Once the newest message is in, stop loading: new messages then arrive at the start as in any chat.

#Viewability

onViewableItemsChange (.on_viewable_items_change(..)) reports which items are on screen, for analytics, autoplaying the visible video, or marking messages read. In TSX it gets { viewableItems, changed }, arrays of tokens { item, key, index, isViewable }; in Rust, ViewableItems with the first and last viewable row (viewable) and the rows whose state changed (changed()). Viewability is computed natively; your code only hears about changes.

viewabilityConfigRustDescription
itemVisiblePercentThresholdViewability { item_visible_percent, .. }Percent of an item that must be visible.
viewAreaCoveragePercentThresholdViewability { view_area_percent, .. }Percent of the viewport an item must cover.
minimumViewTime.minimum_view_time(Duration)Milliseconds an item must stay viewable before it is reported.

Without a threshold, any visible pixel counts.

let playing = signal(None::<usize>);

virtual_list(count, move |at| video_card(videos.clone(), at))
    .estimated_item_size(px(300.0))
    .viewability(Viewability {
        item_visible_percent: Some(60.0),
        ..Viewability::default()
    })
    .minimum_view_time(Duration::from_millis(250))
    .on_viewable_items_change(move |rows: &ViewableItems| {
        playing.set(rows.viewable.map(|(first, _)| first))
    })
    .flex_1()

#Scrolling from code

Give the list a ref (element_ref() and .element_ref(&list) in Rust, ref in TSX):

RustTSXDescription
scroll_to_index(index, view_position, view_offset, animated)scrollToIndex({ index, animated?, viewPosition?, viewOffset? })Scrolls item index into view. The view position places it: 0 at the start of the viewport (default), 0.5 in the middle, 1 at the end; the view offset shifts it further.
scroll_to_offset(offset, animated)scrollToOffset({ offset, animated? })Scrolls to a content offset along the list’s axis.
scroll_to_end(animated)scrollToEnd({ animated? })Scrolls to the end of the content (the start of an inverted list).
scroll_to(point, animated)scrollTo({ x?, y?, animated? })Scrolls to an offset, as on a scroll view.
let list = element_ref();

virtual_list(count, row).estimated_item_size(px(48.0)).flex_1().element_ref(&list)

list.scroll_to_index(120, percentage(0.5), px(0.0), true);

Scrolling to an index (and to the end) is resolved natively with the sizes known at that moment: measured rows where they have been measured, the estimated size elsewhere. The list then follows its target while the rows around it are measured, so it lands exactly, until the user scrolls or the items change. To open a list at an item, use initialScrollIndex (.initial_scroll_index(..)) rather than scrolling after the first frame.

#A settings-style list

The look of a list is the app’s own: the list places and recycles the rows, and the rows, dividers and surfaces are your views. A grouped, settings-style list is a list with padding, a separator and rounded first and last rows. Make it a component of your own and use it wherever the app lists settings:

use std::rc::Rc;

use nsh_core::{element::palette::NEUTRAL, prelude::*};

pub struct Setting {
    pub id: &'static str,
    pub label: &'static str,
}

pub fn settings_list(settings: Rc<Vec<Setting>>, on_select: impl Fn(&Setting) + 'static) -> impl IntoElement {
    let surface = AdaptiveColor::new(white(), NEUTRAL.shade(900));
    let on_select = Rc::new(on_select);
    let count = settings.len();
    view().flex_1().bg(AdaptiveColor::new(NEUTRAL.shade(100), NEUTRAL.shade(950))).child(
        virtual_list(count, move |at| {
            let (pressed, shown, on_select) = (settings.clone(), settings.clone(), on_select.clone());
            view()
                .on_press(move || on_select(&pressed[at.index()]))
                .role(AccessibilityRole::Button)
                .h_12()
                .justify_center()
                .bg(surface)
                .px_4()
                .active(|s| s.bg(NEUTRAL.shade(200)))
                .rounded_t(move || px(if at.is_first() { 12.0 } else { 0.0 }))
                .rounded_b(move || px(if at.is_last() { 12.0 } else { 0.0 }))
                .child(text(move || shown[at.index()].label))
        })
        .estimated_item_size(px(48.0))
        .content_padding(px(16.0))
        .separator(move || {
            view()
                .bg(surface)
                .child(view().ml_4().h(px(1.0)).bg(AdaptiveColor::new(NEUTRAL.shade(200), NEUTRAL.shade(800))))
        })
        .flex_1(),
    )
}

#Performance notes

  • Each cell is laid out on its own, so scrolling only lays out the cells that changed. Keep rows shallow and avoid reading signals in rows that change on every frame.
  • The native side asks for cells in batches, up to several rounds per frame, merged into one update; a fast fling binds many cells in one frame without a call into your code per cell. Rust rows are bound in the core itself, within the commit that needs them.
  • Use nsh test --trace with a scroll step to measure the per-frame cost of your rows.