动画
动画对于创造极佳的用户体验非常重要。静止的物体在开始移动时必须克服惯性。运动中的物体具有动量,很少立即停止。动画允许你在界面中传达物理上可信的运动。
React Native 提供了两个互补的动画系统:Animated 用于对特定值进行粒度和交互式控制,LayoutAnimation 用于动画化的全局布局事务。
Animated API
Animated API 旨在以非常高的性能简洁地表达各种有趣的动画和交互模式。Animated 专注于输入和输出之间的声明式关系,中间可配置变换,并使用 start/stop 方法来控制基于时间的动画执行。
Animated 导出了六种可动画组件类型:View、Text、Image、ScrollView、FlatList 和 SectionList,但你也可以使用 Animated.createAnimatedComponent() 创建自己的组件。
例如,一个挂载时淡入的容器视图可能看起来像这样:
- TypeScript
- JavaScript
让我们分解一下这里发生了什么。在 FadeInView 渲染方法中,一个新的 Animated.Value called fadeAnim 使用 useRef 初始化。View 的不透明度属性映射到这个动画值。在幕后,提取数值并用于设置不透明度。
当组件挂载时,不透明度设置为 0。然后,在 fadeAnim 动画值上启动一个缓动动画,随着值动画化到最终值 1,它将在每一帧更新其所有依赖的映射(在本例中,仅不透明度)。
这是以一种优化的方式完成的,比调用 setState 和重新渲染更快。因为整个配置是声明式的,我们将能够实现进一步的优化,序列化配置并在高优先级线程上运行动画。
配置动画
动画是高度可配置的。自定义和预定义的缓动函数、延迟、持续时间、衰减因子、弹簧常数等都可以根据动画类型进行调整。
Animated 提供了几种动画类型,最常用的是 Animated.timing()。它支持使用各种预定义的缓动函数或你自己的函数随时间动画化一个值。缓动函数通常用于动画中以传达物体的逐渐加速和减速。
默认情况下,timing 将使用 easeInOut 曲线,传达逐渐加速到全速并通过逐渐减速到停止来结束。你可以通过传递 easing 参数来指定不同的缓动函数。还支持自定义 duration 甚至在动画开始前的 delay。
例如,如果我们想创建一个 2 秒长的动画,对象在移动到最终位置之前稍微后退:
Animated.timing(this.state.xPosition, {
toValue: 100,
easing: Easing.back(),
duration: 2000,
useNativeDriver: true,
}).start();
查看 Animated API 参考的 配置动画 部分,了解有关内置动画支持的所有配置参数的更多信息。
组合动画
动画可以组合并按顺序或并行播放。顺序动画可以在上一个动画完成后立即播放,也可以在指定的延迟后开始。Animated API 提供了几种方法,例如 sequence() 和 delay(),每种方法都接受一个要执行的动画数组,并根据需要自动调用 start()/stop()。
例如,以下动画滑行至停止,然后在旋转的同时弹回:
Animated.sequence([
// 衰减,然后弹簧回起点并旋转
Animated.decay(position, {
// 滑行至停止
velocity: {x: gestureState.vx, y: gestureState.vy}, // 来自手势释放的速度
deceleration: 0.997,
useNativeDriver: true,
}),
Animated.parallel([
// 衰减后,并行:
Animated.spring(position, {
toValue: {x: 0, y: 0}, // 返回起点
useNativeDriver: true,
}),
Animated.timing(twirl, {
// 并旋转
toValue: 360,
useNativeDriver: true,
}),
]),
]).start(); // 启动序列组
如果一个动画停止或中断,则组中的所有其他动画也会停止。Animated.parallel 有一个 stopTogether 选项,可以设置为 false 以禁用此功能。
你可以在 Animated API 参考的 组合动画 部分找到组合方法的完整列表。
组合动画值
你可以 通过加、乘、除或模运算组合两个动画值 来制作一个新的动画值。
在某些情况下,动画值需要反转另一个动画值进行计算。一个例子是反转缩放(2x --> 0.5x):
const a = new Animated.Value(1);
const b = Animated.divide(1, a);
Animated.spring(a, {
toValue: 2,
useNativeDriver: true,
}).start();
插值
每个属性都可以先经过插值处理。插值将输入范围映射到输出范围,通常使用线性插值,但也支持缓动函数。默认情况下,它将外推给定范围之外的曲线,但你也可以让它限制输出值。
将 0-1 范围转换为 0-100 范围的基本映射如下:
value.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
});
例如,你可能希望将 Animated.Value 视为从 0 到 1,但将位置从 150px 动画化到 0px,不透明度从 0 到 1。这可以通过修改上面示例中的 style 来完成,如下所示:
style={{
opacity: this.state.fadeAnim, // 直接绑定
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,你可以这样做:
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 模块中实现。interpolate() 还具有用于外推 outputRange 的可配置行为。你可以通过设置 extrapolate、extrapolateLeft 或 extrapolateRight 选项来设置外推。默认值是 extend,但你可以使用 clamp 来防止输出值超出 outputRange。
跟踪动态值
动画值也可以通过将动画的 toValue 设置为另一个动画值而不是普通数字来跟踪其他值。例如,像 Android 上 Messenger 使用的"Chat Heads"动画可以使用固定在另一个动画值上的 spring() 实现,或者使用 timing() 和 duration 为 0 进行刚性跟踪。它们还可以与插值组合:
Animated.spring(follower, {toValue: leader}).start();
Animated.timing(opacity, {
toValue: pan.x.interpolate({
inputRange: [0, 300],
outputRange: [1, 0],
}),
useNativeDriver: true,
}).start();
leader 和 follower 动画值将使用 Animated.ValueXY() 实现。ValueXY 是处理 2D 交互(如平移或拖动)的便捷方法。它是一个基本包装器,包含两个 Animated.Value 实例和一些辅助函数调用它们,使得 ValueXY 在许多情况下成为 Value 的直接替代品。它允许我们在上面的示例中跟踪 x 和 y 值。
跟踪手势
手势(如平移或滚动)和其他事件可以使用 Animated.event 直接映射到动画值。这是通过结构化映射语法完成的,以便可以从复杂的事件对象中提取值。第一级是一个数组,允许跨多个参数进行映射,该数组包含嵌套对象。
例如,在使用水平滚动手势时,你将执行以下操作以将 event.nativeEvent.contentOffset.x 映射到 scrollX(一个 Animated.Value):
onScroll={Animated.event(
// scrollX = e.nativeEvent.contentOffset.x
[{nativeEvent: {
contentOffset: {
x: scrollX
}
}
}]
)}
以下示例实现了一个水平滚动轮播,其中滚动位置指示器使用 ScrollView 中使用的 Animated.event 进行动画化。
带有 Animated Event 示例的 ScrollView
使用 PanResponder 时,你可以使用以下代码从 gestureState.dx 和 gestureState.dy 提取 x 和 y 位置。我们在数组的第一个位置使用 null,因为我们只对传递给 PanResponder 处理程序的第二个参数感兴趣,即 gestureState。
onPanResponderMove={Animated.event(
[null, // 忽略原生事件
// 从 gestureState 提取 dx 和 dy
// 类似 'pan.x = gestureState.dx, pan.y = gestureState.dy'
{dx: pan.x, dy: pan.y}
])}
带有 Animated Event 示例的 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 中产生类型检查错误)。
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- 将此设置为 true
}).start();
动画值仅与一个驱动兼容,因此如果在值上启动动画时使用原生驱动,请确保该值上的每个动画也使用原生驱动。
原生驱动也适用于 Animated.event。这对于跟随滚动位置的动画特别有用,因为如果没有原生驱动,由于 React Native 的异步性质,动画将始终比手势落后一帧。
<Animated.ScrollView // <-- 使用 Animated ScrollView 包装器
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: {y: this.state.animatedValue},
},
},
],
{useNativeDriver: true}, // <-- 将此设置为 true
)}>
{content}
</Animated.ScrollView>
你可以通过运行 RNTester 应用 查看原生驱动的实际效果,然后加载 Native Animated 示例。你也可以查看 源代码 了解这些示例是如何制作的。
注意事项
并非所有你可以用 Animated 做的事情目前都受原生驱动支持。主要限制是你只能动画化非布局属性:像 transform 和 opacity 这样的属性会起作用,但 Flexbox 和 position 属性不会。使用 Animated.event 时,它仅适用于直接事件,不适用于冒泡事件。这意味着它不适用于 PanResponder,但适用于 ScrollView#onScroll 之类的事情。
当动画运行时,它可能会阻止 VirtualizedList 组件渲染更多行。如果你需要在用户滚动列表时运行长时间或循环动画,你可以在动画配置中使用 isInteraction: false 来防止此问题。
记住
使用 rotateY、rotateX 等变换样式时,确保变换样式 perspective 就位。此时,如果没有它,某些动画可能无法在 Android 上渲染。示例如下。
<Animated.View
style={{
transform: [
{scale: this.state.scale},
{rotateY: this.state.rotateY},
{perspective: 1000}, // 没有这一行,此动画在 Android 上将无法渲染,而在 iOS 上工作正常
],
}}
/>
其他示例
RNTester 应用有各种 Animated 使用示例:
LayoutAnimation API
LayoutAnimation 允许你全局配置 create 和 update 动画,这些动画将用于下一个渲染/布局周期中的所有视图。这对于进行 Flexbox 布局更新非常有用,无需费心测量或计算特定属性以便直接为它们制作动画,并且当布局更改可能影响祖先组件时尤其有用,例如“查看更多”展开操作也会增加父组件的大小并向下推动下方的行,否则这需要组件之间的显式协调才能同步为它们所有制作动画。
请注意,虽然 LayoutAnimation 非常强大且相当有用,但它提供的控制力远不如 Animated 和其他动画库,因此如果你无法让 LayoutAnimation 完成你想要的操作,可能需要使用另一种方法。
请注意,为了使其在 Android 上工作,你需要通过 UIManager 设置以下标志:
UIManager.setLayoutAnimationEnabledExperimental(true);
此示例使用预设值,你可以根据需要自定义动画,请参阅 LayoutAnimation.js 以获取更多信息。
附加说明
requestAnimationFrame
requestAnimationFrame 是一个你可能熟悉的浏览器 polyfill。它接受一个函数作为其唯一参数,并在下一次重绘之前调用该函数。它是所有基于 JavaScript 的动画 API underlying 的基本构建块。通常,你不需要自己调用它——动画 API 会为你管理帧更新。
setNativeProps
正如 直接操作部分 中提到的,setNativeProps 允许我们直接修改原生支持组件的属性(实际上由原生视图支持的组件,不同于复合组件),而无需 setState 和重新渲染组件层次结构。
我们可以在 Rebound 示例中使用它来更新缩放——如果我们正在更新的组件嵌套很深且未通过 shouldComponentUpdate 进行优化,这可能会很有帮助。
如果你发现动画出现掉帧(性能低于每秒 60 帧),请考虑使用 setNativeProps 或 shouldComponentUpdate 来优化它们。或者你可以 使用 useNativeDriver 选项 在 UI 线程而不是 JavaScript 线程上运行动画。你可能还希望使用 InteractionManager 将任何计算密集型工作推迟到动画完成后。你可以通过使用应用内开发菜单 "FPS Monitor" 工具来监控帧率。