正多边形的3d轮播实现

本篇记录顶面和底面为正多边形的立体进行旋转轮播的实现。最终的效果可点击这里查看。这个效果主要支持的特性有:

  • 手工调用上一面或下一面(封装的类有提供prev和next方法)
  • 手工跳转到任意面(封装的类有提供slideTo(index)方法)
  • 定时旋转轮播
  • 拖拽切换到上一面或下一面,拖拽距离决定轮播面数,一个面拖拽旋转不到50%,松手时自动回复到初始状态;一个面拖拽旋转超过50%,松手时自动完成剩下角度的旋转
  • 支持任意正多边形

代码较多,本篇不直接粘贴,可以通过上面的demo页面的源码查看。

3d效果实现

html初始结构,只有一个#container元素,每个面都是js动态生成的:

1
2
<div id="container">
</div>

css结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#container {
position: relative;
width: 300px;
height: 300px;
transform-style: preserve-3d;
}

.box {
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: .8;
line-height: 300px;
text-align: center;
font-size: 70px;
font-weight: bold;
color: white;
text-decoration: none;
}

js代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function Polygon3DSlide(sides, duration = .6, interval = 3000){
var degree = 360 / sides;
var boxWidth = container.getBoundingClientRect().width ;
var translateZ = container.getBoundingClientRect().width / 2 * Math.tan((90 - degree/2) * Math.PI / 180);
var rotateMap = {};
var render = function(){
container.innerHTML = '';
rotateMap = {};
var html = [];
for(var i = 0; i < sides; i++) {
rotateMap[i] = rotateMap[i-1] !== undefined ? (rotateMap[i-1] + degree) : 0;
html.push('<a href="javascript:;" index="'+i+'" style="transform-origin:' +
'50% 50% -'+ translateZ +'px' + '; transform: ' +
'translate(0, 0) rotateY('+rotateMap[i]+'deg); transition: all ' + duration + 's' +
';" class="box">' + (i+1) + '</a>');
}

container.innerHTML = html.join('');
}

render();
//....
}

构造函数第一个参数是边数,第二个是每次旋转的过渡时间,第三个是自动旋转的间隔时间。这个例子的3d效果,是通过把每个面,按次序围绕某个中心点渲染出来的。假如slides为5,表示一共有五个面,每个面之间的间隔角度为360 / 5 = 72deg,所以假如第1个面不旋转,第2个面就要旋转1 * 72deg,第3个面要旋转2 * 72deg,第4个面要旋转3 * 72deg,第5个面要旋转4 * 72deg;旋转的中心点,x,y设置为50% 50%,但是如果仅仅是xy坐标平面的这个中心点,肯定是出不来3d效果的,必须调整这个旋转中心点的z值。这个z值是可以求出来的:

1
2
3
var degree = 360 / sides;
var boxWidth = container.getBoundingClientRect().width ;
var translateZ = container.getBoundingClientRect().width / 2 * Math.tan((90 - degree/2) * Math.PI / 180);

只要掌握了三角函数和三角形的一些规律就好理解上面的计算。

通过任意数字求出它在循环数组中的位置

这个示例的数据结构,本质是一个循环数组,因为它的旋转方向是双向,并且收尾相接的,外部指定任意一个数字,我们只要知道数组的长度,都可以找到它在数组中对应的元素索引位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
var resolveTargetIndex = function(targetIndex) {
if(targetIndex % sides === 0) {
targetIndex = 0;
} else {
targetIndex = targetIndex % sides;

if(targetIndex < 0) {
targetIndex = sides + targetIndex;
}
}

return targetIndex;
}

上面这个小算法就是来解决这个问题的,考虑了负数的情况。

判断最近的旋转方向

由于是循环旋转,所以当向指定数字旋转的时候,还要判断往左还是往右是旋转距离是最小的。

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
var slideTo = function(targetIndex){
if(dragging) return;

targetIndex = resolveTargetIndex(targetIndex);

if(targetIndex === activeIndex) return;

//判断方向 比较targetIndex与activeIndex,是往左旋转更近还是往右旋转更近
let slideToLeft = targetIndex > activeIndex;
let slidesCount = Math.abs(targetIndex - activeIndex);

if(slideToLeft) {
//targetIndex - sides对应targetIndex
if(Math.abs(activeIndex - (targetIndex - sides)) < Math.abs(targetIndex - activeIndex)) {
slideToLeft = false;
slidesCount = activeIndex - (targetIndex - sides);
}
} else {
if(Math.abs(targetIndex + sides - activeIndex) < Math.abs(targetIndex - activeIndex)) {
slideToLeft = true;
slidesCount = targetIndex + sides - activeIndex;
}
}

let boxes = [].slice.call(container.children);
for(let [k, box] of boxes.entries()) {
let degreeRotateTotal = degree * slidesCount * (slideToLeft ? -1 : 1);
rotateMap[k]+=degreeRotateTotal;
box.style.transform = 'translate(0, 0) rotateY('+rotateMap[k]+'deg)';
}

activeIndex = targetIndex;
}

在通过resolveTargetIndex得到要旋转的目标元素的位置后,就有了两个变量targetIndex,和原来正面显示元素的activeIndex,默认情况下设置;

1
2
let slideToLeft = targetIndex > activeIndex;
let slidesCount = Math.abs(targetIndex - activeIndex);

slideToLeft表示是否要向左旋转。后面的判断里面,考虑了两种特殊情况,当targetIndex大于activeIndex时,默认是向左旋转,如果向右旋转距离更短,那么slideToLeft就会设置为false(eg: slides=5, targetIndex=4,activeIndex=0);当targetIndex小于activeIndex时,默认是向右旋转,如果向左旋转距离更短的话,slideToLeft会设置为true(eg: slides=5, targetIndex=0,activeIndex=0)

拖拽实现

考虑touch的情况:

1
2
3
4
var touchMode = 'ontouchstart' in document.documentElement;
var dragStartEvent = touchMode ? 'touchstart' : 'mousedown';
var dragMoveEvent = touchMode ? 'touchmove' : 'mousemove';
var dragUpEvent = touchMode ? 'mouseup' : 'mouseup';

主要逻辑都在dragMove和dragUp的时候,这是dragMove:

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
var dragMove = function(e) {
e.preventDefault();
if(!dragging) {
dragging = true;
document.documentElement.style.cursor = 'grabbing';
container.classList.add('dragging');

let event = touchMode ? e.touches[0] : e;
dragStartPosition = {
x: e.clientX,
activeSlideDegree: rotateMap[activeIndex]
};

let boxes = [].slice.call(container.children);
for(let [k, box] of boxes.entries()) {
box.style.transition = '';
}

stopAutoSlide();
}

let {boxes, degreeRotateTotal} = resolveKeyParamsDragMove(e)

for(let [k, box] of boxes.entries()) {
box.style.transform = 'translate(0, 0) rotateY('+(rotateMap[k]+degreeRotateTotal)+'deg)';
}
}

通过dragging这个内部变量,维护一个拖拽的状态。if(!dragging){}里面的逻辑,每次拖拽只会执行一次,做一些拖拽的初始化工作,比如记录拖拽的开始位置和拖拽前正面元素的旋转角度;清空每个元素的transition,不然拖拽过程中动画就乱了。

拖拽时的旋转方向,以及旋转角度是通过下面的算法算的:

1
2
3
4
5
6
7
8
9
10
11
12
13
var resolveKeyParamsDragMove = function(e){
let event = touchMode ? e.touches[0] : e;
let slideToLeft = (dragStartPosition.x - event.clientX) > 0;

let boxes = [].slice.call(container.children);
let degreeRotateTotal = ( Math.abs((dragStartPosition.x - event.clientX)) / boxWidth * degree ) * (slideToLeft ? -1 : 1);

return {
slideToLeft,
degreeRotateTotal,
boxes
};
}

根据拖拽距离与每个元素宽度的比,算出拖拽了几个单位的面。通过拖拽坐标位置和拖拽开始的位置,算出拖拽旋转方向。

拖拽过程中,通过下面的代码,实时渲染旋转效果:

1
2
3
for(let [k, box] of boxes.entries()) {
box.style.transform = 'translate(0, 0) rotateY('+(rotateMap[k]+degreeRotateTotal)+'deg)';
}

这是dragUp的:

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
var dragUp = function(e) {
document.removeEventListener(dragMoveEvent, dragMove);
document.removeEventListener(dragUpEvent, dragUp);

if(!dragging) return;
let {slideToLeft, boxes, degreeRotateTotal} = resolveKeyParamsDragMove(e);

//degreeRotateTotal表示拖拽结束后每个面旋转的角度值

document.documentElement.style.cursor = '';
container.classList.remove('dragging');

// 将slide选装角度归正
let baseDegreeRotate = Math.abs(degreeRotateTotal % degree);
let degreeRotateFix = 0;
if(baseDegreeRotate > (degree / 2)) {
degreeRotateFix = (degree * 1000000 - baseDegreeRotate * 1000000)/1000000;
} else {
degreeRotateFix = -1 * baseDegreeRotate;
}

degreeRotateFix = (slideToLeft ? -1 : 1) * degreeRotateFix;

//degreeRotateFix表示每个面在停止旋转时为了让最接近正面的那个面调整到正面
//而计算出来的应该多旋转或少旋转的角度值

//计算degreeRotateFix这个旋转角度占用的过渡时间
let transDuration = Math.abs(degreeRotateFix)/degree * duration;

for(let [k, box] of boxes.entries()) {
rotateMap[k] = rotateMap[k] + degreeRotateTotal + degreeRotateFix;
box.style.transition = ' all '+(transDuration)+'s';
box.style.transform = 'translate(0, 0) rotateY('+rotateMap[k]+'deg)';
}

//设置一个定时器用来恢复每个box的初始过渡时间
setDegreeRotateFixTransTimer(transDuration);

let activeSlideRotateDegree = rotateMap[activeIndex] - dragStartPosition.activeSlideDegree;
let activeSlideCount = Math.floor((rotateMap[activeIndex] - dragStartPosition.activeSlideDegree) / degree);

activeIndex = resolveTargetIndex(activeIndex - activeSlideCount);
dragStartPosition = null;
dragging = false;
}

这个代码主要是考虑了对拖拽结束后的旋转角度进行修正,可能增加一点,或减少一点,以便让最应该回到正面的那个面,多转一点,回到正面。同时也兼顾了动画时间,毕竟这个修正角度,肯定是小于面与面之间的旋转角度的,所以相应的动画过渡时间也要修正。最后在修正完毕后,对动画过渡时间进行了重置,修正完毕的时机,是通过定时器做的:

1
2
3
4
5
6
7
8
9
10
11
12
var setDegreeRotateFixTransTimer = function(transDuration) {
if(timerForDegreeRotateFix) clearTimeout(timerForDegreeRotateFix);

timerForDegreeRotateFix = setTimeout(function() {
let boxes = [].slice.call(container.children);
for(let [k, box] of boxes.entries()) {
box.style.transition = ' all '+(duration)+'s';
}

initAutoSlide();
}, transDuration * 1000);
}