写在开头
哈喽,各位好吖!😋
轮播动效,这也算是一个 "”老生常谈" 问题了。几年前,但凡踏入前端领域的小伙伴,想必都有过编写轮播效果的经历,无论是 2D 还是 3D 版本。😂
那时,"轮播" 似乎是成为前端入门的必备技能之一。但如今,轮播效果在各类 UI 组件库中屡见不鲜,已然成为标配组件。多数情况下,产品经理所要求的也不过是 2D 轮播,直接调用组件库中的组件即可,无需我们再劳心费神。
然而,掌握自行编写轮播代码的技能亦颇具价值,特别是 3D 轮播,可以给自己博客增加特效,或者用于吹吹牛嘛。😋
本次要分享的就是一个3D的轮播效果,并且是一步一步手撸实现,期望能得诸位青睐,嘿嘿,请诸君按需食用。
基础布局
首先,咱先把结构和样式整上:
<!DOCTYPE html>
<html>
<head>
<style>
* {
box-sizing: border-box;
}
body {
padding: 0;
margin: 0;
background-color: #333;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.container {
width: 300px;
height: 200px;
position: relative;
perspective: 1000px;
}
.carousel {
width: 100%;
height: 100%;
position: absolute;
transform-style: preserve-3d;
transition: transform 1s;
transform: translateZ(-210px) rotateY(0deg);
/* 后续拖动操作时,避免文案的影响 */
user-select: none;
/* 后续拖动操作时,改变鼠标形状 */
cursor: grab;
}
.cell {
position: absolute;
width: 280px;
height: 180px;
left: 10px;
top: 10px;
border: 2px solid #000;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
font-size: 60px;
font-weight: bold;
}
.cell:nth-child(1) {
transform: rotateY(0deg) translateZ(210px);
}
.cell:nth-child(2) {
transform: rotateY(72deg) translateZ(210px);
}
.cell:nth-child(3) {
transform: rotateY(144deg) translateZ(210px);
}
.cell:nth-child(4) {
transform: rotateY(216deg) translateZ(210px);
}
.cell:nth-child(5) {
transform: rotateY(288deg) translateZ(210px);
}
</style>
</head>
<body>
<div class="container">
<div class="carousel">
<div class="cell">1</div>
<div class="cell">2</div>
<div class="cell">3</div>
<div class="cell">4</div>
<div class="cell">5</div>
</div>
</div>
</body>
</html>
效果:
这...没啥可说的,基础的HTML+CSS布局。😋
不过,其中值得留意的是每个轮播子项的 transform: rotateY(deg) translateZ(px);
这一属性的设置。👀
上述代码中,对于 rotate
的值小编是通过 360 / 5
计算得到的,因为是五个元素要围成一个圆嘛。而 translateZ
的值小编则是通过最笨的方式,手动去不断调整得到的。
如果,当前咱们要再增加一个轮播子项,那么,每个子项的样式就不得不重新进行调整,这...就挺麻烦的。😬
我们来通过 JS
解决这个问题:
<script>
const carousel = document.querySelector('.carousel');
const carouselWidth = carousel.offsetWidth;
let cells = [];
// 记录一共有多少个轮播的子项,保持与实际的DOM数量一致,也可以直接使用cells.length
let total = 5;
// 计算单个子项占据的角度
let occupyDeg = 0;
// 计算轮播子项的在 3D 空间中沿Z轴应该平移的距离(即半径值)
let translateZRadius = undefined;
function init() {
// 获取所有轮播子项的DOM
cells = carousel.querySelectorAll('.cell');
// 由于是3D轮播,可以想象成一个圆,计算每个轮播子项占据一个圆360度的多少
occupyDeg = 360 / total;
// 看下面详解👇👇👇
translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));
cells.forEach((cellDOM, i) => {
if (i < total) {
const cellAngle = occupyDeg * i;
// 给每个子项设置对应的值
cellDOM.style.transform = `rotateY(${cellAngle}deg) translateZ(${translateZRadius}px)`;
} else {
// 多余的就隐藏
cellDOM.style.transform = "none";
}
});
}
init();
</script>
使用 JS
去动态计算每个轮播子项的 rotateY
与 translateZ
值,咱们就可以把它们各自设置的 transform
属性的样式给删除了;新增的轮播子项也能自动生成属性值,不用咱们手动设置了,挺棒。😀
rotateY
值的计算应该是比较好理解的,而 translateZ
值的计算就稍微复杂一些,它本质就等同于计算这个 "圆" 的半径,这点可以理解吧❓ 如图:
再来细致分析一下计算过程:
translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));
Math.PI
:这个都认识吧,圆周率 π(约等于 3.14159),一个数学常量。Math.PI / total
:这里的total
应该是表示轮播子项的总数。用圆周率除以子项总数,得到的结果是将一个完整的圆按照子项数量进行等分后,每一份所对应的弧度值。Math.tan(Math.PI / total)
:Math.tan()
是正切函数,三角函数应该都晓得啦,它接受一个弧度值作为参数,并返回该弧度对应的正切值。carouselWidth / 2
:carouselWidth
表示轮播容器的宽度。将容器宽度除以 2,是因为在以容器中心为参考点进行 3D 布局时,我们通常是从中心向两侧分布子项,所以先求出一半的宽度值,以便后续结合正切值等计算出子项沿Z
轴的合适距离(即半径值)。carouselWidth / 2 / Math.tan(Math.PI / total)
:将前面求出的轮播容器宽度的一半,除以每一份弧度对应的正切值,这样就得到了一个与子项在 3D 空间中沿Z
轴分布相关的距离值。这个值的大小决定了子项距离中心在Z
轴方向上的远近程度,从而影响它们在 3D 视觉上的布局效果。Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));
:最后,使用Math.round()
函数对前面计算得到的距离值进行四舍五入取整操作。
应该都能懂哈❗😋完成了轮播子项样式的动态计算。实际上,子项的 DOM 结构同样能够借助代码动态生成,这点可以自己搞一下。
点击切换功能
布局有了,接下来,咱们得让它能动起来,能进行轮播切换,这才是核心功能。
也不难,直接贴代码瞧瞧:
<!DOCTYPE html>
<html>
<body>
<!-- ... -->
<div style="display: flex;margin-top: 10px;">
<button style="margin-right: 10px;" class="previous">上一页</button>
<button class="next">下一页</button>
</div>
<script>
// ...
let index = 0;
function init() {
// ...
// 初始化轮播容器的样式
carouseChange();
}
init();
const previousButton = document.querySelector('.previous');
const nextButton = document.querySelector('.next');
previousButton.addEventListener('click', () => {
index--;
carouseChange();
});
nextButton.addEventListener('click', () => {
index++;
carouseChange();
});
function carouseChange() {
// 计算当前轮播子项应旋转的角度
const angle = occupyDeg * index * -1;
// 更新轮播容器的transform属性以实现切换效果
carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
}
</script>
</body>
</html>
效果:
轮播的切换功能咱们是通过改变轮播容器(.carousel
)的旋转来完成的,每次把容器旋转到对应轮播子项占据的角度,如下图:
在
init
函数中调用carouseChange
函数,能顺便给轮播容器(.carousel
)的transform
属性进行初始化,它的translateZ
值也需要动态根据轮播子项的个数生成。
拖动切换功能
轮播组件,在PC端上最常见的交互方式为点击切换,此外,还常常具备自动切换的功能。例如,可以设定每三秒切换一次,但实现自动切换的逻辑较为简单,只需开启一个定时器(setInterval
),在定时器的回调函数中不断改变 index
的值,并调用 carouseChange
函数即可,注意清除定时器哦,这里小编就不多讲啦。😁
值得一提的是,轮播组件有时还会带有拖动功能,这种功能在移动端更为常见。然而,3D 轮播图独特的视觉效果本身就容易激发用户的拖动操作欲望。(是吧是吧?反正小编看着就想拖动玩玩)
接下来,咱们就来看看这个拖动切换功能要如何完成。
<!DOCTYPE html>
<html>
<body>
<!-- ... -->
<script>
// ...
// 是否在拖动中
let isMouseDown = false;
// 记录开始拖动的位置
let startX = 0;
carousel.addEventListener('mousedown', event => {
isMouseDown = true;
startX = event.clientX;
});
// 记录鼠标拖动的距离
let diffX = 0;
carousel.addEventListener('mousemove', event => {
if (isMouseDown) {
let currentX = event.clientX;
// 计算本次移动与上一次移动的位移差
diffX = currentX - startX;
}
});
// 必须是全局的mouseup事件,要不当拖动在轮播容器.carousel外松开就会出现bug了
document.body.addEventListener('mouseup', () => {
if (isMouseDown) {
// 拖动的距离超过轮播容器的一半就进行切换
if (Math.abs(diffX) > carouselWidth / 2) {
if (diffX > 0) {
// 右
index--;
carouseChange();
} else {
// 左
index++;
carouseChange();
}
}
}
// 鼠标抬起时,清空位移差,否则会出现拖动切换后,点击子项就立马会完成拖动切换
diffX = 0;
isMouseDown = false;
});
</script>
</body>
</html>
效果:
代码量并不多哈😋,都写有注释,其核心在于对鼠标三兄弟事件,即 mousedown
、mousemove
与 mouseup
进行监听并加以处理。就拖动相关的事件处理而言,小编此前已撰写了诸多文章,涵盖了形形色色的拖动场景。倘若有感兴趣了解更多细节的,不妨去翻阅一下小编之前发布过的文章瞧瞧哈。
不过,这拖动切换功能还没做完❗
当前实现的拖动功能略显生硬,虽然也能用,但交互效果不是很好,还需要进行优化。目前的情况是,仅在鼠标松开时才会执行轮播的切换操作,倘若鼠标的 "拖动距离" 不足时,轮播便不会产生任何变化。
而在友好的拖动切换交互设计中,应当是每产生微小的拖动距离,都能即时反馈在轮播展示上。当鼠标松开时,若拖动距离达到了预先设定的切换阈值,则顺利进行轮播切换;反之,若未达到该阈值,轮播则应平滑地回弹至上一次位置。
Em...这才能算是比较好的交互,才能真正赢得用户的青睐,提升用户的好感度与使用体验。当然,这都是后话了,反正,咱们就来加上这么一个小功能吧。😁
<!DOCTYPE html>
<html>
<body>
<!-- ... -->
<script>
// ...
// 允许优先接收外部的值进行切换
function carouseChange(angleOutside) {
const angle = angleOutside || occupyDeg * index * -1;
carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
}
let isMouseDown = false;
let startX = 0;
let diffX = 0;
carousel.addEventListener('mousedown', event => { // ... });
carousel.addEventListener('mousemove', event => {
if (isMouseDown) {
let currentX = event.clientX;
diffX = currentX - startX;
// 由当前的index计算出旋转的角度(可以把这个计算提取到mousedown)
const angle = occupyDeg * index * -1;
// 根据位移差计算角度变化
const angleChange = diffX * (occupyDeg / carouselWidth);
// 原本的角度与不断变化的角度
carouseChange(angle + angleChange);
}
});
document.body.addEventListener('mouseup', () => {
if (isMouseDown) {
if (Math.abs(diffX) > carouselWidth / 2) {
if (diffX > 0) {
// 右
index--;
carouseChange();
} else {
// 左
index++;
carouseChange();
}
}else {
// 当拖动没有满足阈值时,切换成上一次的位置
carouseChange();
}
}
diffX = 0;
isMouseDown = false;
});
</script>
</body>
</html>
效果:
呃...可能录的 GIF 效果不是很好😵,但是,应该是能看出相比上一个效果会有一些不同,鼠标开始拖动轮播也会开始旋转,拖动结束未达阈值也会回弹回去。
代码量增加不多,关键是这行:
const angleChange = diffX * (occupyDeg / carouselWidth);
这句代码的主要目的是根据鼠标的实时拖动距离(diffX
),计算出对应的角度变化值(angleChange
)。
diffX
:鼠标拖动距离。occupyDeg
:每个轮播子项在整个 "圆" 360度中所占据的度数。carouselWidth
:轮播容器,它在计算角度变化与位移差的关系中起到一个比例换算的作用。
整个计算的原理是基于一种线性比例关系。假设轮播图是均匀分布在一个圆上的(从角度分布的角度来看),那么当鼠标在轮播图容器的宽度方向上移动时,移动的像素距离(diffX
)应该与轮播图所对应的角度变化成一定的比例关系。
具体来说,occupyDeg / carouselWidth
这个比值表示每移动一个像素在水平方向上所对应的角度变化量。例如,如果 occupyDeg
是 72
度,carouselWidth
是 300px
,那么每移动 1px
在水平方向上对应的角度变化就是 72 / 300 = 0.24
度。
然后,将这个每像素对应的角度变化量乘以实际的位移差(diffX
),就可以得到总的角度变化值(angleChange
)。
应该非常清楚啦❓😋
增加与删除
讲到这里,核心要点就阐述得差不多了。我们已经深入探讨了主要的实现逻辑,接下来的更多是一些附加功能方面的拓展内容。相信凭借你对前面内容的理解,完全有能力依据特定需求进一步扩展该功能。例如,小编在网上瞧见的 3D 轮播效果中,有结合 CSS 的缩放(scale
)属性进行运用,便能营造出更加出色、更具视觉冲击力的轮播效果,这部分就你自己来啦。👻
最后,我们再来处理一下开头效果图中所涉及的增加与删除功能的实现。这部分相对较为简单,直接贴代码:
<!DOCTYPE html>
<html>
<body>
<!-- ... -->
<div style="display: flex; margin-top: 10px">
<button style="margin-right: 10px" class="add">增加一个</button>
<button class="reduce">减少一个</button>
</div>
<script>
// ...
const addButton = document.querySelector('.add');
const reduceButton = document.querySelector('.reduce');
addButton.addEventListener("click", () => {
total += 1;
// 追加一个新的轮播子项
const cell = document.createElement('div');
cell.classList.add('cell');
cell.innerText = total;
carousel.appendChild(cell);
// 重新初始化
init();
});
reduceButton.addEventListener("click", () => {
if (total === 5) {
// 只有5个就不给删除了,5个围成一个圆的效果已经接近极限了,少于5个就不是很好看了
return;
}
total -= 1;
// 移除最后一个轮播子项
carousel.removeChild(carousel.lastElementChild);
// 重新初始化
init();
});
</script>
</body>
</html>
完整源码
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>通过CSS+JS手撸一个3D轮播</title>
<style>
* {
box-sizing: border-box;
}
body {
padding: 0;
margin: 0;
background-color: #333;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.container {
width: 300px;
height: 200px;
position: relative;
perspective: 1000px;
}
.carousel {
width: 100%;
height: 100%;
position: absolute;
transform-style: preserve-3d;
transition: transform 1s;
user-select: none;
cursor: grab;
}
.cell {
position: absolute;
width: 280px;
height: 180px;
left: 10px;
top: 10px;
border: 2px solid #000;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
font-size: 60px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="carousel">
<div class="cell">1</div>
<div class="cell">2</div>
<div class="cell">3</div>
<div class="cell">4</div>
<div class="cell">5</div>
</div>
</div>
<div style="display: flex;margin-top: 10px;">
<button style="margin-right: 10px;" class="previous">上一页</button>
<button class="next">下一页</button>
</div>
<div style="display: flex; margin-top: 10px">
<button style="margin-right: 10px" class="add">增加一个</button>
<button class="reduce">减少一个</button>
</div>
<script>
const carousel = document.querySelector('.carousel');
const carouselWidth = carousel.offsetWidth;
const carouseHeight = carousel.offsetHeight;
let cells = [];
let total = 5;
let occupyDeg = 0;
let translateZRadius = undefined;
let index = 0;
function init() {
cells = carousel.querySelectorAll('.cell');
index = 0;
occupyDeg = 360 / total;
translateZRadius = Math.round(carouselWidth / 2 / Math.tan(Math.PI / total));
cells.forEach((cellDOM, i) => {
if (i < total) {
const cellAngle = occupyDeg * i;
cellDOM.style.transform = `rotateY(${cellAngle}deg) translateZ(${translateZRadius}px)`;
} else {
cellDOM.style.transform = "none";
}
});
carouseChange();
}
init();
// 点击切换
const previousButton = document.querySelector('.previous');
const nextButton = document.querySelector('.next');
previousButton.addEventListener('click', () => {
index--;
carouseChange();
});
nextButton.addEventListener('click', () => {
index++;
carouseChange();
});
function carouseChange(angleOutside) {
const angle = angleOutside || occupyDeg * index * -1;
carousel.style.transform = `translateZ(${-translateZRadius}px) rotateY(${angle}deg)`;
}
// 拖动切换
let isMouseDown = false;
let startX = 0;
let currentAngle = 0;
carousel.addEventListener('mousedown', event => {
isMouseDown = true;
startX = event.clientX;
currentAngle = occupyDeg * index * -1;
});
let diffX = 0;
carousel.addEventListener('mousemove', event => {
if (isMouseDown) {
let currentX = event.clientX;
diffX = currentX - startX;
const angleChange = diffX * (occupyDeg / carouselWidth);
carouseChange(currentAngle + angleChange);
}
});
document.body.addEventListener('mouseup', () => {
if (isMouseDown) {
if (Math.abs(diffX) > carouselWidth / 2) {
if (diffX > 0) {
index--;
carouseChange();
} else {
index++;
carouseChange();
}
}else {
carouseChange();
}
}
diffX = 0;
isMouseDown = false;
});
// 增加与删除
const addButton = document.querySelector('.add');
const reduceButton = document.querySelector('.reduce');
addButton.addEventListener("click", () => {
total += 1;
const cell = document.createElement('div');
cell.classList.add('cell');
cell.innerText = total;
carousel.appendChild(cell);
init()
});
reduceButton.addEventListener("click", () => {
if (total === 5) return;
total -= 1;
carousel.removeChild(carousel.lastElementChild);
init()
});
</script>
</body>
</html>
至此,本篇文章就写完啦,撒花撒花。