好久不写文章了,感觉也不太好,那就来水一篇吧!!啊哈哈~~~ 本文教你如何快速从 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 还请见谅
最终效果图: