开篇:让界面"活"起来

前八课的界面大多是静态的。点按钮,内容瞬间切换;打开页面,元素直接出现。能用,但不够自然。

真实的应用需要动画来传达信息:按钮按下时的轻微缩放,让用户知道"我点到了";列表项删除时的淡出,让用户知道"这项没了";页面切换时的滑动,让用户知道"我从哪里来,要到哪里去"。

动画不只是好看,它是用户体验的一部分。它告诉用户:发生了什么,接下来会发生什么。

React Native 提供了多种动画方案。这一课从最基础的 Animated API 讲起,再深入到社区主流的 Reanimated,最后结合 Pressable 和手势,做出真正有交互感的界面。


一、动画的基本思路

1.1 动画的本质

动画的本质,是让某个值随时间变化,然后把变化应用到界面上。

比如透明度从 0 变到 1,位置从左边变到右边,大小从 1 倍变到 1.5 倍。这些变化在时间轴上平滑地发生,用户看到的就是动画。

在 React Native 中,动画值不是普通的 state。用 state 做动画性能很差:

// JavaScript + React Native
// 不推荐:用 state 做动画
const [opacity, setOpacity] = useState(0);                          // 创建状态 opacity

setInterval(() => {                                                 // 定时器
  setOpacity((prev) => Math.min(prev + 0.02, 1));                   // 每 16 毫秒增加一点
}, 16);                                                             // 间隔 16 毫秒

每 16 毫秒更新一次 state,触发一次重新渲染。这种方式性能很差,而且不够平滑。

1.2 两套互补的动画系统

React Native 内置了两套互补的动画系统:

  • Animated:对特定值进行细粒度、交互式控制;
  • LayoutAnimation:用于全局布局事务的动画。

这一课重点讲 Animated,同时介绍社区主流的 Reanimated。


二、Animated API 入门

2.1 Animated.Value

Animated 的核心是 Animated.Value——一个可以被动画驱动的值。

// JavaScript + React Native
import { useRef } from 'react';                                     // 引入 useRef
import { Animated, View } from 'react-native';                      // 引入 Animated 和 View

export default function App() {                                     // 定义并默认导出 App
  const fadeAnim = useRef(new Animated.Value(0)).current;           // 创建动画值,初始 0

  return (                                                          // 返回界面描述
    <View>                                                          // 外层容器
      <Animated.View                                                // 可动画的 View
        style={{                                                    // 样式
          opacity: fadeAnim,                                        // 透明度由动画值决定
          width: 100,                                               // 宽度 100
          height: 100,                                              // 高度 100
          backgroundColor: 'blue',                                  // 蓝色背景
        }}                                                          // 结束样式
      />
    </View>                                                         // 结束容器
  );                                                                // 结束 return
}                                                                   // 结束 App

Animated.Value(0) 创建了一个初始值为 0 的动画值。它被连接到 Animated.View 的 opacity 上。当这个值变化时,透明度会跟着变化。

注意:Animated.Value 应该用 useRef 保存,不要每次渲染都重新创建。

2.2 Animated.timing:基于时间的动画

Animated.timing 是最常用的动画类型,它让值在指定时间内从当前值变化到目标值。

// JavaScript + React Native
import { useRef } from 'react';                                     // 引入 useRef
import { Animated, View, Button, StyleSheet } from 'react-native';  // 引入组件

export default function App() {                                     // 定义并默认导出 App
  const fadeAnim = useRef(new Animated.Value(0)).current;           // 创建动画值,初始 0

  const fadeIn = () => {                                            // 定义淡入函数
    Animated.timing(fadeAnim, {                                     // 使用 timing 动画
      toValue: 1,                                                   // 目标值 1
      duration: 1000,                                               // 时长 1000 毫秒
      useNativeDriver: true,                                        // 使用原生驱动
    }).start();                                                     // 启动动画
  };                                                                // 结束 fadeIn

  const fadeOut = () => {                                           // 定义淡出函数
    Animated.timing(fadeAnim, {                                     // 使用 timing 动画
      toValue: 0,                                                   // 目标值 0
      duration: 1000,                                               // 时长 1000 毫秒
      useNativeDriver: true,                                        // 使用原生驱动
    }).start();                                                     // 启动动画
  };                                                                // 结束 fadeOut

  return (                                                          // 返回界面描述
    <View style={styles.container}>                                 // 居中容器
      <Animated.View                                               // 可动画的 View
        style={[styles.box, { opacity: fadeAnim }]}                 // 透明度由动画值决定
      />
      <Button title="淡入" onPress={fadeIn} />                      // 淡入按钮
      <Button title="淡出" onPress={fadeOut} />                     // 淡出按钮
    </View>                                                         // 结束容器
  );                                                                // 结束 return
}                                                                   // 结束 App

const styles = StyleSheet.create({                                  // 创建样式表
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 12 },  // 居中容器样式
  box: { width: 100, height: 100, backgroundColor: 'blue', marginBottom: 20 },      // 方块样式
});                                                                 // 结束样式表

关键参数:

  • toValue:目标值;
  • duration:动画时长(毫秒);
  • useNativeDriver:是否使用原生驱动。

2.3 useNativeDriver 的重要性

useNativeDriver: true 让动画在原生线程执行,不经过 JavaScript 线程。这样即使 JavaScript 在忙,动画也能保持流畅。

但不是所有属性都支持原生驱动。目前只支持:

  • opacity;
  • transform(包括 translateX、translateY、scale、rotate 等)。

像 width、height、backgroundColor 这些属性不支持原生驱动。如果要对这些属性做动画,只能用 useNativeDriver: false,但性能会差一些。

2.4 Animated.spring:基于物理的动画

Animated.spring 使用弹簧物理模型,动画更自然:

// JavaScript + React Native
Animated.spring(scaleAnim, {                                        // 弹簧动画
  toValue: 1.5,                                                     // 目标值 1.5
  friction: 3,                                                      // 摩擦力
  tension: 40,                                                      // 张力
  useNativeDriver: true,                                            // 使用原生驱动
}).start();                                                         // 启动动画
  • friction:摩擦力,值越大越不容易弹;
  • tension:张力,值越大弹得越快。

弹簧动画适合做"按下缩放"“弹出”"回弹"等效果。

2.5 组合动画

多个动画可以组合执行:

// JavaScript + React Native
// 顺序执行:先淡入,再右移
Animated.sequence([                                                 // 顺序组合
  Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),       // 先淡入
  Animated.timing(translateX, { toValue: 100, duration: 500, useNativeDriver: true }),   // 再右移
]).start();                                                         // 启动

// 并行执行:同时淡入和右移
Animated.parallel([                                                 // 并行组合
  Animated.timing(fadeAnim, { toValue: 1, duration: 500, useNativeDriver: true }),       // 淡入
  Animated.timing(translateX, { toValue: 100, duration: 500, useNativeDriver: true }),   // 右移
]).start();                                                         // 启动

2.6 插值

Animated.Value 可以通过 interpolate 映射到不同的输出范围:

// JavaScript + React Native
const rotate = fadeAnim.interpolate({                               // 插值
  inputRange: [0, 1],                                               // 输入范围
  outputRange: ['0deg', '360deg'],                                  // 输出范围
});                                                                 // 结束插值

<Animated.View style={{ transform: [{ rotate }] }} />               // 应用旋转

当 fadeAnim 从 0 变到 1 时,旋转角度从 0 度变到 360 度。插值让一个动画值可以驱动多个属性。

2.7 Animated 的局限

Animated API 能满足基础需求,但有几个局限:

  • 原生驱动只支持有限的属性;
  • 手势驱动的动画需要经过 Bridge 通信,可能卡顿;
  • 复杂的动态动画写起来比较受限。

对于更复杂的场景,社区提供了 Reanimated。


三、Reanimated:更强大的动画方案

3.1 Reanimated 是什么

react-native-reanimated 是目前 React Native 社区最主流的动画库。它的核心优势是:动画在 UI 线程执行,不经过 JavaScript 线程。

React Native 有两个线程:JS 线程和 UI 线程。普通的动画计算在 JS 线程执行,如果 JS 在忙(比如处理大量数据),动画就会卡顿。Reanimated 通过"worklet"把动画计算搬到 UI 线程,即使 JS 很忙,动画也能保持流畅。

3.2 安装

Reanimated v4 需要 React Native New Architecture(0.76 或更高版本)。

# Bash 终端命令
npx expo install react-native-reanimated react-native-worklets       # 安装 Reanimated 和 worklets

然后在 babel.config.js 中添加插件:

// JavaScript
module.exports = {                                                  // 导出配置
  presets: ['module:metro-react-native-babel-preset'],              // 预设
  plugins: [
    'react-native-worklets/plugin',                                 // worklets 插件,必须放在最后
  ],
};

清除缓存并重新构建:

# Bash 终端命令
npm start -- --reset-cache                                          # 清除缓存启动
npx expo prebuild                                                   # 预构建

3.3 useSharedValue:共享值

useSharedValue 是 Reanimated 的核心。它创建的值可以在 JS 线程和 UI 线程之间共享,不需要通信开销。

// JavaScript + React Native
import Animated, { useSharedValue, useAnimatedStyle } from 'react-native-reanimated';  // 引入组件

export default function App() {                                     // 定义并默认导出 App
  const opacity = useSharedValue(0);                                // 创建共享值,初始 0

  const animatedStyle = useAnimatedStyle(() => ({                   // 定义动画样式
    opacity: opacity.value,                                         // 透明度由共享值决定
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <Animated.View                                                // 可动画的 View
      style={[{ width: 100, height: 100, backgroundColor: 'blue' }, animatedStyle]}  // 样式
    />
  );                                                                // 结束 return
}                                                                   // 结束 App

和 Animated.Value 不同,useSharedValue 创建的值通过 .value 访问,而且可以直接在 worklet 中读写。

3.4 useAnimatedStyle:动画样式

useAnimatedStyle 接收一个 worklet 函数,返回样式对象。当依赖的共享值变化时,样式自动更新,不需要重新渲染组件。

// JavaScript + React Native
const animatedStyle = useAnimatedStyle(() => ({                     // 定义动画样式
  transform: [{ scale: scale.value }],                              // 缩放由共享值决定
  opacity: opacity.value,                                           // 透明度由共享值决定
}));                                                                // 结束 useAnimatedStyle

3.5 动画函数速查

函数作用适用场景
withTiming基于时间匀速、变速动画
withSpring弹簧物理自然回弹、按下反馈
withDecay衰减惯性滑动
withSequence顺序执行组合动画
withRepeat重复执行循环旋转、呼吸效果

withTiming:

// JavaScript + React Native
opacity.value = withTiming(1, { duration: 1000 });                  // 1000 毫秒内到 1

withSpring:

// JavaScript + React Native
scale.value = withSpring(1.5, { damping: 10, stiffness: 100 });     // 弹簧到 1.5

withDecay:

// JavaScript + React Native
translateX.value = withDecay({ velocity: 1000, deceleration: 0.997 });  // 惯性滑动

withSequence:

// JavaScript + React Native
scale.value = withSequence(                                         // 顺序执行
  withTiming(1.2, { duration: 100 }),                               // 先放大到 1.2
  withSpring(1)                                                     // 再弹回 1
);                                                                  // 结束

withRepeat:

// JavaScript + React Native
rotation.value = withRepeat(withTiming(360, { duration: 2000 }), -1, true);  // 无限循环旋转

3.6 一个完整的缩放动画

// JavaScript + React Native
import { Pressable, StyleSheet } from 'react-native';               // 引入 Pressable 和 StyleSheet
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';  // 引入 Reanimated

export default function App() {                                     // 定义并默认导出 App
  const scale = useSharedValue(1);                                  // 创建共享值,初始 1

  const animatedStyle = useAnimatedStyle(() => ({                   // 定义动画样式
    transform: [{ scale: scale.value }],                            // 缩放由共享值决定
  }));                                                              // 结束 useAnimatedStyle

  const handlePressIn = () => {                                     // 定义按下处理
    scale.value = withSpring(0.9, { damping: 10, stiffness: 300 }); // 弹簧到 0.9
  };                                                                // 结束 handlePressIn

  const handlePressOut = () => {                                    // 定义松开处理
    scale.value = withSpring(1, { damping: 10, stiffness: 300 });   // 弹簧回 1
  };                                                                // 结束 handlePressOut

  return (                                                          // 返回界面描述
    <Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>  // 可点击容器
      <Animated.View style={[styles.box, animatedStyle]}>           // 可动画的 View
        {/* 内容 */}
      </Animated.View>                                              // 结束 Animated.View
    </Pressable>                                                    // 结束 Pressable
  );                                                                // 结束 return
}                                                                   // 结束 App

const styles = StyleSheet.create({                                  // 创建样式表
  box: {                                                            // 方块样式
    width: 120,                                                     // 宽 120
    height: 120,                                                    // 高 120
    backgroundColor: '#4A90D9',                                     // 蓝色背景
    borderRadius: 16,                                               // 圆角 16
    justifyContent: 'center',                                       // 垂直居中
    alignItems: 'center',                                           // 水平居中
  },                                                                // 结束方块样式
});                                                                 // 结束样式表

按下时缩小到 0.9 倍,松开时弹回 1 倍。这就是 Reanimated 最典型的用法:共享值 + 动画函数 + useAnimatedStyle。


四、Pressable 进阶

4.1 点击生命周期

Pressable 提供了完整的点击状态检测:

  • onPressIn:手指按下时触发;
  • onPressOut:手指抬起时触发;
  • onPress:手指抬起且没有长按时触发;
  • onLongPress:按住超过 500 毫秒时触发。
// JavaScript + React Native (JSX)
<Pressable                                                          // 可点击容器
  onPressIn={() => console.log('按下')}                              // 按下时打印
  onPressOut={() => console.log('抬起')}                             // 抬起时打印
  onPress={() => console.log('点击')}                                // 点击时打印
  onLongPress={() => console.log('长按')}                            // 长按时打印
>                                                                   // 结束开始标签
  <Text>点击我</Text>                                                // 文本
</Pressable>                                                        // 结束 Pressable

4.2 用 style 函数实现按下反馈

Pressable 的 style 可以是一个函数,接收 { pressed } 状态:

// JavaScript + React Native (JSX)
<Pressable                                                          // 可点击容器
  style={({ pressed }) => [                                         // 样式函数
    styles.button,                                                  // 基础样式
    pressed && styles.buttonPressed,                                // 按下时叠加样式
  ]}                                                                // 结束样式函数
  onPress={handlePress}                                             // 点击回调
>                                                                   // 结束开始标签
  <Text>点击我</Text>                                                // 文本
</Pressable>                                                        // 结束 Pressable
// JavaScript + React Native
const styles = StyleSheet.create({                                  // 创建样式表
  button: { padding: 16, backgroundColor: '#4A90D9', borderRadius: 8 },  // 按钮样式
  buttonPressed: { opacity: 0.7 },                                  // 按下时透明度降低
});                                                                 // 结束样式表

这种方式不需要 useState,按下时自动应用 buttonPressed 样式,松开后恢复。

4.3 android_ripple

Android 平台上,android_ripple 可以启用水波纹效果:

// JavaScript + React Native (JSX)
<Pressable                                                          // 可点击容器
  android_ripple={{ color: '#ccc', borderless: false }}             // Android 水波纹
  style={styles.button}                                             // 应用样式
  onPress={handlePress}                                             // 点击回调
>                                                                   // 结束开始标签
  <Text>点击我</Text>                                                // 文本
</Pressable>                                                        // 结束 Pressable

iOS 上可以用 style 函数的 pressed 状态来实现类似的按下效果。

4.4 hitSlop

手指不够精确,用户可能点到按钮边缘。hitSlop 可以扩大点击热区:

// JavaScript + React Native (JSX)
<Pressable hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>  // 扩大热区
  <Text>小按钮</Text>                                                // 文本
</Pressable>                                                        // 结束 Pressable

4.5 用 Reanimated 做按下缩放

把 Pressable 和 Reanimated 结合,做更丰富的按下效果:

// JavaScript + React Native
import { Pressable, StyleSheet, Text } from 'react-native';         // 引入组件
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';  // 引入 Reanimated

export default function AnimatedButton({ title, onPress }) {        // 定义动画按钮组件
  const scale = useSharedValue(1);                                  // 创建共享值,初始 1

  const animatedStyle = useAnimatedStyle(() => ({                   // 定义动画样式
    transform: [{ scale: scale.value }],                            // 缩放由共享值决定
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <Pressable                                                     // 可点击容器
      onPressIn={() => {                                            // 按下时
        scale.value = withSpring(0.92, { damping: 15, stiffness: 400 });  // 弹簧到 0.92
      }}                                                            // 结束按下处理
      onPressOut={() => {                                           // 松开时
        scale.value = withSpring(1, { damping: 15, stiffness: 400 });     // 弹簧回 1
      }}                                                            // 结束松开处理
      onPress={onPress}                                             // 点击回调
    >                                                               // 结束开始标签
      <Animated.View style={[styles.button, animatedStyle]}>        // 可动画的 View
        <Text style={styles.text}>{title}</Text>                    // 按钮文字
      </Animated.View>                                              // 结束 Animated.View
    </Pressable>                                                    // 结束 Pressable
  );                                                                // 结束 return
}                                                                   // 结束 AnimatedButton

const styles = StyleSheet.create({                                  // 创建样式表
  button: {                                                         // 按钮样式
    paddingVertical: 14,                                            // 垂直内边距 14
    paddingHorizontal: 32,                                          // 水平内边距 32
    backgroundColor: '#4A90D9',                                     // 蓝色背景
    borderRadius: 12,                                               // 圆角 12
    alignItems: 'center',                                           // 水平居中
  },                                                                // 结束按钮样式
  text: { color: '#fff', fontSize: 16, fontWeight: 'bold' },        // 文字样式
});                                                                 // 结束样式表

按下时轻微缩小,松开时弹回。这种反馈让按钮感觉更"跟手"。


五、手势:Gesture Handler 基础

5.1 为什么需要手势库

React Native 内置的 PanResponder 能处理手势,但性能有限。它在 JS 线程处理每个移动事件,JS 忙的时候手势会卡顿。

react-native-gesture-handler 是社区主流的手势库。它在原生线程识别手势,配合 Reanimated 的共享值,手势和动画都能保持流畅。

5.2 安装

# Bash 终端命令
npx expo install react-native-gesture-handler                      # 安装手势库

在应用的最外层包裹 GestureHandlerRootView:

// JavaScript + React Native
import { GestureHandlerRootView } from 'react-native-gesture-handler';  // 引入根容器

export default function App() {                                     // 定义并默认导出 App
  return (                                                          // 返回界面描述
    <GestureHandlerRootView style={{ flex: 1 }}>                    // 手势根容器
      {/* 应用内容 */}
    </GestureHandlerRootView>                                       // 结束根容器
  );                                                                // 结束 return
}                                                                   // 结束 App

5.3 拖拽示例

// JavaScript + React Native
import { StyleSheet } from 'react-native';                          // 引入 StyleSheet
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';  // 引入手势组件
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';  // 引入 Reanimated

export default function App() {                                     // 定义并默认导出 App
  const translateX = useSharedValue(0);                             // 水平偏移共享值
  const translateY = useSharedValue(0);                             // 垂直偏移共享值

  const pan = Gesture.Pan()                                         // 创建拖拽手势
    .onUpdate((e) => {                                              // 拖拽过程中
      translateX.value = e.translationX;                            // 更新水平偏移
      translateY.value = e.translationY;                            // 更新垂直偏移
    })                                                              // 结束 onUpdate
    .onEnd(() => {                                                  // 拖拽结束
      translateX.value = withSpring(0);                             // 弹回水平原点
      translateY.value = withSpring(0);                             // 弹回垂直原点
    });                                                             // 结束 onEnd

  const animatedStyle = useAnimatedStyle(() => ({                   // 定义动画样式
    transform: [
      { translateX: translateX.value },                             // 水平偏移
      { translateY: translateY.value },                             // 垂直偏移
    ],
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <GestureHandlerRootView style={styles.container}>               // 手势根容器
      <GestureDetector gesture={pan}>                               // 手势检测器
        <Animated.View style={[styles.box, animatedStyle]} />       // 可拖拽的方块
      </GestureDetector>                                            // 结束检测器
    </GestureHandlerRootView>                                       // 结束根容器
  );                                                                // 结束 return
}                                                                   // 结束 App

const styles = StyleSheet.create({                                  // 创建样式表
  container: { flex: 1, justifyContent: 'center', alignItems: 'center' },  // 居中容器样式
  box: { width: 100, height: 100, backgroundColor: '#4A90D9', borderRadius: 16 },  // 方块样式
});                                                                 // 结束样式表

Gesture.Pan() 创建了一个拖拽手势。onUpdate 中把手指的移动距离赋给共享值,onEnd 中让方块弹回原位。

5.4 点击手势

// JavaScript + React Native
const tap = Gesture.Tap()                                           // 创建点击手势
  .numberOfTaps(2)                                                  // 需要双击
  .onEnd(() => {                                                    // 手势结束时
    scale.value = withSpring(scale.value === 1 ? 1.5 : 1);          // 切换大小
  });                                                               // 结束 onEnd

双击切换大小。numberOfTaps(2) 指定需要双击。

5.5 组合手势

// JavaScript + React Native
const composed = Gesture.Simultaneous(pan, tap);                    // 同时识别拖拽和点击

Gesture.Simultaneous 让多个手势同时识别。其他组合方式还有 Gesture.Race(竞争)和 Gesture.Exclusive(互斥)。


六、布局动画

6.1 LayoutAnimation

LayoutAnimation 是 React Native 内置的布局动画 API。它在下次布局时自动把视图动画到新位置。

// JavaScript + React Native
import { LayoutAnimation, UIManager, Platform } from 'react-native';  // 引入组件

if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {  // Android 需要开启
  UIManager.setLayoutAnimationEnabledExperimental(true);            // 开启实验性布局动画
}                                                                   // 结束判断

const addItem = () => {                                             // 定义添加函数
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);  // 配置下一个动画
  setItems([...items, newItem]);                                    // 更新状态
};                                                                  // 结束 addItem

适合的场景:列表项增删、展开折叠、布局切换。

6.2 Reanimated 的布局动画

Reanimated 提供了更强大的布局动画:

// JavaScript + React Native (JSX)
import Animated, { FadeIn, FadeOut, Layout } from 'react-native-reanimated';  // 引入动画

{items.map((item) => (                                              // 遍历列表
  <Animated.View                                                    // 可动画的 View
    key={item.id}                                                   // 唯一 key
    entering={FadeIn}                                               // 进入动画
    exiting={FadeOut}                                               // 退出动画
    layout={Layout.springify()}                                     // 布局变化动画
  >                                                                 // 结束开始标签
    <Text>{item.title}</Text>                                       // 显示标题
  </Animated.View>                                                  // 结束 Animated.View
))}                                                                 // 结束 map
  • entering:元素出现时的动画;
  • exiting:元素消失时的动画;
  • layout:布局变化时的动画。

这比 LayoutAnimation 更灵活,而且运行在 UI 线程。


七、完整示例:可拖拽的卡片

把这一课的知识组合起来,做一个可拖拽的卡片。

// JavaScript + React Native
import { StyleSheet, Text } from 'react-native';                    // 引入组件
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';  // 引入手势
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';  // 引入 Reanimated

export default function App() {                                     // 定义并默认导出 App
  const translateX = useSharedValue(0);                             // 水平偏移
  const translateY = useSharedValue(0);                             // 垂直偏移
  const scale = useSharedValue(1);                                  // 缩放

  const pan = Gesture.Pan()                                         // 创建拖拽手势
    .onBegin(() => {                                                // 手势开始时
      scale.value = withSpring(1.05);                               // 放大到 1.05
    })                                                              // 结束 onBegin
    .onUpdate((e) => {                                              // 拖拽过程中
      translateX.value = e.translationX;                            // 更新水平偏移
      translateY.value = e.translationY;                            // 更新垂直偏移
    })                                                              // 结束 onUpdate
    .onEnd(() => {                                                  // 拖拽结束
      scale.value = withSpring(1);                                  // 缩放回 1
      translateX.value = withSpring(0);                             // 弹回水平原点
      translateY.value = withSpring(0);                             // 弹回垂直原点
    });                                                             // 结束 onEnd

  const animatedStyle = useAnimatedStyle(() => ({                   // 定义动画样式
    transform: [
      { translateX: translateX.value },                             // 水平偏移
      { translateY: translateY.value },                             // 垂直偏移
      { scale: scale.value },                                       // 缩放
    ],
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <GestureHandlerRootView style={styles.container}>               // 手势根容器
      <GestureDetector gesture={pan}>                               // 手势检测器
        <Animated.View style={[styles.card, animatedStyle]}>        // 可拖拽的卡片
          <Text style={styles.title}>拖拽我</Text>                  // 标题
          <Text style={styles.subtitle}>松手后弹回原位</Text>        // 副标题
        </Animated.View>                                            // 结束 Animated.View
      </GestureDetector>                                            // 结束检测器
    </GestureHandlerRootView>                                       // 结束根容器
  );                                                                // 结束 return
}                                                                   // 结束 App

const styles = StyleSheet.create({                                  // 创建样式表
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },  // 容器
  card: {                                                           // 卡片样式
    width: 200,                                                     // 宽 200
    height: 200,                                                    // 高 200
    backgroundColor: '#fff',                                        // 白色背景
    borderRadius: 20,                                               // 圆角 20
    justifyContent: 'center',                                       // 垂直居中
    alignItems: 'center',                                           // 水平居中
    shadowColor: '#000',                                            // 阴影颜色
    shadowOffset: { width: 0, height: 4 },                          // 阴影偏移
    shadowOpacity: 0.15,                                            // 阴影透明度
    shadowRadius: 12,                                               // 阴影模糊半径
    elevation: 8,                                                   // Android 阴影
  },                                                                // 结束卡片样式
  title: { fontSize: 20, fontWeight: 'bold', marginBottom: 4 },     // 标题样式
  subtitle: { fontSize: 14, color: '#888' },                        // 副标题样式
});                                                                 // 结束样式表

卡片可以拖拽,拖拽时轻微放大,松手后弹回原位。手势和动画都在 UI 线程执行,非常流畅。


八、动画与"视图 = 函数(状态)"

动画也是"视图 = 函数(状态)“的一部分,只是"状态"换成了"动画值”:

// 概念表达
动画视图 = 函数(动画值)                                              // 动画视图由动画值决定
  • Animated.Value 或共享值是动画状态;
  • 动画驱动这些值随时间变化;
  • 样式根据动画值计算;
  • 值变,样式变,界面更新。

和普通状态不同,动画值的更新不触发组件重新渲染。Animated.View 和 useAnimatedStyle 直接在原生侧更新样式,跳过渲染过程。这就是动画流畅的原因。


九、练习题

练习题1:选择题——Animated 和 Reanimated 的区别

题目:Reanimated 相比 Animated API 的主要优势是什么?

A. 语法更简单
B. 动画在 UI 线程执行,不经过 JS 线程
C. 不需要安装
D. 只支持 iOS

参考答案:B

解读:Reanimated 通过 worklet 把动画计算搬到 UI 线程,即使 JS 线程繁忙,动画也能保持流畅。这是它最核心的优势。


练习题2:填空题——创建动画值

题目:补全代码,创建一个初始值为 0 的 Animated.Value。

// JavaScript + React Native
const fadeAnim = useRef(new Animated.Value(____)).current;          // 补全初始值

参考答案:0

解读:Animated.Value(0) 创建初始值为 0 的动画值。用 useRef 保存,避免每次渲染重新创建。


练习题3:改错题——useNativeDriver

题目:下面代码想对 width 使用原生驱动,但有问题。请指出。

// JavaScript + React Native
Animated.timing(widthAnim, {                                        // timing 动画
  toValue: 200,                                                     // 目标 200
  duration: 500,                                                    // 时长 500
  useNativeDriver: true,                                            // 使用原生驱动
}).start();                                                         // 启动

参考答案:width 不支持原生驱动。应该把 useNativeDriver 设为 false,或者改用 transform: [{ scaleX }] 来实现宽度变化效果。

解读:原生驱动只支持 opacity 和 transform 相关属性。其他属性需要 useNativeDriver: false,性能会差一些。


练习题4:代码阅读题——插值

题目:阅读代码,说出 fadeAnim 从 0 变到 1 时,旋转角度如何变化。

// JavaScript + React Native
const rotate = fadeAnim.interpolate({                               // 插值
  inputRange: [0, 1],                                               // 输入范围
  outputRange: ['0deg', '360deg'],                                  // 输出范围
});                                                                 // 结束插值

参考答案:旋转角度从 0 度线性变化到 360 度。

解读:interpolate 把输入范围 [0, 1] 映射到输出范围 ['0deg', '360deg']。


练习题5:动手题——淡入动画

题目:写一个淡入动画,让一个 View 在 800 毫秒内从透明度 0 变为 1。

参考答案:

// JavaScript + React Native
const fadeAnim = useRef(new Animated.Value(0)).current;            // 创建动画值

const fadeIn = () => {                                              // 定义淡入函数
  Animated.timing(fadeAnim, {                                       // timing 动画
    toValue: 1,                                                     // 目标 1
    duration: 800,                                                  // 时长 800
    useNativeDriver: true,                                          // 使用原生驱动
  }).start();                                                       // 启动
};                                                                  // 结束 fadeIn

<Animated.View style={{ opacity: fadeAnim }} />                     // 应用动画

解读:Animated.timing 驱动 fadeAnim 从 0 到 1,useNativeDriver: true 让动画在原生线程执行。


练习题6:简答题——useSharedValue 和 Animated.Value 的区别

题目:useSharedValue 和 Animated.Value 有什么区别?

参考答案:useSharedValue 创建的值可以在 JS 线程和 UI 线程之间共享,通过 .value 读写,配合 worklet 使用。Animated.Value 是 Animated API 的值,通过 Animated.timing 等驱动,连接到 Animated.View 的样式属性。

解读:useSharedValue 是 Reanimated 的核心,支持在 UI 线程读写,不需要通信开销。Animated.Value 是 React Native 内置的动画值。


练习题7:Reanimated 基础题

题目:用 useSharedValue 和 withTiming 让一个方块的透明度从 0 变到 1。

参考答案:

// JavaScript + React Native
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated';  // 引入 Reanimated

const opacity = useSharedValue(0);                                  // 创建共享值,初始 0

const style = useAnimatedStyle(() => ({                             // 定义动画样式
  opacity: opacity.value,                                           // 透明度由共享值决定
}));                                                                // 结束 useAnimatedStyle

opacity.value = withTiming(1, { duration: 1000 });                  // 触发动画

<Animated.View style={[styles.box, style]} />                       // 应用动画

解读:useSharedValue 创建共享值,useAnimatedStyle 把它映射到样式,withTiming 驱动动画。


练习题8:Pressable 生命周期题

题目:Pressable 的 onPressIn、onPressOut、onPress、onLongPress 分别在什么时候触发?

参考答案:onPressIn 手指按下时触发;onPressOut 手指抬起时触发;onPress 手指抬起且没有长按时触发;onLongPress 按住超过 500 毫秒时触发。

解读:这些回调让你可以精确控制点击的每个阶段,实现丰富的交互反馈。


练习题9:Pressable 样式反馈题

题目:用 Pressable 的 style 函数实现按下时透明度变为 0.7。

参考答案:

// JavaScript + React Native (JSX)
<Pressable                                                          // 可点击容器
  style={({ pressed }) => [                                         // 样式函数
    { padding: 16, backgroundColor: '#4A90D9', borderRadius: 8 },   // 基础样式
    pressed && { opacity: 0.7 },                                    // 按下时透明度降低
  ]}                                                                // 结束样式函数
>                                                                   // 结束开始标签
  <Text>点击我</Text>                                                // 文本
</Pressable>                                                        // 结束 Pressable

解读:style 函数接收 { pressed },根据按下状态返回不同样式。不需要 useState,按下时自动应用,松开后恢复。


练习题10:withSpring 题

题目:用 withSpring 实现按下缩放到 0.9,松开弹回 1。

参考答案:

// JavaScript + React Native
const scale = useSharedValue(1);                                    // 创建共享值

const style = useAnimatedStyle(() => ({                             // 定义动画样式
  transform: [{ scale: scale.value }],                              // 缩放由共享值决定
}));                                                                // 结束 useAnimatedStyle

// 按下
scale.value = withSpring(0.9, { damping: 15, stiffness: 400 });     // 弹簧到 0.9

// 松开
scale.value = withSpring(1, { damping: 15, stiffness: 400 });       // 弹簧回 1

解读:withSpring 让动画有弹簧效果,比 withTiming 更自然。damping 和 stiffness 控制弹簧的阻尼和刚度。


练习题11:手势基础题

题目:用 Gesture.Pan() 实现拖拽,并在松手后弹回原位。

参考答案:

// JavaScript + React Native
const translateX = useSharedValue(0);                               // 水平偏移
const translateY = useSharedValue(0);                               // 垂直偏移

const pan = Gesture.Pan()                                           // 创建拖拽手势
  .onUpdate((e) => {                                                // 拖拽过程中
    translateX.value = e.translationX;                              // 更新水平偏移
    translateY.value = e.translationY;                              // 更新垂直偏移
  })                                                                // 结束 onUpdate
  .onEnd(() => {                                                    // 拖拽结束
    translateX.value = withSpring(0);                               // 弹回水平原点
    translateY.value = withSpring(0);                               // 弹回垂直原点
  });                                                               // 结束 onEnd

const style = useAnimatedStyle(() => ({                             // 定义动画样式
  transform: [
    { translateX: translateX.value },                               // 水平偏移
    { translateY: translateY.value },                               // 垂直偏移
  ],
}));                                                                // 结束 useAnimatedStyle

解读:onUpdate 中把手指移动距离赋给共享值,onEnd 中用 withSpring 弹回原位。


练习题12:布局动画题

题目:用 LayoutAnimation 让列表项添加时有淡入动画。

参考答案:

// JavaScript + React Native
import { LayoutAnimation } from 'react-native';                     // 引入 LayoutAnimation

const addItem = () => {                                             // 定义添加函数
  LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);  // 配置下一个动画
  setItems([...items, newItem]);                                    // 更新状态
};                                                                  // 结束 addItem

解读:在更新状态之前调用 configureNext,下一次布局变化会自动添加动画。


练习题13:Reanimated 布局动画题

题目:用 Reanimated 的 FadeIn 和 FadeOut 让列表项出现和消失时有动画。

参考答案:

// JavaScript + React Native (JSX)
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';  // 引入动画

{items.map((item) => (                                              // 遍历列表
  <Animated.View                                                    // 可动画的 View
    key={item.id}                                                   // 唯一 key
    entering={FadeIn}                                               // 进入动画
    exiting={FadeOut}                                               // 退出动画
  >                                                                 // 结束开始标签
    <Text>{item.title}</Text>                                       // 显示标题
  </Animated.View>                                                  // 结束 Animated.View
))}                                                                 // 结束 map

解读:entering 和 exiting 是 Reanimated 的布局动画属性,比 LayoutAnimation 更灵活,而且运行在 UI 线程。


练习题14:综合题——动画按钮

题目:创建一个按钮,按下时缩放并改变透明度,松开时恢复。

参考答案:

// JavaScript + React Native
import { Pressable, StyleSheet, Text } from 'react-native';         // 引入组件
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';  // 引入 Reanimated

export default function AnimatedButton({ title, onPress }) {        // 定义动画按钮
  const scale = useSharedValue(1);                                  // 缩放共享值
  const opacity = useSharedValue(1);                                // 透明度共享值

  const style = useAnimatedStyle(() => ({                           // 定义动画样式
    transform: [{ scale: scale.value }],                            // 缩放
    opacity: opacity.value,                                         // 透明度
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <Pressable                                                     // 可点击容器
      onPressIn={() => {                                            // 按下时
        scale.value = withSpring(0.92, { damping: 15, stiffness: 400 });  // 缩放
        opacity.value = withSpring(0.8);                            // 降低透明度
      }}                                                            // 结束按下处理
      onPressOut={() => {                                           // 松开时
        scale.value = withSpring(1, { damping: 15, stiffness: 400 });     // 恢复缩放
        opacity.value = withSpring(1);                              // 恢复透明度
      }}                                                            // 结束松开处理
      onPress={onPress}                                             // 点击回调
    >                                                               // 结束开始标签
      <Animated.View style={[styles.button, style]}>                // 可动画的 View
        <Text style={styles.text}>{title}</Text>                    // 按钮文字
      </Animated.View>                                              // 结束 Animated.View
    </Pressable>                                                    // 结束 Pressable
  );                                                                // 结束 return
}                                                                   // 结束 AnimatedButton

const styles = StyleSheet.create({                                  // 创建样式表
  button: {                                                         // 按钮样式
    paddingVertical: 14,                                            // 垂直内边距
    paddingHorizontal: 32,                                          // 水平内边距
    backgroundColor: '#4A90D9',                                     // 蓝色背景
    borderRadius: 12,                                               // 圆角 12
    alignItems: 'center',                                           // 水平居中
  },                                                                // 结束按钮样式
  text: { color: '#fff', fontSize: 16, fontWeight: 'bold' },        // 文字样式
});                                                                 // 结束样式表

解读:按下时同时缩放和降低透明度,松开时恢复。Pressable 提供按下和松开的事件,Reanimated 提供流畅的动画。


练习题15:综合应用题——可滑动的卡片

题目:创建一个卡片列表,每张卡片可以左右滑动,滑出屏幕后卡片消失。

参考答案:

// JavaScript + React Native
import { StyleSheet, Text, View } from 'react-native';              // 引入组件
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';  // 引入手势
import Animated, { useSharedValue, useAnimatedStyle, withSpring, runOnJS, interpolate, Extrapolation } from 'react-native-reanimated';  // 引入 Reanimated

function SwipeCard({ title, onDismiss }) {                          // 定义可滑动卡片
  const translateX = useSharedValue(0);                             // 水平偏移

  const pan = Gesture.Pan()                                         // 创建拖拽手势
    .onUpdate((e) => {                                              // 拖拽过程中
      translateX.value = e.translationX;                            // 更新水平偏移
    })                                                              // 结束 onUpdate
    .onEnd((e) => {                                                 // 拖拽结束
      if (Math.abs(e.translationX) > 120) {                         // 滑出超过 120
        translateX.value = withSpring(Math.sign(e.translationX) * 500, {}, () => {  // 飞出屏幕
          runOnJS(onDismiss)();                                     // 调用 JS 回调
        });                                                         // 结束弹簧
      } else {                                                      // 否则
        translateX.value = withSpring(0);                           // 弹回原位
      }                                                             // 结束判断
    });                                                             // 结束 onEnd

  const style = useAnimatedStyle(() => ({                           // 定义动画样式
    transform: [{ translateX: translateX.value }],                  // 水平偏移
    opacity: interpolate(                                           // 透明度插值
      Math.abs(translateX.value),                                   // 输入:偏移绝对值
      [0, 200],                                                     // 输入范围
      [1, 0],                                                       // 输出范围
      Extrapolation.CLAMP                                           // 超出范围时截断
    ),                                                              // 结束插值
  }));                                                              // 结束 useAnimatedStyle

  return (                                                          // 返回界面描述
    <GestureDetector gesture={pan}>                                 // 手势检测器
      <Animated.View style={[styles.card, style]}>                  // 可滑动的卡片
        <Text style={styles.title}>{title}</Text>                   // 显示标题
      </Animated.View>                                              // 结束 Animated.View
    </GestureDetector>                                              // 结束检测器
  );                                                                // 结束 return
}                                                                   // 结束 SwipeCard

export default function App() {                                     // 定义并默认导出 App
  const [cards, setCards] = useState([                              // 卡片列表状态
    { id: '1', title: '卡片一' },                                   // 第一张
    { id: '2', title: '卡片二' },                                   // 第二张
    { id: '3', title: '卡片三' },                                   // 第三张
  ]);                                                               // 结束 useState

  return (                                                          // 返回界面描述
    <GestureHandlerRootView style={styles.container}>               // 手势根容器
      {cards.map((card) => (                                        // 遍历卡片
        <SwipeCard                                                  // 每张卡片
          key={card.id}                                             // 唯一 key
          title={card.title}                                        // 传入标题
          onDismiss={() => setCards((prev) => prev.filter((c) => c.id !== card.id))}  // 滑出后移除
        />
      ))}                                                           // 结束 map
    </GestureHandlerRootView>                                       // 结束根容器
  );                                                                // 结束 return
}                                                                   // 结束 App

const styles = StyleSheet.create({                                  // 创建样式表
  container: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f5f5f5' },  // 容器
  card: {                                                           // 卡片样式
    width: 280,                                                     // 宽 280
    height: 160,                                                    // 高 160
    backgroundColor: '#fff',                                        // 白色背景
    borderRadius: 20,                                               // 圆角 20
    justifyContent: 'center',                                       // 垂直居中
    alignItems: 'center',                                           // 水平居中
    marginBottom: 16,                                               // 底部间距
    shadowColor: '#000',                                            // 阴影颜色
    shadowOffset: { width: 0, height: 4 },                          // 阴影偏移
    shadowOpacity: 0.1,                                             // 阴影透明度
    shadowRadius: 8,                                                // 阴影模糊半径
    elevation: 5,                                                   // Android 阴影
  },                                                                // 结束卡片样式
  title: { fontSize: 20, fontWeight: 'bold' },                      // 标题样式
});                                                                 // 结束样式表

解读:这道题综合运用了手势、共享值、动画函数、插值和条件逻辑。卡片可以左右滑动,滑出超过 120 像素后飞出去并消失,否则弹回原位。滑动过程中透明度根据滑动距离变化。runOnJS 用于在 UI 线程的 worklet 中调用 JS 函数。


十、总结

这一课我们学习了 React Native 中动画和手势的核心知识:

  1. React Native 内置 Animated API,对特定值进行细粒度控制;
  2. Animated.Value 是动画值的载体,通过 Animated.timing、Animated.spring 等驱动;
  3. useNativeDriver: true 让动画在原生线程执行,但只支持 opacity 和 transform;
  4. Animated.sequence 和 Animated.parallel 组合多个动画;
  5. interpolate 把动画值映射到不同输出范围;
  6. Reanimated 是社区主流动画库,动画在 UI 线程执行;
  7. useSharedValue 创建共享值,useAnimatedStyle 映射到样式,withTiming/withSpring 驱动动画;
  8. Pressable 提供完整的点击生命周期:onPressIn、onPressOut、onPress、onLongPress;
  9. Pressable 的 style 函数接收 { pressed },实现按下反馈;
  10. react-native-gesture-handler 配合 Reanimated 实现流畅的手势交互;
  11. Gesture.Pan() 处理拖拽,Gesture.Tap() 处理点击,Gesture.Simultaneous 组合手势;
  12. LayoutAnimation 和 Reanimated 的布局动画处理列表增删。

记住一句话:

动画是让"状态变化"变得可见、可感知,它是"视图 = 函数(状态)"在时间维度上的延伸。


第10课预告

下一课,我们进入 【React Native娓娓道来】第10课 数据持久化与本地存储:AsyncStorage、SecureStore 与文件系统。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐