Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

List

List mostra dati lunghi o illimitati. Esistono solo le righe vicine al viewport: man mano che l’utente scorre, le righe che escono vengono riciclate per quelle che entrano. Quali righe esistono, le loro posizioni, le colonne, gli header sticky e la viewability sono tutti calcolati nativamente, quindi scorrere una lista di migliaia di elementi costa quanto una di venti. In Rust se ne crea una con virtual_list(count, |at| row), in TSX con <List> da nsh.

el-list.tsx
el-list.tsxLive
import { List, type ListRef, mount, signal, Text, View } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, size, tint, ui, wide } from "./palette";

type Trade = { id: string; symbol: string; shares: number; change: number };

const symbols = ["AAPL", "NVDA", "TSLA", "MSFT", "AMZN", "META", "GOOG", "AMD", "NFLX", "INTC"];
const LIMIT = 200;

// A page of made-up trades; ids keep counting so every row has its own key.
let next = 0;
const page = (n: number): Trade[] =>
  Array.from({ length: n }, () => {
    const i = next++;
    return { id: `t${i}`, symbol: symbols[(i * 7) % symbols.length], shares: 10 + ((i * 37) % 290), change: (((i * 53) % 200) - 100) / 10 };
  });

function App() {
  const trades = signal(page(40));
  const loading = signal(false);
  const loads = signal(0);
  const refreshing = signal(false);
  const visible = signal("");
  let list!: ListRef;

  const loadMore = () => {
    if (loading() || trades().length >= LIMIT) return;
    loading.set(true);
    setTimeout(() => {
      trades.set([...trades(), ...page(20)]);
      loads.update((n) => n + 1);
      loading.set(false);
    }, 600);
  };

  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      next = 0;
      trades.set(page(40));
      loads.set(0);
      refreshing.set(false);
    }, 800);
  };
  const viewable = ({ viewableItems }: { viewableItems: { index: number }[] }) =>
    visible.set(viewableItems.length ? `${viewableItems[0].index}–${viewableItems[viewableItems.length - 1].index}` : "");
  const toIndex = () => list.scrollToIndex({ index: Math.min(100, trades().length - 1), viewPosition: 0.5 });
  const toEnd = () => list.scrollToEnd({ animated: true });
  const status = () => (loading() ? "Loading 20 more…" : trades().length >= LIMIT ? "The end" : "");
  const changeColor = (t: Trade) => (t.change >= 0 ? ui().up : ui().down);

  // A phone: cards in one column, pull to refresh.
  const cards = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      <View style={{ paddingHorizontal: 16, gap: 6, paddingBottom: 8 }}>
        <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
        <Text style={{ color: ui().muted }}>
          {trades().length} rows · onLoadMore {loads()}× · visible {visible()}
        </Text>
      </View>
      <List
        ref={list}
        testID="trades"
        each={trades()}
        keyExtractor={(t) => t.id}
        numColumns={1}
        estimatedItemSize={64}
        style={{ flex: 1 }}
        contentStyle={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: 16, gap: 8 }}
        onLoadMore={loadMore}
        loadMoreThreshold={0.5}
        refreshing={refreshing()}
        onRefresh={refresh}
        viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
        onViewableItemsChange={viewable}
        header={
          <View style={{ ...ui().card, borderRadius: 14, padding: 14, gap: 10, marginBottom: 4 }}>
            <Text style={{ color: ui().muted }}>
              {desktop ? "Scroll with the wheel or trackpad" : "Swipe up; pull down to refresh"}: more rows load near the end, up to {LIMIT}.
            </Text>
            <View style={{ flexDirection: "row", gap: 8 }}>
              <Chip label="scrollToIndex 100" onPress={toIndex} />
              <Chip label="scrollToEnd" onPress={toEnd} />
            </View>
          </View>
        }
        footer={<Text style={{ color: ui().faint, textAlign: "center", paddingVertical: 16 }}>{status()}</Text>}
      >
        {(trade, index) => (
          <View style={{ ...ui().row, flex: 1, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}>
            <Text style={{ color: ui().faint, width: 32, fontVariant: ["tabular-nums"] }}>{index()}</Text>
            <View style={{ flex: 1 }}>
              <Text style={{ color: ui().text, fontWeight: "600" }}>{trade().symbol}</Text>
              <Text style={{ color: ui().muted, fontSize: 12 }}>
                {trade().shares} shares · key {trade().id}
              </Text>
            </View>
            <Text
              style={{
                color: changeColor(trade()),
                backgroundColor: tint(changeColor(trade()), 0.12),
                paddingHorizontal: 8,
                paddingVertical: 3,
                borderRadius: 6,
                overflow: "hidden",
                fontWeight: "600",
                fontVariant: ["tabular-nums"],
              }}
            >
              {trade().change >= 0 ? "+" : ""}
              {trade().change.toFixed(1)}%
            </Text>
          </View>
        )}
      </List>
    </View>
  );

  // A wide window: a table with fixed column lanes, its controls beside it.
  const cell = { fontSize: 13, fontVariant: ["tabular-nums" as const] };
  const lanes = { index: 48, shares: 88, key: 104, change: 88 };
  const table = () => (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>list</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Scroll with the wheel or trackpad" : "Swipe up"}: more rows load near the end, up to {LIMIT}.
      </Text>
      <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
        <View style={{ ...ui().card, flex: 1, minWidth: 0, borderRadius: 12, overflow: "hidden" }}>
          <View
            style={{
              flexDirection: "row",
              alignItems: "center",
              gap: 12,
              height: size.rowSm,
              paddingHorizontal: 12,
              borderBottomWidth: 1,
              borderBottomColor: ui().border,
              backgroundColor: ui().inset.backgroundColor,
            }}
          >
            <Text style={{ ...cell, width: lanes.index, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>#</Text>
            <Text style={{ ...cell, flex: 1, fontSize: 12, fontWeight: "600", color: ui().muted }}>Symbol</Text>
            <Text style={{ ...cell, width: lanes.shares, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Shares</Text>
            <Text style={{ ...cell, width: lanes.key, paddingStart: 16, fontSize: 12, fontWeight: "600", color: ui().muted }}>Key</Text>
            <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontSize: 12, fontWeight: "600", color: ui().muted }}>Change</Text>
          </View>
          <List
            ref={list}
            testID="trades"
            each={trades()}
            keyExtractor={(t) => t.id}
            estimatedItemSize={size.row}
            style={{ flex: 1 }}
            onLoadMore={loadMore}
            loadMoreThreshold={0.5}
            refreshing={refreshing()}
            onRefresh={refresh}
            viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
            onViewableItemsChange={viewable}
            footer={<Text style={{ color: ui().faint, fontSize: 13, textAlign: "center", paddingVertical: 12 }}>{status()}</Text>}
          >
            {(trade, index) => (
              <View
                style={{
                  flexDirection: "row",
                  alignItems: "center",
                  gap: 12,
                  height: size.row - 4,
                  paddingHorizontal: 12,
                  borderBottomWidth: 1,
                  borderBottomColor: ui().line,
                }}
              >
                <Text style={{ ...cell, width: lanes.index, textAlign: "right", color: ui().faint }}>{index()}</Text>
                <Text style={{ ...cell, flex: 1, fontWeight: "600", color: ui().text }}>{trade().symbol}</Text>
                <Text style={{ ...cell, width: lanes.shares, textAlign: "right", color: ui().text }}>{trade().shares}</Text>
                <Text style={{ ...cell, width: lanes.key, paddingStart: 16, color: ui().muted }}>{trade().id}</Text>
                <Text style={{ ...cell, width: lanes.change, textAlign: "right", fontWeight: "600", color: changeColor(trade()) }}>
                  {trade().change >= 0 ? "+" : ""}
                  {trade().change.toFixed(1)}%
                </Text>
              </View>
            )}
          </List>
        </View>
        <Inspector>
          <Panel title="rows · onLoadMore · visible">
            <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
              {trades().length} rows · {loads()}× · visible {visible()}
            </Text>
          </Panel>
          <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
            <Chip label="scrollToIndex 100" onPress={toIndex} />
            <Chip label="scrollToEnd" onPress={toEnd} />
          </View>
        </Inspector>
      </View>
    </View>
  );

  return <>{() => (wide() ? table() : cards())}</>;
}

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} {...button()}>
      <Text style={buttonText()}>{props.label}</Text>
    </View>
  );
}

mount(App);
use nsh_core::prelude::*;

#[derive(Clone, PartialEq)]
struct Contact {
    id: usize,
    name: String,
}

fn app() -> impl IntoElement {
    let contacts = signal(
        (0..1000).map(|i| Contact { id: i, name: format!("Contact {}", i + 1) }).collect::<Vec<_>>(),
    );
    virtual_list(move || contacts.with(|c| c.len()), move |at| {
        view().h(px(56.0)).px_4().justify_center().child(
            text(move || contacts.with(|c| format!("{}. {}", at.index() + 1, c[at.index()].name)))
                .text_base(),
        )
    })
    .estimated_item_size(px(56.0))
    .flex_1()
}

In TSX, List è generico: List<T> verifica che each e la funzione di riga concordino sul tipo di elemento, che TypeScript deduce da each (vedi la List API). In Rust, virtual_list riceve il numero di righe (un valore, un signal o una closure: la lista cresce e si riduce sul posto) e la funzione che costruisce la riga in un dato Position; le righe leggono i dati del componente tramite at.index().

Per una lista breve in cui tutte le righe devono esistere (una pagina di impostazioni, un form), un Each dentro una scroll view è più semplice. Per una lista con lo stile di una tabella raggruppata, vedi una lista in stile impostazioni. La guida alle liste tratta i pattern: caricamento infinito, chat, sezioni.

#Le righe vengono riciclate

La funzione di riga viene eseguita una volta per cella, non una volta per elemento. Quando una cella viene riutilizzata per un altro elemento, la sua posizione cambia (TSX: gli accessor item() e index(); Rust: at.index()) e tutto ciò che la legge si aggiorna:

virtual_list(move || messages.with(|m| m.len()), move |at| {
    text(move || messages.with(|m| m[at.index()].body.clone()))
})

Quindi mantieni lo stato di ogni elemento nei tuoi dati (indicizzato per id), non in signal creati dentro la funzione di riga: un signal di questo tipo resta legato alla cella e compare su un altro elemento.

Le celle in attesa del loro prossimo elemento vengono nascoste quando la loro riga non esiste più o è mostrata da un’altra cella, così screen reader, tocchi e test UI incontrano ogni riga una sola volta.

#Props

PropRustTipoPredefinitoDescrizione
eachvirtual_list(count, ..)readonly T[]Gli elementi.
childrenvirtual_list(.., |at| row)(item: () => T, index: () => number) => JSX.ElementRenderizza una riga.
estimatedItemSize.estimated_item_size(px(..))number50La dimensione degli elementi non ancora misurati, in punti. Stime vicine al valore reale mantengono stabile la barra di scorrimento.
horizontal.horizontal()booleanfalseScorre in orizzontale.
viewport.viewport(ListViewport::..)"own" | "parent""own""own": la lista scorre in una scroll view propria. "parent": si estende per tutta la sua lunghezza nel layout del genitore e renderizza le righe vicine al viewport della scroll view circostante, che la fa scorrere insieme al resto della pagina (vedi liste in una pagina). Viene scelto quando la lista è creata.
numColumns.columns(n)number1Elementi per riga (liste verticali).
inverted.inverted()booleanfalseDisposta a partire dalla fine, con l’elemento 0 per ultimo (stile chat): parte scorsa fino alla fine e mantiene la distanza dalla fine al variare del contenuto.
maintainVisibleContentPosition.follow_end(px(threshold)){ autoscrollToBottomThreshold?: number }Per le liste non invertite: alla fine del contenuto, o entro quel numero di punti da essa, la lista resta alla fine quando il contenuto cresce o il viewport si restringe; se è scorsa più in alto, il contenuto visibile resta fermo.
stickyHeaderIndices.sticky_header_indices([..])number[]Elementi che restano agganciati all’inizio quando vengono superati dallo scorrimento (una colonna, non invertita).
contentStyle.content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), …ListContentStylePadding e gap del contenuto, in punti: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
contentClassstringPadding e gap del contenuto come classi (p-*, gap-*); contentStyle ha la precedenza su di esse.
header.header(el)ListSlotPrima degli elementi: un componente (Header) o un elemento (<Header />).
footer.footer(el)ListSlotDopo gli elementi.
empty.empty(el)ListSlotMostrato al posto degli elementi mentre each è vuoto.
separator.separator(|| el)ListSlotTra gli elementi (una colonna).
initialScrollIndex.initial_scroll_index(i)numberScorre fino a questo elemento prima del primo frame.
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key, item.id o l’indiceLa chiave di un elemento nei token di viewability. In Rust, le identità delle righe, così le righe mantengono le loro celle e dimensioni quando altre righe compaiono o scompaiono prima di esse.
onLoadMore.on_load_more(..)() => voidLa fine è vicina: carica altri elementi.
loadMoreThreshold.load_more_threshold(viewports)number1Quanto vicino alla fine (in viewport) scatta onLoadMore.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidL’inizio è vicino, dopo che l’utente o un comando di scorrimento ha mosso la lista: carica ciò che viene prima del primo elemento.
loadMoreAtStartThreshold.load_more_at_start_threshold(viewports)number1Quanto vicino all’inizio (in viewport) scatta onLoadMoreAtStart.
onViewableItemsChange.on_viewable_items_change(..)(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidAlcuni elementi sono diventati visibili (viewable) o hanno smesso di esserlo.
viewabilityConfig.viewability(..), .minimum_view_time(..)ViewabilityConfigQuando gli elementi contano come visibili (vedi sotto).
refreshing.refreshing(..)booleanLo spinner del pull-to-refresh è mostrato.
onRefresh.on_refresh(..)() => voidL’utente ha trascinato per aggiornare.
refreshIndicator.refresh_indicator(Some(height)){ height: number }L’app disegna un proprio indicatore, alto height punti: lo spinner della piattaforma è nascosto, un trascinamento avvia l’aggiornamento una volta raggiunto height, e durante l’aggiornamento height restano liberi sopra il contenuto. Un’altezza pari o inferiore a 0 equivale a nessun indicatore.
pullValue.pull_value(&value)AnimatedValueUn valore animato alimentato con quanto la lista è trascinata oltre il suo bordo superiore (0 a riposo, in crescita con il trascinamento; lo spazio mantenuto sopra il contenuto durante l’aggiornamento: height con refreshIndicator), per l’indicatore dell’app.
scrollbar.scrollbar(..)boolean | ScrollbarThemetrueL’indicatore di scorrimento è mostrato: quello della piattaforma, oppure la barra integrata dell’app una volta che questa ha impostato un tema; un oggetto è un tema proprio della vista. Su desktop e nei browser desktop una vista orizzontale lo mantiene: è la barra di scorrimento del mouse.
scrollValue.scroll_value(&value)AnimatedValueUn valore animato alimentato con l’offset di scorrimento, per animazioni legate allo scorrimento.
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"Indica se il trascinamento chiude la tastiera.
stylemetodi StyledStyleProp<ViewStyle>Lo stile proprio della lista (richiede una dimensione definita: flex: 1 o un’altezza).
testID.test_id(id)stringL’id con cui i test UI trovano la scroll view della lista (nsh test di id:, XCUITest, UI Automator).
ref.element_ref(&handle)ListRef | (node: ListRef) => voidIl ref della lista (vedi metodi del ref).

Le liste accettano anche le prop di scorrimento di Scroll (i metodi Scrollable in Rust): pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle e scrollable. Le liste mantengono la propria scala.

Le prop accettate da ogni elemento (prop comuni) vengono passate così come sono alla scroll view della lista: testID, accessibilityLabel e le altre prop di accessibilità, gli stili, onLayout, le prop di tocco e di responder. I test UI trovano una lista tramite il suo testID come trovano qualsiasi vista.

ViewabilityConfig (Rust: Viewability { item_visible_threshold, view_area_coverage_threshold } e .minimum_view_time(..)):

CampoTipoDescrizione
itemVisibleThresholdnumberQuanta parte di un elemento deve essere visibile, da 0 a 1.
viewAreaCoverageThresholdnumberQuanta parte del viewport un elemento deve coprire, da 0 a 1. Senza nessuna delle due soglie, conta qualsiasi pixel visibile.
minimumViewTimenumberMillisecondi per cui gli elementi devono restare visibili prima di essere segnalati.

ViewToken<T> è { item, key, index, isViewable }. Il listener di Rust riceve ViewableItems: indices() è l’intervallo di righe visibili, changed() le righe comparse o scomparse.

#Eventi

EventoRustPayloadQuando
onScroll.on_scrollScrollEventL’offset è cambiato.
onScrollDragStart / onScrollDragEnd.on_scroll_drag_start / .on_scroll_drag_end{ offset }Il trascinamento è iniziato / terminato.
onMomentumStart / onMomentumEnd.on_momentum_start / .on_momentum_end{ offset }Uno scivolamento (glide) è iniziato / terminato.
onScrollEnd.on_scroll_end{ offset }Lo scorrimento si è fermato.
onLoadMore.on_load_morenessunoEntro loadMoreThreshold viewport dalla fine.
onLoadMoreAtStart.on_load_more_at_startnessunoEntro loadMoreAtStartThreshold viewport dall’inizio, una volta per avvicinamento dopo uno scorrimento.
onViewableItemsChange.on_viewable_items_change{ viewableItems, changed } (ViewableItems)La viewability è cambiata.
onRefresh.on_refreshnessunoTrascinato per aggiornare.

#Metodi del ref

MetodoRustDescrizione
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, percentage(0.5), px(0.0), animated)Scorre fino a rendere visibile l’elemento index. viewPosition lo posiziona: 0 all’inizio del viewport (predefinito), 0.5 al centro, 1 alla fine; viewOffset lo sposta ulteriormente. Risolto nativamente con le dimensioni correnti degli elementi (misurate o stimate).
scrollToOffset({ offset, animated? })scroll_to_offset(px(..), animated)Scorre fino a un offset lungo l’asse della lista.
scrollToEnd({ animated? })scroll_to_end(animated)Scorre fino alla fine del contenuto (l’inizio, per una lista invertita).
scrollTo({ x?, y?, animated? })scroll_to(point(..), animated)Scorre fino a un offset.

#Esempi

Una griglia con header e viewability:

let visible = signal(SharedString::default());
let list = element_ref();

virtual_list(move || photos.with(|p| p.len()), move |at| {
    image(move || photos.with(|p| p[at.index()].url.clone())).h(px(120.0)).rounded(px(8.0))
})
.element_ref(&list)
.columns(3)
.estimated_item_size(px(120.0))
.content_padding(px(12.0))
.row_gap(px(8.0))
.column_gap(px(8.0))
.viewability(Viewability { item_visible_threshold: Some(Percentage(0.5)), ..Viewability::default() })
.on_viewable_items_change(move |items: &ViewableItems| {
    visible.set(format!("{} visible", items.indices().count()).into())
})
.header(
    text("Jump to 60")
        .on_press(move || list.scroll_to_index(60, percentage(0.5), px(0.0), true)),
)
.flex_1()

Una chat:

virtual_list(move || messages.with(|m| m.len()), move |at| bubble(messages, at))
    .keys(move || messages.with(|m| m.iter().map(|m| m.id).collect::<Vec<_>>()))
    .inverted()
    .estimated_item_size(px(64.0))
    .keyboard_dismiss_mode(KeyboardDismissMode::Interactive)
    .flex_1()

L’elemento 0 è il messaggio più recente, disegnato in basso; i nuovi messaggi anteposti agli elementi compaiono senza spostare ciò che l’utente sta leggendo.

#Note sulle piattaforme

  • L’inserimento di elementi sopra le righe visibili mantiene le righe visibili al loro posto su entrambe le piattaforme.
  • Le liste orizzontali nei layout da destra a sinistra sono specchiate nativamente.
  • Gli header sticky vengono fissati nativamente a ogni frame di scorrimento.