Chapter 07

动画与手势

从 Animated API 到 Reanimated 3 worklet,在 UI 线程运行 60/120fps 流畅动画,配合 Gesture Handler 实现丝滑交互。

RN 动画的两种运行模式

React Native 动画存在一个根本性的性能挑战:JS 线程和 UI 线程是分离的。当 JS 线程忙于处理业务逻辑、网络请求、React 渲染时,动画帧就会丢失,表现为卡顿和掉帧。

解决方案是将动画逻辑"移到" UI 线程执行,即使 JS 线程完全冻结,动画也能保持流畅。这就是 Reanimated 3 的核心价值。在 RN 0.77.x + 新架构下,Reanimated 与 JSI 的集成更加紧密,worklet 函数的性能进一步提升。

核心名词解释

Animated API
RN 内置动画库。通过 useNativeDriver: true 可以让部分属性动画(transform、opacity)在 UI 线程运行,但不支持 layout 属性(width、height、padding 等)的原生驱动。适合简单动画,复杂场景用 Reanimated。
Reanimated 3
Software Mansion 维护的第三方动画库,所有动画逻辑通过 worklet 在 UI 线程执行,不依赖 JS 线程。支持任意样式属性的动画(包括 width、height 等 Animated API 不支持的属性)。是目前 RN 动画的最佳实践。
worklet
用 'worklet' 指令标记的函数,由 Reanimated Babel 插件在构建时序列化为可在 UI 线程运行的字节码。worklet 内部不能访问 JS 上下文(如 useState、外部模块的引用),只能用 SharedValue 等跨线程原语。
useSharedValue
Reanimated 的跨线程共享值,可同时在 JS 线程和 UI 线程读写。通过 .value 属性访问(.value = x 触发更新)。是 Reanimated 所有动画的基础驱动力——相当于 Animated.Value,但跨线程。
withTiming / withSpring / withDecay
Reanimated 动画函数,在 UI 线程执行。withTiming 是线性/缓动动画(精确控制时长和缓动曲线),withSpring 是弹簧物理动画(自然弹性),withDecay 是惯性滑动(根据初速度逐渐减速)。
useAnimatedStyle
将 SharedValue 绑定到组件样式的 Hook,返回一个动态样式对象。内部函数是 worklet,在 UI 线程执行,当 SharedValue 变化时自动重算样式并更新原生视图,不经过 JS 线程。
Gesture Handler
Software Mansion 维护的手势识别库,在原生层(iOS UIGestureRecognizer / Android GestureDetector)识别手势,避免通过 Bridge 传递事件带来的延迟。支持 Pan、Tap、Pinch、Rotation、Fling 等手势类型,以及手势组合(Simultaneous/Exclusive)。
interpolate
将一个值从输入范围映射到输出范围。例如:将拖拽距离(0-300px)映射为旋转角度(0-360deg)或透明度(1-0)。内置 Extrapolation.CLAMP(夹紧,超出范围时取边界值)避免极端值。
Layout Animation(布局动画)
Reanimated 提供的布局变化动画:元素进入(entering)、退出(exiting)、布局改变(layout)时自动插值动画。无需手动计算,只需给组件声明动画类型即可(如 FadeIn、SlideInRight)。

JS 驱动 vs UI 线程动画:原理对比

JS 驱动动画(旧方式,Animated API 不加 useNativeDriver) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 每帧(16ms): JS Thread → 计算新动画值 → Bridge(序列化)→ UI Thread → 更新样式 问题:JS Thread 忙碌时(执行业务逻辑、处理网络响应) 这一帧的动画计算被推迟,UI Thread 没有收到新样式 → 掉帧! ────────────────────────────────────────────────────── UI 线程动画(Reanimated 3 worklet) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ JS Thread(可以很忙) UI Thread(独立,始终 60fps) ┌─────────────────┐ ┌────┬────┬────┬────┬────┐ │ React 渲染 │ │ f1 │ f2 │ f3 │ f4 │ f5 │ │ 网络请求处理 │ │动画│动画│动画│动画│动画│ │ 状态更新 │ └────┴────┴────┴────┴────┘ └─────────────────┘ ↑ worklet 在这里执行 SharedValue 跨线程共享

Animated API(内置方案)

Animated API 是 React Native 内置的动画解决方案,无需额外安装。适合简单的渐入渐出、位移动画。关键规则:始终设置 useNativeDriver: true,让动画在 UI 线程运行。

import { Animated, Easing, StyleSheet } from 'react-native';
import { useRef, useEffect } from 'react';

export function FadeInCard({ children }: { children: React.ReactNode }) {
  // useRef 保存 Animated.Value,避免每次渲染重建
  // 注意:不能用 useState 保存 Animated.Value,因为它本身就是可变的
  const opacity = useRef(new Animated.Value(0)).current;
  const translateY = useRef(new Animated.Value(20)).current;

  useEffect(() => {
    // Animated.parallel:多个动画同时运行
    Animated.parallel([
      Animated.timing(opacity, {
        toValue: 1,
        duration: 400,
        easing: Easing.out(Easing.cubic), // 先快后慢的缓出曲线
        useNativeDriver: true,              // 必须!让动画跑在 UI 线程
      }),
      Animated.timing(translateY, {
        toValue: 0,
        duration: 400,
        easing: Easing.out(Easing.cubic),
        useNativeDriver: true,
      }),
    ]).start(); // .start() 触发动画执行
  }, []);

  return (
    <Animated.View   {/* 必须用 Animated.View,普通 View 不支持动画 */}
      style={[
        styles.card,
        {
          opacity,
          transform: [{ translateY }],
        },
      ]}
    >
      {children}
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#0d2137',
    borderRadius: 12,
    padding: 16,
  },
});
useNativeDriver 的限制 useNativeDriver: true 只支持 transform(translateX/Y/scale/rotate)和 opacity 的原生驱动动画。不支持 width、height、backgroundColor、padding、margin 等布局属性。如果需要动画这些属性,必须使用 Reanimated 3,或者接受 JS 线程驱动(性能较差)。

Reanimated 3:UI 线程动画全解

import Animated, {
  useSharedValue,    // 跨线程共享值
  useAnimatedStyle,  // 将 SharedValue 绑定到样式
  withSpring,         // 弹簧动画
  withTiming,         // 线性/缓动动画
  withSequence,       // 顺序动画
  interpolate,        // 值映射
  Extrapolation,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

export function DraggableCard() {
  // SharedValue:可在 JS 和 UI 线程同时读写的值
  // 通过 .value = x 赋值(不是 useState 的 setter!)
  const translateX = useSharedValue(0);
  const translateY = useSharedValue(0);
  const scale = useSharedValue(1);

  // Pan 手势:监听拖拽
  // 所有回调函数自动识别为 worklet(在 UI 线程执行)
  const gesture = Gesture.Pan()
    .onBegin(() => {
      // 按下时:弹簧放大(视觉反馈)
      scale.value = withSpring(1.05, { damping: 15, stiffness: 300 });
    })
    .onUpdate((event) => {
      // 拖拽中:直接跟随手指位置(无动画,立即响应)
      translateX.value = event.translationX;
      translateY.value = event.translationY;
    })
    .onEnd(() => {
      // 松手:弹回原位(弹簧动画)
      translateX.value = withSpring(0, { damping: 20, stiffness: 200 });
      translateY.value = withSpring(0, { damping: 20, stiffness: 200 });
      scale.value = withSpring(1);
    });

  // useAnimatedStyle:将 SharedValue 绑定为组件样式
  // 内部函数是 worklet,每次 SharedValue 变化时在 UI 线程重新执行
  const animatedStyle = useAnimatedStyle(() => {
    // interpolate:将 X 位移(-150 到 150)映射为旋转角度(-15° 到 15°)
    // Extrapolation.CLAMP:超出范围时取边界值(不会超过 ±15°)
    const rotation = interpolate(
      translateX.value,
      [-150, 0, 150],
      [-15, 0, 15],
      Extrapolation.CLAMP
    );

    return {
      transform: [
        { translateX: translateX.value },
        { translateY: translateY.value },
        { scale: scale.value },
        { rotate: `${rotation}deg` },
      ],
    };
  });

  return (
    {/* GestureDetector 包裹需要手势的组件 */}
    <GestureDetector gesture={gesture}>
      {/* Animated.View:Reanimated 的动画容器 */}
      <Animated.View style={[styles.card, animatedStyle]}>
        <Text style={styles.text}>拖拽我!</Text>
      </Animated.View>
    </GestureDetector>
  );
}

布局动画:进入/退出动画

Reanimated 的 Layout Animation 让元素的出现、消失和布局变化自动有动画,无需手动管理动画值。

import Animated, {
  FadeIn,        // 淡入进入
  FadeOut,       // 淡出退出
  SlideInRight,  // 从右侧滑入
  SlideOutLeft,  // 向左侧滑出
  ZoomIn,        // 缩放进入
  Layout,        // 布局改变动画
} from 'react-native-reanimated';

// 通知列表:条目删除时有滑出动画,其他条目有重排动画
function NotificationList({ items }: { items: Notification[] }) {
  return (
    <View>
      {items.map((item, index) => (
        <Animated.View
          key={item.id}
          entering={SlideInRight.delay(index * 50)}  // 延迟进入(错落感)
          exiting={SlideOutLeft}                      // 删除时滑出
          layout={Layout.springify()}                   // 其他元素重排时弹簧动画
          style={styles.notificationItem}
        >
          <Text>{item.message}</Text>
        </Animated.View>
      ))}
    </View>
  );
}

// 自定义进入动画(弹簧效果)
const MyEntering = ZoomIn
  .springify()         // 使用弹簧插值
  .damping(15)          // 阻尼(越小越弹)
  .stiffness(200)       // 刚度(越大越快)
  .delay(100);           // 延迟 100ms 后开始

滑动删除(Swipeable)

// 使用 Reanimated + Gesture Handler 实现 iOS 风格的左滑删除
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
  useSharedValue, useAnimatedStyle,
  withSpring, withTiming, runOnJS,
} from 'react-native-reanimated';
import { Dimensions } from 'react-native';

const SCREEN_WIDTH = Dimensions.get('window').width;
const DELETE_THRESHOLD = -SCREEN_WIDTH * 0.4; // 滑动超过 40% 宽度触发删除

function SwipeableItem({ item, onDelete }: { item: Item; onDelete: () => void }) {
  const translateX = useSharedValue(0);

  const gesture = Gesture.Pan()
    .activeOffsetX([-10, 10])  // 水平偏移超过 10px 才激活手势
    .onUpdate((event) => {
      // 只允许向左滑(负方向),并限制最大滑动距离
      translateX.value = Math.max(event.translationX, -SCREEN_WIDTH * 0.5);
    })
    .onEnd(() => {
      if (translateX.value < DELETE_THRESHOLD) {
        // 滑动超过阈值:继续滑出屏幕,然后触发删除回调
        // runOnJS:在 worklet 中调用 JS 函数(跨线程)
        translateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
          runOnJS(onDelete)();
        });
      } else {
        // 未达到阈值:弹回原位
        translateX.value = withSpring(0);
      }
    });

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View style={[styles.item, animatedStyle]}>
        <Text>{item.title}</Text>
      </Animated.View>
    </GestureDetector>
  );
}
runOnJS 使用注意 runOnJS 用于在 worklet(UI 线程)中调用普通 JS 函数(如 setState、onDelete 回调)。它的执行是异步的——会在下一帧的 JS 线程执行,不是立即执行。不要在 runOnJS 的函数中执行耗时操作,也不要依赖其返回值。
Reanimated 安装注意 Reanimated 3 需要在 babel.config.js 中添加插件:plugins: ['react-native-reanimated/plugin'],且必须放在所有插件的最后。修改 babel 配置后需要清除缓存重启:npx expo start --clear。忘记清除缓存是 Reanimated 最常见的安装问题。
本章小结 RN 动画的选择策略:① 简单淡入/位移动画 → Animated API + useNativeDriver: true;② 复杂动画(布局属性、物理弹簧、手势联动)→ Reanimated 3;③ 手势识别(拖拽/捏合/旋转)→ react-native-gesture-handler;④ 列表条目进入/退出动画 → Reanimated Layout Animation。核心原则:动画逻辑尽量在 UI 线程执行,不依赖 JS 线程,这样即使 JS 线程繁忙也能保持 60fps。