首发于Motion Fun
云音乐十年听歌报告H5中的动效落地实践

云音乐十年听歌报告H5中的动效落地实践

大家好,4月23日是网易云音乐的生日,今年是第十周年,我们也推出了一系列活动庆祝,其中的「十年听歌报告」H5,你它被刷屏了吗?如果你很遗憾的错过了,现在扫码还来得及↓↓↓

或者点击链接前往:

也可以看看录屏:

https://www.zhihu.com/video/1642490080534241280

动效设计

这个H5概念是将用户来到云村后每一年的「重要歌曲」穿在一起,组成一条时光隧道,唤起你灵魂深处的音乐回忆。从封面进入后,标题文案会从屏幕内向外飞来,当用户用手指向上滑动,就可以「穿过」文案看到歌曲信息,每一首歌曲停驻的页面是网易云音乐经典的黑胶播放页面,同样手指上划,就能穿过一首首歌曲,拾取云村和你一路相伴的点点滴滴…



当用户一路滑到最后,可以点击跳转到结果页,是我们为大家精心准备的「十年精选辑」,每个人看到的可能是不同的「专辑封面」。



这里还有个小彩蛋,点击专辑盒子,可以看到它的背面,是专辑的曲目和歌手信息哦。



而以上提到这些动效效果,在这个H5中主要是用CSS来实现的,下面就和大家简单聊聊。

CSS3D

在初期碰需求的时候提到需要有3D场景,技术选型曾经考虑是不是用真·3D的ThreeJS,但分析下来CSS的效果能提供足够的支持了,上ThreeJS有点「杀鸡用牛刀」,而且团队之前用它比较少,需要一定的熟悉成本。

CSS全称层叠样式表,是呈现H5页面的基础技术之一,不能说多强大,但胜在:


  1. 浏览器原生支持,兼容性好,不需引入第三方库;

  2. 对于前端同学来说是基本技能之一,对接成本较低;

  3. 对于设计(动效)同学来说,很多设计软件(Sketch、Figma、Photoshop等)都有标注甚至直接导出CSS属性及切图的能力,前端再基于这些资源编写动画代码也相对比较方便。

而我之前也编写过AE插件可以将在AE中制作的动画直接导出为CSS代码,在某些场景下更简化了这个流程(不过这次没用上)。



(项目地址:github.com/bigxixi/ae2c)

CSS中有个所谓的「3D」属性,但其实它并不能显示我们常说的3D模型、渲染贴图等,而是像AE中的「3D 图层」,是让不同的「平面」在空间中变换,这种效果其实应该算「2.5D」,云音乐之前也有很多用CSS3D做过的项目:



动效的落地都是「戴着脚镣跳舞」,现在脚镣已选好,剩下就看我们怎么优化动效的体验了。

(阅读以下内容可能需要一些前端基础)

搭建场景

我们来看下歌曲数据页面,它的结构大概是这样



搭建这样一个场景,需要注意几点:

  1. HTML中所有需要3D效果的元素的根元素设置了CSS属性 perspective:500 px。这个属性是用于计算透视(镜头畸变)效果的,值越小畸变越大(类似广角镜头的效果),反之越「正」,这里设为500px,你也可以调大调小看看区别。
  2. 每一个需要3D效果的子元素设置了CSS属性 transform-style:preserve-3d;
  3. 位移、旋转、缩放、斜切这几个属性的变化都在CSS属性 transform 中完成,因此如果只需要改变其中一个属性,而保持另一个属性的变化,需要给它「套娃」包一层父元素,再在这个父元素上进行处理;

上边第3点说的有些复杂,我们结合代码看下(假设有5首歌)。

先看HTML结构。
每张「黑胶」其实是一组div,他们之间的间距我们暂时设定为500px,并被设在最外层disc上,然后同为transform属性的旋转动画设在其包含的子div上,再在其内包含其他dom去构建「黑胶」。而所有disc都是被包含在一个discGroup中,整体的穿梭效果也是通过在这层div上设置translateZ来完成,这样就不会影响disc们的间距了。

这就是所谓的「套娃」。

<!--  scroll容器,高度为滑动行程,必须大于屏幕高度才能滑动  -->
<div class="scrollFrame">
<!--  所有需要3D效果的元素的根元素,在这层设置perspective  -->
   <div class="scrollContent">
<!--  所有黑胶盘的父元素  -->
     <div class="discGroup">
<!--  每一组「黑胶」,通过transform:translateZ()拉开距离,这里将间距设置为500px  -->
       <div class="disc" style="transform:translateZ(0px)">
<!--  每一张「黑胶」上的内容,可以是文字、图片或者再包一些复杂结构进来  -->
<!--  这里加了一个旋转动画,由于rotate和translateZ都是通过transform来设置,如果旋转动画加在父元素disc上就会把间距抹平 -->
<!--  所以将旋转动画放在了子元素上,也就是「套娃」 -->
            <div class="rotateAni">
                <div class="text">内容1</div>
            </div>
       </div>
       <div class="disc" style="transform:translateZ(-500px)">
            <div class="rotateAni">
                <div class="text">内容2</div>
            </div>
       </div>
       <div class="disc" style="transform:translateZ(-1000px)">
            <div class="rotateAni">
                <div class="text">内容3</div>
            </div>
       </div>
       <div class="disc" style="transform:translateZ(-1500px)">
            <div class="rotateAni">
                <div class="text">内容4</div>
            </div>
       </div>
       <div class="disc" style="transform:translateZ(-2000px)">
            <div class="rotateAni">
                <div class="text">内容5</div>
            </div>
       </div>
     </div>
  </div>
</div>

CSS样式如下。
可以看到scrollFrame这个class的height属性我们设置了一个很大的值,这就是滑动的「行程」,为了确保划到底部时正好的最后一张「黑胶」这个数值还需要根据不同机型做适配,这里为演示设置为500*5=2500px。

body{
  padding:0;
  margin:0;
}
.scrollFrame{
  width:100vw;
/* 高度为滑动行程 */
  height:2500px;
}
.scrollContent{
  width:100vw;
  height:100vh;
/*  定位设置为fixed,否则会随滑动上移 */
  position:fixed;
/*  设置perspective */ 
  perspective:500px;
}
.discGroup{
  transform-style:preserve-3d;
}
.disc{
/*  居中 */  
  left:calc((100vw - 100px)/2);
  top:calc((100vh - 100px)/2);
  width:100px;
  height:100px;
  position:absolute;
  transform-style:preserve-3d;
  background:rgba(0,128,255,0.9);
  border-radius:50%;
  border:1px solid rgb(255,128,128);
}
.rotateAni{
  width:100%;
  height:100%;
  animation:rotateAniKey 10s infinite linear;
}
@keyframes rotateAniKey{
  0%{
    transform:rotate(0)
  }
  100%{
    transform:rotate(360deg)
  }
}
.text{
  color:white;
  font-size:20px;
  text-align: center;
  transform:translateY(35px);
}

另外比较重要的一点是scrollContent这个div的position要设置为fixed以保证其固定于浏览器viewPort,不然「黑胶」们会在Y轴上也有滑动效果。

滑动交互

这个页面最核心的交互效果是手指滑动屏幕「驱动」黑胶在垂直屏幕方向的移动。



我们很自然的会想到当页面内容超出屏幕范围时手指滑动页面产生的scroll效果。




scroll默认只有x和y方向,而垂直屏幕的是z方向,所以我们需要做一些转换:

  1. 监听window元素的scroll事件
  2. 获取垂直方向的滚动距离window.scrollY
  3. 将黑胶(组)的translateZ设为scrollY的值

这部分用JavaScript来完成:

// 获取discGroup元素
const discGroup = document.querySelector(".discGroup");
// 监听scroll事件,并计算discGroup的translateZ值
window.addEventListener("scroll",()=>{
    let scrollDistance = window.scrollY;
    discGroup.style.transform =`translateZ(${scrollDistance}px)`;
})

点击体验完整demo↓↓↓

codepen.io/bigxixi/pen/

这个方案完全利用了浏览器的scroll实现,简单直接也丝滑,每次滑动松手后也不是直接停止而是模拟了阻尼效果缓缓停下。

但经过一些测试,我们发现滑动的停止位置是有一定随机性的,比如会出现下图这种情况:



而且我们希望用户尽量在每一首歌前都听一段时间,而不是滑太快跳过,毕竟真正唤起用户尘封的记忆的是音乐本身。

也就是说,在滑动的同时需要有个「吸附」效果,松开手后自动滑动到展示当前歌曲封面的最佳位置,并稍微阻挡一下继续下滑的势头。

研究了一下scroll,没找到很好的方案来实现,那么我们换条路试试?

滑动改进

移动端手指与屏幕的交互会触发很多「事件」,scroll只是其中一种,其实在这类有滑动交互的项目中被用到更多的还是touch事件,它包括当手指触屏的屏幕瞬间触发的「touchstart」,手指在屏幕移动时触发的「touchmove」,以及离开屏幕瞬间的「touchend」等,这些事件触发时我们都能获取到手指在屏幕上的坐标,那么我们是不是可以用这些坐标数据直接计算并转换成元素在Z轴的运动呢?

主要思路如下:

  1. 触发touchstart时获取触点screenY值 scrollYStart ;
  2. 同样在touchstart事件中,用上一次松手时的z方向位移距离scrollYFixed来初始化touchmove过程中z方向实际位移的距离scrollYFixedStart
  3. 每次触发touchmove,获取触点screenY值,并计算其与touchstart时获取的scrollYStart 的差值deltaY,正值向前滑,负值后滑;
  4. 同样在touchmove事件中,计算z方向位移距离scrollYFixed = scrollYFixedStart + deltaY,并以之设置 discGroup 的 translateZ;
  5. touchmove事件中,记录手指离开屏幕时的scrollYFixed,留待下一次touchstart事件时调用;

这过程说起来可能有点绕,我们看下代码实现就清楚了。

首先scrollFrame的高度不需要那么高了,可以设成100vh:

.scrollFrame{
  width:100vw;
  height:100vh;
}

html结构不变,主要功能在JavaScript实现:

const discGroup = document.querySelector(".discGroup");
const discCount = 5;//disc内容组数
const gapDist = 500;//每组disc内容距离
let deltaY = 0;//用以计算触摸滑动过程中Y方向变化了多少
let scrollYStart = 0;//触发touchstart时记录触点screenY值
let scrollYFixed = 0;//松手后记录当前位移距离,并被下次触摸时提供初始距离
let scrollYFixedStart = 0;//touchmove过程中实际位移的距离

const handleScrollStart = (e) => {
    // 触发touchstart时记录触点screenY值
    scrollYStart = e.touches[0].screenY;
    scrollYFixedStart = scrollYFixed;
};
const handleScrollMove = (e) => {
    // 手指滑动过程中计算距离初始点Y滑动的距离
    deltaY = scrollYStart - e.touches[0].screenY;
    // 计算实际滑动距离
    scrollYFixed = scrollYFixedStart + deltaY;
    discGroup.style = transform:translateZ(${scrollYFixed}px);
};
const handleScrollEnd = () => {
    scrollYFixed = scrollYFixedStart + deltaY;
};
document.addEventListener('touchstart', handleScrollStart, { passive: true });
document.addEventListener('touchmove', handleScrollMove, { passive: true });
document.addEventListener('touchend', handleScrollEnd, { passive: true });

点击体验完整demo↓↓↓

codepen.io/bigxixi/pen/

现在滑动是能滑了,但效果非常生硬,松手瞬间就停住了,而且也没实现松开手后自动滑动到展示当前歌曲封面的最佳位置的需求,所以我们需要继续改进。

首先我们要判断一下当前是哪张「黑胶」,即歌曲序号,可以通过在touchmove事件中实时计算滑动距离 ÷ 间距再取整+1得出;

有了当前歌曲序号,那当前歌曲的最佳展示位就是discGroup的translateZ位于序号 × gapDist的位置,我们要做的就是松手瞬间播放一个从当前位置位移到最佳位置的动画。

curPos = Math.floor(scrollYFixed / gapDist) + 1;

这个位移动画我们可以用简单的CSS Transition来完成,当然也可以引入一些第三方动画库做更精细的优化。

discGroup.style = `transition:transform 0.4s cubic-bezier(0,0,0.3,1);
                   transform:translateZ(${endScrollToPos}px)`;

另外滑动方向也需要判定一下,如果是往回划,那就要回到上一首歌的最佳展示位置。

scrollYBack = deltaY < 0;

很多操作是发生在松手后,所以需要在handleScrollEnd()中判断和处理动画。

我们把JavaScript部分改为:

const discGroup = document.querySelector(".discGroup");

const discCount = 5;//disc内容组数
const gapDist = 500;//每组disc内容距离

let deltaY = 0;//用以计算触摸滑动过程中Y方向变化了多少
let scrollYStart = 0;//触发touchstart时记录触点screenY值
let scrollYFixed = 0;//松手后记录当前位移距离,并被下次触摸时提供初始距离
let scrollYFixedStart = 0;//touchmove过程中实际位移的距离
let scrollYBack = false;//是否往回滑动
let curPos = 1;

const handleScrollStart = (e) => {
    // 触发touchstart时记录触点screenY值
    scrollYStart = e.touches[0].screenY;
    scrollYFixedStart = scrollYFixed;
};
const handleScrollMove = (e) => {
    // 手指滑动过程中计算距离初始点Y滑动的距离
    deltaY = scrollYStart - e.touches[0].screenY;
    // 判断是滑向下一页(手指上划)还是上一页(手指下划)
    scrollYBack = deltaY < 0;
    scrollYFixed = scrollYFixedStart + deltaY;
    discGroup.style = `transform:translateZ(${scrollYFixed}px)`;
    //计算当前内容索引(第几张disc)
    curPos = Math.floor(scrollYFixed / gapDist) + 1;

};
const handleScrollEnd = () => {
    // 手指离开屏幕,开始播放动画「吸附」到对应disc
    let endScrollToPos = 0;
    // 判断滑动方向
    if (scrollYBack) {
        endScrollToPos = scrollYFixed < (curPos) * gapDist ? (curPos - 1) * gapDist : curPos * gapDist;
    } else {
        endScrollToPos = scrollYFixed > (curPos) * gapDist ? (curPos + 1) * gapDist : curPos * gapDist;
    }

    if ((scrollYFixed >= 0) && (scrollYFixed < (discCount - 1) * gapDist)) {
        // 若滑动距离在第一张和最后一张disc范围内,根据滑动方向滑到对应位置
        discGroup.style = `transition:transform 0.4s cubic-bezier(0,0,0.3,1);
                           transform:translateZ(${endScrollToPos}px)`;

       scrollYFixed = endScrollToPos;
    } else if (scrollYFixed < 0) {

        // 当松手时滑动距离小于0,松手后滑到第一张disc
        discGroup.style = `transition:transform 0.4s cubic-bezier(0,0,0.3,1);
                            transform:translateZ(0px)`;
        scrollYFixed = 0;

        setTimeout(() => {
            alert("已是第一页")
        }, 300);

    } else {
        // 当处在最后一张disc位置并滑动
        if (scrollYBack) {
            // 若是往回滑,回到倒数第二张
            discGroup.style = `transition:transform 0.4s cubic-bezier(0,0,0.3,1);
                                transform:translateZ(${(discCount - 2) * gapDist}px)`;
            scrollYFixed = (discCount - 2) * gapDist;
        } else {
            // 若是继续向前,就滑到下一页
            discGroup.style = `transition:transform 0.4s cubic-bezier(0,0,0.3,1);
                                transform:translateZ(${(discCount - 1) * gapDist}px)`;
            scrollYFixed = (discCount - 1) * gapDist;

            setTimeout(() => {
                alert("前往下一页")
            }, 300);
            
        }
    }
};


document.addEventListener('touchstart', handleScrollStart, { passive: true });
document.addEventListener('touchmove', handleScrollMove, { passive: true });
document.addEventListener('touchend', handleScrollEnd, { passive: true });

点击体验完整demo↓↓↓

当然这个demo还有很多改进的空间,例如滑动时的速度优化,松手后的滑动细节,力度反馈等等,本文仅提供一个思路,限于篇幅就不深入展开了。

其它动效

除了数据页的滑动动效,还有很多地方的动效也使用了CSS技术落地,例如背景这些飞散的元素:



结果页的黑胶盒子也是:



最后再分享一些我学习CSS3D过程中写的简单demo,希望以后项目中可以用上~

位移、旋转、缩放、透视

吸附效果、陀螺仪控制(iOS需要弹窗确认)

方块时钟

发布于 2023-05-18 11:37・IP 属地浙江