Skip to content
动画与手势

动画与手势

移动端动画比 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,不写运行时直接报警告——这是新手最常踩的第一坑。

可以走原生驱动的(动画白名单):

  • opacity
  • transform: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。


坑清单

注意 以下都是会真实发生在线上项目里的问题。

  1. worklet 里闭包捕获普通 JS 值。const config = { damping: 10 } 在组件作用域,worklet 函数里直接引用——运行时不是编译错误就是行为诡异。worklet 运行在 UI 线程的独立运行时,只有 shared value、字面量和显式传递的值可用;要回调 JS 函数走 runOnJS,反向走 runOnUI。
  2. jest 下 Reanimated 炸测试。worklets 编译产物在 Node 环境跑不起来,必须按官方文档给 jest 配置 reanimated 的 mock/setup(react-native-reanimated 自带 mock 入口,具体路径随版本调整,以官方测试文档为准),否则 useSharedValue 相关组件一测就红。
  3. 手势嵌在 ScrollView 里"没反应"。滚动容器抢走手势是默认正确行为。解法按优先级:给 Pan 设 activeOffset/failOffset 划清触发边界;.shouldCancelWhenOutside(false) 允许手指拖出容器后继续跟踪;再不行用 simultaneousHandlers 与滚动共存。见 02-核心组件.md 的滚动章节。
  4. Android 的 android_ripple 吃掉按压动画。Pressable 配了 ripple 后,按下瞬间被水波效果接管,再叠一层自绘按压动画会互相干扰、观感脏。二选一:要水波就别做缩放动画,要缩放就去掉 ripple 改用状态色。见 06-表单与交互.md。
  5. 忘包 GestureHandlerRootView:红屏 “No handler found for tag” 或手势全部失灵,十有八九是根包裹层漏了或包在了 Modal/portal 外面。
  6. 内置 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 的丝滑不代表千元机不掉帧。