苹果官网Ipad mini滚动动画实现原理探究

lxf2023-05-11 22:49:01

背景

最近因工作需要,要开发两个比较炫酷的动画效果,早期我个人在这方面积累太少,导致实际开发过程走了不少弯路,本文特此总结一下,希望各位同行能吸取经(jia)验(ban)教训少走弯路早日下班。

废话不多说,我们先来看几个苹果官网比较炫酷的几个动画效果,你在此也可以思考一下,该如何实现这些动画效果。 苹果官网Ipad mini滚动动画实现原理探究 苹果官网Ipad mini滚动动画实现原理探究 苹果官网Ipad mini滚动动画实现原理探究

走过的弯路

当我拿到设计同学的设计稿,告知我要实现的第一个动画就类似这种时,我第一反应是想到用一个视频来进行实现,通过控制视频 currentTime 属性来进行视频进度的控制,再监听scroll事件,当他触发时我就将 currentTime 的值进行加减操作,结果这是非常傻的一个操作。因为当用户在触摸板上进行快速向上或向下滚动时,scroll 事件的响应并不是连续触发,而是节流之后的最后几次响应,如下图所示: 苹果官网Ipad mini滚动动画实现原理探究 对此,一时我竟不知所措,只好一番Google,找到了原来监听 scroll 事件只是第一步,第二步是需要基于scrollTopscrollHeightclientHeight 三者进行计算,求出一个滚动系数,基于该计算结果再进行相关动效计算。

const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
const scrolled = scrollTop / (scrollHeight - clientHeight);

套用上面的公式,我满心欢喜的拿着计算出来的 scrolled 值去设置 currentTime,以为能踩点下班时,却发现动画效果是实现了,但是当我快速的滚动时竟然会出现掉帧卡顿跳跃的情况,对于一个追求极致用户体验的我来说,这种情况我是没法忍受的。

奈何自己不够聪明,只有承认自己的无知,被迫向苹果官网妥协。在工位上认真研究苹果官网上的效果是如何实现的,通过认真调试研究后发现,苹果官网竟然是使用几十张甚至百多张图片逐一去加载、再拼接的方式实现的类似效果,当看到请求面板中那一大堆的网络请求时,我的内心是排斥的,网络上那么多性能优化的文章都告诫我们,要尽可能的减少资源加载的请求,图片能合并就合并,代码能压缩就压缩。但苹果官网却玩的这么开放,找了一圈社区发现已经有博主实现了该效果,效果看起来还相当不错-> Apple iPhone SE - Rotation,但我个人实在是看不下去这一堆网络请求,最终还是没采用该方案去实现

那几日我一直在想有没有一种可能,既能让请求变少,又能不掉帧,还能实现丝滑的效果,如果能看起来有那么点牛逼哄哄的感觉就更好了。通过Google搜索换了各种关键词均找不到相关资料,那几日真的到了怀疑自己能力的地步,每日百思不得其解、朝思夜想,次日夜里在梦中都在思考这个怎么才能实现,我一直不信邪,也不相信苹果官网所有的类似动效,都使用这种方式去实现的,我翻遍苹果官网每一个产品页面,终于在 ipad-mini 的页面找到了想要的答案。 苹果官网Ipad mini滚动动画实现原理探究

由于线上代码均被压缩又没有源码,只好在苹果官网压缩后的代码文件中大海捞针,找到他的关键代码实现,通过断点调试一行一行的跟进,最终不负有心人,自己成功复刻了苹果的这个实现效果。

Ipad mini的实现方式分解

1. 搭建基本的sticky结构

要实现Ipad mini的这个滚动动效,首先需要搭建一个基于position: sticky定位的页面基本结构,在结构中.sticky节点的高度为100vh,并设置overflow: hidden,这里我们需要让sticky节点一直固定在屏幕顶部,不需要让它进行滚动。.content节点中每一个 section 节点都是一块内容区域,他们的高度由自身需要占用多少滚动距离自行设定,我实现的例子中将content节点下的每一个section子节点的高度都和.timeline-wrapper下的.timeline三个节点高度进行了绑定,因为在用户滚动的时候,用户肉眼看到的是.sticky节点下的内容位移变化,但滚动的响应区域是.timeline-wrapper节点,这样即可实现.timeline-wrapper滚动多少距离,.content节点就设置多少偏移量,从而达到交互与肉眼看到的视觉内容进行匹配。 苹果官网Ipad mini滚动动画实现原理探究

2. 缓存视频帧

有了上述基本协同的结构后,就可以开始在页面加载的时候,去遍历我们要请求的视频资源列表,拿到列表中每一个视频资源地址,调用createVideo工具函数获取video节点对象

function createVideo(url) {
  const video = document.createElement("video");

  video.src = url;
  video.muted = true;
  video.playbackRate = 1;
  video.currentTime = 0;
  video.setAttribute("muted", "");
  video.setAttribute("playsinline", "");
  video.setAttribute("type", "video/webm");
  video.setAttribute("preload", "none");
  video.classList.add("video");
  video.style.display = "none";
  window.document.body.appendChild(video);

  return video;
}

获取到视频资源信息后,我们就可以开始进行视频资源帧的缓存操作,在进行帧缓存之前,我们需要大概计算一个视频资源我们需要具体缓存多少帧,例如我例子中拟定的一个视频大概缓存230帧,默认每一个缓存帧的位置都为false状态。

在正式进行缓存之前,我们需要让视频开始进行播放,并立即去执行视频资源缓冲操作,同时监听canplaythrough事件,该事件触发时表示当前已经加载足够的数据来播放视频,直到其结束都不会再进行缓冲内容了。这样我们在该事件触发的时候就可以放心的去轮训该视频资源,我这里设置的是每30ms就去执行一次视频缓存帧的创建操作,再基于当前视频资源创建视频帧,将创建了的视频帧存储到framsStore数组中以供后续使用。

function cacheFrame(videoMetaData) {
  return new Promise((resolve, reject) => {
    const { url, frameCount } = videoMetaData;
    const video = createVideo(url);
    const framsStore = new Array(frameCount).fill(false);
    let videoWidth = 0;
    let videoHeight = 0;
    let setIn = 0;
    let framsNumber = 0;

    video.play();
    video.addEventListener("loadedmetadata", (res) => {
      videoWidth = video.videoWidth;
      videoHeight = video.videoHeight;
    });

    video.addEventListener("ended", () => {
      resolve(framsStore);
    });

    video.addEventListener("waiting", (res) => {
      clearInterval(setIn);
    });

    video.addEventListener("error", () => {
      reject([]);
    });

    video.addEventListener("canplaythrough", (res) => {
      clearInterval(setIn);

      setIn = setInterval(() => {
        if (framsNumber >= frameCount) clearInterval(setIn);

        framsStore[framsNumber] = createFrame(video, videoWidth, videoHeight);
        framsNumber++;
      }, fps);
    });
  });
}

在进行视频帧缓存时,我们需要将每一次轮训时的视频资源绘制到承载对象上。如果浏览器支持 OffscreenCanvas 则优先使用该API,否则则降级为通过Canvas画布的方式进行承载。

function createFrame(video, videoWidth, videoHeight) {
  const canvas = window.OffscreenCanvas
    ? new OffscreenCanvas(videoWidth, videoHeight)
    : document.createElement("canvas");
  const context = canvas.getContext("2d");

  canvas.width = videoWidth;
  canvas.height = videoHeight;
  context.drawImage(video, 0, 0, videoWidth, videoHeight);

  return canvas;
}

3. 基于滚动系数渲染缓存的视频帧

当缓存完毕视频帧后,则可以监听scroll事件,在滚动触发时基于计算出的系数与缓存的帧总数相乘,则能求出当前滚动的距离与应该绘制的视频帧。

window.addEventListener("scroll", () => {
  const scrolled =
    document.documentElement.scrollTop /
    (document.documentElement.scrollHeight -
      document.documentElement.clientHeight);
      
  frames = frames.filter((item) => item !== false);

  const frameIndex = parseInt(frames.length * scrolled) + 1;

  if (frames[frameIndex] !== undefined) {
      renderFrame(ctx, frames[frameIndex]);
  }

  document.querySelector(".content").style.transform = `matrix(1, 0, 0, 1, 0, -${document.documentElement.scrollTop})`;
});

function renderFrame(ctx, frame) {
  ctx.clearRect(0, 0, 1600, 1176);
  ctx.drawImage(frame, 0, 0);
}

最终完整版代码 -> DEMO代码

在线预览地址 -> 地址

ps:由于github服务器访问比较慢,需要加载视频资源,可能会花一点点时间

总结

本文通过对苹果官网Ipad mini滚动动画的实现原理进行了探究,手动实现了该动画的核心原理部分,还有很多细枝末梢的部分未进行一一实现,苹果官网在实现的过程中还考虑到了更多性能和兼容性方面的问题,这些细节的地方都值得细细推敲和学习,感兴趣的可以去苹果官网Ipad Mini产品页详细调试查看。

对比苹果官网已有的2种滚动动画的实现可以发现,当滚动动效需要在第一屏就进行显示的场景下时,更推荐使用多图的方式进行实现,如果你的网页第一屏没有动画,当用户滚动到第一屏以外的区域才进行动画展示时,则通过视频的方式实现会更好。

最后,若本文有不足之处还望大佬们评论区指出。