跳到主要内容
新架构实战课 实操 + 基建 + 原理全维度包揽,抢先掌握 React Native 新架构精髓 立即查看 >

动画

流畅、有意义的动画对于移动应用用户体验来说是非常重要的。现实生活中的物体在开始移动和停下来的时候都具有一定的惯性,我们在界面中也可以使用动画来实现契合物理规律的交互。

React Native 提供了两个互补的动画系统:用于创建精细的交互控制的动画Animated和用于全局的布局动画LayoutAnimation

Animated

Animated使得开发者可以简洁地实现各种各样的动画和交互方式,并且具备极高的性能。Animated旨在以声明的形式来定义动画的输入与输出,在其中建立一个可配置的变化函数,然后使用start/stop方法来控制动画按顺序执行。 Animated仅封装了 6 个可以动画化的组件:ViewTextImageScrollViewFlatListSectionList,不过你也可以使用Animated.createAnimatedComponent()来封装你自己的组件。下面是一个在加载时带有淡入动画效果的视图:

我们来分解一下这个过程。在FadeInView的构造函数里,我们创建了一个名为fadeAnimAnimated.Value,并放在state中。而View的透明度是和这个值绑定的。

组件加载时,透明度首先设为 0。然后一个 easing 动画开始改变fadeAnim值,同时会导致所有与其相关联的值(本例中是透明度)也逐帧更新,最终和fadeAnim一样变为 1。

这一过程经过特别优化,执行效率会远高于反复调用setState和多次重渲染。

因为这一过程是纯声明式的,因此还有进一步优化的空间,比如我们可以把这些声明的配置序列化后发送到一个高优先级的线程上运行。

配置动画

动画拥有非常灵活的配置项。自定义的或预定义的 easing 函数、延迟、持续时间、衰减系数、弹性常数等都可以在对应类型的动画中进行配置。

Animated提供了多种动画类型,其中最常用的要属Animated.timing()。它可以使用一些预设的easing曲线函数来控制动画值的变化速度,也支持自定义的曲线函数。动画中通常使用easing曲线函数来控制物体的加速或减速变化。

默认情况下timing使用easeInOut曲线,它使动画体逐渐加速到最大然后逐渐减速到停止。你可以通过传递easing参数来指定不同的变化速度,还支持自定义duration持续时间,甚至是动画开始前的delay延迟。

下面这个例子创建了一个 2 秒长的动画,在移动目标到最终位置前会稍微往后退一点:

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

如果想了解更多配置参数,请参阅Animated文档的配置动画章节。

组合动画

多个动画可以通过parallel(同时执行)、sequence(顺序执行)、staggerdelay来组合使用。它们中的每一个都接受一个要执行的动画数组,并且自动在适当的时候调用start/stop

例如,以下的动画滑行停止,然后在平行旋转的同时弹回:

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,
}),
Animated.parallel([
// after decay, in parallel:
Animated.spring(position, {
toValue: {x: 0, y: 0}, // return to start
}),
Animated.timing(twirl, {
// and twirl
toValue: 360,
}),
]),
]).start(); // start the sequence group

默认情况下,如果任何一个动画被停止或中断了,组内所有其它的动画也会被停止。Parallel 有一个stopTogether属性,如果设置为false,可以禁用自动停止。

Animated文档的组合动画一节中列出了所有的组合方法。

合成动画值

你可以使用加减乘除以及取余等运算来把两个动画值合成为一个新的动画值

有些时候,一些动画值需要依赖另一些值来做计算。比如下面的例子,以一个动画值为分母,增大其值以实现合成值的缩小(1x --> 0.5x)

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

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

插值

所有动画值都可以执行插值(interpolation)操作。插值是指将一定范围的输入值映射到另一组不同的输出值,一般我们使用线性的映射,但是也可以使用 easing 函数。默认情况下,它会将曲线外推到给定范围之外,但您也可以让它限制为输出值。

一个简单的将范围 0-1 转换为范围 0-100 的映射操作是:

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

例如,你可能想通过使用 Animated.Value 的值从 0 变化到 1 来让 position 从 150px 变化到 0px,同时 opacity 从 0 变为 1。这一点可以通过将 style 从 example 修改为下面的样子来实现:

  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,最后形成一个始终为 0 的静止区间,对于任何大于 100 的输入都返回 0。具体写法如下:

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

它的最终映射结果如下:

| 输入 | 输出 |
| ---- | ---- |
| -400 | 450 |
| -300 | 300 |
| -200 | 150 |
| -100 | 0 |
| -50 | 0.5 |
| 0 | 1 |
| 50 | 0.5 |
| 100 | 0 |
| 101 | 0 |
| 200 | 0 |

interpolate()还支持到字符串的映射,从而可以实现颜色以及带有单位的值的动画变换。例如你可以像下面这样实现一个旋转动画:

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

interpolate()还支持任意的渐变函数,其中有很多已经在Easing类中定义了,包括二次、指数、贝塞尔等曲线以及 step、bounce 等方法。interpolation还支持限制输出区间outputRange。你可以通过设置extrapolateextrapolateLeftextrapolateRight属性来限制输出区间。默认值是extend(允许超出),不过你可以使用clamp选项来阻止输出值超过outputRange

跟踪动态值

动画中所设的值还可以通过跟踪别的值得到。你只要把 toValue 设置成另一个动态值而不是一个普通数字就行了。比如我们可以用弹跳动画来实现聊天头像的闪动,又比如通过timing设置duration:0来实现快速的跟随。他们还可以使用插值来进行组合:

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

变量 leaderfollower 通过 Animated.ValueXY() 来定义。这是一个方便的处理 2D 交互的办法,譬如旋转或拖拽。它是一个简单的包含了两个Animated.Value实例的包装,然后提供了一系列辅助函数,使得ValueXY在许多时候可以替代Value来使用。比如在上面的代码片段中,leaderfollower可以同时为valueXY类型,这样 x 和 y 的值都会被跟踪。

跟踪手势

Animated.event是 Animated 中与输入有关的部分,允许手势或其它事件直接绑定到动态值上。它通过一个结构化的映射语法来完成,使得复杂事件对象中的值可以被正确的解开。第一层是一个数组,允许同时映射多个值,然后数组的每一个元素是一个嵌套的对象。在下面的例子里,你可以发现scrollX被映射到了event.nativeEvent.contentOffset.x(event通常是回调函数的第一个参数),并且pan.xpan.y分别映射到gestureState.dxgestureState.dygestureState是传递给PanResponder回调函数的第二个参数)。

例如,在使用水平的滚动手势时,可以像下面这样将 event.nativeEvent.contentOffset.x 映射到 scrollX (一个 Animated.Value):

 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

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回调函数,提供一个最近的值作为参数。这在用于触发状态切换的时候非常有用,譬如当用户拖拽一个东西靠近的时候弹出一个新的气泡选项。不过这个状态切换可能并不会十分灵敏,因为它不像许多连续手势操作(如旋转)那样在 60fps 下运行。

Animated 被设计为完全可序列化的,因此动画可以以高性能的方式运行,独立于正常的 JavaScript 事件循环。这确实会影响 API 的一些设计,因此与完全同步的系统相比,做某些需求可能看起来会有些棘手。Animated.Value.addListener 可能可以用来解决一些场景问题,但要谨慎使用它,因为它可能带来一些性能上的影响。

注:社区有另一个设计思路不太一样的高性能动画库 react-native-reanimated,对性能有更高要求的开发者可以参考一下。

启用原生动画驱动

Animated的 API 是可序列化的(即可转化为字符串表达以便通信或存储)。通过启用原生驱动,我们在启动动画前就把其所有配置信息都发送到原生端,利用原生代码在 UI 线程执行动画,而不用每一帧都在两端间来回沟通。如此一来,动画一开始就完全脱离了 JS 线程,因此此时即便 JS 线程被卡住,也不会影响到动画了。

在动画中启用原生驱动非常简单。只需在开始动画之前,在动画配置中加入一行useNativeDriver: true,如下所示:

Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- 加上这一行
}).start();

动画值在不同的驱动方式之间是不能兼容的。因此如果你在某个动画中启用了原生驱动,那么所有和此动画依赖相同动画值的其他动画也必须启用原生驱动。

原生驱动还可以在Animated.event中使用,其对于滚动操作相关的动画优势更突出。在滚动事件中如果不使用原生驱动,由于数值需要通过 js 桥异步传输,动画将始终比用户的操作落后一帧。

<Animated.ScrollView // <-- 使用可动画化的ScrollView组件
scrollEventThrottle={1} // <-- 设为1以确保滚动事件的触发频率足够密集
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: {y: this.state.animatedValue},
},
},
],
{useNativeDriver: true}, // <-- 加上这一行
)}>
{content}
</Animated.ScrollView>

您也可以通过运行 RNTester app 查看原生驱动程序的运行情况,然后再载入原生动画示例。您也可以查看 source code 来了解这些例子是如何产生的。

注意事项

原生驱动程序目前并不足以支持您使用 Animated 执行所有操作。主要限制是您只能为非布局属性设置动画:像 transformopacity 这样的东西可以工作,但 flexbox 和 position 属性不能。使用 Animated.event 时,它只适用于直接事件,而不适用于冒泡事件。这意味着它不能与 PanResponder 一起使用,但可以与 ScrollView#onScroll 之类的东西一起使用。

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

牢记

在使用 rotateYrotateX 等变换样式时,请确保使用了变换样式 perspective。此时,如果没有它,某些动画可能无法在 Android 上呈现。下面是一个例子。

<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 应用程序有各种使用中的“动画”示例:

LayoutAnimation API

LayoutAnimation允许你在全局范围内创建更新动画,这些动画会在下一次渲染或布局周期运行。它常用来更新 flexbox 布局,因为它可以无需测量或者计算特定属性就能直接产生动画。尤其是当布局变化可能影响到父节点(譬如“查看更多”展开动画既增加父节点的尺寸又会将位于本行之下的所有行向下推动)时,如果不使用LayoutAnimation,可能就需要显式声明组件的坐标,才能使得所有受影响的组件能够同步运行动画。

注意尽管LayoutAnimation非常强大且有用,但它对动画本身的控制没有Animated或者其它动画库那样方便,所以如果你使用LayoutAnimation无法实现一个效果,那可能还是要考虑其他的方案。

另外,如果要在Android上使用 LayoutAnimation,那么目前还需要在UIManager中启用::

// 在执行任何动画代码之前,比如在入口文件App.js中执行
UIManager.setLayoutAnimationEnabledExperimental &&
UIManager.setLayoutAnimationEnabledExperimental(true);

本示例使用预设值,您可以根据需要自定义动画,更多信息参见 LayoutAnimation.js

其他要注意的地方

requestAnimationFrame

requestAnimationFrame是一个对浏览器标准 API 的兼容实现,你可能已经熟悉它了。它接受一个函数作为唯一的参数,并且在下一次重绘之前调用此函数。一些基于 JavaScript 的动画库高度依赖于这一 API。通常你不必直接调用它——那些动画库会替你管理好帧的更新。

setNativeProps

正如直接操作文档所说,setNativeProps方法可以使我们直接修改基于原生视图的组件的属性,而不需要使用setState来重新渲染整个组件树。

如果我们要更新的组件有一个非常深的内嵌结构,并且没有使用shouldComponentUpdate来优化,那么使用setNativeProps就将大有裨益。

如果你发现你的动画丢帧(低于 60 帧每秒),可以尝试使用setNativeProps或者shouldComponentUpdate来优化它们。或者可以在 UI 线程上运行动画,而不是在 JavaScript 线程上使用 useNativeDriver 选项。你还可以考虑将部分计算工作放在动画完成之后进行,这时可以使用InteractionManager。你还可以使用应用内的开发者菜单中的“FPS Monitor”工具来监控应用的帧率。