指南
触摸与手势
nsh 的触摸处理分三个层次。按压(onPress 等)用于按钮、列表行和链接,按压反馈由原生绘制。原生手势(pan、pinch、rotation)在 UI 线程上驱动原生值,即使 JS 正忙,被拖动的卡片也能紧跟手指。原始触摸与手势响应者系统(onTouchStart、onResponderMove、PanResponder)把每根手指的位置都交给 JS,用来实现前两者表达不了的交互,例如手写绘图。
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 | 按压结束:抬起、滑出,或被滚动视图接管。 | |
delayLongPress | number | 500 | 按压变为长按前的毫秒数。 |
disabled | boolean | false | 不接受按压,也不显示反馈;应用 disabled: 类。 |
按压的行为:
- 只有最内层的可按压视图响应。 可按压的行里包含一个可按压的按钮时,按下按钮不会触发行的按压。
- 在滚动视图内,按压会等待约 130 ms 才开始(并显示反馈),这样从按钮上开始的拖动会滚动页面,而不是按下按钮。
- 滑出即取消。 手指移出视图时,按压以
onPressOut结束,不触发onPress。 disabled会把按压监听从挂载的视图上移除,因此完全没有反馈,同时应用disabled:类(class="disabled:opacity-40")。- 带
onPress的嵌套<Text>片段就是链接。 整段文字是一个原生视图,按压会交给手指下的那个片段。见文本。
#按压反馈
按压反馈在手指按下的瞬间由平台绘制,不需要往返 JS。每个视图选一种:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
activeOpacity | number | 按下时视图的不透明度。 | |
underlayColor | ColorValue | 按下时的背景色;子视图淡到 underlayOpacity。 | |
underlayOpacity | number | 0.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>
| 属性 | 类型 | 说明 |
|---|---|---|
hitSlop | number | { 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 必须独占一段触摸序列时(手写绘图、自定义滑块、原生识别器没有提供的手势),使用手势响应者系统。同一时间只有一个视图是响应者并接收移动事件,由各视图协商出谁来担任。
成为响应者:
| 属性 | 返回值 | 说明 |
|---|---|---|
onStartShouldSetResponder | boolean | 手指按下时从目标向外逐层询问。返回 true 即成为响应者。 |
onMoveShouldSetResponder | boolean | 在没有响应者时(或为了接管)每次移动都会询问。 |
onStartShouldSetResponderCapture | boolean | 同上,但从外向内询问,先于冒泡阶段。父视图用它从子视图手里拿走触摸。 |
onMoveShouldSetResponderCapture | boolean | 移动时的版本。 |
成为响应者之后:
| 属性 | 说明 |
|---|---|
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参考。- 无障碍:可按压视图是无障碍元素;给它们设置角色和标签。