Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

List

List показывает длинные или неограниченные данные. Существуют только строки рядом с областью просмотра: по мере прокрутки строки, которые уходят за её пределы, переиспользуются для строк, которые появляются. Какие строки существуют, их позиции, колонки, закреплённые заголовки и видимость — всё это вычисляется нативно, поэтому прокрутка списка из тысяч элементов обходится так же дёшево, как списка из двадцати. В Rust список создаётся с помощью virtual_list(count, |at| row), в TSX — с помощью <List> из 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()
}

В TSX List является обобщённым: List<T> проверяет, что each и функция строки согласованы по типу элемента, который TypeScript выводит из each (см. List API). В Rust virtual_list принимает количество строк (значение, сигнал или замыкание: список растёт и сокращается на месте) и функцию, которая строит строку по Position; строки читают данные компонента через at.index().

Для короткого списка, строки которого должны существовать все сразу (страница настроек, форма), проще использовать Each внутри scroll view. Список, оформленный как сгруппированная таблица, описан в разделе список в стиле настроек. В руководстве по спискам разобраны типовые шаблоны: бесконечная загрузка, чат, секции.

#Строки переиспользуются

Функция строки выполняется один раз для каждой ячейки, а не для каждого элемента. Когда ячейка переиспользуется для другого элемента, её позиция меняется (TSX: аксессоры item() и index(); Rust: at.index()), и обновляется всё, что её читает:

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

Поэтому храните состояние отдельных элементов в своих данных (по ключу id), а не в сигналах, созданных внутри функции строки: такой сигнал остаётся за ячейкой и окажется на другом элементе.

Ячейки, ожидающие следующего элемента, скрываются, как только их строка исчезла или показана другой ячейкой, поэтому программы чтения с экрана, касания и UI-тесты встречают каждую строку один раз.

#Свойства

СвойствоRustТипПо умолчаниюОписание
eachvirtual_list(count, ..)readonly T[]Элементы.
childrenvirtual_list(.., |at| row)(item: () => T, index: () => number) => JSX.ElementОтрисовывает одну строку.
estimatedItemSize.estimated_item_size(px(..))number50Размер ещё не измеренных элементов, в пунктах. Близкие оценки делают полосу прокрутки стабильной.
horizontal.horizontal()booleanfalseПрокрутка по горизонтали.
viewport.viewport(ListViewport::..)"own" | "parent""own""own": список прокручивается в собственном scroll view. "parent": он занимает в раскладке родителя всю свою длину и отрисовывает строки рядом с областью просмотра окружающего scroll view, который прокручивает его вместе с остальной страницей (см. списки на странице). Выбирается при создании списка.
numColumns.columns(n)number1Количество элементов в строке (вертикальные списки).
inverted.inverted()booleanfalseРаскладка с конца, элемент 0 последний (как в чате): список изначально прокручен в конец и сохраняет расстояние до конца при изменении содержимого.
maintainVisibleContentPosition.follow_end(px(threshold)){ autoscrollToBottomThreshold?: number }Для неинвертированных списков: в конце содержимого или в пределах указанного числа пунктов от него список остаётся в конце, когда содержимое растёт или область просмотра уменьшается; если прокручено выше, видимое содержимое остаётся на месте.
stickyHeaderIndices.sticky_header_indices([..])number[]Элементы, которые прилипают к началу после прокрутки мимо них (одна колонка, не инвертированный список).
contentStyle.content_padding(..), .content_padding_x(..), .row_gap(..), .column_gap(..), …ListContentStyleОтступы и промежутки содержимого, в пунктах: padding, paddingHorizontal, paddingVertical, paddingTop, paddingRight, paddingBottom, paddingLeft, gap, rowGap, columnGap.
contentClassstringОтступы и промежутки содержимого в виде классов (p-*, gap-*); contentStyle имеет над ними приоритет.
header.header(el)ListSlotПеред элементами: компонент (Header) или элемент (<Header />).
footer.footer(el)ListSlotПосле элементов.
empty.empty(el)ListSlotПоказывается вместо элементов, пока each пуст.
separator.separator(|| el)ListSlotМежду элементами (одна колонка).
initialScrollIndex.initial_scroll_index(i)numberПрокручивает к этому элементу до первого кадра.
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key, item.id или индексКлюч элемента в токенах видимости. В Rust — идентичность строк, благодаря которой строки сохраняют свои ячейки и размеры, когда перед ними строки появляются или исчезают.
onLoadMore.on_load_more(..)() => voidКонец близко: загрузить ещё.
loadMoreThreshold.load_more_threshold(viewports)number1Насколько близко к концу (в областях просмотра) срабатывает onLoadMore.
onLoadMoreAtStart.on_load_more_at_start(..)() => voidНачало близко, после того как пользователь или команда прокрутки сдвинули список: загрузить то, что идёт перед первым элементом.
loadMoreAtStartThreshold.load_more_at_start_threshold(viewports)number1Насколько близко к началу (в областях просмотра) срабатывает onLoadMoreAtStart.
onViewableItemsChange.on_viewable_items_change(..)(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => voidЭлементы стали видимыми или перестали быть видимыми.
viewabilityConfig.viewability(..), .minimum_view_time(..)ViewabilityConfigКогда элементы считаются видимыми (см. ниже).
refreshing.refreshing(..)booleanПоказан индикатор pull-to-refresh.
onRefresh.on_refresh(..)() => voidПользователь потянул список для обновления.
refreshIndicator.refresh_indicator(Some(height)){ height: number }Приложение рисует собственный индикатор высотой height пунктов: системный индикатор скрыт, обновление запускается, когда натяжение достигает height, а во время обновления над содержимым сохраняется height. Высота 0 или меньше означает отсутствие индикатора.
pullValue.pull_value(&value)AnimatedValueАнимированное значение, в которое подаётся, насколько список оттянут за верхний край (0 в покое, растёт с натяжением; место, сохраняемое над содержимым во время обновления: height при refreshIndicator), для индикатора приложения.
scrollbar.scrollbar(..)boolean | ScrollbarThemetrueПоказывается индикатор прокрутки: системный или встроенная полоса приложения, если оно задало тему; объект задаёт тему для самого представления. На десктопе и в десктопных браузерах горизонтальное представление сохраняет индикатор: это полоса прокрутки для мыши.
scrollValue.scroll_value(&value)AnimatedValueАнимированное значение, в которое подаётся смещение прокрутки, для анимаций, связанных с прокруткой.
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"Скрывает ли перетаскивание клавиатуру.
styleметоды StyledStyleProp<ViewStyle>Собственный стиль списка (нужен ограниченный размер: flex: 1 или высота).
testID.test_id(id)stringИдентификатор, по которому UI-тесты находят scroll view списка (nsh test — id:, XCUITest, UI Automator).
ref.element_ref(&handle)ListRef | (node: ListRef) => voidСсылка (ref) на список (см. методы ref).

Списки также принимают свойства прокрутки Scroll (в Rust — методы Scrollable): pagingEnabled, snapToInterval, snapToOffsets, snapToAlignment, disableIntervalMomentum, decelerationRate, bounces, contentInset, contentOffset, keyboardShouldPersistTaps, scrollEventThrottle и scrollable. Списки сохраняют свой масштаб.

Свойства, которые принимает любой элемент (общие свойства), передаются в scroll view списка без изменений: testID, accessibilityLabel и остальные свойства доступности, стили, onLayout, свойства касаний и responder. UI-тесты находят список по его testID так же, как любое другое представление.

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

ПолеТипОписание
itemVisibleThresholdnumberКакая часть элемента должна быть видна, от 0 до 1.
viewAreaCoverageThresholdnumberКакую часть области просмотра должен занимать элемент, от 0 до 1. Если ни один порог не задан, учитывается любой видимый пиксель.
minimumViewTimenumberСколько миллисекунд элементы должны оставаться видимыми, прежде чем о них будет сообщено.

ViewToken<T> — это { item, key, index, isViewable }. Обработчик в Rust получает ViewableItems: indices() — диапазон видимых строк, changed() — строки, которые появились или исчезли.

#События

СобытиеRustДанныеКогда
onScroll.on_scrollScrollEventСмещение изменилось.
onScrollDragStart / onScrollDragEnd.on_scroll_drag_start / .on_scroll_drag_end{ offset }Перетаскивание началось / закончилось.
onMomentumStart / onMomentumEnd.on_momentum_start / .on_momentum_end{ offset }Инерционная прокрутка началась / закончилась.
onScrollEnd.on_scroll_end{ offset }Прокрутка остановилась.
onLoadMore.on_load_moreнетВ пределах loadMoreThreshold областей просмотра от конца.
onLoadMoreAtStart.on_load_more_at_startнетВ пределах loadMoreAtStartThreshold областей просмотра от начала, один раз за приближение после прокрутки.
onViewableItemsChange.on_viewable_items_change{ viewableItems, changed } (ViewableItems)Видимость изменилась.
onRefresh.on_refreshнетСписок потянули для обновления.

#Методы ref

МетодRustОписание
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })scroll_to_index(index, percentage(0.5), px(0.0), animated)Прокручивает так, чтобы элемент index оказался в области просмотра. viewPosition задаёт его положение: 0 — в начале области просмотра (по умолчанию), 0.5 — посередине, 1 — в конце; viewOffset сдвигает его дальше. Вычисляется нативно с учётом текущих (измеренных или оценённых) размеров элементов.
scrollToOffset({ offset, animated? })scroll_to_offset(px(..), animated)Прокручивает до смещения вдоль оси списка.
scrollToEnd({ animated? })scroll_to_end(animated)Прокручивает до конца содержимого (до начала для инвертированного списка).
scrollTo({ x?, y?, animated? })scroll_to(point(..), animated)Прокручивает до смещения.

#Примеры

Сетка с заголовком и отслеживанием видимости:

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

Чат:

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

Элемент 0 — самое новое сообщение, отображаемое внизу; новые сообщения, добавленные в начало списка элементов, появляются, не сдвигая то, что читает пользователь.

#Особенности платформ

  • Вставка элементов выше видимых строк сохраняет видимые строки на месте на обеих платформах.
  • Горизонтальные списки в раскладках справа налево зеркально отражаются нативно.
  • Закреплённые заголовки фиксируются нативно на каждом кадре прокрутки.