跳到主要内容

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

·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 接受 props maskElementchildren。 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 的缺点是您只能更新一组特定的属性,主要是 transformopacity。 您无法使用 useNativeDriver 为背景颜色等内容制作动画,至少目前还不能——我们将在稍后添加更多,当然,您始终可以为您项目需要的属性提交 PR,从而使整个社区受益 😀。

由于我们希望此动画流畅,因此我们将在这些约束条件下工作。 有关 useNativeDriver 在底层如何工作的更深入了解,请查看我们宣布它的博客文章

分解我们的动画

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

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

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

我们有多个想要同时运行的动画,它们都彼此密切相关。 例如,我们希望应用程序在遮罩处于中间显示状态时开始淡入。 因为这些动画密切相关,所以我们将使用带有单个 Animated.ValueAnimated.timing

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 徽标变小的过程中。 根据官方动画,我希望在小鸟放大到一半时开始显示它,并在整体动画进行到 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。