最近在浏览掘金网站的时候发现新上线的扣子平台,它是一款无需代码,即刻开发新一代AI Chat Bot的应用编辑平台,可以通过这个平台快速创建各种类型的Chat Bot;不过让笔者更感兴趣的是它首页酷炫的卡片辉光效果是如何实现的,本文就来探讨一下它的实现过程。
我们看下动画效果如下:
如果我们打开控制台去看元素上面的属性,我们会发现,这样的效果其实主要是两种动画效果叠加:卡片3D旋转和后面的辉光层的效果,我们逐一来看下实现的逻辑。
本文的实现代码实现基于Vue3,对Vue3语法不了解的可以先看一下这篇文章;想看最终的实现效果的小伙伴可以直接戳这里查看
鼠标属性的区别
在实现效果之前,我们先来回顾一下,我们在打印鼠标事件的时候,经常会看到事件对象event上面有各种属性,offsetX、clientX、pageX和screenX等等,那么这些属性有什么区别呢?我们在使用时到底该用哪个属性呢?
为了简化流程,下面主要介绍X轴方向上的属性,Y轴方向属性同理。
offsetX
我们按照从小范围到大范围的原则,先来看下offsetX属性,根据MDN上的介绍,只读属性offsetX
规定了事件对象与目标节点的内填充边(padding edge)在X轴方向上的偏移量;从offset单词也能看出,它是相对于目标div的偏移量,我们用图形通俗的表示如下:
clientX
然后是clientX,client有客户端的意思,因此它表示事件发生时的浏览器客户端区域的水平坐标,是相对于浏览器窗口
的边距,会随页面滚动而改变,用图形表示如下:
注:clientY是不包含浏览器的书签、地址栏和标签栏等的高度。
pageX
再是pageX属性,它是相对于整个文档
的水平坐标,不随着页面滚动而改变;它和上面clientX看似是相同的,大多数情况下它们的值也是相同的;但pageX会考虑页面的水平方向上的滚动,因此有下面的公式:
ev.pageX = ev.clientX + window.scrollX
通过图形我们也能看出,当文档大小超出浏览器区域时,pageX明显是要大于clientX的值:
screenX
最后是screenX,表示范围是最大的,使用频率也是最低的,表示距离电脑屏幕边缘的水平坐标偏移量:
除此之外,还有一个属性是movementX,它提供了当前鼠标移动事件
和上一次鼠标移动事件
之间鼠标在水平方向的移动值;在处理移动元素等场景时就不需要我们记录上次的位置数据了,因此它的计算公式如下:
currentEvent.movementX = currentEvent.screenX - previousEvent.screenX
卡片3D旋转
首先我们来看下第一个实现效果,首先是让卡片实现3D的旋转效果,我们先在页面上将卡片的布局排列好:
vue
<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
文件:
javascript
// 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鼠标移出函数;使用方式也很简单,导入组合式函数,传入宽高以及卡片的节点,卡片节点在后面要用到。
javascript
<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的代码了;我们想象一下,需要在鼠标移动的时候,让卡片旋转向前倾斜对应的角度,而在鼠标离开的时候,则清空角度样式:
javascript
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,然后用一个笨办法,在四个区域分别计算出不同的角度:
javascript
// 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倾斜的效果了。
继续对代码进行优化,四个区域太麻烦了,我们只考虑X轴和Y轴方向上的rotateX和rotateY的计算方式,上面代码最终可以优化成两行:
javascript
const mouseMove = (ev) => {
// 省略其他代码
rotateX = -((halfHeight - offsetY) / halfHeight) * MAX_DEG;
rotateY = ((halfWidth - offsetX) / halfWidth) * MAX_DEG;
}
辉光效果
接下来就是鼠标移动时候的背景辉光效果了,我们首先向卡片下插入一个dom节点,用来渲染辉光:
javascript
export function useMouse(width, height, cardEl) {
let hoverEl = null;
onMounted(() => {
hoverEl = document.createElement("div");
hoverEl.className = "hover-element";
cardEl.value.append(hoverEl);
});
}
给hover-element元素
设置样式,在卡片悬浮的时候才让元素显示出来:
css
.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;
}
}
在鼠标移动时,去设置悬浮元素的位置偏移:
javascript
// 悬浮高亮元素的尺寸
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元素的上方:
vue
<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上,即可修正偏差:
javascript
/**
* 解析字符串中的数值 例如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文件中,方便复用逻辑;最后我们发现某些元素下鼠标悬浮的效果会有偏差,排查原因后对偏差进行修正。
本文所有源码敬请关注公众号【前端壹读】,后台回复关键词【辉光卡片】即可获取。