跳到主要内容

在 React Native 中实现 Twitter 的应用加载动画

·12 分钟阅读
Eli White
Eli White
Meta 软件工程师

Twitter的iOS应用有一个我非常喜欢的加载动画。

应用程序准备好后,Twitter 标志会令人愉悦地展开,露出应用程序。

我想弄清楚如何用 React Native 重现这个加载动画。


为了理解**如何**构建它,我首先必须理解加载动画的不同部分。最容易看出细微之处的方法是放慢速度。

这里有几个主要部分需要我们弄清楚如何构建。

  1. 鸟的缩放。
  2. 随着小鸟长大,下面的应用程序也随之显示
  3. 最后稍微缩小应用程序

我花了很长时间才弄清楚如何制作这个动画。

我开始时有一个*错误*的假设:蓝色背景和 Twitter 小鸟是应用程序*之上*的一层,随着小鸟的变大,它变得透明,从而显示出下面的应用程序。这种方法行不通,因为 Twitter 小鸟变得透明会显示蓝色层,而不是下面的应用程序!

幸运的是,亲爱的读者,您不必经历我同样的沮丧。您将获得这个直接进入精彩部分的教程!


正确的方法

在开始编写代码之前,理解如何分解动画很重要。为了帮助您直观地了解这种效果,我已经在 CodePen 中重新创建了它(嵌入在几个段落中),因此您可以交互式地查看不同的图层。

这种效果主要有三层。第一层是蓝色背景层。尽管这看起来出现在应用程序的顶部,但它实际上在后面。

然后我们有一个纯白层。最后,在最前面,是我们的应用程序。


这个动画的主要诀窍是使用 Twitter 标志作为 mask,并同时遮罩应用程序和白色层。我不会深入探讨遮罩的细节,网上有大量资源可供参考

在此背景下,遮罩的基本原理是:遮罩的不透明像素显示其遮罩的内容,而透明像素则隐藏其遮罩的内容。

我们使用 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 接受 maskElementchildren 属性。子元素会被 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>;

这将为我们提供下面看到的图层。

现在是动画部分

我们拥有了使这能正常工作所需的所有部分,下一步是为它们制作动画。为了让这个动画感觉良好,我们将利用 React Native 的 Animated API。

Animated 允许我们在 JavaScript 中声明式地定义动画。默认情况下,这些动画在 JavaScript 中运行,并告诉原生层每一帧需要进行的更改。尽管 JavaScript 会尝试每一帧更新动画,但它可能无法足够快地完成,导致丢帧(卡顿)发生。这不是我们想要的!

Animated 具有特殊行为,可以让您在没有卡顿的情况下获得动画。Animated 有一个名为 useNativeDriver 的标志,它在动画开始时将动画定义从 JavaScript 发送到原生层,从而允许原生侧处理动画更新,而无需每一帧都来回与 JavaScript 交互。useNativeDriver 的缺点是您只能更新特定的属性集,主要是 transformopacity。您不能使用 useNativeDriver 动画背景颜色之类的东西,至少目前还不能——我们会随着时间的推移添加更多,当然,您也可以随时为您项目所需的属性提交 PR,造福整个社区 😀。

由于我们希望这个动画流畅,我们将在这些限制内进行工作。要更深入地了解 useNativeDriver 的内部工作原理,请查看我们发布它的博客文章

分解我们的动画

我们的动画有4个组成部分

  1. 放大鸟,显示应用程序和纯白层
  2. 应用程序淡入
  3. 缩小应用程序
  4. 完成后隐藏白色层和蓝色层

使用 Animated,有两种主要方式定义动画。第一种是使用 Animated.timing,它允许您精确地指定动画将运行多长时间,以及一个缓动曲线来平滑运动。另一种方法是使用基于物理的 API,例如 Animated.spring。使用 Animated.spring,您可以指定弹簧的摩擦和张力等参数,然后让物理原理运行您的动画。

我们有多个动画需要同时运行,并且它们彼此之间密切相关。例如,我们希望在遮罩正在显示时,应用程序开始淡入。由于这些动画密切相关,我们将使用 Animated.timing 和单个 Animated.Value

`Animated.Value` 是一个原生值的包装器,Animated 用它来了解动画的状态。通常,您希望一个完整的动画只拥有一个这样的值。大多数使用 Animated 的组件会将该值存储在状态中。

由于我将此动画视为在整个动画的不同时间点发生的步骤,因此我们将Animated.Value的起始值设为0(表示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.ViewAnimated.TextAnimated.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 上试用它。

更多阅读 / 扩展学习

  1. 这本 gitbook 是一个很好的资源,在阅读 React Native 文档后可以了解更多关于 Animated 的知识。
  2. 实际的 Twitter 动画似乎在蒙版显示接近尾声时加速。尝试修改加载器以使用不同的缓动函数(或弹簧!)来更好地匹配该行为。
  3. 蒙版当前的结束缩放是硬编码的,可能无法在平板电脑上显示整个应用程序。根据屏幕尺寸和图像尺寸计算结束缩放将是一个很棒的 PR。