Elementos
List
List muestra datos largos o sin límite. Existen las filas cercanas al viewport: a medida que el usuario se desplaza, las filas que salen se reciclan para las filas que entran. Qué filas existen, sus posiciones, columnas, cabeceras fijas y visibilidad se calculan de forma nativa, de modo que desplazar una lista de miles de elementos cuesta lo mismo que una de veinte. Rust crea una con virtual_list(count, |at| row), TSX con <List> desde 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);En TSX, List es genérico: List<T> comprueba que each y la función de fila coinciden en el tipo de elemento, que TypeScript infiere a partir de each (consulta la List API). En Rust, virtual_list recibe el número de filas (un valor, una señal o una closure: la lista crece y se encoge in situ) y la función que construye la fila en un Position; las filas leen los datos del componente mediante at.index().
Para una lista corta cuyas filas deben existir todas (una página de ajustes, un formulario), un Each dentro de una vista de desplazamiento es más sencillo. Para una lista con estilo de tabla agrupada, consulta una lista al estilo de ajustes. La guía de listas cubre patrones: carga infinita, chat, secciones.
#Las filas se reciclan
La función de fila se ejecuta una vez por celda, no una vez por elemento. Cuando una celda se reutiliza para otro elemento, su posición cambia (TSX: los accesores item() y index(); Rust: at.index()) y todo lo que la lee se actualiza:
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>Por ello, mantén el estado de cada elemento en tus datos (con clave por id), no en señales creadas dentro de la función de fila: una señal así permanece con la celda y aparece en otro elemento.
Las celdas que esperan su siguiente elemento se ocultan cuando su fila ya no está o la muestra otra celda, de modo que los lectores de pantalla, los toques y las pruebas de UI encuentran cada fila una sola vez.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
each |
virtual_list(count, ..) |
readonly T[] |
Los elementos. | |
children |
virtual_list(.., |at| row) |
(item: () => T, index: () => number) => JSX.Element |
Renderiza una fila. | |
estimatedItemSize |
.estimated_item_size(px(..)) |
number |
50 |
El tamaño de los elementos aún no medidos, en puntos. Las estimaciones cercanas mantienen estable la barra de desplazamiento. |
horizontal |
.horizontal() |
boolean |
false |
Se desplaza lateralmente. |
viewport |
.viewport(ListViewport::..) |
"own" | "parent" |
"own" |
"own": la lista se desplaza en una vista de desplazamiento propia. "parent": se extiende a su longitud completa en el layout de su padre y renderiza las filas cercanas al viewport de la vista de desplazamiento que la rodea, que la desplaza junto con el resto de la página (consulta listas en una página). Se elige al crear la lista. |
numColumns |
.columns(n) |
number |
1 |
Elementos por fila (listas verticales). |
inverted |
.inverted() |
boolean |
false |
Se dispone desde el final, con el elemento 0 en último lugar (estilo chat): empieza desplazada al final y mantiene su distancia al final cuando el contenido cambia. |
maintainVisibleContentPosition |
.follow_end(px(threshold)) |
{ autoscrollToBottomThreshold?: number } |
Para listas no invertidas: al final del contenido, o a esa cantidad de puntos o menos de él, la lista permanece al final cuando el contenido crece o el viewport se reduce; si está desplazada más arriba, el contenido visible se mantiene. | |
stickyHeaderIndices |
.sticky_header_indices([..]) |
number[] |
Elementos que se quedan fijos al inicio mientras se desplaza más allá de ellos (una columna, no invertida). | |
contentStyle |
.content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), … |
ListContentStyle |
Relleno y espacios del contenido, en puntos: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap. |
|
contentClass |
string |
Relleno y espacios del contenido como clases (p-*, gap-*); contentStyle prevalece sobre ellas. |
||
header |
.header(el) |
ListSlot |
Antes de los elementos: un componente (Header) o un elemento (<Header />). |
|
footer |
.footer(el) |
ListSlot |
Después de los elementos. | |
empty |
.empty(el) |
ListSlot |
Se muestra en lugar de los elementos mientras each está vacío. |
|
separator |
.separator(|| el) |
ListSlot |
Entre elementos (una columna). | |
initialScrollIndex |
.initial_scroll_index(i) |
number |
Se desplaza a este elemento antes del primer fotograma. | |
keyExtractor |
.keys(|| ids) |
(item: T, index: number) => string |
item.key, item.id o el índice |
La clave de un elemento en los tokens de visibilidad. En Rust, las identidades de las filas, de modo que las filas conservan sus celdas y tamaños cuando se añaden o quitan filas antes que ellas. |
onLoadMore |
.on_load_more(..) |
() => void |
El final está cerca: carga más. | |
loadMoreThreshold |
.load_more_threshold(viewports) |
number |
1 |
A qué distancia del final (en viewports) se dispara onLoadMore. |
onLoadMoreAtStart |
.on_load_more_at_start(..) |
() => void |
El inicio está cerca, después de que el usuario o un comando de desplazamiento moviera la lista: carga lo que viene antes del primer elemento. | |
loadMoreAtStartThreshold |
.load_more_at_start_threshold(viewports) |
number |
1 |
A qué distancia del inicio (en viewports) se dispara onLoadMoreAtStart. |
onViewableItemsChange |
.on_viewable_items_change(..) |
(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => void |
Los elementos pasaron a ser visibles o dejaron de serlo. | |
viewabilityConfig |
.viewability(..), .minimum_view_time(..) |
ViewabilityConfig |
Cuándo se considera que los elementos son visibles (abajo). | |
refreshing |
.refreshing(..) |
boolean |
Se muestra el indicador de pull-to-refresh. | |
onRefresh |
.on_refresh(..) |
() => void |
El usuario tiró para refrescar. | |
refreshIndicator |
.refresh_indicator(Some(height)) |
{ height: number } |
La app dibuja su propio indicador, de height puntos de alto: el indicador de la plataforma se oculta, un tirón refresca al alcanzar height, y mientras refresca se mantiene height por encima del contenido. Una altura de 0 o menos equivale a ninguno. |
|
pullValue |
.pull_value(&value) |
AnimatedValue |
Un valor animado alimentado con cuánto se ha tirado de la lista más allá de su parte superior (0 en reposo, creciendo con el tirón; el espacio que se mantiene sobre el contenido mientras refresca: height con refreshIndicator), para el indicador de la app. |
|
scrollbar |
.scrollbar(..) |
boolean | ScrollbarTheme |
true |
Se muestra el indicador de desplazamiento: el de la plataforma, o la barra integrada de la app una vez que esta define un tema; un objeto es un tema propio de la vista. En el escritorio y en los navegadores de escritorio una vista horizontal lo conserva: es la barra de desplazamiento del ratón. |
scrollValue |
.scroll_value(&value) |
AnimatedValue |
Un valor animado alimentado con el offset de desplazamiento, para animaciones vinculadas al scroll. | |
keyboardDismissMode |
.keyboard_dismiss_mode(..) |
"none" | "on-drag" | "interactive" |
"none" |
Si arrastrar descarta el teclado. |
style |
métodos de Styled |
StyleProp<ViewStyle> |
El estilo propio de la lista (necesita un tamaño acotado: flex: 1 o una altura). |
|
testID |
.test_id(id) |
string |
El id con el que las pruebas de UI encuentran la vista de desplazamiento de la lista (nsh test de id:, XCUITest, UI Automator). |
|
ref |
.element_ref(&handle) |
ListRef | (node: ListRef) => void |
La ref de la lista (consulta métodos de ref). |
Las listas también aceptan las props de desplazamiento de Scroll (los métodos de Scrollable en Rust):
pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate,
bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle y scrollable.
Las listas conservan su escala.
Las props que acepta todo elemento (props comunes) pasan tal cual a la vista de desplazamiento de la lista:
testID, accessibilityLabel y las demás props de accesibilidad, los estilos, onLayout, y las props de toque y de responder.
Las pruebas de UI encuentran una lista por su testID igual que cualquier vista.
ViewabilityConfig (Rust: Viewability { item_visible_threshold, view_area_coverage_threshold } y .minimum_view_time(..)):
| Campo | Tipo | Descripción |
|---|---|---|
itemVisibleThreshold |
number |
Cuánto de un elemento debe ser visible, de 0 a 1. |
viewAreaCoverageThreshold |
number |
Cuánto del viewport debe cubrir un elemento, de 0 a 1. Sin ninguno de los dos umbrales, cuenta cualquier píxel visible. |
minimumViewTime |
number |
Milisegundos que los elementos deben permanecer visibles antes de ser notificados. |
ViewToken<T> es { item, key, index, isViewable }. El listener de Rust recibe ViewableItems: indices() es el rango de filas visibles, changed() las filas que entraron o salieron.
#Eventos
| Evento | Rust | Payload | Cuándo |
|---|---|---|---|
onScroll |
.on_scroll |
ScrollEvent |
El offset cambió. |
onScrollDragStart / onScrollDragEnd |
.on_scroll_drag_start / .on_scroll_drag_end |
{ offset } |
El arrastre empezó / terminó. |
onMomentumStart / onMomentumEnd |
.on_momentum_start / .on_momentum_end |
{ offset } |
Un deslizamiento por inercia empezó / terminó. |
onScrollEnd |
.on_scroll_end |
{ offset } |
El desplazamiento se detuvo. |
onLoadMore |
.on_load_more |
ninguno | A loadMoreThreshold viewports o menos del final. |
onLoadMoreAtStart |
.on_load_more_at_start |
ninguno | A loadMoreAtStartThreshold viewports o menos del inicio, una vez por aproximación tras un desplazamiento. |
onViewableItemsChange |
.on_viewable_items_change |
{ viewableItems, changed } (ViewableItems) |
La visibilidad cambió. |
onRefresh |
.on_refresh |
ninguno | Se tiró para refrescar. |
#Métodos de ref
| Método | Rust | Descripción |
|---|---|---|
scrollToIndex({ index, animated?, viewPosition?, viewOffset? }) |
scroll_to_index(index, percentage(0.5), px(0.0), animated) |
Desplaza el elemento index hasta hacerlo visible. viewPosition lo coloca: 0 al inicio del viewport (por defecto), 0.5 en el medio, 1 al final; viewOffset lo desplaza aún más. Se resuelve de forma nativa con los tamaños de elemento actuales (medidos o estimados). |
scrollToOffset({ offset, animated? }) |
scroll_to_offset(px(..), animated) |
Desplaza a un offset a lo largo del eje de la lista. |
scrollToEnd({ animated? }) |
scroll_to_end(animated) |
Desplaza al final del contenido (al inicio en una lista invertida). |
scrollTo({ x?, y?, animated? }) |
scroll_to(point(..), animated) |
Desplaza a un offset. |
#Ejemplos
Una cuadrícula con cabecera y visibilidad:
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>;Un 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>El elemento 0 es el mensaje más reciente, dibujado en la parte inferior; los mensajes nuevos añadidos al principio de los elementos aparecen sin mover lo que el usuario está leyendo.
#Notas de plataforma
- Insertar elementos por encima de las filas visibles mantiene las filas visibles en su sitio en ambas plataformas.
- Las listas horizontales en layouts de derecha a izquierda se reflejan de forma nativa.
- Las cabeceras fijas se anclan de forma nativa en cada fotograma de desplazamiento.