Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Elementos

List

List exibe dados longos ou ilimitados. Existem apenas as linhas próximas da viewport: conforme o usuário rola, as linhas que saem são recicladas para as linhas que entram. Quais linhas existem, suas posições, colunas, cabeçalhos fixos e visibilidade são todos calculados nativamente, então rolar uma lista com milhares de itens custa o mesmo que rolar uma lista com vinte. Em Rust, uma lista é criada com virtual_list(count, |at| row); em TSX, com <List> de 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()
}

Em TSX, List é genérico: List<T> verifica se each e a função de linha concordam quanto ao tipo do item, que o TypeScript infere a partir de each (veja a List API). Em Rust, virtual_list recebe o número de linhas (um valor, um signal ou uma closure: a lista cresce e diminui no lugar) e a função que constrói a linha em um Position; as linhas leem os dados do componente por at.index().

Para uma lista curta, cujas linhas devem existir todas (uma página de configurações, um formulário), um Each dentro de uma scroll view é mais simples. Para uma lista estilizada como tabela agrupada, veja uma lista no estilo de configurações. O guia de listas cobre padrões: carregamento infinito, chat, seções.

#Linhas são recicladas

A função de linha é executada uma vez por célula, não uma vez por item. Quando uma célula é reutilizada para outro item, sua posição muda (TSX: os acessores item() e index(); Rust: at.index()) e tudo que a lê é atualizado:

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

Portanto, mantenha o estado por item nos seus dados (indexado por id), e não em signals criados dentro da função de linha: esse signal permanece com a célula e aparece em outro item.

As células que aguardam seu próximo item ficam ocultas quando sua linha deixa de existir ou é exibida por outra célula, de modo que leitores de tela, toques e testes de UI encontrem cada linha uma única vez.

#Props

Prop Rust Tipo Padrão Descrição
each virtual_list(count, ..) readonly T[] Os itens.
children virtual_list(.., |at| row) (item: () => T, index: () => number) => JSX.Element Renderiza uma linha.
estimatedItemSize .estimated_item_size(px(..)) number 50 O tamanho dos itens ainda não medidos, em pontos. Estimativas próximas mantêm a barra de rolagem estável.
horizontal .horizontal() boolean false Rola na horizontal.
viewport .viewport(ListViewport::..) "own" | "parent" "own" "own": a lista rola em uma scroll view própria. "parent": ela ocupa todo o seu comprimento no layout do pai e renderiza as linhas próximas da viewport da scroll view ao redor, que a rola junto com o restante da página (veja listas em uma página). Escolhido quando a lista é criada.
numColumns .columns(n) number 1 Itens por linha (listas verticais).
inverted .inverted() boolean false Disposta a partir do fim, com o item 0 por último (estilo chat): começa rolada até o fim e mantém sua distância do fim conforme o conteúdo muda.
maintainVisibleContentPosition .follow_end(px(threshold)) { autoscrollToBottomThreshold?: number } Para listas não invertidas: no fim do conteúdo, ou a essa quantidade de pontos dele, a lista permanece no fim conforme o conteúdo cresce ou a viewport diminui; se estiver rolada mais para cima, o conteúdo visível permanece.
stickyHeaderIndices .sticky_header_indices([..]) number[] Itens que ficam fixos no início depois de rolados para além dele (uma coluna, não invertida).
contentStyle .content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), … ListContentStyle Padding e espaçamentos do conteúdo, em pontos: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
contentClass string Padding e espaçamentos do conteúdo como classes (p-*, gap-*); contentStyle prevalece sobre elas.
header .header(el) ListSlot Antes dos itens: um componente (Header) ou um elemento (<Header />).
footer .footer(el) ListSlot Depois dos itens.
empty .empty(el) ListSlot Exibido no lugar dos itens enquanto each estiver vazio.
separator .separator(|| el) ListSlot Entre os itens (uma coluna).
initialScrollIndex .initial_scroll_index(i) number Rola até este item antes do primeiro frame.
keyExtractor .keys(|| ids) (item: T, index: number) => string item.key, item.id ou o índice A chave de um item nos tokens de visibilidade. Em Rust, as identidades das linhas, de modo que elas mantêm suas células e tamanhos quando linhas entram e saem antes delas.
onLoadMore .on_load_more(..) () => void O fim está próximo: carregue mais.
loadMoreThreshold .load_more_threshold(viewports) number 1 A que distância do fim (em viewports) onLoadMore é disparado.
onLoadMoreAtStart .on_load_more_at_start(..) () => void O início está próximo, depois que o usuário ou um comando de rolagem moveu a lista: carregue o que vem antes do primeiro item.
loadMoreAtStartThreshold .load_more_at_start_threshold(viewports) number 1 A que distância do início (em viewports) onLoadMoreAtStart é disparado.
onViewableItemsChange .on_viewable_items_change(..) (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => void Itens se tornaram visíveis ou deixaram de ser visíveis.
viewabilityConfig .viewability(..), .minimum_view_time(..) ViewabilityConfig Quando os itens contam como visíveis (abaixo).
refreshing .refreshing(..) boolean O spinner de pull-to-refresh é exibido.
onRefresh .on_refresh(..) () => void O usuário puxou para atualizar.
refreshIndicator .refresh_indicator(Some(height)) { height: number } O app desenha seu próprio indicador, com height pontos de altura: o spinner da plataforma fica oculto, um puxão atualiza assim que atinge height, e durante a atualização height é mantido acima do conteúdo. Uma altura igual ou menor que 0 significa nenhum.
pullValue .pull_value(&value) AnimatedValue Um valor animado alimentado com o quanto a lista foi puxada além do topo (0 em repouso, crescendo com o puxão; o espaço mantido acima do conteúdo durante a atualização: height com refreshIndicator), para o indicador do app.
scrollbar .scrollbar(..) boolean | ScrollbarTheme true O indicador de rolagem é exibido: o da plataforma, ou a barra integrada do app depois que ele define um tema; um objeto é um tema próprio da view. No desktop e em navegadores desktop, uma view horizontal o mantém: ele é a barra de rolagem do mouse.
scrollValue .scroll_value(&value) AnimatedValue Um valor animado alimentado com o offset de rolagem, para animações vinculadas à rolagem.
keyboardDismissMode .keyboard_dismiss_mode(..) "none" | "on-drag" | "interactive" "none" Se arrastar dispensa o teclado.
style métodos de Styled StyleProp<ViewStyle> O estilo próprio da lista (ela precisa de um tamanho limitado: flex: 1 ou uma altura).
testID .test_id(id) string O id pelo qual os testes de UI encontram a scroll view da lista (id: de nsh test, XCUITest, UI Automator).
ref .element_ref(&handle) ListRef | (node: ListRef) => void A ref da lista (veja métodos de ref).

As listas também aceitam as props de rolagem de Scroll (os métodos de Scrollable em Rust): pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle e scrollable. As listas mantêm sua escala.

As props que todo elemento aceita (props comuns) vão para a scroll view da lista como estão: testID, accessibilityLabel e as demais props de acessibilidade, os estilos, onLayout, e as props de toque e de responder. Os testes de UI encontram uma lista pelo seu testID como qualquer outra view.

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

Campo Tipo Descrição
itemVisibleThreshold number Quanto de um item deve estar visível, de 0 a 1.
viewAreaCoverageThreshold number Quanto da viewport um item deve cobrir, de 0 a 1. Sem nenhum dos limites, qualquer pixel visível conta.
minimumViewTime number Milissegundos em que os itens devem permanecer visíveis antes de serem reportados.

ViewToken<T> é { item, key, index, isViewable }. O listener do Rust recebe ViewableItems: indices() é o intervalo de linhas visíveis e changed() são as linhas que entraram ou saíram.

#Eventos

Evento Rust Payload Quando
onScroll .on_scroll ScrollEvent O offset mudou.
onScrollDragStart / onScrollDragEnd .on_scroll_drag_start / .on_scroll_drag_end { offset } O arrasto começou / terminou.
onMomentumStart / onMomentumEnd .on_momentum_start / .on_momentum_end { offset } Um deslizamento começou / terminou.
onScrollEnd .on_scroll_end { offset } A rolagem parou.
onLoadMore .on_load_more nenhum A até loadMoreThreshold viewports do fim.
onLoadMoreAtStart .on_load_more_at_start nenhum A até loadMoreAtStartThreshold viewports do início, uma vez por aproximação após uma rolagem.
onViewableItemsChange .on_viewable_items_change { viewableItems, changed } (ViewableItems) A visibilidade mudou.
onRefresh .on_refresh nenhum Puxado para atualizar.

#Métodos de ref

Método Rust Descrição
scrollToIndex({ index, animated?, viewPosition?, viewOffset? }) scroll_to_index(index, percentage(0.5), px(0.0), animated) Rola o item index para dentro da área visível. viewPosition o posiciona: 0 no início da viewport (padrão), 0.5 no meio, 1 no fim; viewOffset o desloca ainda mais. Resolvido nativamente com os tamanhos atuais dos itens (medidos ou estimados).
scrollToOffset({ offset, animated? }) scroll_to_offset(px(..), animated) Rola até um offset ao longo do eixo da lista.
scrollToEnd({ animated? }) scroll_to_end(animated) Rola até o fim do conteúdo (o início de uma lista invertida).
scrollTo({ x?, y?, animated? }) scroll_to(point(..), animated) Rola até um offset.

#Exemplos

Uma grade com cabeçalho e visibilidade:

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

Um 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()

O item 0 é a mensagem mais recente, desenhada na parte inferior; novas mensagens adicionadas no início dos itens aparecem sem mover o que o usuário está lendo.

#Notas de plataforma

  • Inserir itens acima das linhas visíveis mantém as linhas visíveis no lugar em ambas as plataformas.
  • Listas horizontais em layouts da direita para a esquerda são espelhadas nativamente.
  • Os cabeçalhos fixos são afixados nativamente a cada frame de rolagem.