Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Éléments

List

List affiche des données longues ou non bornées. Seules les lignes proches de la zone visible existent : lorsque l’utilisateur fait défiler, les lignes qui sortent sont recyclées pour celles qui entrent. Les lignes qui existent, leurs positions, les colonnes, les en-têtes collants et la visibilité sont tous calculés nativement, si bien que faire défiler une liste de milliers d’éléments coûte autant qu’une liste de vingt. Rust en crée une avec virtual_list(count, |at| row), TSX avec <List> depuis 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 est générique : List<T> vérifie que each et la fonction de ligne s’accordent sur le type des éléments, que TypeScript infère à partir de each (voir l’API List). En Rust, virtual_list prend le nombre de lignes (une valeur, un signal ou une closure : la liste grandit et rétrécit sur place) et la fonction qui construit la ligne à un Position donné ; les lignes lisent les données du composant par at.index().

Pour une liste courte dont toutes les lignes doivent exister (une page de réglages, un formulaire), un Each dans une scroll view est plus simple. Pour une liste stylée comme un tableau groupé, voir une liste de style réglages. Le guide des listes couvre les patterns : chargement infini, chat, sections.

#Les lignes sont recyclées

La fonction de ligne s’exécute une fois par cellule, et non une fois par élément. Lorsqu’une cellule est réutilisée pour un autre élément, sa position change (TSX : les accesseurs item() et index() ; Rust : at.index()) et tout ce qui la lit est mis à jour :

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

Conservez donc l’état de chaque élément dans vos données (indexé par id), et non dans des signaux créés à l’intérieur de la fonction de ligne : un tel signal reste attaché à la cellule et apparaît sur un autre élément.

Les cellules qui attendent leur prochain élément sont masquées une fois leur ligne disparue ou affichée par une autre cellule, de sorte que les lecteurs d’écran, les touches et les tests d’UI rencontrent chaque ligne une seule fois.

#Props

PropRustTypeDéfautDescription
eachvirtual_list(count, ..)readonly T[]Les éléments.
childrenvirtual_list(.., |at| row)(item: () => T, index: () => number) => JSX.ElementRend une ligne.
estimatedItemSize.estimated_item_size(px(..))number50La taille des éléments pas encore mesurés, en points. Des estimations proches stabilisent la barre de défilement.
horizontal.horizontal()booleanfalseDéfile horizontalement.
viewport.viewport(ListViewport::..)"own" | "parent""own""own" : la liste défile dans une scroll view qui lui est propre. "parent" : elle s’étend sur toute sa longueur dans la mise en page de son parent et rend les lignes proches de la zone visible de la scroll view qui l’entoure, laquelle la fait défiler avec le reste de la page (voir les listes dans une page). Choisi à la création de la liste.
numColumns.columns(n)number1Éléments par ligne (listes verticales).
inverted.inverted()booleanfalseDisposée à partir de la fin, l’élément 0 en dernier (style chat) : elle démarre défilée jusqu’à la fin et conserve sa distance à la fin lorsque le contenu change.
maintainVisibleContentPosition.follow_end(px(threshold)){ autoscrollToBottomThreshold?: number }Pour les listes non inversées : à la fin du contenu, ou à moins de ce nombre de points de celle-ci, la liste reste à la fin lorsque le contenu grandit ou que la zone visible rétrécit ; si elle est défilée plus haut, le contenu visible reste en place.
stickyHeaderIndices.sticky_header_indices([..])number[]Éléments qui restent collés au début une fois dépassés par le défilement (une colonne, non inversée).
contentStyle.content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), …ListContentStyleMarges intérieures et espacements du contenu, en points : padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
contentClassstringMarges intérieures et espacements du contenu sous forme de classes (p-*, gap-*) ; contentStyle l’emporte sur elles.
header.header(el)ListSlotAvant les éléments : un composant (Header) ou un élément (<Header />).
footer.footer(el)ListSlotAprès les éléments.
empty.empty(el)ListSlotAffiché à la place des éléments tant que each est vide.
separator.separator(|| el)ListSlotEntre les éléments (une colonne).
initialScrollIndex.initial_scroll_index(i)numberDéfile jusqu’à cet élément avant la première image.
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key, item.id ou l’indexLa clé d’un élément dans les jetons de visibilité. En Rust, l’identité des lignes, afin que les lignes conservent leurs cellules et leurs tailles lorsque des lignes apparaissent ou disparaissent avant elles.
onLoadMore.on_load_more(..)() => voidLa fin est proche : charger davantage.
loadMoreThreshold.load_more_threshold(viewports)number1À quelle distance de la fin (en zones visibles) onLoadMore se déclenche.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidLe début est proche, après que l’utilisateur ou une commande de défilement a déplacé la liste : charger ce qui précède le premier élément.
loadMoreAtStartThreshold.load_more_at_start_threshold(viewports)number1À quelle distance du début (en zones visibles) onLoadMoreAtStart se déclenche.
onViewableItemsChange.on_viewable_items_change(..)(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidDes éléments sont devenus visibles ou ont cessé de l’être.
viewabilityConfig.viewability(..), .minimum_view_time(..)ViewabilityConfigQuand les éléments comptent comme visibles (ci-dessous).
refreshing.refreshing(..)booleanLe spinner de pull-to-refresh s’affiche.
onRefresh.on_refresh(..)() => voidL’utilisateur a tiré pour rafraîchir.
refreshIndicator.refresh_indicator(Some(height)){ height: number }L’app dessine son propre indicateur, de height points de haut : le spinner de la plateforme est masqué, un tirage rafraîchit dès qu’il atteint height, et pendant le rafraîchissement height est conservé au-dessus du contenu. Une hauteur de 0 ou moins signifie aucun.
pullValue.pull_value(&value)AnimatedValueUne valeur animée alimentée par la distance dont la liste est tirée au-delà de son sommet (0 au repos, croissante avec le tirage ; l’espace conservé au-dessus du contenu pendant le rafraîchissement : height avec refreshIndicator), pour l’indicateur de l’app.
scrollbar.scrollbar(..)boolean | ScrollbarThemetrueL’indicateur de défilement s’affiche : celui de la plateforme, ou la barre intégrée de l’app dès qu’elle a défini un thème ; un objet est un thème propre à la vue. Sur le bureau et dans les navigateurs de bureau, une vue horizontale le conserve : c’est la barre de défilement de la souris.
scrollValue.scroll_value(&value)AnimatedValueUne valeur animée alimentée par le décalage de défilement, pour les animations liées au défilement.
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"Indique si le glissement ferme le clavier.
styleméthodes StyledStyleProp<ViewStyle>Le style propre de la liste (elle a besoin d’une taille bornée : flex: 1 ou une hauteur).
testID.test_id(id)stringL’id par lequel les tests d’UI trouvent la scroll view de la liste (id: de nsh test, XCUITest, UI Automator).
ref.element_ref(&handle)ListRef | (node: ListRef) => voidLa ref de la liste (voir les méthodes de ref).

Les listes acceptent aussi les props de défilement de Scroll (les méthodes Scrollable en Rust) : pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle et scrollable. Les listes conservent leur échelle.

Les props que tout élément accepte (props communes) sont transmises telles quelles à la scroll view de la liste : testID, accessibilityLabel et les autres props d’accessibilité, les styles, onLayout, ainsi que les props de touch et de responder. Les tests d’UI trouvent une liste par son testID comme n’importe quelle vue.

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

ChampTypeDescription
itemVisibleThresholdnumberLa part d’un élément qui doit être visible, de 0 à 1.
viewAreaCoverageThresholdnumberLa part de la zone visible qu’un élément doit couvrir, de 0 à 1. Sans l’un ou l’autre seuil, tout pixel visible compte.
minimumViewTimenumberMillisecondes pendant lesquelles les éléments doivent rester visibles avant d’être signalés.

ViewToken<T> est { item, key, index, isViewable }. Le listener Rust reçoit ViewableItems : indices() est la plage des lignes visibles, changed() les lignes arrivées ou parties.

#Événements

ÉvénementRustPayloadQuand
onScroll.on_scrollScrollEventLe décalage a changé.
onScrollDragStart / onScrollDragEnd.on_scroll_drag_start / .on_scroll_drag_end{ offset }Le glissement a commencé / s’est arrêté.
onMomentumStart / onMomentumEnd.on_momentum_start / .on_momentum_end{ offset }Une glisse a commencé / s’est arrêtée.
onScrollEnd.on_scroll_end{ offset }Le défilement s’est immobilisé.
onLoadMore.on_load_moreaucunÀ moins de loadMoreThreshold zones visibles de la fin.
onLoadMoreAtStart.on_load_more_at_startaucunÀ moins de loadMoreAtStartThreshold zones visibles du début, une fois par approche après un défilement.
onViewableItemsChange.on_viewable_items_change{ viewableItems, changed } (ViewableItems)La visibilité a changé.
onRefresh.on_refreshaucunTiré pour rafraîchir.

#Méthodes de ref

MéthodeRustDescription
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, percentage(0.5), px(0.0), animated)Fait défiler l’élément index jusqu’à le rendre visible. viewPosition le place : 0 au début de la zone visible (par défaut), 0,5 au milieu, 1 à la fin ; viewOffset le décale davantage. Résolu nativement avec les tailles d’éléments actuelles (mesurées ou estimées).
scrollToOffset({ offset, animated? })scroll_to_offset(px(..), animated)Défile jusqu’à un décalage le long de l’axe de la liste.
scrollToEnd({ animated? })scroll_to_end(animated)Défile jusqu’à la fin du contenu (le début d’une liste inversée).
scrollTo({ x?, y?, animated? })scroll_to(point(..), animated)Défile jusqu’à un décalage.

#Exemples

Une grille avec un en-tête et la visibilité :

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

L’élément 0 est le message le plus récent, dessiné en bas ; les nouveaux messages ajoutés au début des éléments apparaissent sans déplacer ce que l’utilisateur est en train de lire.

#Notes sur les plateformes

  • Insérer des éléments au-dessus des lignes visibles garde les lignes visibles en place sur les deux plateformes.
  • Les listes horizontales dans les mises en page de droite à gauche sont inversées nativement.
  • Les en-têtes collants sont épinglés nativement à chaque image de défilement.