指南

滚动

<Scroll> 就是平台自己的滚动视图(iOS 上的 UIScrollView,Android 上的 ScrollView):惯性滑动、回弹、滚动条、下拉刷新以及吸附都是原生实现。JS 通过事件得知滚动情况,也可以把滚动偏移绑定到原生值上,做出逐帧跟随手指的效果。

guide-scrolling.tsx 在线编辑 在新标签页打开
import { mount, Scroll, signal, Text, View, type YNode } from "nsh";
import { createNativeValue } from "nsh/animated";

const palette = ["#0969da", "#8250df", "#bf3989", "#cf222e", "#bc4c00", "#1a7f37"];
const muted = "#57606a";

/** Pages as wide as the scroll view, with a page indicator. */
function Pager() {
  const width = signal(340);
  const page = signal(0);
  return (
    <View style={{ gap: 8 }}>
      <Scroll
        horizontal
        pagingEnabled
        showsIndicator={false}
        style={{ height: 110, borderRadius: 12 }}
        onLayout={(e) => width.set(e.width)}
        onMomentumScrollEnd={(e) => page.set(Math.round(e.x / width()))}
        onScrollEnd={(e) => page.set(Math.round(e.x / width()))}
      >
        <View style={{ flexDirection: "row" }}>
          <Each of={palette.slice(0, 4)}>
            {(color, i) => (
              <View style={{ width: width(), height: 110, backgroundColor: color(), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: "#ffffff", fontSize: 24, fontWeight: "700" }}>Page {i() + 1}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
      <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
        <Each of={[0, 1, 2, 3]}>
          {(i) => <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: page() === i() ? "#0969da" : "#d0d7de" }} />}
        </Each>
      </View>
    </View>
  );
}

/** Cards that snap: one stop every card plus gap. */
function Cards() {
  return (
    <Scroll horizontal showsIndicator={false} snapToInterval={152} decelerationRate="fast" style={{ height: 90 }}>
      <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 4 }}>
        <Each of={Array.from({ length: 12 }, (_, i) => i)}>
          {(i) => (
            <View style={{ width: 140, height: 90, borderRadius: 14, backgroundColor: palette[i() % palette.length], alignItems: "center", justifyContent: "center" }}>
              <Text style={{ color: "#ffffff", fontWeight: "700" }}>Card {i() + 1}</Text>
            </View>
          )}
        </Each>
      </View>
    </Scroll>
  );
}

function App() {
  // The vertical offset, written natively in the frame it changes.
  const offset = createNativeValue(0);
  const y = signal(0);
  const refreshing = signal(false);
  const refreshed = signal(0);
  let scroll!: YNode;
  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      refreshed.update((n) => n + 1);
      refreshing.set(false);
    }, 1200);
  };
  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", paddingTop: 24 }}>
      {/* A bar that fades in and shrinks its title as the page scrolls: no JS per frame. */}
      <View
        style={{
          height: 48,
          paddingHorizontal: 16,
          justifyContent: "center",
          backgroundColor: offset.interpolate({ inputRange: [0, 80], outputRange: ["#f6f8fa", "#ffffff"] }),
          borderBottomWidth: 1,
          borderBottomColor: "#d0d7de",
        }}
      >
        <Text
          style={{
            fontSize: 22,
            fontWeight: "700",
            transformOrigin: "left center",
            transform: [{ scale: offset.interpolate({ inputRange: [0, 80], outputRange: [1, 0.8] }) }],
          }}
        >
          Scrolling
        </Text>
      </View>
      <Scroll
        ref={scroll}
        style={{ flex: 1 }}
        scrollValue={offset}
        scrollEventThrottle={100}
        onScroll={(e) => y.set(Math.round(e.y))}
        refreshing={refreshing()}
        onRefresh={refresh}
        keyboardDismissMode="on-drag"
      >
        <View style={{ padding: 16, gap: 18 }}>
          <Text style={{ color: muted }}>
            Pull down to refresh ({refreshed()}×). onScroll: y = {y()}
          </Text>
          <Text style={{ fontWeight: "600" }}>pagingEnabled</Text>
          <Pager />
          <Text style={{ fontWeight: "600" }}>snapToInterval</Text>
          <Cards />
          <Each of={Array.from({ length: 24 }, (_, i) => i + 1)}>
            {(n) => (
              <View style={{ padding: 14, borderRadius: 10, backgroundColor: "#ffffff" }}>
                <Text>Row {n()}</Text>
              </View>
            )}
          </Each>
          <View
            onPress={() => scroll.scrollTo({ y: 0 })}
            activeOpacity={0.6}
            style={{ padding: 14, borderRadius: 10, backgroundColor: "#0969da", alignItems: "center" }}
          >
            <Text style={{ color: "#ffffff", fontWeight: "600" }}>Back to the top (ref.scrollTo)</Text>
          </View>
        </View>
      </Scroll>
    </View>
  );
}

mount(App);

<Scroll> 适合尺寸已知、规模适中的内容:设置页、表单、文章、一排标签。长列表或无限数据请用 List,它会复用行,并接受同样的滚动属性。

#滚动视图

<Scroll> 本身是视口,它的子元素是内容。视口需要父元素给出尺寸(通常是 flex: 1);内容按视口的宽度(纵向)或高度(横向)排版,长度按需延伸。

import { mount, Scroll, Text, View } from "nsh";

function App() {
  return (
    <Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
      <View style={{ padding: 16, gap: 12 }}>
        <Each of={Array.from({ length: 50 }, (_, i) => i + 1)}>
          {(n) => (
            <View style={{ padding: 16, borderRadius: 10, backgroundColor: "#ffffff" }}>
              <Text>Row {n()}</Text>
            </View>
          )}
        </Each>
      </View>
    </Scroll>
  );
}

mount(App);

<Scroll> 没有 contentContainerStyle:像上面那样,把内边距和间距放在唯一的子视图上。横向滚动视图使用 horizontal,并以一个横排视图作为子元素:

<Scroll horizontal showsIndicator={false}>
  <View style={{ flexDirection: "row", gap: 8, paddingHorizontal: 16 }}>
    <Each of={["Today", "This week", "This month", "This year"]}>
      {(label) => <Text style={{ paddingHorizontal: 14, paddingVertical: 8, borderRadius: 16, overflow: "hidden", backgroundColor: "#ffffff" }}>{label()}</Text>}
    </Each>
  </View>
</Scroll>

在从右到左布局中(见设备与环境),横向滚动视图从最右端开始,并保持与右端的距离。

#行为属性

属性类型默认值说明
horizontalbooleanfalse横向滚动。
scrollEnabledbooleantrue用户可以滚动。从代码滚动不受影响。
bouncesbooleantrue允许越过边缘回弹。
showsIndicatorbooleantrue显示滚动条。
contentInset{ top?, right?, bottom?, left? }内容四周可以滚动进入的留白。
contentOffset{ x?, y? }初始位置。
decelerationRate"normal" | "fast" | number平台默认惯性滑动减速的快慢:"normal"、"fast",或每毫秒保留的速度比例(0.998 为 normal,0.99 为 fast;0 表示平台默认)。

#分页与吸附

吸附在手指抬起时由原生决定,依据松手位置、预计滑行距离和速度;随后滚动视图滑到选中的停靠点。整个过程不涉及 JS。

属性类型说明
pagingEnabledboolean停在视口尺寸的整数倍处。
snapToIntervalnumber停在该长度的整数倍处(0 为关闭)。
snapToOffsetsnumber[]停在这些偏移处。
snapToAlignment"start" | "center" | "end"配合 snapToInterval:条目的哪个位置与视口对齐。
disableIntervalMomentumboolean一次快速滑动最多移动一个停靠点。

一个分页器:每页与滚动视图等宽,滑行结束时更新页码指示器。

import { mount, Scroll, signal, Text, View } from "nsh";

const colors = ["#0969da", "#8250df", "#bf3989", "#1a7f37"];

function Pager() {
  const width = signal(0);
  const page = signal(0);
  const settle = (e: { x: number }) => page.set(Math.round(e.x / width()));
  return (
    <View style={{ gap: 8, padding: 16 }}>
      <Scroll
        horizontal
        pagingEnabled
        showsIndicator={false}
        style={{ height: 160, borderRadius: 12 }}
        onLayout={(e) => width.set(e.width)}
        onScrollEnd={settle}
      >
        <View style={{ flexDirection: "row" }}>
          <Each of={colors}>
            {(color, i) => (
              <View style={{ width: width(), height: 160, backgroundColor: color(), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: "#ffffff", fontSize: 24, fontWeight: "700" }}>Page {i() + 1}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
      <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
        <Each of={colors}>
          {(_, i) => <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: page() === i() ? "#0969da" : "#d0d7de" }} />}
        </Each>
      </View>
    </View>
  );
}

mount(Pager);

吸附到中间、每次滑动只移动一张的卡片轮播:

<Scroll
  horizontal
  snapToInterval={232}          // 卡片宽度 (220) + 间距 (12)
  snapToAlignment="center"
  decelerationRate="fast"
  disableIntervalMomentum
  showsIndicator={false}
>
  <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 16 }}>…</View>
</Scroll>

List 接受同样的吸附属性,长轮播用它更合适。

#滚动事件

属性参数触发时机
onScrollScrollEvent偏移改变。每帧最多一次;scrollEventThrottle 可以进一步拉开间隔。
scrollEventThrottlenumber两次 onScroll 之间的最少毫秒数。
onScrollBeginDrag{ x, y }用户开始拖动。
onScrollEndDrag{ x, y }用户抬起手指。
onMomentumScrollBegin{ x, y }快速滑动后的惯性滑行开始。
onMomentumScrollEnd{ x, y }惯性滑行结束(被新的拖动打断时也会触发)。
onScrollEnd{ x, y }用户的滚动停了下来:惯性滑行结束后,或没有惯性的拖动结束时。

ScrollEvent 包含 x、y(偏移)、contentWidth、contentHeight(内容尺寸,含缩放)、width、height(视口)以及 zoomScale。

const atBottom = signal(false);

<Scroll
  style={{ flex: 1 }}
  scrollEventThrottle={100}
  onScroll={(e) => atBottom.set(e.y + e.height >= e.contentHeight - 20)}
>
  …
</Scroll>

#滚动联动效果:scrollValue

scrollValue 接收一个原生值,并在内容移动的同一帧,由原生把(主轴方向的)滚动偏移写进去。把这个值,或由它经 interpolate、diffClamp、算术运算得到的节点,绑定到样式上。

import { mount, Scroll, Text, View } from "nsh";
import { createNativeValue } from "nsh/animated";

function App() {
  const offset = createNativeValue(0);
  return (
    <View style={{ flex: 1 }}>
      <View
        style={{
          height: 56,
          justifyContent: "center",
          paddingHorizontal: 16,
          // 顶部透明,滚过 80 点后变为白色。
          backgroundColor: offset.interpolate({ inputRange: [0, 80], outputRange: ["rgba(255,255,255,0)", "#ffffff"] }),
        }}
      >
        <Text
          style={{
            fontSize: 22,
            fontWeight: "700",
            transformOrigin: "left center",
            transform: [{ scale: offset.interpolate({ inputRange: [0, 80], outputRange: [1, 0.8] }) }],
          }}
        >
          Inbox
        </Text>
      </View>
      <Scroll style={{ flex: 1 }} scrollValue={offset}>
        <View style={{ padding: 16, gap: 8 }}>…</View>
      </Scroll>
    </View>
  );
}

mount(App);

内容上滑时隐藏、一往下滑就重新出现的标题栏,就是对偏移做 diffClamp:

const offset = createNativeValue(0);
const header = offset.diffClamp(0, 56);

<View style={{ flex: 1 }}>
  <Scroll style={{ flex: 1 }} scrollValue={offset}>
    <View style={{ paddingTop: 56 }}>…</View>
  </Scroll>
  <View style={{ position: "absolute", top: 0, left: 0, right: 0, height: 56, transform: [{ translateY: header.mul(-1) }] }}>…</View>
</View>

插值在输入范围之外默认会截断(clamp),所以下拉越过顶部(偏移为负)时效果不会超出终点;想做随下拉放大的弹性头图,传入 extrapolate: "extend"。

#下拉刷新

refreshing 和 onRefresh 会加上平台的下拉刷新控件。refreshing 为 true 时控件显示;在 onRefresh 中把它设为 true,工作完成后再清除。

const refreshing = signal(false);
const reload = async () => {
  refreshing.set(true);
  try {
    items.set(await (await fetch("https://api.example.com/items")).json());
  } finally {
    refreshing.set(false);
  }
};

<Scroll style={{ flex: 1 }} refreshing={refreshing()} onRefresh={reload}>…</Scroll>

iOS 上的控件是 UIRefreshControl;Mac Catalyst 的 Mac 界面风格没有 UIRefreshControl,nsh 会显示自己的加载指示器,行为相同。List 也接受这两个属性。

#缩放

当 maximumZoomScale 大于 minimumZoomScale 时,<Scroll>(List 不行)就可以缩放:捏合会缩放内容,放大后可以双向滚动。

属性类型默认值说明
minimumZoomScalenumber1最小缩放。
maximumZoomScalenumber1最大缩放;大于最小值时捏合即可缩放。
zoomScalenumber该属性改变时缩放到这个比例;否则保留用户的缩放。
bouncesZoombooleantrue捏合可以越过限制并弹回。
pinchGestureEnabledbooleantrue用户可以捏合缩放。
centerContentbooleanfalse比视口小的内容(缩小时)居中显示。

onScroll 会报告 zoomScale 和缩放后的内容尺寸。在代码中,ref.zoomTo({ scale, x, y, animated }) 会缩放,使内容中的点(x、y,未缩放的点坐标)落在视口中央;双击放大就是在按压处理函数里调用它:

import { mount, Scroll, signal, type YNode } from "nsh";
import { Image } from "nsh/image";

function Photo() {
  let scroll!: YNode;
  const scale = signal(1);
  return (
    <Scroll
      ref={scroll}
      style={{ flex: 1, backgroundColor: "#000000" }}
      minimumZoomScale={1}
      maximumZoomScale={4}
      centerContent
      onScroll={(e) => scale.set(e.zoomScale)}
    >
      <Image
        src="https://picsum.photos/id/1015/1200/800"
        style={{ width: 390, height: 260 }}
        onPress={() => scroll.zoomTo({ scale: scale() > 1.5 ? 1 : 3 })}
      />
    </Scroll>
  );
}

mount(Photo);

在 iOS 上,缩放结束后,缩放视图里的文字和画布会按新的分辨率重新绘制,保持清晰。Android 没有自带可缩放的滚动视图:nsh 会把滚动视图切换为自己的实现,围绕捏合焦点缩放,支持任意手指数平移、惯性滑动,并在边缘和缩放极限处产生橡皮筋效果。与系统的手势检测一致,Android 要等两指间距达到约 27 mm 才开始捏合。

#从代码滚动

给滚动视图设置 ref 并调用它的方法。命令会在同一次更新的其他改动之后执行,所以滚动到刚刚添加的内容也没问题。

方法说明
scrollTo({ x?, y?, animated? })滚动到某个偏移。默认带动画。
zoomTo({ scale, x?, y?, animated? })可缩放的滚动视图:缩放到 scale(限制在范围内),以内容点(x、y)或中心为基准。
let scroll!: YNode;

<Scroll ref={scroll} style={{ flex: 1 }}>…</Scroll>
<View onPress={() => scroll.scrollTo({ y: 0 })}>…</View>

列表另外还有 scrollToIndex、scrollToOffset 和 scrollToEnd;见列表。

#滚动视图与键盘

nsh 会自动让获得焦点的输入框保持可见:键盘出现时,输入框所在的滚动视图会得到与键盘等高的底部 inset,并把输入框滚动到可见位置。这是原生完成的,应用中不需要任何代码。只有滚动视图之外的内容(例如固定在底部的消息栏)才需要 KeyboardAvoidingView。

有两个属性决定滚动和点击如何与键盘交互:

属性类型默认值说明
keyboardDismissMode"none" | "on-drag" | "interactive""none"拖动内容时是否收起键盘。iOS 上 "interactive" 让键盘随手指下移;Android 上它与 "on-drag" 相同。
keyboardShouldPersistTaps"never" | "always" | "handled""never"键盘弹出时点击的行为。

keyboardShouldPersistTaps:

  • "never":点击输入框之外只会收起键盘,按钮收不到这次点击。用户要先点一次收起键盘,再点一次才能按下。
  • "handled":点在会处理它的东西(按钮)上时,点击照常传递,键盘保持;点在空白处则收起键盘。表单通常应选它。
  • "always":点击从不收起键盘。

两者都在点击或拖动发生时由原生决定,不等待 JS。要从代码收起键盘,调用 nsh/ui/mobile 中的 Keyboard.dismiss()。

#嵌套滚动视图

纵向滚动视图里放横向滚动视图会如预期工作:各自接管自己方向上的拖动。同方向的两个滚动视图也可以嵌套(内层先滚动)。需要独占手指的 JS 响应者,比如滚动视图里的绘图画布,在 onResponderGrant 中返回 true,以阻止外层滚动视图接管触摸;见触摸与手势。

#相关内容

  • Scroll:元素参考,包含所有属性和事件。
  • 列表:使用相同滚动属性的虚拟化列表。
  • 动画:用于滚动联动效果的原生值、interpolate 和 diffClamp。
  • 表单与键盘:输入框、焦点和键盘。