Twitter 的 iOS 应用程序有一个我非常喜欢的加载动画。
一旦应用程序准备就绪,Twitter 徽标就会愉快地展开,显示应用程序。
我想弄清楚如何使用 React Native 重现此加载动画。
为了理解如何构建它,我首先必须理解加载动画的不同部分。查看微妙之处的最简单方法是放慢速度。
这里有几个主要部分,我们需要弄清楚如何构建。
- 缩放小鸟。
- 随着小鸟长大,显示底部的应用程序
- 最后稍微缩小应用程序
我花了一段时间才弄清楚如何制作这个动画。
我最初的错误假设是蓝色背景和 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
接受 maskElement
和 children
属性。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>;
这将为我们提供下面看到的图层。
现在开始介绍动画部分
我们已经拥有使之工作所需的所有部分,下一步是对它们进行动画处理。为了使这个动画感觉良好,我们将使用 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,
}).start();
然后,我尝试粗略估计动画的不同部分以及我希望它们在整个动画不同阶段具有的值。下面是一个表格,列出了动画的不同部分,以及我认为它们在随着时间推移的不同点应具有的值。
![](/assets/images/loading-screen-05-9b5c5f9b785287a11b6444ad4a8afcad.png)
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',
}),
};
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 上找到。
- 这本 gitbook 是一个很好的资源,可以在你阅读 React Native 文档后了解更多关于
Animated
的信息。
- 实际的 Twitter 动画似乎在接近尾声时加速了遮罩的显示。尝试修改加载器以使用不同的缓动函数(或弹簧动画!)来更好地匹配该行为。
- 当前遮罩的最终缩放比例是硬编码的,可能无法在平板电脑上显示整个应用程序。根据屏幕尺寸和图像尺寸计算最终缩放比例将是一个很棒的 PR。