API

nsh/list

nsh/list 导出 List:一个基于原生滚动视图的虚拟化列表。只有视口附近的行才真正存在,滚动时单元格会被复用。哪些行存在、它们的位置、多列布局、吸顶标题和可见性都在原生侧计算,因此滚动一个上万条目的列表时,除了单元格被复用时变化的行绑定之外,不会运行任何 JS。

import { List, type ListProps, type ViewToken, type ViewabilityConfig } from "nsh/list";

教程见列表,元素的属性见 <List>。

#List

function List<T>(props: ListProps<T>): YNode;
import { mount, Text, View } from "nsh";
import { List } from "nsh/list";

const contacts = Array.from({ length: 1000 }, (_, i) => ({ id: String(i), name: `Contact ${i}` }));

function App() {
  return (
    <List each={contacts} estimatedItemSize={48} style={{ flex: 1 }}>
      {(item, index) => (
        <View style={{ height: 48, paddingHorizontal: 16, justifyContent: "center", backgroundColor: index() % 2 ? "#f8fafc" : "#fff" }}>
          <Text>{item().name}</Text>
        </View>
      )}
    </List>
  );
}

mount(App);

#行会被复用

children 是一个渲染单个单元格的函数。它按单元格运行一次,而不是按条目运行一次:单元格滚出视口后会被复用来显示另一个条目,此时 item() 和 index()(两者都是 accessor)会变化。请在 JSX 表达式中读取它们({item().name}),这样单元格被复用时才会更新。

#属性

ListProps<T> 由列表自己的属性和与 <Scroll> 共用的滚动属性组成。

#数据与渲染

属性类型默认值说明
eachreadonly T[](必填)条目数组。传入 signal 的值(each={items()})即可跟随变化;在可见行之上插入条目时,可见行保持不动。
children(item: () => T, index: () => number) => JSX.Element(必填)渲染一行(见上文)。
estimatedItemSizenumber50尚未测量的行沿列表方向的尺寸(点)。估计越准,滚动条和 scrollToIndex 越准确。
numColumnsnumber1每行的条目数(纵向列表):即网格。
horizontalbooleanfalse横向滚动。
invertedbooleanfalse从末端开始布局,条目 0 在最后(聊天风格);初始滚动到末端,内容变化时保持与末端的距离。
stickyHeaderIndicesnumber[]滚动经过时吸附在顶部的条目(仅单列且非 inverted)。
ListHeaderComponentListSlot渲染在所有条目之前。
ListFooterComponentListSlot渲染在所有条目之后。
ListEmptyComponentListSloteach 为空时代替条目显示。
ItemSeparatorComponentListSlot渲染在条目之间(单列)。
keyExtractor(item: T, index: number) => stringitem.key、item.id 或索引条目在可见性 token 中的 key。
initialScrollIndexnumber在第一帧之前滚动到该条目。
contentContainerStyleListContentStyle内容的内边距和间距:padding、paddingHorizontal、paddingVertical、paddingTop、paddingRight、paddingBottom、paddingLeft、gap、rowGap、columnGap(数字)。
styleStyleProp<ViewStyle>列表自身的样式(需要给它尺寸:flex: 1 或高度)。
refYNode | ((node: YNode) => void)见 ref 方法。

ListSlot 是一个组件(Header)或一个元素(<Header />)。

#加载、刷新与可见性

属性类型默认值说明
onEndReached() => void距离末端不超过 onEndReachedThreshold 个视口时调用:用于加载更多。
onEndReachedThresholdnumber1距离末端多近(以视口长度计)时触发 onEndReached。
refreshingboolean显示下拉刷新的加载指示器。
onRefresh() => void启用下拉刷新;加载期间设置 refreshing。
onViewableItemsChanged(info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => void变为可见或不再可见的条目。
viewabilityConfigViewabilityConfig条目何时算作可见。
interface ViewToken<T> {
  item: T;
  key: string;
  index: number;
  isViewable: boolean;
}

interface ViewabilityConfig {
  /** 条目需覆盖视口的百分比才算可见。 */
  viewAreaCoveragePercentThreshold?: number;
  /** 条目自身需可见的百分比。两个阈值都不设时,只要有一个像素可见就算。 */
  itemVisiblePercentThreshold?: number;
  /** 条目需保持可见多少毫秒才会被报告。 */
  minimumViewTime?: number;
}

#滚动

属性类型默认值说明
showsIndicatorbooleantrue显示滚动条。
scrollEnabledbooleantrue用户可以滚动。
bouncesbooleantrue允许越过边缘回弹。
pagingEnabledbooleanfalse按视口长度的整数倍停靠。
snapToIntervalnumber0按该长度的整数倍停靠(0:关闭)。
snapToOffsetsnumber[]停靠在这些偏移位置。
snapToAlignment"start" | "center" | "end""start"使用 snapToInterval 时条目的哪个位置与视口对齐。
disableIntervalMomentumbooleanfalse一次快速滑动最多移动一个停靠点。
decelerationRate"normal" | "fast" | number平台默认快速滑动的减速快慢(0.998 / 0.99,或每毫秒保留的速度比例)。
contentInset{ top?, right?, bottom?, left? }内容四周的留白。
contentOffset{ x?, y? }初始滚动位置。
keyboardShouldPersistTaps"never" | "always" | "handled" | boolean"never"点击是否收起键盘("never":这次点击只用来收起键盘)。
keyboardDismissMode"none" | "on-drag" | "interactive""none"拖动时是否收起键盘。
scrollEventThrottlenumberonScroll 事件之间的最小间隔毫秒数(无论如何每帧最多一次)。
scrollValueNativeValue在 UI 线程上接收滚动偏移(主轴方向)的原生值,用于与滚动联动的效果。

#滚动事件

事件数据时机
onScrollScrollEvent:{ x, y, contentWidth, contentHeight, width, height, zoomScale }滚动过程中。
onScrollBeginDrag{ x, y }开始拖动。
onScrollEndDrag{ x, y }手指抬起。
onMomentumScrollBegin{ x, y }拖动后开始惯性滑动。
onMomentumScrollEnd{ x, y }惯性滑动结束(被新的拖动打断时也会触发)。
onScrollEnd{ x, y }滚动停止。

列表也接受所有元素通用的属性(通用属性)。

#ref 方法

列表的 ref 拿到的 YNode 有以下方法:

方法说明
scrollToIndex({ index, animated?, viewPosition?, viewOffset? })把条目 index 滚动到可见区域。viewPosition 决定位置:0 在视口开头(默认),0.5 在中间,1 在末尾;viewOffset 再额外偏移。由原生侧根据已测量(或估计)的行尺寸计算。
scrollToOffset({ offset, animated? })沿列表方向滚动到某个内容偏移。
scrollToEnd({ animated? })滚动到内容末尾(inverted 列表的开头)。
scrollTo({ x?, y?, animated? })滚动到某个偏移。
import { Text, View, type YNode } from "nsh";
import { List } from "nsh/list";

let list!: YNode;

<View style={{ flex: 1 }}>
  <Text onPress={() => list.scrollToIndex({ index: 60, viewPosition: 0.5 })}>Jump to 60</Text>
  <List ref={list} each={items} numColumns={3} estimatedItemSize={90} style={{ flex: 1 }}
        contentContainerStyle={{ padding: 12, gap: 8 }}>
    {(item) => <View style={{ height: 90, backgroundColor: "#e0e7ff" }}><Text>{item().title}</Text></View>}
  </List>
</View>

#示例

聊天:最新消息在底部,向上滚动时加载更早的消息。

<List
  each={messages()}
  inverted
  estimatedItemSize={44}
  style={{ flex: 1 }}
  contentContainerStyle={{ padding: 12, gap: 6 }}
  keyboardDismissMode="interactive"
  onEndReached={loadOlder}
  onEndReachedThreshold={0.5}
>
  {(m) => (
    <View style={{ alignSelf: m().mine ? "flex-end" : "flex-start", maxWidth: "75%", padding: 10, borderRadius: 14, backgroundColor: m().mine ? "#6366f1" : "#e2e8f0" }}>
      <Text style={{ color: m().mine ? "#fff" : "#0f172a" }}>{m().text}</Text>
    </View>
  )}
</List>

下拉刷新:

const refreshing = signal(false);
const refresh = async () => {
  refreshing.set(true);
  items.set(await load());
  refreshing.set(false);
};

<List each={items()} refreshing={refreshing()} onRefresh={refresh} style={{ flex: 1 }}>
  {(item) => <Text style={{ padding: 16 }}>{item().title}</Text>}
</List>

#ListItemsProps

type ListItemsProps<T> = Pick<ListProps<T>,
  "each" | "children" | "keyExtractor" | "stickyHeaderIndices" | "initialScrollIndex"
  | "onViewableItemsChanged" | "viewabilityConfig" | "refreshing" | "onRefresh"
  | "onEndReached" | "onEndReachedThreshold"
  | "ListHeaderComponent" | "ListFooterComponent" | "ListEmptyComponent">;

ListProps 中关于数据和行的那一半:传给自行布局列表的组件(例如 nsh/ui/mobile 的 ListView)的内容。