指南

触摸与手势

nsh 的触摸处理分三个层次。按压(onPress 等)用于按钮、列表行和链接,按压反馈由原生绘制。原生手势(pan、pinch、rotation)在 UI 线程上驱动原生值,即使 JS 正忙,被拖动的卡片也能紧跟手指。原始触摸与手势响应者系统(onTouchStart、onResponderMove、PanResponder)把每根手指的位置都交给 JS,用来实现前两者表达不了的交互,例如手写绘图。

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

const muted = "#57606a";

/** A card dragged on the UI thread: `pan` drives two native values. */
function DragCard() {
  const x = createNativeValue(0);
  const y = createNativeValue(0);
  const flings = signal(0);
  return (
    <View style={{ height: 150, borderRadius: 12, backgroundColor: "#eaeef2", alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
      <View
        pan={{ x, y }}
        onPanEnd={(e) => {
          flings.update((n) => n + 1);
          // Spring home, starting with the release velocity.
          x.spring(0, { velocity: e.velocityX, stiffness: 200, damping: 16 });
          y.spring(0, { velocity: e.velocityY, stiffness: 200, damping: 16 });
        }}
        style={{
          width: 120,
          height: 80,
          borderRadius: 16,
          backgroundColor: "#8250df",
          alignItems: "center",
          justifyContent: "center",
          transform: [{ translateX: x }, { translateY: y }],
        }}
      >
        <Text style={{ color: "#ffffff", fontWeight: "700" }}>Drag me</Text>
        <Text style={{ color: "#ffffff", fontSize: 12 }}>released {flings()}×</Text>
      </View>
    </View>
  );
}

/** A dot moved with PanResponder: every move goes through JS. */
function ResponderDot() {
  const pos = signal({ x: 0, y: 0 });
  const state = signal("idle");
  let start = { x: 0, y: 0 };
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => {
      start = pos();
      state.set("dragging");
    },
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => state.set(`released, vx ${g.vx.toFixed(2)} pt/ms`),
    onPanResponderTerminate: () => state.set("taken over"),
  });
  return (
    <View style={{ gap: 6 }}>
      <View style={{ height: 110, borderRadius: 12, backgroundColor: "#eaeef2", overflow: "hidden" }}>
        <View
          {...responder.panHandlers}
          style={{
            position: "absolute",
            left: 150,
            top: 30,
            width: 50,
            height: 50,
            borderRadius: 25,
            backgroundColor: "#0969da",
            transform: [{ translateX: pos().x }, { translateY: pos().y }],
          }}
        />
      </View>
      <Text style={{ color: muted, fontSize: 13 }}>PanResponder: {state()}</Text>
    </View>
  );
}

function App() {
  const log = signal<string[]>([]);
  const add = (event: string) => log.update((l) => [event, ...l].slice(0, 4));
  const dot = signal(0);
  const under = signal(0);
  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
      <View style={{ flexDirection: "row", gap: 10 }}>
        <View
          onPressIn={() => add("pressIn")}
          onPressOut={() => add("pressOut")}
          onPress={() => add("press")}
          onLongPress={() => add("longPress")}
          activeOpacity={0.5}
          style={{ flex: 1, padding: 14, borderRadius: 12, backgroundColor: "#0969da", alignItems: "center" }}
        >
          <Text style={{ color: "#ffffff", fontWeight: "600" }}>activeOpacity</Text>
        </View>
        <View
          onPress={() => add("underlay")}
          underlayColor="#ddf4ff"
          style={{ flex: 1, padding: 14, borderRadius: 12, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "#d0d7de", alignItems: "center" }}
        >
          <Text style={{ fontWeight: "600" }}>underlayColor</Text>
        </View>
        <View
          onPress={() => add("active:")}
          class="flex-1 items-center rounded-xl bg-emerald-600 p-3.5 transition active:scale-95 active:bg-emerald-800"
        >
          <Text class="font-semibold text-white">active:</Text>
        </View>
      </View>
      <Text style={{ color: muted, fontSize: 13 }}>{log().join(" · ") || "Tap, hold, or slide off a button"}</Text>

      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <View
          hitSlop={24}
          onPress={() => dot.update((n) => n + 1)}
          style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#cf222e", margin: 12 }}
        />
        <Text style={{ flex: 1 }}>hitSlop 24: the dot takes taps around it ({dot()})</Text>
      </View>

      <View style={{ height: 52 }}>
        <View
          onPress={() => under.update((n) => n + 1)}
          style={{ flex: 1, borderRadius: 12, backgroundColor: "#1a7f37", alignItems: "center", justifyContent: "center" }}
        >
          <Text style={{ color: "#ffffff" }}>Under an overlay: {under()}</Text>
        </View>
        <View
          pointerEvents="none"
          style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 12, borderWidth: 3, borderColor: "#bf8700", borderStyle: "dashed" }}
        />
      </View>

      <DragCard />
      <ResponderDot />
    </View>
  );
}

mount(App);

选择能完成任务的最高层次:层次越高,从 UI 线程传到 JS 的工作就越少。

#按压

所有元素都接受按压事件,不需要单独的 “touchable” 组件。把 onPress 加在 <View>、<Text>、<Image> 或任何其他元素上即可。

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

function App() {
  const count = signal(0);
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <View
        onPress={() => count.update((n) => n + 1)}
        onLongPress={() => count.set(0)}
        activeOpacity={0.6}
        accessibilityRole="button"
        style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
      >
        <Text style={{ color: "#ffffff", fontWeight: "600" }}>Pressed {count()} times</Text>
      </View>
    </View>
  );
}

mount(App);
属性类型默认值说明
onPress() => void手指在视图内抬起(普通按压,不是长按)。
onLongPress() => void手指按住达到 delayLongPress。之后不会再触发 onPress。
onPressIn() => void按压开始。
onPressOut() => void按压结束:抬起、滑出,或被滚动视图接管。
delayLongPressnumber500按压变为长按前的毫秒数。
disabledbooleanfalse不接受按压,也不显示反馈;应用 disabled: 类。

按压的行为:

  • 只有最内层的可按压视图响应。 可按压的行里包含一个可按压的按钮时,按下按钮不会触发行的按压。
  • 在滚动视图内,按压会等待约 130 ms 才开始(并显示反馈),这样从按钮上开始的拖动会滚动页面,而不是按下按钮。
  • 滑出即取消。 手指移出视图时,按压以 onPressOut 结束,不触发 onPress。
  • disabled 会把按压监听从挂载的视图上移除,因此完全没有反馈,同时应用 disabled: 类(class="disabled:opacity-40")。
  • 带 onPress 的嵌套 <Text> 片段就是链接。 整段文字是一个原生视图,按压会交给手指下的那个片段。见文本。

#按压反馈

按压反馈在手指按下的瞬间由平台绘制,不需要往返 JS。每个视图选一种:

属性类型默认值说明
activeOpacitynumber按下时视图的不透明度。
underlayColorColorValue按下时的背景色;子视图淡到 underlayOpacity。
underlayOpacitynumber0.85显示 underlayColor 时子视图的不透明度。
android={{ ripple }}{ color?, borderless?, radius?, foreground? }Android 的 Material 水波纹。iOS 上忽略。
<View onPress={open} activeOpacity={0.5}>…</View>

<View onPress={open} underlayColor="#ddf4ff" style={{ padding: 16 }}>…</View>

<View
  onPress={open}
  activeOpacity={0.6}
  android={{ ripple: { color: "rgba(9,105,218,0.3)" } }}
>
  …
</View>

无边界水波纹会扩散到视图之外,适合图标按钮:android={{ ripple: { borderless: true, radius: 24 } }}。activeOpacity 与水波纹常常一起使用:iOS 用不透明度,Android 用水波纹。见平台特定代码。

第三种选择是 Tailwind 的 active: 变体。它在视图被按下时应用对应的类,配合 transition 类时变化会有动画:

<View
  onPress={save}
  class="rounded-xl bg-emerald-600 p-4 transition active:scale-95 active:bg-emerald-800"
>
  <Text class="font-semibold text-white">Save</Text>
</View>

#点击区域:hitSlop

hitSlop 把接收触摸的区域扩展到视图边界之外,而不改变布局。适合关闭按钮、开关之类的小目标。

<View hitSlop={12} onPress={close} style={{ width: 20, height: 20 }}>…</View>
<View hitSlop={{ top: 10, bottom: 10, left: 4, right: 4 }} onPress={next}>…</View>
属性类型说明
hitSlopnumber | { top?, right?, bottom?, left? }在边界四周增加的点数。传数字时四边相同。

扩展出的区域要留在父视图的范围内(给小视图留出一些 margin 或 padding):命中测试要经过父视图才能到达子视图,落在父视图之外的触摸到不了子视图的扩展区域。

#pointerEvents

pointerEvents 决定子树中哪些视图可以成为触摸的目标。它在原生命中测试时求值,每次触摸都不产生额外开销。

值视图本身子视图
"auto"(默认)接收触摸接收触摸
"none"永远不是目标永远不是目标:触摸穿透到后面的内容
"box-none"永远不是目标接收触摸
"box-only"接收触摸永远不是目标

典型用法:不能挡住下层内容的覆盖层("none");全屏容器的空白处要把触摸让给后面的内容、而其中的按钮仍然可用("box-none");手指落在子视图上时也要由整行接收按压("box-only")。

<View style={{ flex: 1 }}>
  <Map />
  {/* 浮动工具栏:空白区域把触摸交给地图。 */}
  <View pointerEvents="box-none" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "flex-end", padding: 16 }}>
    <View onPress={locate} style={{ alignSelf: "flex-end", padding: 12, borderRadius: 24, backgroundColor: "#ffffff" }}>
      <Text>Locate</Text>
    </View>
  </View>
</View>

Tailwind 类 pointer-events-none 和 pointer-events-auto 设置的是同一件事;显式属性优先于类。

#原生手势:拖动、捏合与旋转

要让拖动、捏合和旋转移动屏幕上的东西,就把原生值绑定到手势上。平台的手势识别器在 UI 线程上写入这些值,绑定了它们的视图在同一帧移动;JS 只在手势开始和结束时得到通知。

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

function App() {
  const x = createNativeValue(0);
  const y = createNativeValue(0);
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <View
        pan={{ x, y }}
        onPanEnd={(e) => {
          // 以松手时的速度弹回原位。
          x.spring(0, { velocity: e.velocityX });
          y.spring(0, { velocity: e.velocityY });
        }}
        style={{ width: 120, height: 120, borderRadius: 24, backgroundColor: "#8250df", transform: [{ translateX: x }, { translateY: y }] }}
      />
    </View>
  );
}

mount(App);
属性类型说明
pan{ x?: NativeValue; y?: NativeValue }单指拖动驱动这些值,单位为点。
pinch{ scale?: NativeValue }双指捏合驱动 scale(倍数)。
rotation{ angle?: NativeValue }双指旋转驱动 angle,单位为度。
onPanStart / onPanEnd(e: PanEvent) => void拖动开始 / 结束。
onPinchStart / onPinchEnd(e: { scale; velocity }) => void捏合开始 / 结束。
onRotationStart / onRotationEnd(e: { rotation; velocity }) => void旋转开始 / 结束。

PanEvent 包含 translationX / translationY(自开始以来移动的点数)、velocityX / velocityY(每秒的点数)以及 valueX / valueY(此刻所绑定的值)。

原生端替你做了这些事:

  • 拖动从值当前的位置继续。 开始时读取这些值,再把位移加上去;抓住一个仍在动画中的值(例如正在弹回的弹簧)会让动画停在原处。
  • 只绑定一个轴时,另一个轴照常滚动。 只有 pan={{ x }} 时,只有手指的水平移动大于垂直移动,拖动才会开始,所以竖向滚动视图里的横向滑动行仍然可以上下滚动。这个判断在系统询问手势能否开始时由原生做出。
  • 拖动、捏合和旋转可以同时进行:同一张卡片可以同时被拖动、缩放和旋转。
  • 用松手速度做物理效果。 onPanEnd 的速度可以直接传给 spring({ velocity }) 或 decay({ velocity });valueX / valueY 用来挑选吸附点。
// 吸附到左边或右边的卡片。
<View
  pan={{ x }}
  onPanEnd={(e) => {
    const projected = (e.valueX ?? 0) + e.velocityX * 0.2;
    x.spring(projected > 0 ? 140 : -140, { velocity: e.velocityX });
  }}
  style={{ transform: [{ translateX: x }] }}
/>

#原始触摸事件

触摸事件报告每根手指的按下、移动和抬起。它们适合记录日志、自定义命中测试和响应多点触摸;它们不会占有触摸(按压和滚动视图照常工作)。

属性说明
onTouchStart有手指在视图上或其内部按下。
onTouchMove手指移动。同一引擎轮次内的移动会被合并。
onTouchEnd手指抬起。
onTouchCancel系统接管了触摸(滚动视图开始滚动、来电等)。
onTouchStartCapture、onTouchMoveCapture、onTouchEndCapture、onTouchCancelCapture同上,但从外向内调用,先于冒泡阶段的处理函数。

每个处理函数收到一个 GestureResponderEvent:

字段说明
identifier手指按下期间的标识。
locationX、locationY在手指按下的那个视图(target)中的位置。
pageX、pageY在整个 surface 中的位置。
target手指按下的那个视图的 id。
timestamp毫秒。
touches所有按下的手指。
changedTouches本次事件涉及的手指。
nativeEvent事件本身,因此 e.nativeEvent.locationX 也可用。
stopPropagation()外层视图不再收到这次触摸事件。
<View
  onTouchStart={(e) => console.log("down", e.locationX, e.locationY, e.touches.length)}
  onTouchMove={(e) => console.log("move", e.pageX, e.pageY)}
  onTouchEnd={() => console.log("up")}
/>

#手势响应者系统

当 JS 必须独占一段触摸序列时(手写绘图、自定义滑块、原生识别器没有提供的手势),使用手势响应者系统。同一时间只有一个视图是响应者并接收移动事件,由各视图协商出谁来担任。

成为响应者:

属性返回值说明
onStartShouldSetResponderboolean手指按下时从目标向外逐层询问。返回 true 即成为响应者。
onMoveShouldSetResponderboolean在没有响应者时(或为了接管)每次移动都会询问。
onStartShouldSetResponderCaptureboolean同上,但从外向内询问,先于冒泡阶段。父视图用它从子视图手里拿走触摸。
onMoveShouldSetResponderCaptureboolean移动时的版本。

成为响应者之后:

属性说明
onResponderGrant该视图现在是响应者。返回 true 可阻止原生滚动视图接管触摸。
onResponderReject另一个视图是响应者且不肯放手。
onResponderStart又有一根手指按下。
onResponderMove手指移动。
onResponderEnd有手指抬起。
onResponderRelease最后一根手指抬起:手势结束。
onResponderTerminationRequest另一个视图想要接管。返回 false 保留触摸(默认 true)。
onResponderTerminate另一个视图或某个原生手势接管了触摸。
import { type GestureResponderEvent, mount, signal } from "nsh";
import { Canvas } from "nsh/canvas";

function Sketch() {
  const strokes = signal<[number, number][][]>([]);
  const point = (e: GestureResponderEvent): [number, number] => [e.locationX, e.locationY];
  return (
    <Canvas
      style={{ flex: 1 }}
      onStartShouldSetResponder={() => true}
      onResponderGrant={(e) => {
        strokes.set([...strokes(), [point(e)]]);
        return true; // 外层的滚动视图不能接管触摸
      }}
      onResponderTerminationRequest={() => false}
      onResponderMove={(e) => {
        const all = strokes();
        strokes.set([...all.slice(0, -1), [...all[all.length - 1], point(e)]]);
      }}
      draw={(ctx) => {
        ctx.lineWidth = 3;
        for (const s of strokes()) {
          ctx.beginPath();
          s.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
          ctx.stroke();
        }
      }}
    />
  );
}

mount(Sketch);

运行方式:带有任何触摸或响应者处理函数的视图会在原生端监听触摸;平台把发生在它上面(及其内部)的触摸连同沿途正在监听的视图路径一起上报,协商过程在运行时的 Rust 代码中进行,并按顺序调用你的处理函数:先从最外层视图向内调用 …Capture 处理函数,再从目标向外调用其他处理函数。由此有两个结果:

  • 原生滚动视图与 JS 响应者相互竞争。 如果 onResponderGrant 返回 true,响应者外层的滚动视图会停止接管触摸,直到手势结束。如果原生滚动视图先开始拖动,则滚动视图获胜:响应者收到 onResponderTerminate,触摸处理函数收到 onTouchCancel。
  • 每次移动都是一次发往 JS 的事件。 由响应者驱动的拖动,流畅程度取决于 JS 的速度;只是移动视图的拖动,优先使用 pan。

#PanResponder

nsh/gesture 中的 PanResponder 封装了手势响应者系统,并附加手势状态:自开始以来的距离、速度、手指数量。把它的 panHandlers 展开到视图上。

import { mount, signal, View } from "nsh";
import { PanResponder } from "nsh/gesture";

function Ball() {
  const pos = signal({ x: 0, y: 0 });
  let start = { x: 0, y: 0 };
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => {
      start = pos();
    },
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => console.log("released at", g.vx, g.vy, "pt/ms"),
  });
  return (
    <View
      {...responder.panHandlers}
      style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: "#0969da", transform: [{ translateX: pos().x }, { translateY: pos().y }] }}
    />
  );
}

mount(Ball);

回调参数为 (event, gestureState):

gestureState 字段说明
stateID手势的标识。
moveX、moveY各触点中心的最新位置,surface 坐标(点)。
x0、y0成为响应者时触点中心的位置。
dx、dy自成为响应者以来移动的距离。
vx、vy速度,单位为每毫秒的点数。
numberActiveTouches按下的手指数。

可用的回调有 onStartShouldSetPanResponder(Capture)、onMoveShouldSetPanResponder(Capture)、onPanResponderGrant、onPanResponderReject、onPanResponderStart、onPanResponderMove、onPanResponderEnd、onPanResponderRelease、onPanResponderTerminate、onPanResponderTerminationRequest 和 onShouldBlockNativeResponder。最后一个默认为 true:成为响应者的 PanResponder 会阻止原生滚动视图接管它的触摸。

#为什么有些答案是属性

主线程从不等待 JS。当系统需要立即得到答案时(这个拖动手势能否开始、这个点是否命中视图、这次点击是否应该收起键盘),nsh 根据事先挂载好的属性在原生端直接回答:pan(它的轴)、pinch、hitSlop、pointerEvents、keyboardShouldPersistTaps。你的 JS 处理函数事后才得知发生了什么。所以这些是声明式的属性而不是回调;也正因如此,会调用 JS 的响应者协商只在 JS 响应者之间做决定,从不阻塞原生手势。

#相关内容

  • View:基础元素上的所有触摸属性。
  • 动画:用于手势驱动运动的原生值、弹簧和衰减。
  • 滚动:keyboardShouldPersistTaps、滚动视图与嵌套手势。
  • nsh/gesture:PanResponder 参考。
  • 无障碍:可按压视图是无障碍元素;给它们设置角色和标签。