跳至主要内容

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

·阅读时间:11 分钟
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 中“基本遮罩”字词所在的位置可见。我们只是制作了复杂的蓝色文本。

我们要做的是呈现蓝色层,然后在其顶部呈现我们使用 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 的缺点是您只能更新特定的一组属性,主要是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 不够大 😀。不过,有趣的是,数字越大,它看起来增长的速度就越快,因为它必须在相同的时间内到达那里。这个数字需要一些反复试验才能使这个 logo 看起来不错。不同尺寸的 logo/设备需要不同的最终缩放比例,以确保完全显示整个屏幕。

应用程序应该保持不透明一段时间,至少在 Twitter logo 缩小期间。根据官方动画,我希望在小鸟放大到一半时开始显示它,并在整个动画进行到 30% 时完全显示它。因此,在 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。