让我们来打造苹果产品页面上那些花哨的滚动动画

Avatar of Jurn van Wissen
Jurn van Wissen 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200美元免费额度!

苹果以其产品页面上流畅的动画而闻名。例如,当您向下滚动页面时,产品可能会滑入视野,MacBook 会展开,iPhone 会旋转,同时展示硬件、演示软件并以互动的方式讲述产品的使用故事。

请查看 iPad Pro 移动网页体验的这段视频

来源:Twitter

您在那里看到的许多效果并非仅用 HTML 和 CSS 创建。那么,您可能会问,是怎么做到的呢?嗯,这可能有点难以弄清楚。即使使用浏览器的 DevTools 也并不总是能揭示答案,因为它通常无法看到 <canvas> 元素后面的内容。

让我们深入了解其中一个效果,看看它是如何制作的,以便您可以在我们自己的项目中重新创建一些这些神奇的效果。具体来说,让我们复制 AirPods Pro 产品页面 中的英雄图片中的光线变化效果。

基本概念

我们的想法是创建动画,就像快速连续播放一系列图像一样。您知道,就像翻书一样!不需要复杂的 WebGL 场景或高级 JavaScript 库。

通过将每个帧与用户的滚动位置同步,我们可以在用户向下(或向上)滚动页面时播放动画。

从标记和样式开始

此效果的 HTML 和 CSS 非常简单,因为魔法发生在 <canvas> 元素内部,我们通过赋予其 ID 来使用 JavaScript 控制它。

在 CSS 中,我们将文档的高度设置为 100vh,并将 <body> 的高度设置为该高度的 5⨉,从而为我们提供必要的滚动长度以使此效果生效。我们还将文档的背景颜色与图像的背景颜色匹配。

最后,我们将定位 <canvas>,将其居中,并限制 max-widthheight,以使其不超过视口尺寸。

html {
  height: 100vh;
}


body {
  background: #000;
  height: 500vh;
}


canvas {
  position: fixed;
  left: 50%;
  top: 50%;
  max-height: 100vh;
  max-width: 100vw;
  transform: translate(-50%, -50%);
}

现在,我们可以向下滚动页面(即使内容没有超出视口高度),并且我们的 <canvas> 始终停留在视口顶部。这就是我们需要的全部 HTML 和 CSS。

让我们继续加载图像。

获取正确的图像

由于我们将使用图像序列(再次,就像翻书一样),因此我们将假设文件名按升序顺序依次编号(即 0001.jpg、0002.jpg、0003.jpg 等),位于同一目录中。

我们将编写一个函数,根据用户的滚动位置返回我们想要的图像文件的路径和编号。

const currentFrame = index => (
  `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index.toString().padStart(4, '0')}.jpg`
)

由于图像编号是整数,我们需要将其转换为字符串并使用 padStart(4, '0') 在索引前面添加零,直到达到四位数字以匹配我们的文件名。例如,将 1 传递给此函数将返回 0001。

这样我们就有了处理图像路径的方法。这是在 <canvas> 元素上绘制的序列中的第一张图像

如您所见,第一张图像位于页面上。此时,它只是一个静态文件。我们想要的是根据用户的滚动位置更新它。我们不仅仅想要加载一个图像文件,然后通过加载另一个图像文件来替换它。我们想要在 <canvas> 上绘制图像,并使用序列中的下一张图像更新绘制内容(但我们稍后再讨论这一点)。

我们已经创建了根据我们传递给它的数字生成图像文件路径的函数,因此我们现在需要做的就是跟踪用户的滚动位置并确定该滚动位置对应的图像帧。

将图像连接到用户的滚动进度

要了解我们需要传递哪个数字(以及因此要加载哪个图像)到序列中,我们需要计算用户的滚动进度。我们将创建一个事件侦听器来跟踪该进度并处理一些数学计算以计算要加载的图像。

我们需要知道

  • 滚动开始和结束的位置
  • 用户的滚动进度(即用户向下滚动页面的百分比)
  • 与用户滚动进度相对应的图像

我们将使用 scrollTop 获取元素的垂直滚动位置,在本例中,该元素恰好是文档顶部。这将用作起始点值。我们将通过从文档滚动高度中减去窗口高度来获取结束(或最大)值。然后,我们将 scrollTop 值除以用户可以向下滚动的最大值,这将给我们用户的滚动进度。

然后,我们需要将该滚动进度转换为与图像编号序列相对应的索引号,以便我们为该位置返回正确的图像。我们可以通过将进度数乘以我们拥有的帧数(图像数)来做到这一点。我们将使用 Math.floor() 将该数字向下取整,并将其包装在 Math.min() 中,并使用我们的最大帧数,以确保它永远不会超过总帧数。

window.addEventListener('scroll', () => {  
  const scrollTop = html.scrollTop;
  const maxScrollTop = html.scrollHeight - window.innerHeight;
  const scrollFraction = scrollTop / maxScrollTop;
  const frameIndex = Math.min(
    frameCount - 1,
    Math.floor(scrollFraction * frameCount)
  );
});

使用正确的图像更新 <canvas>

我们现在知道随着用户滚动进度的变化需要绘制哪个图像。这就是  <canvas> 的魔力发挥作用的地方。<canvas> 具有许多很酷的功能,可以构建从 游戏 和动画到 设计模型生成器 等各种内容!

其中一项功能是一个名为 requestAnimationFrame 的方法,它与浏览器配合使用以更新 <canvas>,如果我们使用的是普通图像文件而不是 <canvas>,则无法做到这一点。这就是我选择 <canvas> 方法而不是,比如,<img> 元素或带有背景图像的 <div> 的原因。

requestAnimationFrame 将与浏览器刷新率匹配,并通过使用 WebGL 使用设备的显卡或集成显卡进行渲染来启用硬件加速。换句话说,我们将获得帧之间非常平滑的过渡——没有图像闪烁!

让我们在滚动事件侦听器中调用此函数,以便在用户向上或向下滚动页面时交换图像。requestAnimationFrame 接受一个回调参数,因此我们将传递一个函数,该函数将更新图像源并在 <canvas> 上绘制新图像

requestAnimationFrame(() => updateImage(frameIndex + 1))

我们将 frameIndex 增加 1,因为虽然图像序列从 0001.jpg 开始,但我们的滚动进度计算实际上从 0 开始。这确保了这两个值始终保持一致。

我们传递给更新图像的回调函数如下所示

const updateImage = index => {
  img.src = currentFrame(index);
  context.drawImage(img, 0, 0);
}

我们将 frameIndex 传递给函数。这将使用序列中的下一张图像设置图像源,该图像绘制在我们的 <canvas> 元素上。

使用图像预加载效果更佳

从技术上讲,我们到此为止已经完成了。但是,说真的,我们可以做得更好!例如,快速滚动会导致图像帧之间出现一点延迟。这是因为每个新图像都会发送一个新的网络请求,需要重新下载。

我们应该尝试预加载图像新的网络请求。这样,每个帧都已下载,从而使过渡速度更快,动画更流畅!

我们只需要循环遍历整个图像序列并加载它们。

const frameCount = 148;


const preloadImages = () => {
  for (let i = 1; i < frameCount; i++) {
    const img = new Image();
    img.src = currentFrame(i);
  }
};


preloadImages();

演示!

关于性能的快速说明

虽然这种效果非常酷炫,但它也使用了**大量的图像**。确切地说,是 148 张。

无论我们如何优化图像,或者为它们服务的 CDN 多么快速,加载数百张图像始终会导致页面臃肿。假设我们在同一页面上有多个这样的实例。我们可能会得到这样的性能统计数据

1,609 requests, 55.8 megabytes transferred, 57.5 megabytes resources, load time of 30.45 seconds.

对于高速互联网连接且没有严格数据上限的用户来说,这可能没问题,但对于没有这种便利的用户来说,我们不能这么说。这是一个难以把握的平衡,但我们必须注意每个人的体验——以及我们的决策如何影响他们。

我们可以采取一些措施来帮助找到这种平衡,包括

  • 加载单个备用图像,而不是整个图像序列
  • 为某些设备创建使用较小图像文件的序列
  • 允许用户启用序列,也许可以使用一个按钮来启动和停止序列

Apple 采用了第一个选项。如果您在连接到缓慢的 3G 连接的移动设备上加载AirPods Pro 页面,那么,性能统计数据看起来好多了。

8 out of 111 requests, 347 kilobytes of 2.6 megabytes transferred, 1.4 megabytes of 4.5 megabytes resources, load time of one minute and one second.

是的,它仍然是一个重量级的页面。但它比完全没有考虑性能的情况下要轻得多。这就是 Apple 能够在单个页面上获得如此多复杂序列的方式。


进一步阅读

如果您对这些图像序列的生成方式感兴趣,一个好的起点是 AirBnB 的Lottie 库。文档将引导您完成使用 After Effects 生成动画的基础知识,同时提供了一种简单的方法将其包含在项目中。