动画与手势
移动端动画比 Web 动画多一条铁律:主线程被拆成了 JS 线程和 UI 线程,一个要横跨两个线程的动画基本注定掉帧。这一章先把移动端的动画性能模型讲透,再分层过工具——内置 Animated(RN 0.85 起换了全新 C++ 动画后端)、react-native-reanimated(v4)、react-native-gesture-handler(v3),最后把手势和动画合流,做一个可以直接抄走的"拖拽关闭底部卡片"。
注意 本手册基线是 RN 0.88,旧架构已在 0.82 移除。Reanimated 4 与 Gesture Handler 3 都建立在新架构和 worklets 运行时之上,网上大量面向旧架构的教程写法已过时,遇到
useNativeDriver之外的桥接时代话题请谨慎采信。架构背景见01-核心概念与架构.md。
依赖安装(Expo 与 bare 的差别)见 00-安装与快速开始.md:
npx expo install react-native-reanimated react-native-gesture-handler移动端动画的性能模型
帧预算:16.6ms 里没有你的容身之处
60fps 屏幕每帧只有 16.6ms 可用,120Hz(iPhone ProMotion、多数安卓旗舰)则压到 8.3ms。RN 应用典型的关键线程分工:
- JS 线程:跑 Hermes,处理业务逻辑、setState、React 渲染、事件回调
- UI 线程:接收 Fabric 下发的视图指令,完成布局与绘制(安卓对应渲染线程,iOS 对应主线程)
任何一帧内没做完的工作,都会直接表现为掉帧。动画是"每帧都要有变化"的工作,它对线程占用的敏感度远高于普通交互。
JS 线程动画为什么必掉帧
最原始的 JS 驱动动画:setInterval 或 requestAnimationFrame 里逐步 setState 改 opacity。问题不在"动画本身难",而在JS 线程是被争抢的:
- 用户正在滚动长列表,JS 线程要处理滚动事件和增量渲染;
- 手势识别、网络回调、状态更新都在同一个线程排队;
- 动画采样恰好排在队尾,两帧之间值没变,视觉上就是卡顿、拖影。
越是复杂的页面(长列表 + 手势 + 频繁 setState),JS 驱动动画越不可靠。这就是所有"动画下放"方案的动机。
native driver:把动画下放给 UI 线程
内置 Animated 的 useNativeDriver: true 做的事很直白:启动时把初始值、动画配置(时长、缓动曲线、弹簧参数)一次性序列化发给原生侧,之后的每一帧由原生侧按曲线自行推进插值,不再回 JS 线程。动画进行中你就算把 JS 线程卡死(比如同步执行一个 2 秒的循环),动画照样丝滑。
代价是:能下放的只有原生侧独立可算的属性——opacity 和 transform 系列。布局类与颜色类不在其列,下一节详述。
RN 0.85 之后的动画后端:C++ 重写 + 事件驱动
RN 0.85(2026-04)引入了全新 Animation Backend:内置 Animated 的驱动层用 C++ 重写,并且改为事件驱动——原生驱动的动画闭环整个搬到了原生侧推进,不再依赖 JS 线程的调度节拍。0.86 的 changelog 里有 “Animated: Fix 1-frame latency in C++ NativeAnimatedNodesManager for event-driven animations” 这类条目,说明这条路还在持续打磨。
对你的实际意义:内置 Animated 加上 useNativeDriver: true,在 0.85+ 上的表现比桥接时代可靠得多。很多过去"必须上 Reanimated 才够顺"的场景,现在内置方案也顶得住;但 Reanimated 在可编排性、手势联动、确定性执行上的优势依然不可替代,这也是本章后半部分的重点。
内置 Animated:够用且零依赖的一层
以下 API 都是多年稳定的公开接口,可以放心作为选型基线。
Animated.Value 与三种动画类型
Animated.Value 是一个"值容器",动画就是让它的值随时间变化,再由 interpolate 映射到样式属性:
import Animated, { Easing } from 'react-native';
const fade = new Animated.Value(0); // 0 到 1 的进度
// 1. timing:按时长和缓动曲线推进
Animated.timing(fade, {
toValue: 1,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
// 2. spring:物理弹簧,收尾有轻微回弹,按压反馈首选(friction 越大回弹越少,tension 为刚度)
Animated.spring(fade, { toValue: 1, friction: 6, tension: 60, useNativeDriver: true }).start();
// 3. decay:惯性滑动,给一个初速度让它自己滑走
Animated.decay(fade, { velocity: 0.8, deceleration: 0.998, useNativeDriver: true }).start();start() 可带回调 (completion) => completion.finished,用于判断动画是否被 stopAnimation 打断。
useNativeDriver 与不支持原生驱动的样式清单
必须显式写 useNativeDriver,不写运行时直接报警告——这是新手最常踩的第一坑。
可以走原生驱动的(动画白名单):
opacitytransform:translateX / translateY / scale / scaleX / scaleY / rotate / rotateX / rotateY / skewX / skewY / matrix
不能走原生驱动的(公开稳定事实):
- 布局几何属性:
width、height、top、left、right、bottom、margin系列 - 颜色类:
backgroundColor、borderColor等(颜色插值只能在 JS 侧算) fontSize等排版属性
对局:需要动画化宽高时,改用 scaleX / scaleY 伪装缩放(文字会被拉伸变形时不可接受,就退回 useNativeDriver: false 并控制动画时长)。
interpolate:一个进度值映射出千变万化
const style = {
opacity: progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
}),
translateY: progress.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [40, 8, 0], // 三段折线,0.5 处减速
extrapolate: 'clamp', // 超界钳制,防止手势拖出边界值飞出去
}),
};多段 inputRange 配合 outputRange 可以做出"先快后慢"“中途停顿"等效果,不需要额外动画对象。
多值组合与派生值
// 编排:并发 / 串行 / 错峰并发 / 循环
Animated.parallel([animA, animB]).start();
Animated.sequence([animA, animB]).start();
Animated.stagger(80, items.map((_, i) => rowAnim(i))).start(); // 列表逐行错峰入场
Animated.loop(Animated.sequence([inAnim, outAnim])).start();
// 派生:对 Value 做算术得到新的 Animated 值;任意组件可用 createAnimatedComponent 挂动画样式
const bounded = Animated.diffClamp(Animated.add(offsetX, offsetY), 0, 120); // 折叠头部常用
Animated.event 还能把手势/滚动原生事件直接绑到 Animated.Value,配合 native driver 时事件连 JS 线程都不经过,用法形态与 onScroll 常规绑定一致。
完整示例:淡入卡片 + 按压反馈
两个 Animated.Value(入场进度、按压缩放)组合进同一份样式,是内置方案最典型的用法:
import React, { useEffect, useRef } from 'react';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';
export function FadeInCard({ title, onPress }: { title: string; onPress?: () => void }) {
const appear = useRef(new Animated.Value(0)).current; // 0 未显示,1 完全显示
const press = useRef(new Animated.Value(1)).current; // 按压缩放,1 为基准
useEffect(() => {
Animated.timing(appear, {
toValue: 1,
duration: 320,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
return () => { appear.stopAnimation(); press.stopAnimation(); };
}, [appear, press]);
const pressIn = () =>
Animated.spring(press, { toValue: 0.97, friction: 5, useNativeDriver: true }).start();
const pressOut = () =>
Animated.spring(press, { toValue: 1, friction: 4, useNativeDriver: true }).start();
// 两个值分别驱动 opacity / translateY(appear)和 scale(press)
const cardStyle = {
opacity: appear,
transform: [
{ translateY: appear.interpolate({ inputRange: [0, 1], outputRange: [24, 0] }) },
{ scale: press },
],
};
return (
<Pressable onPressIn={pressIn} onPressOut={pressOut} onPress={onPress}>
<Animated.View style={[styles.card, cardStyle]}>
<Text style={styles.title}>{title}</Text>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
card: { backgroundColor: '#fff', borderRadius: 16, padding: 20, margin: 12 },
title: { fontSize: 17, fontWeight: '600' },
});完整示例:输入框聚焦时标题收缩
一个共享 Animated.Value 同时驱动两个视图(label 上浮缩小 + 下划线横向展开),注意两者全部用 transform 实现,所以都能走原生驱动:
import React, { useRef } from 'react';
import { Animated, StyleSheet, Text, TextInput, View } from 'react-native';
export function FocusableField() {
const focus = useRef(new Animated.Value(0)).current; // 0 未聚焦,1 聚焦
const animateTo = (value: 0 | 1) =>
Animated.timing(focus, {
toValue: value,
duration: 200,
useNativeDriver: true,
}).start();
// 同一个值,映射出 label 的位移与缩放
const labelStyle = {
transform: [
{ translateY: focus.interpolate({ inputRange: [0, 1], outputRange: [0, -18] }) },
{ scale: focus.interpolate({ inputRange: [0, 1], outputRange: [1, 0.78] }) },
],
opacity: focus.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }),
};
// 下划线用 scaleX 伪装"宽度从 0 展开",规避 width 不能上原生驱动的限制
const underlineStyle = {
transform: [
{ scaleX: focus.interpolate({ inputRange: [0, 1], outputRange: [0.1, 1] }) },
],
};
return (
<View style={styles.field}>
<Animated.Text style={[styles.label, labelStyle]}>邮箱</Animated.Text>
<TextInput placeholder="you@example.com" onFocus={() => animateTo(1)} onBlur={() => animateTo(0)} />
<View style={styles.underlineTrack}>
<Animated.View style={[styles.underline, underlineStyle]} />
</View>
</View>
);
}
const styles = StyleSheet.create({
field: { paddingHorizontal: 16, paddingTop: 24 },
label: { fontSize: 13, color: '#666', marginBottom: 4 },
underlineTrack: { height: 1, backgroundColor: '#ddd' },
underline: { height: 1, backgroundColor: '#3b82f6' },
});LayoutAnimation:系统自带的最小方案
LayoutAnimation 是 RN 内置的另一条路:声明"下一次布局变化"用什么动画,让系统对增删、位移的视图做补间,不需要你为每个视图持有动画值。
import { LayoutAnimation, Platform, UIManager } from 'react-native';
// 安卓旧版本需要显式开启实验开关;新架构下各版本的开关行为有调整,
// 建议在目标 RN 版本真机实测后再决定是否保留这行。
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
function addItem() {
LayoutAnimation.configureNext(
LayoutAnimation.Presets.easeInEaseOut, // 还有 linear / spring
);
setItems((prev) => [...prev, nextItem()]);
}也支持自定义参数(duration、type 取 LayoutAnimation.Types.easeInEaseOut 等、property 取 LayoutAnimation.Properties);较新版本中动画器配置的具体入口(setAnimator 相关)文档口径不够明确,以你项目 RN 版本的官方文档与真机表现为准。
适用边界要拎清楚:
- 合适:小规模的增删、折叠展开、表单出现——一屏内十几个视图的布局变化,它是最省事的。
- 不合适:大型列表(它会对所有布局变化生效,容易殃及无辜)、需要精确控制单个元素时序、需要动画完成回调的业务。列表场景直接考虑下文 Reanimated 的 layout animations。
Reanimated 4:手势时代的标准答案
Reanimated 是社区事实标准的动画库(npm 当前 v4 世代)。与内置 Animated 的根本差别:它把 JS 侧动画描述升级成了在 UI 线程执行的 worklet 函数,动画和手势的推进完全不回 JS 线程。
Reanimated 4 相对 3 的口径:底层与 New Architecture、worklets 运行时的结合更紧密(旧架构已死,v4 只按新架构生态写)。日常 API 与 v3 基本连续,babel 插件等工程配置项以官方文档为准。
worklet 机制:一段代码怎么跑到 UI 线程去的
标记了 'worklet' 指令的函数,会被 babel 插件在编译期转换并序列化,运行时通过 JSI 注入到 UI 线程的 JS 运行时里执行:
const onDrag = (x: number) => {
'worklet'; // 没有这行,下面这些代码在 JS 线程跑,等于白用 Reanimated
sharedX.value = x;
};关键推论(也是最大的坑源):worklet 里不能闭包捕获普通 JS 值——它根本不在你的 JS 线程上下文里。跨线程共享状态的唯一正道是 shared value;要调用普通 JS 函数(比如上报埋点、setState),必须显式 runOnJS(fn)();反过来从 JS 线程调 worklet 用 runOnUI。
shared value 与动画函数
import Animated, {
useSharedValue, useAnimatedStyle,
withTiming, withSpring, withDecay,
withSequence, withDelay, cancelAnimation,
} from 'react-native-reanimated';
const opacity = useSharedValue(0);
// JS 线程里读写 .value 都合法;worklet 里读写走的是 UI 线程视图
opacity.value = withTiming(1, { duration: 300 });
opacity.value = withSpring(1, { damping: 12, stiffness: 120, mass: 0.8 });
opacity.value = withDecay({ velocity: 0.9, deceleration: 0.995 });
// 编排:withSequence 串行,withDelay 插队延时
opacity.value = withSequence(
withTiming(0.4, { duration: 100 }),
withDelay(200, withSpring(1)),
);
// 完成回调 + 中途取消
opacity.value = withTiming(1, {}, (finished) => {
'worklet';
if (!finished) console.log('被 cancelAnimation 打断');
});
// cancelAnimation(opacity);
useAnimatedStyle 把 shared value 变成视图样式——UI 线程每帧自算,不需要 React 重渲染:
const x = useSharedValue(0);
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
}));
<Animated.View style={style} />拖拽类联动的完整形态见后文"手势 + Reanimated 的标准联动"一节。
实战:滚动联动的折叠头部
useAnimatedScrollHandler 让滚动事件在 UI 线程直接喂给 shared value,配合 interpolate 做头部收缩:
import Animated, {
useSharedValue, useAnimatedStyle, useAnimatedScrollHandler,
interpolate, Extrapolation,
} from 'react-native-reanimated';
const HEADER_HEIGHT = 160;
export function CollapsingHeader() {
const offsetY = useSharedValue(0);
const onScroll = useAnimatedScrollHandler((e) => {
offsetY.value = e.contentOffset.y;
});
// 头部整体上移,最多吃掉 HEADER_HEIGHT
const headerStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -Math.min(offsetY.value, HEADER_HEIGHT) }],
}));
// 头部内容随滚动淡出;导航栏小标题随滚动淡入
const coverOpacity = useAnimatedStyle(() => ({
opacity: interpolate(offsetY.value, [0, HEADER_HEIGHT], [1, 0], Extrapolation.CLAMP),
}));
const titleOpacity = useAnimatedStyle(() => ({
opacity: interpolate(offsetY.value, [HEADER_HEIGHT - 60, HEADER_HEIGHT], [0, 1], Extrapolation.CLAMP),
}));
return (
<Animated.ScrollView onScroll={onScroll} scrollEventThrottle={16}>
<Animated.View style={[{ height: HEADER_HEIGHT }, headerStyle]}>
<Animated.View style={coverOpacity} />
</Animated.View>
<Animated.Text style={[styles.navTitle, titleOpacity]}>文章标题</Animated.Text>
{/* 正文 */}
</Animated.ScrollView>
);
}动画组件:entering / exiting / layout
Reanimated 内置了一套声明式入场出场动画,直接挂 props,不需要手写 Value:
import Animated, {
FadeIn, FadeInUp, SlideInLeft, SlideInUp, ZoomIn, ZoomOut,
FadeOut, SlideOutDown,
Layout,
} from 'react-native-reanimated';
<Animated.View entering={FadeInUp.duration(250)} exiting={FadeOut}>...</Animated.View>进入动画家族:FadeIn / FadeInUp / FadeInDown / FadeInLeft / FadeInRight、ZoomIn、SlideInLeft / SlideInRight / SlideInUp / SlideInDown,退出对应 FadeOut* / ZoomOut / SlideOut*;都支持 .duration(ms)、.delay(ms) 链式调整。
layout animation(位置补间)是它的杀手锏:元素布局变了(兄弟增删导致位移),自动补间到新位置。列表项删除自动补位的完整例子:
import React, { useState } from 'react';
import { Text } from 'react-native';
import Animated, {
SlideInRight, ZoomOut, Layout,
} from 'react-native-reanimated';
const INITIAL = Array.from({ length: 8 }, (_, i) => `第 ${i + 1} 项`);
export function RemovableList() {
const [items, setItems] = useState(INITIAL);
return (
<Animated.FlatList
data={items}
keyExtractor={(item) => item}
renderItem={({ item, index }) => (
<Animated.View
entering={SlideInRight}
exiting={ZoomOut}
layout={Layout.Linear.duration(240)} // 删除后其余项滑向新位置
>
<Text onPress={() => setItems((prev) => prev.filter((_, i) => i !== index))}>
{item}(点我删除)
</Text>
</Animated.View>
)}
/>
);
}Layout.Linear 之外还有 Layout.Spring(弹簧补间,观感更自然)与自定义 layout animation;数据源必须是稳定的 key,否则补位会算错基准。
Gesture Handler 3:比 Touchable 更可靠的手势层
为什么不用 PanResponder/Touchable 凑合:RN 的手势识别要和原生滚动容器共存,GestureDetector 把识别逻辑下沉到原生手势系统,避免了"JS 里拼事件、滚动一出现手势就废"的老问题。
前置要求:应用根部必须包一层 GestureHandlerRootView,否则所有手势直接红屏报错:
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<RootNavigator />
</GestureHandlerRootView>
);
}声明式 API:Gesture.Pan() / Tap() / LongPress()
v2 引入、v3 延续的 builder 链式写法,事件回调全部是 worklet:
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const tap = Gesture.Tap()
.taps(2) // 双击
.maxDuration(250) // 按下-抬起超时判定
.onEnd(() => { 'worklet'; /* 双击达成 */ });
const longPress = Gesture.LongPress()
.minDuration(400)
.onStart(() => { 'worklet'; /* 触发震动反馈 */ })
.onEnd((_, success) => { 'worklet'; if (!success) { /* 提前松手,未达标 */ } });
const pan = Gesture.Pan()
.activeOffsetX([-12, 12]) // 横向移动超过 12px 才激活
.failOffsetY([-20, 20]) // 纵向先动则放弃(让位给滚动)
.onBegin((e) => { 'worklet'; })
.onUpdate((e) => { 'worklet'; /* e.translationX / e.velocityX */ })
.onEnd((e) => { 'worklet'; })
.onFinalize(() => { 'worklet'; });事件对象的 translationX/Y(累计位移)、velocityX/Y(速度)、absoluteX/Y 等都可直接在 worklet 里取。
多指:activeTouches 与 maxPointers
const twoFingerPan = Gesture.Pan().activeTouches(2); // 至少两个触点才激活
const singleTap = Gesture.Tap().maxPointers(1); // 按下第二根手指即失败
activeTouches(n) 是所有手势通用的"至少要 n 个触点才激活”,maxPointers(n) 限定"超过 n 个触点即失败";更复杂的双指缩放组合以官方文档为准。
手势组合:Simultaneous / Race / Exclusive
const pan = Gesture.Pan();
const pinch = Gesture.Pan(); // 示意,双指场景
// 同时进行(地图拖拽 + 缩放互不干扰)
const combined1 = Gesture.Simultaneous(pan, pinch);
// 竞速:谁先满足激活条件谁赢,另一个作废(点 vs 拖)
const combined2 = Gesture.Race(tap, pan);
// 有序仲裁:按声明顺序判定(区别于 Race 的"先到先得")
const combined3 = Gesture.Exclusive(pan, tap);和原生滚动容器共存的通道:RNGH 提供的 ScrollView/FlatList 等组件支持 simultaneousHandlers 属性(传入手势 ref 数组),自定义手势也支持 .simultaneousWith(other) 之类的组合声明;具体属性签名以官方文档为准。
手势 + Reanimated 的标准联动
记住一个模式就够了:手势 worklet 只负责更新 shared value,useAnimatedStyle 负责把它变成样式。两个库在 UI 线程会师,全程不碰 JS 线程:
Gesture.Pan().onUpdate((e) => {
'worklet';
progress.value = e.translationX / 300; // 只写 shared value
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: progress.value * 300 }],
}));完整实战:可拖拽关闭的底部卡片
本章所有知识的合流:Pan 手势跟踪位移、向下拖出阈值则 withTiming 滑出并回调 JS 关闭、否则 withDecay 带惯性回弹、clamp 防拖过头、runOnJS 完成跨线程收尾。这段可以直接抄进项目:
import React from 'react';
import { Dimensions, Pressable, StyleSheet, Text, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue, useAnimatedStyle,
withTiming, withDecay, runOnJS,
} from 'react-native-reanimated';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const CLOSE_DISTANCE = SCREEN_HEIGHT / 3;
export function DismissibleCard({ onClose }: { onClose: () => void }) {
const translateY = useSharedValue(0);
const startY = useSharedValue(0);
const gesture = Gesture.Pan()
// 只响应向上拖出的手势起点,纵向位移全部接管
.shouldCancelWhenOutside(false)
.onBegin(() => {
'worklet';
startY.value = translateY.value;
})
.onUpdate((e) => {
'worklet';
const next = startY.value + e.translationY;
// 允许向下拖出,向上最多给 24px 橡皮筋阻力
translateY.value = next > 0 ? next : next * 0.2;
})
.onEnd((e) => {
'worklet';
if (translateY.value > CLOSE_DISTANCE) {
// 拖过阈值:滑出屏幕,动画结束后回 JS 线程通知关闭
translateY.value = withTiming(
SCREEN_HEIGHT,
{ duration: 220 },
(finished) => {
'worklet';
if (finished) runOnJS(onClose)();
},
);
} else {
// 未过阈值:用松手速度做惯性衰减,clamp 限制在可拖区间
translateY.value = withDecay({
velocity: e.velocity.y,
deceleration: 0.995,
clamp: [-24, CLOSE_DISTANCE],
});
}
});
const style = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
return (
<Pressable style={styles.backdrop} onPress={onClose}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, style]}>
<View style={styles.handle} />
<Text style={styles.title}>详情</Text>
<Text>往下拖这张卡片试试——松手会回弹,拖过三分之一屏直接划走。</Text>
</Animated.View>
</GestureDetector>
</Pressable>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.4)', justifyContent: 'flex-end' },
card: {
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
padding: 20,
paddingBottom: 40,
},
handle: { width: 40, height: 5, borderRadius: 3, backgroundColor: '#ddd', alignSelf: 'center', marginBottom: 16 },
title: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
});几个实现细节值得留意:onBegin 里先把当前 translateY 存进 startY,否则多次拖拽会叠加跳动;withDecay 的 clamp 让惯性回弹不会过冲;关闭回调必须 runOnJS——onClose 是普通 JS 函数,worklet 环境里直接调用会炸。
性能检查清单
- 只动画
transform与opacity。translate / scale / rotate 三件套是唯一能上原生驱动、且不触发布局的属性;动画里出现 width/height/top/left 的改动基本等于自缚。 - 动画进行中不要 setState。每帧 setState 会把渲染压力塞回 JS 线程,正好和手势/滚动抢资源。用 shared value 或 Animated.Value 替代状态。
- 列表动画控制项数。entering/exiting/layout 对每个挂载项都生效,几百项的长列表全开补位动画会明显掉帧——只给可见区/变化区加动画,或干脆只在折叠面板这类小集合上用。
- 内置 Animated 必写
useNativeDriver: true,写了之后确认动画属性在白名单内,否则运行时警告会直接告诉你哪不对。 - 手势根部三件套:
GestureHandlerRootView包根、Reanimated 的 babel 插件配置正确、Hermes 开启(见00-安装与快速开始.md)。 - 验证手段:开发者菜单的 FPS/JS 帧率监视器先排除动画掉帧;帧率异常再用 Perf Monitor 和 Profiler 定位是 JS 线程还是渲染侧的问题,流程见
10-调试测试与性能.md。
选型:内置 Animated vs Reanimated vs Lottie
内置 Animated:状态过渡、一次性淡入淡出、按压反馈这类"启动后不需要中途改参数、不跟手势较劲"的动画,优先它——零额外依赖,0.85 新后端之后可靠性又上了一档。代码里已经见过它的样子。
Reanimated:需要满足任一条就上——动画由手势驱动(拖拽、跟手)、需要列表 entering/exiting/layout 补位、需要复杂编排(一个值同时驱动多个属性 + 条件分支 + 中途换目标)、需要动画完成后的确定性回调。它是社区标准,文档、示例、导航库(04-导航.md 里转场动画的底层多半就是它)全都围着它转。
Lottie 类(lottie-react-native):设计师在 After Effects 里做好、导出 JSON 的装饰性动画——启动页、空状态插画、点赞爆炸效果。它的价值是"设计产物原样落地",但交互驱动能力弱、JSON 体积要控制(回指 11-构建与发布.md 的包体积话题),不适合做需要跟手的界面动效。
一个项目里三者共存很常见:微交互 Reanimated、零碎过渡内置 Animated、品牌插画 Lottie。
坑清单
注意 以下都是会真实发生在线上项目里的问题。
- worklet 里闭包捕获普通 JS 值。
const config = { damping: 10 }在组件作用域,worklet 函数里直接引用——运行时不是编译错误就是行为诡异。worklet 运行在 UI 线程的独立运行时,只有 shared value、字面量和显式传递的值可用;要回调 JS 函数走runOnJS,反向走runOnUI。 - jest 下 Reanimated 炸测试。worklets 编译产物在 Node 环境跑不起来,必须按官方文档给 jest 配置 reanimated 的 mock/setup(
react-native-reanimated自带 mock 入口,具体路径随版本调整,以官方测试文档为准),否则useSharedValue相关组件一测就红。 - 手势嵌在 ScrollView 里"没反应"。滚动容器抢走手势是默认正确行为。解法按优先级:给 Pan 设
activeOffset/failOffset划清触发边界;.shouldCancelWhenOutside(false)允许手指拖出容器后继续跟踪;再不行用simultaneousHandlers与滚动共存。见02-核心组件.md的滚动章节。 - Android 的
android_ripple吃掉按压动画。Pressable配了 ripple 后,按下瞬间被水波效果接管,再叠一层自绘按压动画会互相干扰、观感脏。二选一:要水波就别做缩放动画,要缩放就去掉 ripple 改用状态色。见06-表单与交互.md。 - 忘包
GestureHandlerRootView:红屏 “No handler found for tag” 或手势全部失灵,十有八九是根包裹层漏了或包在了 Modal/portal 外面。 - 内置 Animated 不写
useNativeDriver:运行时每次启动动画都警告刷屏;写false能跑但列表一滚就卡——回到本节第一条原则,改属性而不是改开关。
实战建议
- 把 16.6ms 预算当成设计约束:动效评审阶段就问一句"这个动画走原生驱动吗",能上
transform+opacity的全部上。 - 简单状态过渡先用内置 Animated 起步,量级一上来(跟手、列表、编排)迁 Reanimated,两者的
Animated.Value心智模型一致,迁移成本低。 - Reanimated 的 API 世代更迭快(v3 到 v4 有工程配置层面的调整),升级前通读它的 changelog 再动手,babel 插件与 mock 配置最容易被版本差异打穿。
- 列表转场、折叠面板这类需求,先试
entering/exiting/layout三个 props,往往十几行代码就收工,不要手写逐元素 Value。 - 手势调参的核心是激活边界:
activeOffset/failOffset给得宽松一点,宁可手势迟钝,不可误触滚动。 - 上线前在真机低端安卓上跑一遍全部动效路径——模拟器 120fps 的丝滑不代表千元机不掉帧。