苹果官网鼠标滚动仿影片动效实现

10,590 阅读5分钟

每次浏览苹果官网产品详情页的时候,相信很多小伙胖都和我一样,会发现界面与人机交互磨合得如此丝滑与赏心悦目。出于职业的惯性喜好,所以每当苹果发布新产品的时候,我都马上会体验一翻苹果前端开发工程师给大家带来精雕细琢的产品介绍页,去感受苹果是如何通过动画来极力还原和体验产品的极致效果。

这次我们的主角是AirPods Pro界面中页面随鼠标滚动而像播放影片这样的效果。大家可以访问www.apple.com.cn/airpods-pro 这个地址去体验这种效果。

方案分析

在AirPods Pro这个页面,我们滚动鼠标,页面随着鼠标滚动的节奏快慢而展示当前滚动条进度的图像;当用鼠标拖动滚动条直接滚动某一个进度时,界面也会直接快速过渡到当前进度的画面,仿佛让用户觉得自己在用鼠标来控制着影片的播放,让人产生极强的操控感和体验感。

问题来了,这真的是用户在操纵影片的播放进度吗?

视频实现: 鼠标控制Video的进度,JS是可以实现的,通过监听鼠标滚动的距离来动态的控制Video对象的currentTime属性(当前播放时间),但是这种方案从性能上来看很明显是不切实际的,因为首先视频得要预加载,这需要用户等待时间;其次,有些浏览器会默认记录之前访问离开页面时的浏览进度,即离开时页面滚动条的进度,若用户再次访问时会跳转到之前浏览过的进度,视频由于需要加载显然是不能马上就加载到当前进度对应的图像而显示的。所以,这种方案不适合。

Canvas实现: 复杂动画实现,Canvas无疑是首选。该方案思路是通过Canvas画布的drawImage方法来动态显示当前进度的图像,滚动条从某一位置移动,计算当前进度需要显示的图像索引,然后通过requestAnimationFrame方法来将始末位置索引的图片进行每一帧的过渡显示,从而达到鼠标滚动控制动效果。

代码实现

首先我们要先准备好图片素材。访问上面的地址,打开浏览器控制台,查看图片的加载并获取图片链接,如下图:

image.png 可以看到,官网上加载了仿影片的每一帧图片并且文件名按顺序命名好。本文章中就选取其中一段效果来实现我们的例子。 先定义好Canvas画布的HTML结构:

const ScrollPlayer: React.FC = props => {
    const width = 1458;
    const height = 820;
    const canvasId = 'scroll-player';
    
    return (
        <div className="scroll-player-container">
          <div className="scroll-sequence">
            <div className="image-sequence">
              <div className="canvas-container">
                <canvas id={canvasId} width={width} height={height} style={{background: '#000'}}></canvas>
              </div>
            </div>
          </div>
        </div>
      )
}

设置样式:

.scroll-player-container {
  height: 300vh;
  width: 100%;
  position: relative;
}

.scroll-sequence {
  position: absolute;
  top: 0;
  width: 100%;
  height: 100%;
}

.image-sequence {
  position: sticky;
  top: 0;
  overflow: hidden;
}

.canvas-container {
  position: relative;
  width: 100%;
  height: 100vh;
}

然后将所有需要显示的每一帧图片放到数组中,如下:

import numeral from 'numeral';
const imagesLength = 128; // 图片总数量
let images: any[] = []; // 图片路径数组集合
const baseUrl = 'https://www.apple.com.cn/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/02-head-bob-turn/';
for(let i = 0; i < imagesLength; i++) {
    images.push(baseUrl + numeral(i).format('0000') + '.jpg')
}

Canvas初始化和图片加载:

let imagesManager: any[] = [];
let canvas: HTMLCanvasElement;
let context: CanvasRenderingContext2D;
// 加载图片
function loadImages() {
    const loadNextImage = (src: string) => {
          const img = new Image();
          // 这里是同步加载图片,有性能问题,可优化为异步
          img.onload = (e) => {
                imagesManager.push(img)
                if(imagesManager.length === imagesLength) {
                    // 所有图片加载完成后回调方法
                    imagesLoadComplete()
                }
          }
          img.src = src;
          if(images.length === 0) return;
          loadNextImage(images.shift())
    }
    loadNextImage(images.shift());
}

// 初始化
function init():void {
    canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    context = canvas.getContext('2d') as CanvasRenderingContext2D;
    // 加入scroll事件监听
    document.addEventListener('scroll', handleScroll)
    // 执行加载每一帧的所有图片
    loadImages();
}

useEffect(() => {
    init()
    return () => {
          document.removeEventListener('scroll', handleScroll)
    }
})

图片加载完成后的操作,执行requestAnimationFrame进行画图:

let scrollIndex = 0; // 当前滚动进度待显示的图片索引值
let currentIndex = 0; // 当前显示的图片索引值
let raf = null;
// 图片加载完成回调
function imagesLoadComplete(): void{
    console.log('images all loaded!')
    run()
}
function run():void {
    raf = window.requestAnimationFrame(draw)
}

通过draw方法进行边界处理,同时处理画布加载显示当前索引的图片。边界其实就是判断滚动条的滚动方向来处理currentIndex自增或者自减。

// 
function draw():void {
    if(currentIndex <= scrollIndex) {
      drawImages(imagesManager[currentIndex])
      currentIndex + 1 < scrollIndex && currentIndex++;
    } else if(currentIndex >= scrollIndex){
      drawImages(imagesManager[currentIndex])
      currentIndex - 1 > scrollIndex && currentIndex--;
    }
    raf = window.requestAnimationFrame(draw)
}

// 画布画图
function drawImages(img: CanvasImageSource):void 
    context.clearRect(0, 0, width, height);
    context.drawImage(img, 0, 0);
}

实际控制动效的因子是currentIndexscrollIndex的关系,计算出当前滚动条进度对应的scrollIndex,然后调用requestAnimationFrame来将currentIndex自增或者自减至scrollIndex,这个自增或者自减过程,就实现了仿影片效果。所以,我们得要计算出scrollIndex的值:

// 鼠标滚动事件回调,计算出scrollIndex
function handleScroll() {
    const docElement = document.documentElement;
    const scrollHeight = docElement.scrollHeight;
    const clientHeight = docElement.clientHeight;
    const scrollTop = docElement.scrollTop;
    // 根据滚动距离,等比例算出应该滚动到第几张图
    scrollIndex = Math.round(scrollTop * imagesLength / (scrollHeight - clientHeight));
}

至此,我们就完成了简单的仿影片动画效果。如下图所示

仿影片动效.gif

在线预览 code.juejin.cn/pen/7148329…

总结

本文主要对苹果官网AirPods Pro产品介绍页中的仿影片效果作一个简单的例子实现。苹果具体的技术实现会更加复杂以及考虑的因素比较全面,但原理上差不多。技术要点总结为:

  • scroll事件监听回调中计算出scrollIndex的值
  • requestAnimationFrame处理currentIndexscrollIndex的值
  • 将自增或自减过程通过CanvasdrawImage方法来加载显示图片,实现动画效果

文中内容如有错误或纰漏,望请指正!有兴趣的同学可以在评论区一起交流、讨论,互相学习~

注:文章所有图片素材皆源自苹果官网原网站图片素材,图片素材版权归苹果所有,此处只作文章例子演示所用,不作他用!