跳到主要内容

动画

动画对于创造出色的用户体验至关重要。静止的物体在开始移动时必须克服惯性。运动中的物体具有动量,很少会立即停止。动画让你能够在界面中传达物理上可信的运动。

React Native 提供了两种互补的动画系统:Animated 用于对特定值进行细粒度交互式控制,LayoutAnimation 用于动画化全局布局事务。

Animated API

Animated API 旨在以高性能的方式简洁地表达各种有趣的动画和交互模式。Animated 专注于输入和输出之间的声明性关系,中间带有可配置的变换,以及控制基于时间的动画执行的 start/stop 方法。

Animated 导出了六种可动画组件类型:ViewTextImageScrollViewFlatListSectionList,但你也可以使用 Animated.createAnimatedComponent() 创建自己的组件。

例如,一个在挂载时淡入的容器视图可能看起来像这样

让我们来分解一下这里发生的事情。在 FadeInView 的渲染方法中,一个新的 Animated.Value,名为 fadeAnim,使用 useRef 进行初始化。View 上的不透明度(opacity)属性被映射到这个动画值。在幕后,数值被提取并用于设置不透明度。

当组件挂载时,不透明度被设置为 0。然后,在 fadeAnim 动画值上启动一个缓动动画,它将随着值动画到最终值 1,在每一帧更新其所有依赖的映射(在本例中,只有不透明度)。

这是以一种优化方式完成的,比调用 setState 和重新渲染更快。由于整个配置是声明性的,我们将能够实现进一步的优化,将配置序列化并在高优先级线程上运行动画。

配置动画

动画具有高度可配置性。自定义和预定义的缓动函数、延迟、持续时间、衰减因子、弹簧常数等都可以根据动画类型进行调整。

Animated 提供了多种动画类型,最常用的是 Animated.timing()。它支持使用各种预定义的缓动函数之一随时间动画一个值,或者你可以使用自己的缓动函数。缓动函数通常用于动画中,以传达物体的逐渐加速和减速。

默认情况下,timing 将使用 easeInOut 曲线,它传达逐渐加速到全速,并以逐渐减速到停止结束。你可以通过传递 easing 参数来指定不同的缓动函数。还支持自定义 duration 或在动画开始前设置 delay

例如,如果我们想创建一个 2 秒长的动画,让一个物体在移动到最终位置之前稍微后退一下

tsx
Animated.timing(this.state.xPosition, {
toValue: 100,
easing: Easing.back(),
duration: 2000,
useNativeDriver: true,
}).start();

查看 Animated API 参考中的配置动画部分,了解内置动画支持的所有配置参数。

组合动画

动画可以组合并按顺序或并行播放。顺序动画可以在上一个动画完成后立即播放,或者在指定的延迟后开始。Animated API 提供了多种方法,例如 sequence()delay(),它们都接受一个动画数组来执行,并根据需要自动调用 start()/stop()

例如,以下动画会滑行停止,然后同时旋转并弹回

tsx
Animated.sequence([
// decay, then spring to start and twirl
Animated.decay(position, {
// coast to a stop
velocity: {x: gestureState.vx, y: gestureState.vy}, // velocity from gesture release
deceleration: 0.997,
useNativeDriver: true,
}),
Animated.parallel([
// after decay, in parallel:
Animated.spring(position, {
toValue: {x: 0, y: 0}, // return to start
useNativeDriver: true,
}),
Animated.timing(twirl, {
// and twirl
toValue: 360,
useNativeDriver: true,
}),
]),
]).start(); // start the sequence group

如果一个动画停止或中断,那么该组中的所有其他动画也会停止。Animated.parallel 有一个 stopTogether 选项,可以设置为 false 以禁用此功能。

你可以在 Animated API 参考的组合动画部分找到完整的组合方法列表。

组合动画值

你可以通过加法、乘法、除法或取模运算组合两个动画值,从而生成一个新的动画值。

在某些情况下,一个动画值需要反转另一个动画值进行计算。一个例子是反转比例(2x --> 0.5x)。

tsx
const a = new Animated.Value(1);
const b = Animated.divide(1, a);

Animated.spring(a, {
toValue: 2,
useNativeDriver: true,
}).start();

插值

每个属性都可以先通过插值处理。插值将输入范围映射到输出范围,通常使用线性插值,但也支持缓动函数。默认情况下,它会超出给定范围外推曲线,但你也可以让它限制输出值。

将 0-1 范围转换为 0-100 范围的基本映射将是

tsx
value.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
});

例如,你可能希望将 Animated.Value 视为从 0 到 1,但将位置从 150px 动画到 0px,并将不透明度从 0 动画到 1。这可以通过修改上述示例中的 style 来实现,如下所示

tsx
  style={{
opacity: this.state.fadeAnim, // Binds directly
transform: [{
translateY: this.state.fadeAnim.interpolate({
inputRange: [0, 1],
outputRange: [150, 0] // 0 : 150, 0.5 : 75, 1 : 0
}),
}],
}}

interpolate() 也支持多个范围段,这对于定义死区和其他实用技巧非常方便。例如,要获得一个在 -300 处为负数,在 -100 处变为 0,然后回到 0 处为 1,然后在 100 处再次降为零,接着是对于超出此范围的所有值都保持为 0 的死区,你可以这样做

tsx
value.interpolate({
inputRange: [-300, -100, 0, 100, 101],
outputRange: [300, 0, 1, 0, 0],
});

这将映射如下

Input | Output
------|-------
-400| 450
-300| 300
-200| 150
-100| 0
-50| 0.5
0| 1
50| 0.5
100| 0
101| 0
200| 0

interpolate() 也支持映射到字符串,允许你动画颜色以及带单位的值。例如,如果你想动画一个旋转,你可以这样做

tsx
value.interpolate({
inputRange: [0, 360],
outputRange: ['0deg', '360deg'],
});

interpolate() 还支持任意缓动函数,其中许多已在 Easing 模块中实现。interpolate() 还具有可配置的行为,用于外推 outputRange。你可以通过设置 extrapolateextrapolateLeftextrapolateRight 选项来设置外推。默认值是 extend,但你可以使用 clamp 来防止输出值超出 outputRange

跟踪动态值

动画值还可以通过将动画的 toValue 设置为另一个动画值而不是一个普通数字来跟踪其他值。例如,Android Messenger 中使用的“聊天头像”动画可以通过固定在另一个动画值上的 spring() 实现,或者通过 timing()duration 为 0 来实现刚性跟踪。它们也可以与插值组合使用。

tsx
Animated.spring(follower, {toValue: leader}).start();
Animated.timing(opacity, {
toValue: pan.x.interpolate({
inputRange: [0, 300],
outputRange: [1, 0],
}),
useNativeDriver: true,
}).start();

leaderfollower 动画值将使用 Animated.ValueXY() 实现。ValueXY 是处理 2D 交互(例如平移或拖动)的便捷方式。它是一个基本的包装器,包含两个 Animated.Value 实例和一些调用它们的辅助函数,使得 ValueXY 在许多情况下可以替代 Value。它允许我们在上面的示例中跟踪 x 和 y 值。

跟踪手势

手势(如平移或滚动)和其他事件可以使用 Animated.event 直接映射到动画值。这通过结构化映射语法完成,以便可以从复杂的事件对象中提取值。第一层是一个数组,允许跨多个参数进行映射,并且该数组包含嵌套对象。

例如,在使用水平滚动手势时,为了将 event.nativeEvent.contentOffset.x 映射到 scrollX(一个 Animated.Value),你会这样做

tsx
 onScroll={Animated.event(
// scrollX = e.nativeEvent.contentOffset.x
[{nativeEvent: {
contentOffset: {
x: scrollX
}
}
}]
)}

以下示例实现了一个水平滚动轮播图,其中滚动位置指示器使用 ScrollView 中使用的 Animated.event 进行动画。

带动画事件的 ScrollView 示例

使用 PanResponder 时,你可以使用以下代码从 gestureState.dxgestureState.dy 中提取 x 和 y 位置。我们在数组的第一个位置使用 null,因为我们只对传递给 PanResponder 处理函数的第二个参数 gestureState 感兴趣。

tsx
onPanResponderMove={Animated.event(
[null, // ignore the native event
// extract dx and dy from gestureState
// like 'pan.x = gestureState.dx, pan.y = gestureState.dy'
{dx: pan.x, dy: pan.y}
])}

带动画事件的 PanResponder 示例

响应当前动画值

你可能会注意到,在动画过程中没有明确的方法读取当前值。这是因为由于优化,该值可能只在原生运行时中已知。如果你需要根据当前值运行 JavaScript,有两种方法

  • spring.stopAnimation(callback) 将停止动画并使用最终值调用 callback。这在进行手势过渡时很有用。
  • spring.addListener(callback) 将在动画运行时异步调用 callback,提供一个最近的值。这对于触发状态变化很有用,例如当用户拖动气泡靠近新选项时将其吸附到新选项,因为与需要以 60 fps 运行的连续手势(如平移)相比,这些较大的状态变化对几帧的延迟不那么敏感。

Animated 被设计为完全可序列化,因此动画可以以高性能方式运行,独立于正常的 JavaScript 事件循环。这确实影响了 API,所以当与完全同步系统相比做某些事情看起来有点棘手时,请记住这一点。查看 Animated.Value.addListener 作为解决其中一些限制的方法,但要谨慎使用它,因为它将来可能对性能产生影响。

使用原生驱动

Animated API 被设计为可序列化。通过使用原生驱动,我们在开始动画之前将所有关于动画的信息发送到原生端,允许原生代码在 UI 线程上执行动画,而无需在每一帧都通过桥接。一旦动画开始,JS 线程可以被阻塞而不会影响动画。

对于普通动画,可以通过在启动动画时在动画配置中设置 useNativeDriver: true 来使用原生驱动。由于历史原因,没有 useNativeDriver 属性的动画将默认为 false,但会发出警告(以及 TypeScript 中的类型检查错误)。

tsx
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- Set this to true
}).start();

动画值只兼容一个驱动程序,因此如果你在启动某个值的动画时使用原生驱动,请确保该值上的每个动画也都使用原生驱动。

原生驱动也适用于 Animated.event。这对于跟随滚动位置的动画特别有用,因为如果没有原生驱动,由于 React Native 的异步特性,动画总是会比手势慢一帧。

tsx
<Animated.ScrollView // <-- Use the Animated ScrollView wrapper
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: {y: this.state.animatedValue},
},
},
],
{useNativeDriver: true}, // <-- Set this to true
)}>
{content}
</Animated.ScrollView>

你可以通过运行 RNTester 应用,然后加载原生动画示例来查看原生驱动的实际效果。你还可以查看源代码,了解这些示例是如何生成的。

注意事项

并非所有您可以使用 Animated 完成的操作都受原生驱动支持。主要限制是您只能对非布局属性进行动画:例如 transformopacity 将起作用,但 Flexbox 和位置属性则不行。使用 Animated.event 时,它只适用于直接事件而不适用于冒泡事件。这意味着它不适用于 PanResponder,但适用于 ScrollView#onScroll 等。

当动画运行时,它可能会阻止 VirtualizedList 组件渲染更多行。如果你需要在用户滚动列表时运行长时间或循环动画,可以在动画配置中使用 isInteraction: false 来防止此问题。

请记住

在使用 rotateYrotateX 等变换样式时,请确保 perspective 变换样式已到位。目前,在 Android 上,如果没有它,某些动画可能无法渲染。示例如下。

tsx
<Animated.View
style={{
transform: [
{scale: this.state.scale},
{rotateY: this.state.rotateY},
{perspective: 1000}, // without this line this Animation will not render on Android while working fine on iOS
],
}}
/>

更多示例

RNTester 应用中包含 Animated 的各种使用示例

LayoutAnimation API

LayoutAnimation 允许你全局配置 createupdate 动画,这些动画将用于下一个渲染/布局周期中的所有视图。这对于执行 Flexbox 布局更新非常有用,无需费心测量或计算特定属性来直接动画它们,并且当布局更改可能影响祖先时特别有用,例如“查看更多”展开,它还会增加父组件的大小并将下面的行向下推,否则需要组件之间进行明确协调才能同步动画它们。

请注意,尽管 LayoutAnimation 非常强大且非常有用,但它提供的控制比 Animated 和其他动画库要少得多,因此如果 LayoutAnimation 无法满足你的需求,你可能需要使用其他方法。

请注意,为了使其在 Android 上工作,你需要通过 UIManager 设置以下标志

tsx
UIManager.setLayoutAnimationEnabledExperimental(true);

此示例使用预设值,你可以根据需要自定义动画,有关更多信息,请参阅 LayoutAnimation.js

补充说明

requestAnimationFrame

requestAnimationFrame 是一个你可能熟悉的浏览器 polyfill。它接受一个函数作为唯一参数,并在下一次重绘之前调用该函数。它是所有基于 JavaScript 的动画 API 的基础,是动画的重要组成部分。通常,你不需要自己调用它——动画 API 会为你管理帧更新。

setNativeProps

正如在直接操作部分中提到的,setNativeProps 允许我们直接修改由原生支持的组件(实际由原生视图支持的组件,与复合组件不同)的属性,而无需 setState 并重新渲染组件层级。

我们可以在 Rebound 示例中使用它来更新比例——如果我们要更新的组件嵌套很深并且没有使用 shouldComponentUpdate 进行优化,这可能会有所帮助。

如果你发现动画掉帧(每秒低于 60 帧),请考虑使用 setNativePropsshouldComponentUpdate 来优化它们。或者,你可以使用 useNativeDriver 选项在 UI 线程而不是 JavaScript 线程上运行动画。你可能还希望使用 InteractionManager 将任何计算密集型工作推迟到动画完成后。你可以使用应用内开发菜单中的“FPS 监控器”工具来监控帧率。