在 React Native 中实现 Twitter 应用加载动画
Twitter 的 iOS 应用有一个我相当喜欢的加载动画。

应用准备就绪后,Twitter 徽标会愉快地展开,显示出应用。
我想弄清楚如何使用 React Native 重现这个加载动画。
为了理解如何构建它,我首先需要理解加载动画的不同部分。最简单的方法是放慢速度来观察其精妙之处。

其中有几个主要部分我们需要弄清楚如何构建。
- 缩放小鸟。
- 随着小鸟长大,显示下面的应用
- 最后将应用稍微缩小
我花了很长时间才弄清楚如何制作这个动画。
我最初有一个错误的假设,认为蓝色背景和 Twitter 小鸟是位于应用之上的一个图层,并且随着小鸟的变大,它会变得透明,从而显示出下面的应用。这种方法行不通,因为 Twitter 小鸟变得透明只会显示蓝色图层,而不是下面的应用!
幸运的是,亲爱的读者,您不必经历我同样的沮丧。您可以直接跳到这个精彩的教程!
正确的方法
在我们开始编写代码之前,了解如何分解它非常重要。为了帮助您可视化这个效果,我在 CodePen 中重新创建了它(嵌入在几个段落中),以便您可以交互式地查看不同的图层。

这个效果主要有三个图层。第一个是蓝色背景图层。尽管它看起来出现在应用之上,但它实际上在最底层。
然后我们有一个纯白色图层。最后,在最前面,是我们的应用。

这个动画的主要技巧是使用 Twitter 徽标作为遮罩
,遮罩应用和白色图层。我不会深入遮罩的细节,网上有大量的资源可供参考在线。
在此背景下,遮罩的基本原理是:遮罩的不透明像素显示其遮罩的内容,而遮罩的透明像素则隐藏其遮罩的内容。
我们使用 Twitter 徽标作为遮罩,并用它遮罩两个图层:纯白色图层和应用图层。
为了显示应用,我们将遮罩放大,直到它大于整个屏幕。
在遮罩放大的同时,我们逐渐增加应用图层的不透明度,显示应用并隐藏其后面的纯白色图层。为了完成效果,我们在动画结束时将应用图层从大于 1 的比例开始缩小到 1。然后我们隐藏非应用图层,因为它们将不再被看到。
俗话说一图胜千言。那么一个交互式可视化又值多少言呢?点击“下一步”按钮浏览动画。显示图层可以为您提供侧视图。网格有助于可视化透明图层。
接下来,关于 React Native
好的。现在我们已经知道要构建什么以及动画如何工作,我们可以开始编写代码了——这也是您真正来这里的原因。
这个谜题的主要部分是 MaskedViewIOS,一个核心的 React Native 组件。
import {MaskedViewIOS} from 'react-native';
<MaskedViewIOS maskElement={<Text>Basic Mask</Text>}>
<View style={{backgroundColor: 'blue'}} />
</MaskedViewIOS>;
MaskedViewIOS
接受 maskElement
和 children
属性。子元素会被 maskElement
遮罩。请注意,遮罩不需要是图像,它可以是任何任意视图。上面示例的行为是渲染蓝色视图,但它只在 maskElement
中的“Basic Mask”文字所在的位置可见。我们只是制作了复杂的蓝色文本。
我们想做的是渲染我们的蓝色图层,然后在上面用 Twitter 徽标渲染我们被遮罩的应用和白色图层。
{
fullScreenBlueLayer;
}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Image source={twitterLogo} />
</View>
}>
{fullScreenWhiteLayer}
<View style={{flex: 1}}>
<MyApp />
</View>
</MaskedViewIOS>;
这将给我们带来下面看到的图层。

接下来是 Animated 部分
我们已经拥有使之工作所需的所有部分,下一步是为它们添加动画。为了使这个动画感觉良好,我们将利用 React Native 的 Animated API。
Animated 允许我们用 JavaScript 声明性地定义动画。默认情况下,这些动画在 JavaScript 中运行,并告诉原生层每帧要进行的更改。尽管 JavaScript 会尝试每帧更新动画,但它可能无法足够快地完成,从而导致掉帧(卡顿)发生。这可不是我们想要的!
Animated 具有特殊行为,可以让您在没有卡顿的情况下获得动画。Animated 有一个名为 useNativeDriver
的标志,它在动画开始时将您的动画定义从 JavaScript 发送到原生层,允许原生端处理动画更新,而无需每帧都往返于 JavaScript。useNativeDriver
的缺点是您只能更新特定的属性集,主要是 transform
和 opacity
。您无法使用 useNativeDriver
动画化背景颜色之类的事物,至少目前还不能——我们会随着时间推移添加更多功能,当然,您也可以随时为您项目所需的属性提交 PR,造福整个社区 😀。
既然我们希望这个动画流畅,我们将在这些约束下工作。要更深入地了解 useNativeDriver
的底层工作原理,请查看我们宣布它的博客文章。
分解我们的动画
我们的动画有 4 个组成部分
- 放大小鸟,显示应用和纯白色图层
- 应用淡入
- 缩小应用
- 完成后隐藏白色和蓝色图层
使用 Animated,有两种主要方式来定义动画。第一种是使用 Animated.timing
,它允许您精确地设置动画运行的时间长度,以及用于平滑运动的缓动曲线。另一种方法是使用基于物理的 API,例如 Animated.spring
。使用 Animated.spring
,您可以指定弹簧中的摩擦力和张力等参数,然后让物理引擎运行您的动画。
我们有多个动画需要同时运行,并且它们之间都紧密相关。例如,我们希望在遮罩正在显示的过程中应用开始淡入。由于这些动画紧密相关,我们将使用 Animated.timing
和一个单独的 Animated.Value
。
Animated.Value
是一个原生值的包装器,Animated 用它来了解动画的状态。对于一个完整的动画,通常只需要一个这样的值。大多数使用 Animated 的组件会将该值存储在 state 中。
由于我将此动画视为在整个动画过程中在不同时间点发生的步骤,我们将从 0 开始我们的 Animated.Value
,表示 0% 完成,并以 100 结束我们的值,表示 100% 完成。
我们最初的组件状态将是以下。
state = {
loadingProgress: new Animated.Value(0),
};
当我们准备开始动画时,我们告诉 Animated 将此值动画化到 100。
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true, // This is important!
}).start();
然后我尝试粗略估计动画的不同部分,以及我希望它们在整个动画的不同阶段具有的值。下面是一个表格,列出了动画的不同部分,以及我认为它们在时间推移的不同点应该具有的值。
Twitter 小鸟遮罩应该从比例 1 开始,在尺寸猛增之前会变小。因此,在动画进行到 10% 时,它应该具有 0.8 的比例值,然后最终猛增到比例 70。坦率地说,选择 70 相当随意,它需要足够大才能让小鸟完全显示屏幕,而 60 不够大 😀。不过,这一部分有趣的是,数字越大,它看起来增长得越快,因为它必须在相同的时间内到达那里。这个数字经过了一些反复试验才使它与这个徽标看起来不错。不同尺寸的徽标/设备将需要不同的最终比例,以确保整个屏幕都能显示出来。
应用应该保持不透明一段时间,至少在 Twitter 徽标变小的过程中。根据官方动画,我希望在小鸟放大到一半时开始显示它,并很快完全显示出来。因此,在 15% 时我们开始显示它,在整个动画进行到 30% 时它完全可见。
应用比例从 1.1 开始,并在动画结束时缩小到其常规比例。
现在,来看代码。
我们上面做的本质上是将动画进度百分比的值映射到各个部分的值。我们使用 Animated 的 .interpolate
方法来实现。我们创建了 3 个不同的样式对象,每个对象对应动画的一个部分,并使用基于 this.state.loadingProgress
的插值。
const loadingProgress = this.state.loadingProgress;
const opacityClearToVisible = {
opacity: loadingProgress.interpolate({
inputRange: [0, 15, 30],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
// clamp means when the input is 30-100, output should stay at 1
}),
};
const imageScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 10, 100],
outputRange: [1, 0.8, 70],
}),
},
],
};
const appScale = {
transform: [
{
scale: loadingProgress.interpolate({
inputRange: [0, 100],
outputRange: [1.1, 1],
}),
},
],
};
现在我们有了这些样式对象,我们可以在渲染文章前面提到的视图片段时使用它们。请注意,只有 Animated.View
、Animated.Text
和 Animated.Image
能够使用包含 Animated.Value
的样式对象。
const fullScreenBlueLayer = (
<View style={styles.fullScreenBlueLayer} />
);
const fullScreenWhiteLayer = (
<View style={styles.fullScreenWhiteLayer} />
);
return (
<View style={styles.fullScreen}>
{fullScreenBlueLayer}
<MaskedViewIOS
style={{flex: 1}}
maskElement={
<View style={styles.centeredFullScreen}>
<Animated.Image
style={[styles.maskImageStyle, imageScale]}
source={twitterLogo}
/>
</View>
}>
{fullScreenWhiteLayer}
<Animated.View
style={[opacityClearToVisible, appScale, {flex: 1}]}>
{this.props.children}
</Animated.View>
</MaskedViewIOS>
</View>
);

太棒了!我们现在已经让动画片段看起来像我们想要的那样。现在我们只需要清理我们的蓝色和白色图层,它们将永远不会再被看到。
为了知道何时可以清理它们,我们需要知道动画何时完成。幸运的是,我们调用 Animated.timing
的地方,.start
方法接受一个可选的回调函数,该函数在动画完成时运行。
Animated.timing(this.state.loadingProgress, {
toValue: 100,
duration: 1000,
useNativeDriver: true,
}).start(() => {
this.setState({
animationDone: true,
});
});
既然我们在 state
中有了一个值来知道动画是否完成,我们就可以修改我们的蓝色和白色图层来使用它。
const fullScreenBlueLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenBlueLayer]} />
);
const fullScreenWhiteLayer = this.state.animationDone ? null : (
<View style={[styles.fullScreenWhiteLayer]} />
);
大功告成!我们的动画现在可以工作了,并且在动画完成后我们清理了未使用的图层。我们已经构建了 Twitter 应用加载动画!
但是等等,我的不起作用!
别担心,亲爱的读者。我也讨厌那些只给您部分代码而不提供完整源代码的指南。
这个组件已经发布到 npm,并且在 GitHub 上作为 react-native-mask-loader 提供。要在您的手机上尝试,它在 Expo 上可用。

更多阅读/额外内容
- 阅读 React Native 文档后,这本 gitbook 是学习更多 Animated 知识的绝佳资源。
- 实际的 Twitter 动画似乎在接近结束时加速了遮罩的显示。尝试修改加载器,使用不同的缓动函数(或弹簧!)以更好地匹配该行为。
- 当前遮罩的最终比例是硬编码的,可能无法在平板电脑上完全显示整个应用。根据屏幕尺寸和图像尺寸计算最终比例将是一个很棒的 PR(Pull Request)。