上班摸鱼时写了个 b 站 banner 的鼠标跟随动画效果

好久不写文章了,感觉也不太好,那就来水一篇吧!!啊哈哈~~~ 本文教你如何快速从 b 站抠出一个完整的动态 banner 出来!源码和素材在文章最后。

在 bilibili 主站 PC 端,时不时会看到其顶部横幅栏是一个随鼠标变化的动画:

平时我们自己在开发官网的时候,也能用的上,这里就记录一下他的实现方式。

b 站在 safari(16) 下并没有实现这个动效,不知道是不是针对国内 safari 用户的营销策略不同还是动画库的兼容性问题?

分析

它本质上也是一种视差效果,不了解的可以看我这一篇官网滚动动画。监听鼠标移动的相对位置,计算滑动区域各个元素的偏移量和移动速度,从而达到视差动画的效果。

搜集素材

F12 大法打开,看到是一个叫 animated-banner 的 div

这篇文章不知不觉拖到春天了,官方的图片都换了😂

初始化的时候,用了一堆的div绝对定位在里边的,计算好相对位置即可:

为了达成这个效果,我把这些个图片都下载到本地,这样素材就收集完了。

一共23张图片,一个视频

堆砌元素

我们把上面的结构复制一份到自己的 html中,并将图片和视频的链接改为自己本地的应用:

拷贝官网的好处是,初始化的相对位置都计算好了,可以看到下面的 style 中有初始化的 transform

html 复制代码
<div class="animated-banner">
  <div class="layer"><img src="1.webp"
      data-height="187" data-width="2000" height="187" width="2000"
      style="height: 187px; width: 2000px; transform: translate(0px, 0px) rotate(0deg) scale(1); opacity: 1;"></div>
  <div class="layer"><img src="2.webp"
      data-height="187" data-width="2000" height="187" width="2000"
      style="height: 187px; width: 2000px; transform: translate(0px, 0px) rotate(0deg) scale(1); opacity: 1;"></div>
  <div class="layer"><img src="3.webp"
      data-height="187" data-width="2000" height="224" width="2400"
      style="height: 224.4px; width: 2400px; transform: translate(300px, 24px) rotate(0deg) scale(1); opacity: 1;"></div>
  <div class="layer"><video loop muted src="1.webm" playsinline="" width="180" height="100"
      style="object-fit: cover; height: 100px; width: 180px; transform: translate(-245px, 15px) rotate(0deg) scale(1); opacity: 1;"
      data-height="100" data-width="180"></video></div>
  ...
 </div>

注意这里各个 layer 的先后顺序不能乱,否则层级关系就会有问题。在下面的 css 中也可以通过设置 zIndex 来控制。

此时界面:

实现布局

把官网的 css 也借鉴过来吧:

css 复制代码
body * {
  margin: 0;
  padding: 0;
}

.animated-banner {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  overflow: hidden;
  min-width: 1000px;
  min-height: 155px;
  height: 9.375vw;
}

.animated-banner>.layer {
  position: absolute;
  left: 0;
  top: 0;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

嘿,效果出来了:

实现动效

像这种监听鼠标移动的,css 就显得无力了,我们另外写一个 js 脚本来控制元素位移。

video 元素处理

首先让我们的 video 动起来:

js 复制代码
(function() {
  const video = document.querySelector('video');
  video.play();
})();

当然你也可以用 autoplay。这里为了防止一些浏览器的安全策略:没有用户交互的时候,多媒体自动播放会被拦截,在 Chrome 66 以后,要给 video 加上 muted 静音属性才能自动播放。

效果:

监听鼠标移动

我们从使用上来黑盒分析一下。

  • 鼠标移动到 banner 上后开始监听鼠标偏移量
  • 根据鼠标的偏移量,计算各个元素的位置
  • 鼠标移出 banner 后,还原初始位置

设置滑入/滑出监听

按照这个思路,我们先来写鼠标滑入/滑出的监听:

js 复制代码
const banner = document.querySelector('.animated-banner');
banner.addEventListener('mouseenter', function(event) {
  event.stopPropagation();
  // 开始监听偏移量
  startListener();
})
banner.addEventListener('mouseleave', function(event) {
  event.stopPropagation();
  // 还原
  clearListener();
})

mouseleave 和 mouseout 是相似的,但是两者的不同在于 mouseleave 不会冒泡而 mouseout 会冒泡。 这意味着当指针离开元素及其所有后代时,会触发 mouseleave ,而当指针离开元素或离开元素的后代(即使指针仍在元素内)时,会触发 mouseout 。

计算鼠标与元素的相对位置

那么,鼠标的偏移量要怎么计算呢?这里其实算的是鼠标距离 banner 这个元素的相对位置。

然后我们来监听鼠标滑动:

js 复制代码
function startListener() {
  banner.addEventListener('mousemove', function(event) {
    event.stopPropagation()
    console.log(event)
  })
}

这样就可获取到当前鼠标的位置。我们注意到,鼠标上下垂直移动的时候,banner 是不会出现动画的,左右移动(x坐标变化)时,才播放动画,因此,我们就关注一下鼠标事件与 x 坐标相关的返回参数:

  • clientX:

距离当前body可视区域的 x 坐标,以目标元素的左上角为原点,向右为正方向。所以有横滚条后可能会不准确

  • pageX:

距离当前body的 x 坐标,以body元素的左上角为原点,向右为正方向。有横滚条后数据仍然是撑开的宽度

  • movementX:

表示自上次鼠标移动事件以来,鼠标指针在水平方向上移动的距离。

  • screenX:

表示鼠标指针相对于整个屏幕(浏览器以外也算)的水平坐标位置。 以屏幕左上角为原点,向右为正方向。

  • offsetX:

相对于上一级带有定位的父盒子最左边的x坐标

  • x:

相对于触发事件的元素在可视区域内的最左边的相对距离(包含边框)


从应用场景来看,背景偏移量是根据鼠标位移距离有关的,似乎没有哪一个单一的属性满足条件,我们就组合一下使用。设置 body 为 position: relative;,然后:

js 复制代码
const bannerLeft = banner.offsetLeft;
const mouseLeft = event.pageX - bannerLeft;

这样,mouseLeft 就是当前鼠标相对于 banner 元素左侧的距离了。

计算一个元素的偏移量

假设鼠标从最左边开始,位置是 0,往右侧移动,我们总结一下官网看到的效果:

  • 天上的云:往上浮动
  • 背景的花:往右移动
  • 猫猫:向右移动并拉长一些,眼睛是先往右再往左再回来

看到官网是这么加的样式:

我们也直接添加 style。先看天上的云,他是根据鼠标向右偏移量的多少,往上的幅度做出相应的改变,我们自己定义一个比例换算就行(假设云最大幅度 10px),计算规则:

  • 记录鼠标第一次进入 banner 区域的 x 方向坐标,标记 x0,以这个点为坐标原点,那么鼠标偏移量就是:鼠标x位置 - x0
  • 使用公式计算

<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> ( 鼠标 x 位置 − x 0 ) / b a n n e r 宽度 = ? / 10 p x (鼠标x位置 - x0)/banner宽度 = ?/10px </math>(鼠标x位置−x0)/banner宽度=?/10px

用代码来计算 ?的值:

js 复制代码
// 当前 banner 的宽度
const bannerWidth = banner.offsetWidth;

let initMouseLeft = 0;

function calcutedPosition(mouseLeft) {
  // 运动方向是反的,就取反就行
  return -(mouseLeft  - initMouseLeft) * 10 / bannerWidth;
}

banner.addEventListener('mouseenter', function (event) {
  event.stopPropagation();
  // 计算初始鼠标 x 位置 ✅ (mouseenter 在进入元素及其子元素时,只触发一次)
  initMouseLeft = event.pageX - bannerLeft;
  // 开始监听偏移量
  startListener();
})

// 云的垂直位置
function startListener() {
  banner.addEventListener('mousemove', function(event) {
    event.stopPropagation()
    const mouseLeft = event.pageX - bannerLeft;
    const cloudY = calcutedPosition(mouseLeft);
    const secondElement = banner.querySelector('.layer:nth-child(2)').querySelector('img');
    secondElement.style = `height: 187px; width: 2000px; transform: translate(0px, ${cloudY}px) rotate(0deg) scale(1); opacity: 1;`;
  })
}

还有一个问题,鼠标离开 banner 区域的时候,应将 translate 还原。我们看官网的例子,他也是直接改变 style 里的 translate,慢慢渐进还原的,并不是一下跳变的,其实就是设置一个定时器,比如 200毫秒,渐进改变样式:

js 复制代码
function clearListener() {
  const secondElement = banner.querySelector('.layer:nth-child(2)').querySelector('img');
  const mouseLeft = event.pageX - bannerLeft;
  const cloudY = calcutedPosition(mouseLeft);
  let startValue = cloudY;
  let endValue = 0;
  let duration = 200; // 总时间,单位为毫秒
  let interval = 50; // 每次更新的间隔时间,单位为毫秒
  let steps = duration / interval; // 总步数
  let stepValue = (startValue - endValue) / steps; // 每一步的值变化量

  let currentValue = startValue;
  
  // 设置一个定时器,不停的加减步长,直到最后一步时,直接等於最终值即可
  let timer = setInterval(() => {
    currentValue -= stepValue;
    secondElement.style = `height: 187px; width: 2000px; transform: translate(0px, ${currentValue}px) rotate(0deg) scale(1); opacity: 1;`;
    if (Math.abs(currentValue - endValue) < Math.abs(stepValue)) {
      clearInterval(timer);
      secondElement.style = `height: 187px; width: 2000px; transform: translate(0px, ${endValue}px) rotate(0deg) scale(1); opacity: 1;`;
    }
  }, interval);
}

最后我们看看效果(gif有点掉帧😂):

由于代码里没有用到任何第三方库,所以都是基于等比例的线性渐变的。你也可以使用js 动画库(比如 gsap)来实现更复杂的过渡动画。

完成封装

由于banner里元素很多,我们不能每一个都写一套上面的代码,所以要做数据封装:

  • 定义一个map,关联各个元素初始样式、当前样式、最大偏移量、偏移方向和dom元素等
js 复制代码
  const styleMap = {
    0: {
      initialStyle: {
        height: '187px',
        width: '2000px',
        translateX: 0,
        translateY: 0,
        rotate: 0,
        scale: 1,
        opacity: 1
      },
      style: {
        direction: 'x',
        scale: 400
      },
      element: banner.querySelector('.layer:nth-child(1)').querySelector('img')
    },
    ...
}

// 遍历将每一个元素的初始样式赋值
const init = () => {
    Object.keys(styleMap).forEach(item => {
      const current = styleMap[item];
      const initStyle = current.initialStyle;
      current.element.style = `height: ${initStyle.height}; width: ${initStyle.width}; transform: translate(${initStyle.translateX}px, ${initStyle.translateY}px) rotate(${initStyle.rotate}deg) scale(${initStyle.scale}); opacity: ${initStyle.opacity}; object-fit: ${initStyle.objectFit}`;
    });
}
  • 将各个样式的偏移量写入map,并在事件中动态修改
js 复制代码
function calcutedPosition(mouseLeft, scale) {
  // 这里计算偏移量,多了一个减去这个元素初始偏移的步骤,scale 表示这个元素最大移动尺度
  return -(mouseLeft - initMouseLeft) * scale / bannerWidth;
}

banner.addEventListener('mousemove', function (event) {
    event.stopPropagation()
    const mouseLeft = event.pageX - bannerLeft;
    Object.keys(styleMap).forEach(item => {
      const current = styleMap[item];
      // 需要偏移的元素
      if (current.style) {
        const initStyle = current.initialStyle;
        const style = current.style;
        const element = current.element;
        // 计算偏移
        const offset = calcutedPosition(mouseLeft, style.scale);
        let styleResult = `height: ${initStyle.height}; width: ${initStyle.width}; opacity: ${initStyle.opacity}; object-fit: ${initStyle.objectFit};`;

        // 这里设置比较粗糙,设计复杂动画的时候,需要重构
        if (style.direction === 'y') {
          styleResult += `transform: translate(${initStyle.translateX}px, ${initStyle.translateY - offset}px) rotate(${initStyle.rotate}deg) scale(${initStyle.scale});`
        } else {
          styleResult += `transform: translate(${initStyle.translateX - offset}px, ${initStyle.translateY}px) rotate(${initStyle.rotate}deg) scale(${initStyle.scale});`
        }
        element.style = styleResult;
      }
    });
  })
  • 提取全局变量
js 复制代码
const banner = document.querySelector('.animated-banner');
const bannerLeft = banner.offsetLeft;
const bannerWidth = banner.offsetWidth;
let initMouseLeft = 0;
  • 计算函数单独封装
js 复制代码
const init = () => {}
const playVideo = () => {}
const touchListener = () => {}

window.onload = function () {
  init();
  playVideo();
  touchListener();
}

完整的素材与源码见 banner-mouse-animation

代码写的匆忙,有 bug 还请见谅

最终效果图:

相关推荐
小曲曲1 小时前
接口上传视频和oss直传视频到阿里云组件
javascript·阿里云·音视频
学不会•2 小时前
css数据不固定情况下,循环加不同背景颜色
前端·javascript·html
EasyNTS3 小时前
H.264/H.265播放器EasyPlayer.js视频流媒体播放器关于websocket1006的异常断连
javascript·h.265·h.264
活宝小娜4 小时前
vue不刷新浏览器更新页面的方法
前端·javascript·vue.js
程序视点4 小时前
【Vue3新工具】Pinia.js:提升开发效率,更轻量、更高效的状态管理方案!
前端·javascript·vue.js·typescript·vue·ecmascript
coldriversnow4 小时前
在Vue中,vue document.onkeydown 无效
前端·javascript·vue.js
我开心就好o4 小时前
uniapp点左上角返回键, 重复来回跳转的问题 解决方案
前端·javascript·uni-app
刚刚好ā5 小时前
js作用域超全介绍--全局作用域、局部作用、块级作用域
前端·javascript·vue.js·vue
yqcoder7 小时前
reactflow 中 useNodesState 模块作用
开发语言·前端·javascript
会发光的猪。7 小时前
css使用弹性盒,让每个子元素平均等分父元素的4/1大小
前端·javascript·vue.js