元素

List

List 用来展示很长或无上限的数据。视口附近的行才真正存在:用户滚动时,离开视口的行会被回收,用来显示新进入的行。哪些行存在、它们的位置、多列布局、吸顶标题和可见性都在原生侧计算,所以滚动一个上千项的列表,开销和二十项的列表相当。Rust 用 virtual_list(count, |at| row) 创建,TSX 用从 nsh 导入的 <List>。

el-list.tsx
el-list.tsx运行中
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!("联系人 {}", 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() 读取组件的数据。

如果列表很短、所有行都应该存在(设置页、表单),在滚动视图里用 Each 更简单。分组表格样式的列表见设置风格的列表。列表指南 介绍了常见模式:无限加载、聊天、分组。

#行会被复用

行函数对每个单元格执行一次,而不是对每个条目执行一次。单元格被复用来显示另一个条目时,它的位置会变化(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 区分),不要放在行函数内部创建的 signal 里:那样的 signal 会跟着单元格走,出现在别的条目上。

等待下一个条目的单元格,一旦它的行已不存在或由别的单元格显示,就会被隐藏,所以屏幕阅读器、触摸和 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":列表在自己的滚动视图中滚动。"parent":列表没有自己的滚动视图,以完整长度排在父视图的布局中,只渲染外层滚动视图视口附近的行,随页面一起滚动(见页面中的列表)。在列表创建时确定。
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)ListSloteach 为空时代替条目显示。
separator.separator(|| el)ListSlot条目之间的分隔(单列)。
initialScrollIndex.initial_scroll_index(i)number第一帧之前先滚动到该条目。
keyExtractor.keys(|| ids)(item: T, index: number) => stringitem.key、item.id 或索引条目在可见性 token 中的 key。在 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显示下拉刷新的转圈。
onRefresh.on_refresh(..)() => void用户下拉刷新。
refreshIndicator.refresh_indicator(Some(height)){ height: number }应用自己绘制指示器,高 height 点:平台的转圈隐藏,下拉到 height 即刷新,刷新中在内容上方留出 height。height 不大于 0 时视为未设。
pullValue.pull_value(&value)AnimatedValue一个动画值,写入列表被拉过顶部的距离(静止为 0,随下拉增大;刷新中为内容上方留出的空间,设了 refreshIndicator 时等于 height),用来绘制应用的指示器。
scrollbar.scrollbar(..)boolean | ScrollbarThemetrue显示滚动条:平台的滚动指示器,或应用设置主题后的内置滚动条;对象是该视图自己的主题。在桌面和桌面浏览器中,横向视图始终保留滚动条:它是鼠标的滚动条。
scrollValue.scroll_value(&value)AnimatedValue接收滚动偏移的动画值,用于随滚动变化的动画。
keyboardDismissMode.keyboard_dismiss_mode(..)"none" | "on-drag" | "interactive""none"拖动时是否收起键盘。
styleStyled 方法StyleProp<ViewStyle>列表自身的样式(需要有确定的尺寸:flex: 1 或高度)。
testID.test_id(id)stringUI 测试查找列表滚动视图所用的 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、触摸和响应者属性。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!("{} 项可见", items.indices().count()).into())
})
.header(
    text("跳到第 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 项是最新的消息,画在最底部;新消息插到条目开头时,用户正在看的内容不会跳动。

#平台说明

  • 在可见行之上插入条目时,两个平台都会保持可见行不动。
  • 从右到左布局中的横向列表在原生侧镜像。
  • 吸顶标题在每一帧滚动时于原生侧固定。