主题
11 · RN 手势与 Reanimated 动画
目标:用 RNGH 声明式手势跑通 Tap/Pan/Pinch;理解 useSharedValue 为何像 Vue 的 .value,以及动画如何绕过 React 重渲染跑在 UI 线程。
前置:10 · Fabric C++ 核心
下一篇:12 · 权限、推送与后台
在 React Native 中,原生手势(Gesture)如果直接在 JS 线程处理,很容易因为主线程阻塞而出现跟手卡顿、延迟或掉帧的问题。
为了达到 Native 原生的流畅度,目前 RN 社区的绝对标准是使用 react-native-gesture-handler (RNGH),它结合了我们上一轮聊到的 Reanimated,把手势的识别和动画计算完全下沉到了 UI 线程。
一、 核心思想:声明式手势 (Gesture API)
在 RNGH v2+ 中,推荐使用 GestureDetector 配合 Gesture 链式语法。
它的工作机制非常清晰:
- 定义手势对象:用
Gesture.Tap()、Gesture.Pan()等构造手势。 - 监听回调:在
.onStart(),.onUpdate(),.onEnd()等生命周期里编写逻辑(如果配合 Reanimated,这些逻辑会作为 Worklet 运行在 UI 线程)。 - 绑定组件:用
<GestureDetector gesture="{customGesture}">包裹需要响应手势的 View。
二、 常用手势代码详解与应用场景
下面为你拆解点击、双击、长按、滑动(拖拽)、缩放(双指捏合)的实现方式。
1. 点击 (Tap) & 双击 (Double Tap)
- 应用场景:按钮点击、双击点赞(类似抖音/Instagram 点赞)。
- 核心 API:
Gesture.Tap().numberOfTaps(2)
tsx
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export default function TapExample() {
// 单击手势
const singleTap = Gesture.Tap().onEnd(() => {
console.log('触发了普通单击');
});
// 双击手势 (设置 numberOfTaps)
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => {
console.log('触发了双击点赞!');
});
return (
<GestureDetector gesture={doubleTap}>
<View style={styles.box}>
<Text>双击我试试</Text>
</View>
</GestureDetector>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2. 长按 (Long Press)
- 应用场景:微信消息长按弹出菜单、卡片长按拖拽重排。
- 核心 API:
Gesture.LongPress().minDuration(500)
tsx
import React from 'react';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
export default function LongPressExample() {
const scale = useSharedValue(1);
const longPress = Gesture.LongPress()
.minDuration(500) // 触屏持续 500ms 触发
.onStart(() => {
// 在 UI 线程响应长按,缩小卡片给出物理按压反馈
scale.value = withSpring(0.9);
})
.onFinalize(() => {
// 手势结束或被打断时复原
scale.value = withSpring(1);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<GestureDetector gesture={longPress}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
3. 滑动 / 拖拽 (Pan)
- 应用场景:卡片拖拽、抽屉跟手拉出、悬浮窗位置拖动。
- 核心 API:
Gesture.Pan() - 关键属性:使用
translationX/translationY记录本次拖拽的相对位移;结合savedTranslationX保存历史累积位移。
tsx
import React from 'react';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';
export default function PanExample() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// 用于保存上一次拖拽结束时的绝对位置
const savedTranslationX = useSharedValue(0);
const savedTranslationY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate((event) => {
// 拖拽过程中:当前位置 = 历史保存位置 + 当前拖拽偏移量
translateX.value = savedTranslationX.value + event.translationX;
translateY.value = savedTranslationY.value + event.translationY;
})
.onEnd(() => {
// 拖拽结束:记录最新的终点位置
savedTranslationX.value = translateX.value;
savedTranslationY.value = translateY.value;
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
4. 放大与缩小 (Pinch)
- 应用场景:相册图片放大缩小、地图缩放。
- 核心 API:
Gesture.Pinch() - 关键属性:
event.scale(缩放比例,从1开始递增或递减)。
tsx
import React from 'react';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';
export default function PinchExample() {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const pinchGesture = Gesture.Pinch()
.onUpdate((event) => {
// 计算新的缩放比
scale.value = savedScale.value * event.scale;
})
.onEnd(() => {
// 保存缩放比例
savedScale.value = scale.value;
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<GestureDetector gesture={pinchGesture}>
<Animated.Image
source={{ uri: 'https://picsum.photos/300/300' }}
style={[{ width: 200, height: 200 }, animatedStyle]}
/>
</GestureDetector>
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
三、 高级难点:手势冲突与复合手势
在真实开发中,我们往往需要多个手势组合在一起(例如:图片既能捏合缩放,又能单指拖拽,还能双击复原)。这时需要用到 RNGH 的手势组合器:
1. 同时触发 (Gesture.Simultaneous)
让两个手势可以同时生效。最典型的场景就是图片预览:一边单指拖拽(Pan),一边双指缩放(Pinch)。
tsx
const panGesture = Gesture.Pan().onUpdate(...);
const pinchGesture = Gesture.Pinch().onUpdate(...);
// 组合手势:允许拖拽和缩放同时进行
const composedGesture = Gesture.Simultaneous(panGesture, pinchGesture);
return (
<GestureDetector gesture={composedGesture}>
<Animated.Image style={animatedStyle} />
</GestureDetector>
);1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
2. 互斥与优先级 (Gesture.Exclusive & requireExternalGestureToFail)
- 场景:同一个图片,单击显示操作栏,双击放大。如果直接写,双击的第一下会误触发单击。
- 解法:指定优先级,只有当“双击手势判定失败”时,才去触发“单击”。
tsx
const singleTap = Gesture.Tap().onEnd(() => console.log('单击'));
const doubleTap = Gesture.Tap().numberOfTaps(2).onEnd(() => console.log('双击'));
// Exclusive 表示互斥:优先等待 doubleTap,如果没触发双击,才执行 singleTap
const exclusiveGesture = Gesture.Exclusive(doubleTap, singleTap);1
2
3
4
5
2
3
4
5
总结
RN 处理手势的黄金法则:
- 库选型:认准
react-native-gesture-handler,放弃原生旧版PanResponder。 - 组合拳:
GestureDetector+Reanimated= UI 线程零掉帧跟手体验。 - 状态记录:拖拽或缩放时,永远需要两个
useSharedValue—— 一个记录当前过程中的动态偏移,另一个记录抬手后的最终静态值。
一、 为什么 React 里会出现“像 Vue”的 .value 修改?
在标准的 React 中,修改状态必须调用 setState 或 dispatch,这会触发组件的重新渲染(Re-render)。
但动画不能触发组件重新渲染!如果每秒 60 帧(甚至 120 帧)的动画都去触发 React 的 Virtual DOM Diff 和组件 Re-render,JS 线程瞬间就会卡死掉帧。
Reanimated 的核心目标是:绕过 React 的 setState 重新渲染机制,直接在 UI 线程(原生层)修改视图的属性。
为了做到这一点,它引入了类似于 Vue 3 ref 的设计:
tsx
const translateX = useSharedValue(-DRAWER_WIDTH);
// 修改动画值:不触发 React Re-render,只更新共享内存中的值
translateX.value = withTiming(0);1
2
3
4
2
3
4
二、 useSharedValue 背后到底发生了什么?
当你声明一个 useSharedValue 时,背后有三个核心角色在工作:
text
[ JS 线程 ] [ C++ JSI 共享内存 ] [ UI 线程 (Native) ]
translateX (Proxy 对象) ──(直接读写)──> Shared Value (原生内存数据) ──(同步更新)──> 渲染引擎 (Draw)1
2
2
1. 确实用了 Proxy 拦截 .value
在 JS 线程中,useSharedValue 返回的其实是一个被 Proxy 包装过的对象(或者定义了 Getter/Setter 的属性)。
- 当你读取
translateX.value时:触发 Getter,从底层 C++ 共享内存中读取最新的值。 - 当你写入
translateX.value = ...时:触发 Setter,它不会通知 React 重新渲染组件,而是通过 JSI (JavaScript Interface) 直接把新值刷入 C++ 层的共享内存中,并唤醒 UI 线程去更新画面。
2. 什么是 JSI 和“共享内存”?
在传统 RN 中,JS 线程和原生 UI 线程之间隔着一条异步的“桥”(Bridge)。 而 Reanimated 依赖 RN 的新架构 JSI (JavaScript Interface)。JSI 允许 JavaScript 对象直接持有 C++ 原生对象的指针。
也就是说,useSharedValue 创建的值,在 JS 线程和 UI 线程是共享同一块内存的。
3. useAnimatedStyle 做了什么?
当你把 animatedStyle 传给 <Animated.View> 时:
tsx
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));1
2
3
2
3
- Reanimated 会把这个回调函数提取出来,编译成一个 Worklet(一种可以运行在原生 UI 线程上的独立 JS 函数)。
- Reanimated 会自动追踪依赖(就像 Vue 的
effect依赖收集一样):它发现这个 Worklet 依赖了translateX这个 Shared Value。 - 当你修改
translateX.value时,UI 线程不需要等 JS 线程通知,直接在 UI 线程自己运行这个 Worklet 函数,算完新的 transform 样式,直接作用给原生的 View。
三、 对比:React vs Vue vs Reanimated
为了方便直观理解,我们可以把它们的响应式逻辑做个对比:
| 机制 | React 标准 useState | Vue 3 ref | Reanimated useSharedValue |
|---|---|---|---|
| 修改方式 | setCount(1) | count.value = 1 | translateX.value = 1 |
| 底沉实现 | 调度器压入队列,等待 Re-render | Proxy 拦截,触发 trigger() | Proxy / Setter 拦截,通过 JSI 刷入 C++ 共享内存 |
| 响应结果 | 触发整个组件函数重新执行 (Re-render) | 触发依赖该 ref 的 DOM 节点更新 | 完全不触发 React 组件重渲染,直接在 UI 线程更新原生 View |
