Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

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.

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

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

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

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

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.