苹果官网动效探索-滚动驱动视频及分区动画执行

lxf2023-03-13 20:15:01

开启AdminJS成长之旅!这是我参与「AdminJS日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

前言

作为数码爱好者+前端开发,每次苹果发布新品我总会第一时间打开苹果官网,除了了解新品信息之外,也很期待苹果又搞出了什么样的web特效。看多了也总结出了规律,就想着自己动手试试,顺便给自己的个人网站的特效升升级

苹果官网动效要点分析

先从一个普通用户的角度来谈谈感受。

这里只针对动画不谈设计,不然篇幅可就多了去了。 相对于平时见到的网站来说,我觉得苹果官网给我的感受就是很不一样,包括对比其他厂商的产品介绍页,到底是为什么会“很不一样”?在我看来除了产品本身好看、展示物料质量高之外,最重要的就是给人的“交互感”很强,仿佛我作为一个用户,在控制产品介绍的进度,节奏,其中又夹杂着许多好看的特效,很舒服。

分析下这种动画“交互感“,我觉得是以下几个点带来的:

  • 可暂停:动画全程并不是自顾自的在循环执行,而是伴随着我的滚动停止,动画也会停止,甚至会有速度变化,我可以很自如的看到动画的某一瞬间。

  • 可倒退:随着滚动回退,动画也会同时会退,这种感觉非常美妙,仿佛我可以控制时空倒流,回顾我错过的部分,特别是当年的airpods pro的结构动画,美妙而震撼人心。

  • 悬停感:当用户快速滚动页面的时候,其实很容易什么也看不清的,获取信息也会觉得疲惫,而动画始终悬停在屏幕中间,执行完成再自动移出视线,也会让人注意力集中、更好的传达产品信息。

放一点滚动动画的示例(gif帧率较低):

苹果官网动效探索-滚动驱动视频及分区动画执行 苹果官网动效探索-滚动驱动视频及分区动画执行

再从技术角度分析一下

先说说悬停的实现。

  • 检查元素后发现大量运用了sticky布局(黏性布局)来实现动画的悬停,sticky布局可以实现某个元素在滚动到距离顶部(当然也可以左右)某个数值的时候不再跟随页面滚动——也就是悬停,直到它接触到他的父级元素的底部,再跟随父级一起正常滚动。具体定义可以网上查询,不再赘述。
  • 另外动画也不能一直悬停的,需要在滚动到某个进度开始,某个进度结束,要避免所有动画都在随着滚动同时执行,如果把每一个动画所在的区域划分开的话,最完美的状态就是每一个区块动画的开始和结束和上一个区块动画相互衔接。所以这里我简单定义一下这种逻辑称为“分区动画执行”。

再分析下动画进退、暂停的实现。

通过对大部分动画的分析,我发现苹果的动画进度控制主要通过三种方式实现:

  • 视频(控制视频进度)
  • 图片类/canvas/svg(通过大量控制切换实现动效进度分割)
  • webGL(了解程度不够,本文不做解析)

分析完毕,编码实现

根据上面分析,本文选择实现以下要点:

1.分区动画

sticky布局+分区执行函数设计,完成分区动画的悬停、触发、衔接功能。

2.滚动驱动视频控制

我了解到的有两种方案:

  1. 直接通过滚动比例映射到视频进度,设置视频的currentTime来控制,这种方案会有严重的卡顿问题。
  2. 常规方案-将视频帧进行缓存,通过控制生成的帧图像切换来控制进度。

最终实现我在这里选择了方案1,想办法去解决卡顿的问题。

不过出于好奇方案2我也尝试完成了一下,关于其中的实现与优劣,可参考我的另一篇文章:《js实现对视频按帧缓存》

代码实现-分区动画

页面布局

首先需要思考页面的布局,可以将每一个分区定义为一个section,这里定了四个分区,如下:

<section id="head" class="titleBox">
</section>
<section id="intro" class="titleBox">
</section>
<section id="tech" class="titleBox">
</section>
<section id="company" class="titleBox">
</section>

每个section内置一个div,sticky布局用于实现悬停的效果

<section id="head" class="titleBox">
    <div style="width: 80%;height: 200px;background-color: #8d6969;position: sticky;top:0;">
    xxxxxx
    </div>
</section>

监听每个分区的起始和结束

通过滚动条的总体距离,结合每个分区的高度,可以计算出当前滚动到了哪个分区,再根据分发到分区的具体动画,并带上这个分区的动画执行到了什么进度的参数。

初始化计算section的一些数据:

data.sections是section的节点id的list,不再赘述获取。

// 计算每个section的动画触发时机,例如head在0.12-0.32之间触发,存入animateMomentInfo
function countAnimateMomentInfo() {
    for (const node of data.sections) {
        data.animateMomentInfo[node.id] = blockAnimateStart(node)
    }
}
/**
 * 根据section的高度,计算它在页面的位置,得出一个section的动画在什么滚动比例触发
 * @param node 节点
 * @returns {{end: number, begin: number}} 开始滚动比例,结束滚动比例
 */
function blockAnimateStart(node) {
    let begin = countScrollRatio(node.offsetTop);// 节点头部距离页面顶部距离,占页面比例,例如xx节点头部在页面20%高度位置
    let end = countScrollRatio(node.offsetTop + node.clientHeight)// 节点底部距离页面顶部距离
    return {begin, end}
}

将滚动距离发送给计算函数:

let top
window.onscroll = (e) => {
    top = document.documentElement.scrollTop;
    activateAnimate(countScrollRatio(top))
}

计算当前位置距离顶部高度占整个页面的百分比,即当前滚动进度/比例:

/**
 * 计算当前位置距离顶部高度占整个页面的百分比,即当前滚动进度/比例
 * @param scrollTop 当前位置
 * @returns {number} 滚动进度0.0000-1.0000
 */
function countScrollRatio(scrollTop) {
    return Number((100 * scrollTop / (data.scrollHeight - data.clientHeight)).toFixed(4))
}

计算出当前滚动进度属于哪个id对应section,并且带上此section的执行进度rate

/**
 *
 * @param rate 当前滚动进度
 */
function activateAnimate(rate) {
    for (let key in data.animateMomentInfo) {
        let {begin, end} = data.animateMomentInfo[key]
        if (rate > begin && rate < end) {
            executeAnimate(key, ((rate - begin) / (end - begin)).toFixed(3))
        }
    }
}

根据上一步计算出id和进度rate,分发到具体的某个section的动画执行函数:

function executeAnimate(id, rate) {
    switch (id) {
        case "head":
            headAnimate(rate)
            break
        case "intro":
            introAnimate(rate)
            break
        case "tech":
            techAnimate(rate)
            break
        case "company":
            companyAnimate(rate)
            break
        default:
            log("no action")
    }
}

也就是说,到了这一步,已经可以实现每个section的动画起始和结束的识别,子节点的悬停效果,以及对应执行进度进度的获取,为了更直观,粗糙展示一下效果:

苹果官网动效探索-滚动驱动视频及分区动画执行

注意这里动画进度的是可以暂停和倒退的,例如你的动画是字体从10px到50px的效果,此时字体随意随着滚动的上下改变大小了。

简单应用下做个小动画:

苹果官网动效探索-滚动驱动视频及分区动画执行 苹果官网动效探索-滚动驱动视频及分区动画执行

代码实现-视频进度控制

直接根据动画执行比例设置视频进度

一开始想的是直接根据上一步获取到的分区动画执行比例,再结合视频的长度,可以计算出当前视频应该播放的位置,再不断设置为当前视频的currentTime就好了 例如这样:

videoNode = document.getElementById("head_video") // 节点
videoLength = Number(videoNode.duration) // 视频长度
videoNode.currentTime = rate*videoLength; //rate是动画进度

但是浏览器自带的scroll监听事件是不平滑的,自带节流,而且用户可能会极快的速度上下来回滚动,所以获取到的滚动进度并不是连续均匀的,根据用户的滚动速度,获取到的一组scrollTop的值可能是:10px,11px,20px,90px,50px,110px,60px,200px这样的非线性数值,也就会导致计算出的比例也是严重跳跃的,最终的问题就是视频会非常卡顿,不可接受,因为设置的视频进度会出现断层。

此时按照常规思路,应该要走帧缓存的方案了,把视频转为图片来处理,但是我这里还是想着,既然滚动的速率不是平滑的,那我能不能自己写一个转换函数,来抹平这种不平滑?

速率平滑转换函数

(这一部分比较抽象,慢慢看)

先简化一下问题,来实现两个函数,一个是测试函数,模拟滚动视频进度的操作,按照指定间隔设置无规律的进度数值。

试着动手实现这个模拟滚动函数: 随机一个无规律的数组(模拟scrollTop值),例如:arr = [5, 7, 10, 2, 9, 12], 间隔arr[i]秒后将aim改为arr[i+1],不断循环直到数组每个元素都被设置一次

注意:stopFlag是判断滚动开始和停止的标志,具体实现不在这里展开

let aim = 0//当前目标进度
// 模拟按间隔n秒改变一个变量的值为n,例如3秒后把aim改为3,5秒后把aim改为5
loopCallMock() {
   let timeArr = [5, 7, 10, 2, 9, 12]
   let timer2 = (arr) => {
       if (!arr.length) {
           log("test over")
           return
       }
       stopFlag = false
       let currStep = arr.shift();
       aim = currStep;
       log(`aim change:${currStep}`)
       setTimeout(() => {
           log(`next loop time:${arr[0]}`)
           timer2(arr)
       }, currStep * 1000)
   }
   timer2(timeArr)
}

另一个就是平滑函数,无论目标值aim是多少,它都应该以相同的速度(其实就是后面的帧率)来执行某个操作,直到达到传入目标值。

// 匀速执行函数
UniformFun() {
    if (stopFlag) {
        clearInterval(timer)
        return
    }
    timer = setInterval(() => {
        if (stopFlag) {
            clearInterval(timer)
        }
        if (currProgress === aim) {
            log("at end aim")
            return
        }
        currProgress < aim ? currProgress++ : currProgress--;
        log(`1 second log this,curr:${curr},aim:${aim}`)
    }, 1000)
}

将转换函数应用到实际场景

上一步的模拟测试函数,也就是headAnimation(rate),其中rate就是改变的aim值;

经过之前分区动画部分的实现,已经可以通过进度比例rate,结合视频长度video.duration计算出当前视频的播放位置。 那么实际headAnimation(rate)的内容就应该是:

let aim = 3;
let timer;
let end;
let currProgress = 0; // 当前视频进度
const frameRate = 30;// 动画帧率,每秒渲染帧数量
const frameSpeedSecond = Number((1 / frameRate).toFixed(4));// 秒,单帧时长,即单次动画执行周期
const frameSpeedMs = frameSpeedSecond * 1000; //frameSpeedSecond 毫秒
const videoNode = document.getElementById("head-video")
let videoLength = 0 // 视频时长
function head2Animate(rate) {
    // 进度动画开始,本次视频目标进度,秒
    aim = (rate * videoLength).toFixed(4)
    /*  每次触发的rate范围不平滑,需要把rate处理平滑再设置动画进度,例如视频进度data.videoNode.currentTime
      只要滑动没有停止,此计时器永远在以frameSpeedMs帧(frameSpeedMs毫秒执行一次,每次进退长度33毫秒)的速率执行视频进度修改
      每次触发headAnimate只是在改变它的执行终点,即aim
      */
    if (!timer) {
        timer = setInterval(() => {
            // 这个计时器本身要具备清除自己的能力
            if (stopFlag) {
                clearInterval(timer)
                return;
            }
            // 根据目标进度与当前进度关系判断前进还是后退
            currProgress < aim ? currProgress += frameSpeedSecond : currProgress -= frameSpeedSecond;
            videoNode.currentTime = currProgress; // 设置视频进度
        }, frameSpeedMs)
    }
}

到这一步,已经实现无论下一刻的视频被滚动到什么进度,都可以保证视频能以指定的帧率跑到目标播放位置,不会卡顿了。

苹果官网动效探索-滚动驱动视频及分区动画执行

结语:

本文只选择了核心的功能点去实现,实际完成还是有很多细节没有展示的,比如两个区块动画的联动、动画开始的时间范围规划、自适应的兼容、部分动画的进度需要加速或者减速等等。

因为是探索性质的项目,重点在于实现过程的思路和思考,所以代码本身或许不够完美,欢迎指教和讨论。