跳到主要内容

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

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

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

一旦应用准备就绪,Twitter 标志会愉快地展开,露出应用。

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


为了理解如何构建它,我首先必须理解加载动画的不同组成部分。 要看到其中的微妙之处,最简单的方法是放慢速度。

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

  1. 缩放小鸟。
  2. 随着小鸟长大,显示下方的应用
  3. 在结尾稍微缩小应用

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

我最初错误地假设蓝色背景和 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 接受 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 的组件都将该值存储在状态中。

由于我将此动画视为在整个动画的不同时间点发生的步骤,我们将从 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 徽标变小的过程中保持不透明。 根据官方动画,我希望在小鸟正在中间向上缩放时开始显示它,并在整个动画的 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。