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.
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()
}import { List, mount, Text, View } from "nsh";
const contacts = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `Contact ${i + 1}` }));
function App() {
return (
<List each={contacts} estimatedItemSize={56} style={{ flex: 1 }}>
{(contact, index) => (
<View style={{ height: 56, paddingHorizontal: 16, justifyContent: "center" }}>
<Text style={{ fontSize: 16 }}>
{index() + 1}. {contact().name}
</Text>
</View>
)}
</List>
);
}
mount(App);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()))
})<List each={messages()}>
{(message) => <Text>{message().body}</Text>}
</List>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()const visible = signal("");
let list!: ListRef;
<List
ref={list}
each={photos}
numColumns={3}
estimatedItemSize={120}
contentStyle={{ padding: 12, gap: 8 }}
viewabilityConfig={{ itemVisibleThreshold: 0.5 }}
onViewableItemsChange={({ viewableItems }) => visible.set(`${viewableItems.length} visible`)}
header={<Text onPress={() => list.scrollToIndex({ index: 60, viewPosition: 0.5 })}>Jump to 60</Text>}
style={{ flex: 1 }}
>
{(photo) => <Image src={photo().url} style={{ height: 120, borderRadius: 8 }} />}
</List>;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()<List each={messages()} inverted estimatedItemSize={64} keyboardDismissMode="interactive" style={{ flex: 1 }}>
{(m) => <Bubble message={m()} />}
</List>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.