本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

List

List 顯示冗長或無上限的資料。只有視口附近的列才會存在:用戶捲動時,離開的列會被回收,供進入的列使用。哪些列存在、它們的位置、欄、置頂標題和可見性,全部由原生層計算,因此捲動數千個項目的列表,成本與捲動二十個項目的列表相同。Rust 用 virtual_list(count, |at| row) 建立列表,TSX 則使用來自 nsh 的 <List>。

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 接受列數(可以是值、signal 或閉包:列表會原地增減),以及在 Position 建立該列的函數;各列透過 at.index() 讀取組件的資料。

對於所有列都應該存在的短列表(設定頁、表單),在 scroll view 內放一個 Each 會更簡單。若要將列表設計成分組表格樣式,請參閱設定風格列表。列表指南涵蓋各種模式:無限載入、聊天、分組。

#列會被回收

列函數是每個儲存格執行一次,而不是每個項目執行一次。當儲存格被重用於另一個項目時,其位置會改變(TSX:item() 和 index() accessor;Rust:at.index()),所有讀取它的內容也會隨之更新:

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

因此,請把每個項目的狀態放在你的資料中(以 id 為鍵),而不是放在列函數內建立的 signal:這類 signal 會留在儲存格上,並出現在另一個項目上。

等待下一個項目的儲存格,在其列消失或改由另一個儲存格顯示後會被隱藏,因此螢幕閱讀器、觸控和 UI 測試對每一列只會遇到一次。

#屬性

屬性 Rust 類型 預設值 說明
each virtual_list(count, ..) readonly T[] 項目。
children virtual_list(.., |at| row) (item: () => T, index: () => number) => JSX.Element 渲染一列。
estimatedItemSize .estimated_item_size(px(..)) number 50 尚未測量的項目的尺寸,單位為點。估算越接近,捲動條越穩定。
horizontal .horizontal() boolean false 橫向捲動。
viewport .viewport(ListViewport::..) "own" | "parent" "own" "own":列表在自己的捲動視圖中捲動。"parent":列表以完整長度排在父層的版面中,並渲染外圍捲動視圖視口附近的列,由該捲動視圖連同頁面其餘部分一起捲動(見頁面中的列表)。於建立列表時選定。
numColumns .columns(n) number 1 每列的項目數(垂直列表)。
inverted .inverted() boolean false 從末端開始排版,項目 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。
contentClass string 以 class 形式設定內容的內距和間距(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) => string item.key、item.id 或索引 項目在可見性 token 中的鍵。在 Rust 中,則是各列的識別,讓列在其前方的列增減時仍保留其儲存格和尺寸。
onLoadMore .on_load_more(..) () => void 接近末端:載入更多。
loadMoreThreshold .load_more_threshold(viewports) number 1 距離末端多近(以視口計)時觸發 onLoadMore。
onLoadMoreAtStart .on_load_more_at_start(..) () => void 在用戶或捲動指令移動列表後,接近起點:載入第一個項目之前的內容。
loadMoreAtStartThreshold .load_more_at_start_threshold(viewports) number 1 距離起點多近(以視口計)時觸發 onLoadMoreAtStart。
onViewableItemsChange .on_viewable_items_change(..) (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => void 項目變為可見或不再可見。
viewabilityConfig .viewability(..)、.minimum_view_time(..) ViewabilityConfig 項目何時算作可見(見下文)。
refreshing .refreshing(..) boolean 顯示下拉刷新的轉圈指示器。
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 | ScrollbarTheme true 顯示捲動指示器:使用平台的指示器,或在應用程式設定主題後使用內建捲動條;傳入物件則是該視圖專屬的主題。在桌面平台和桌面瀏覽器上,橫向視圖會保留它:它就是滑鼠的捲動條。
scrollValue .scroll_value(&value) AnimatedValue 一個動畫值,其數值為捲動偏移量,供與捲動連動的動畫使用。
keyboardDismissMode .keyboard_dismiss_mode(..) "none" | "on-drag" | "interactive" "none" 拖曳時是否收起鍵盤。
style Styled 方法 StyleProp<ViewStyle> 列表自身的樣式(需要有限的尺寸:flex: 1 或高度)。
testID .test_id(id) string UI 測試用來找到列表捲動視圖的 id(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。 列表會保持其縮放比例。

每個元素都接受的屬性(通用屬性)會原樣傳給列表的捲動視圖: testID、accessibilityLabel 和其他無障礙屬性、樣式、onLayout,以及觸控和 responder 屬性。 UI 測試可以像尋找任何視圖一樣,透過 testID 找到列表。

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

欄位 類型 說明
itemVisibleThreshold number 項目必須有多少部分可見,範圍為 0 至 1。
viewAreaCoverageThreshold number 項目必須覆蓋視口的多少部分,範圍為 0 至 1。若兩個門檻都沒有設定,則只要有任何一個像素可見即算可見。
minimumViewTime number 項目必須保持可見多少毫秒後才會被回報。

ViewToken<T> 為 { item, key, index, isViewable }。Rust 的監聽器會收到 ViewableItems:indices() 是可見列的範圍,changed() 是進入或離開的列。

#事件

事件 Rust 負載 時機
onScroll .on_scroll ScrollEvent 偏移量改變。
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 是最新的訊息,繪製在底部;加入到項目前端的新訊息會出現,而不會移動用戶正在閱讀的內容。

#平台說明

  • 在可見列上方插入項目時,兩個平台上的可見列都會保持原位。
  • 在由右至左的版面中,橫向列表會由原生層鏡像處理。
  • 置頂標題在每個捲動影格都由原生層固定。