谁会拒绝一款酷炫流畅的辉光卡片动画呢?

  最近在浏览掘金网站的时候发现新上线的扣子平台,它是一款无需代码,即刻开发新一代AI Chat Bot的应用编辑平台,可以通过这个平台快速创建各种类型的Chat Bot;不过让笔者更感兴趣的是它首页酷炫的卡片辉光效果是如何实现的,本文就来探讨一下它的实现过程。

  我们看下动画效果如下:

辉光效果

  如果我们打开控制台去看元素上面的属性,我们会发现,这样的效果其实主要是两种动画效果叠加:卡片3D旋转和后面的辉光层的效果,我们逐一来看下实现的逻辑。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本文的实现代码实现基于Vue3,对Vue3语法不了解的可以先看一下这篇文章;想看最终的实现效果的小伙伴可以直接戳这里查看

鼠标属性的区别

  在实现效果之前,我们先来回顾一下,我们在打印鼠标事件的时候,经常会看到事件对象event上面有各种属性,offsetX、clientX、pageX和screenX等等,那么这些属性有什么区别呢?我们在使用时到底该用哪个属性呢?

为了简化流程,下面主要介绍X轴方向上的属性,Y轴方向属性同理。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

offsetX

  我们按照从小范围到大范围的原则,先来看下offsetX属性,根据MDN上的介绍,只读属性offsetX规定了事件对象与目标节点的内填充边(padding edge)在X轴方向上的偏移量;从offset单词也能看出,它是相对于目标div的偏移量,我们用图形通俗的表示如下:

offsetX

clientX

  然后是clientX,client有客户端的意思,因此它表示事件发生时的浏览器客户端区域的水平坐标,是相对于浏览器窗口的边距,会随页面滚动而改变,用图形表示如下:

clientX

注:clientY是不包含浏览器的书签、地址栏和标签栏等的高度。

pageX

  再是pageX属性,它是相对于整个文档的水平坐标,不随着页面滚动而改变;它和上面clientX看似是相同的,大多数情况下它们的值也是相同的;但pageX会考虑页面的水平方向上的滚动,因此有下面的公式:

ev.pageX = ev.clientX + window.scrollX

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  通过图形我们也能看出,当文档大小超出浏览器区域时,pageX明显是要大于clientX的值:

pageX

screenX

  最后是screenX,表示范围是最大的,使用频率也是最低的,表示距离电脑屏幕边缘的水平坐标偏移量:

screenX

  除此之外,还有一个属性是movementX,它提供了当前鼠标移动事件上一次鼠标移动事件之间鼠标在水平方向的移动值;在处理移动元素等场景时就不需要我们记录上次的位置数据了,因此它的计算公式如下:

currentEvent.movementX = currentEvent.screenX - previousEvent.screenX

卡片3D旋转

  首先我们来看下第一个实现效果,首先是让卡片实现3D的旋转效果,我们先在页面上将卡片的布局排列好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<template>
<div class="clip-box">
<div class="title">如何实现一款酷炫流畅的辉光卡片动画?</div>
<div class="clip-cont">
<div class="card card1">Card1</div>
<div class="card card2">Card2</div>
<div class="card card3">Card3</div>
<div class="card card4">Card4</div>
<div class="card card5">Card5</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.clip-cont {
width: 1204px;
position: relative;
}
.card {
background: #161616;
position: absolute;
}
.card1 {
width: 434px;
height: 450px;
}
.card2 {
width: 434px;
height: 290px;
top: 460px;
}
.card3 {
width: 760px;
height: 290px;
left: 444px;
}
.card4 {
width: 375px;
height: 450px;
left: 444px;
top: 300px;
}
.card5 {
width: 375px;
height: 450px;
left: 829px;
top: 300px;
}
</style>

  我们给每个卡片设置一个固定的宽高和位置,使用绝对布局让其堆叠排列;

布局排列

  接下来,我们就需要在dom节点上监听鼠标事件后对5个卡片进行样式的修改;但是这里,如果写五遍监听函数显然是不合适的;想要在多个节点元素上复用相同的逻辑,可以把这个逻辑以组合式函数的形式提取,我们单独新建一个useMouse.js文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// useMouse.js
// width:卡片宽度
// height:卡片高度
// cardEl:卡片的dom节点
export function useMouse(width, height, cardEl) {
const styles = ref('');

const mouseMove = (ev) => {}
const mouseOut = (ev) => {}
return {
styles,
mouseMove,
mouseOut,
};
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  导出我们需要用到的styles样式、mouseMove鼠标移动函数、mouseOut鼠标移出函数;使用方式也很简单,导入组合式函数,传入宽高以及卡片的节点,卡片节点在后面要用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<template>
<div class="card card1"
ref="card1"
:style="styles1"
@mousemove="mouseMove1"
@mouseout="mouseOut1"
>Card1</div>
</template>
<script setup>
import { ref } from "vue";
import { useMouse } from "./useMouse.js";
const card1 = ref(null);
const {
styles: styles1,
mouseMove: mouseMove1,
mouseOut: mouseOut1
} = useMouse(434, 450, card1); // 传入卡片宽高
</script>

  接下来就是最最最最核心的useMouse的代码了;我们想象一下,需要在鼠标移动的时候,让卡片旋转向前倾斜对应的角度,而在鼠标离开的时候,则清空角度样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function useMouse(width, height, cardEl) {
const styles = ref('');
// 一半的宽度
const halfWidth = width / 2;
// 一半的高度
const halfHeight = height / 2;
const mouseMove = (ev) => {
// todo 设置styles
}
const mouseOut = (ev) => {
styles.value = '';
}
}

  我们想象一下,鼠标某一时刻的位置假设在卡片的左上方某个点,获取鼠标距离卡片左上角的offsetX和offsetY,旋转角度需要计算出蓝色框的长宽:

鼠标位置

绕着Y轴旋转的角度就是蓝色框的长度除以一半的宽度,绕着X轴旋转的角度就是蓝色框的高度除以一半的宽度。

  我们定义一个最大的旋转角度MAX_DEG,然后用一个笨办法,在四个区域分别计算出不同的角度:

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// X轴和Y轴最大的翻转角度
const MAX_DEG = 10;

const mouseMove = (ev) => {
const { offsetX, offsetY } = ev;

let rotateX = 0;
let rotateY = 0;
if (offsetX < halfWidth && offsetY < halfHeight) {
rotateY = ((halfWidth - offsetX) / halfWidth) * MAX_DEG;
rotateX = -((halfHeight - offsetY) / halfHeight) * MAX_DEG;
} else if (offsetX >= halfWidth && offsetY < halfHeight) {
rotateY = -((offsetX - halfWidth) / halfWidth) * MAX_DEG;
rotateX = -((halfHeight - offsetY) / halfHeight) * MAX_DEG;
} else if (offsetX < halfWidth && offsetY >= halfHeight) {
rotateY = ((halfWidth - offsetX) / halfWidth) * MAX_DEG;
rotateX = ((offsetY - halfHeight) / halfHeight) * MAX_DEG;
} else if (offsetX >= halfWidth && offsetY >= halfHeight) {
rotateY = -((offsetX - halfWidth) / halfWidth) * MAX_DEG;
rotateX = ((offsetY - halfHeight) / halfHeight) * MAX_DEG;
}
styles.value = `transform:perspective(1400px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
}

  这里的CSS3的perspective指定了观察者与平面的距离,使具有三维位置变换的元素产生透视效果,如果不设置就没有透视的效果;我们看下鼠标的效果基本就可以实现3D倾斜的效果了。

3D倾斜效果

  继续对代码进行优化,四个区域太麻烦了,我们只考虑X轴和Y轴方向上的rotateX和rotateY的计算方式,上面代码最终可以优化成两行:

1
2
3
4
5
const mouseMove = (ev) => {
// 省略其他代码
rotateX = -((halfHeight - offsetY) / halfHeight) * MAX_DEG;
rotateY = ((halfWidth - offsetX) / halfWidth) * MAX_DEG;
}

辉光效果

  接下来就是鼠标移动时候的背景辉光效果了,我们首先向卡片下插入一个dom节点,用来渲染辉光:

1
2
3
4
5
6
7
8
9
export function useMouse(width, height, cardEl) {
let hoverEl = null;

onMounted(() => {
hoverEl = document.createElement("div");
hoverEl.className = "hover-element";
cardEl.value.append(hoverEl);
});
}

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  给hover-element元素设置样式,在卡片悬浮的时候才让元素显示出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.card {
&:hover {
color: #fff;
.hover-element {
visibility: visible;
}
}
.hover-element {
background: rgba(77, 82, 232, 0.6);
border-radius: 50%;
-webkit-filter: blur(64px);
filter: blur(64px);
width: 300px;
height: 300px;
left: 0px;
top: 0px;
position: absolute;
visibility: hidden;
z-index: -1;
}
}

  在鼠标移动时,去设置悬浮元素的位置偏移:

1
2
3
4
5
6
7
8
9
10
// 悬浮高亮元素的尺寸
const HOVER_SIZE = 300;

const mouseMove = (ev) => {
if (hoverEl) {
const hX = offsetX - HOVER_SIZE / 2;
const hY = offsetY - HOVER_SIZE / 2;
hoverEl.style = `transform: translate(${hX}px, ${hY}px);`;
}
}

  但是我们这样设置之后会发现,后面的hover-element元素在鼠标移动的时候,一直在不断的闪现:

辉光闪现效果

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

  这是因为鼠标在卡片上移动的时候,我们给hover-element元素设置了偏移,偏移的hover-element元素阻断了mousemove事件,让hover-element元素又回到了原点,在鼠标不断移动时,导致了辉光呈现出了闪现效果。

  我们可以在卡片下面再嵌套一层div作为卡片内容,让它始终位于hover-element元素的上方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="card card1" ref="card1" :style="styles1">
<div class="card-cont"
@mousemove="mouseMove1"
@mouseout="mouseOut1">
卡片内容
</div>
</div>
</template>
<style lang="scss">
.card-cont {
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
}
</style>

  这样我们的辉光效果就很流畅了。

流畅的辉光效果

修正偏差

  我们将文案和图片添加到卡片内容后会发现,当鼠标悬浮到图片元素上时,辉光元素会出现偏差;由于笔者这边是将图片元素设置成absolute的,而offsetX/offsetY是相对偏差,当悬浮到目标图片上是,offset是相对于图片的位移,而不是相对于外层元素,这样就会导致错位。

  我们在mouse移动时,获取图片的left和top,将其数据加到offsetX/offsetY上,即可修正偏差:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 解析字符串中的数值 例如auto、120px
*/
const parseNumber = (str) => {
if (!str) return 0;
const mt = str.match(/(\d*)px/);
if (mt) {
return parseFloat(mt[1]);
}
return 0;
};

const mouseMove = (ev) => {
let { offsetX, offsetY } = ev;

// 修正offset偏差
if (ev.target && ev.target.nodeName === "IMG") {
let style = getComputedStyle(ev.target);
offsetX += parseNumber(style.left);
offsetY += parseNumber(style.top);
}
}

  这里getComputedStyle实时获取图片的left和top会比较耗费性能,这里的优化点,可以在mounted的时候提前获取图片的left/top,提前将其存储起来。

谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本文最终的实现效果可以戳这里查看

总结

  本文首先总结了鼠标事件中offsetX/clientX/pageX/screenX每个属性的用法,了解了每个属性的用法和区别;然后实现了3D倾斜旋转和辉光元素的效果,将具体的实现逻辑抽取到了独立的js文件中,方便复用逻辑;最后我们发现某些元素下鼠标悬浮的效果会有偏差,排查原因后对偏差进行修正。

本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【辉光卡片】即可获取。


谢小飞博客专用防爬虫链接,想要看最新的前端博客请点这里

本网所有内容文字和图片,版权均属谢小飞所有,任何媒体、网站或个人未经本网协议授权不得转载、链接、转贴或以其他方式复制发布/发表。如需转载请关注公众号【前端壹读】后回复【转载】。