持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情
最近遇到一个需求,是实现一个3D轮播图的需求,有点像旋转木马,基于css3手写一个轮播图,反反复复的思考了很多东西,写下此文记录自己一个心路旅程
1、布局
- 首先,我们需要一个最外层容器container。
- 其次,我们需要一个“卡片盒子”,他在容器的中心位置。
- 再来,卡片盒子里有N张卡片。
- 最后,我们在给其添加点样式。
<div class="box-container">
<div class="box-wrapper">
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
</div>
</div>
.box-container {
width: 320px;
height: 300px;
position: relative;
display:flex;
align-items: center;
justify-content: center;
margin: 0 auto;
border: 1px solid red;
}
.box-wrapper {
width: 150px;
height: 200px;
}
.box {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px solid blue;
}
2、样式
- 这里我们需要想象下,我们可以将其想象成一个”圆柱体“,而每个面都在试圆柱体上旋转。
- 最外层容器的宽其实就是圆柱体的直径。
- 每张在”卡片盒子“里的卡片,先依照Y轴旋转自己特定角度,假设一共有6个面,则每个面旋转的角度就是360 / 6 * 下标。
- 旋转完后,再向Z轴平移一个半径的距离,就到了自己各自的位置。
- "卡片盒子“开启3d视觉
class RotateBox {
constructor(container) {
this.container = this.renderContainer(container)
this.r = this.container.clientWidth / 2 // 半径
this.cardBox = this.renderCardBox(this.container)
this.cardList = this.renderCardList(this.cardBox, this.r)
}
// 容器添加3d效果看上去更明显一些
renderContainer(container) {
container.style.transformStyle = 'preserve-3d'
container.style.transform = 'rotateX(-15deg) rotateY(15deg)'
return container
}
// 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
renderCardBox(container) {
const selector = '.box-wrapper'
const cardBox = container.querySelector(selector)
cardBox.style.transformStyle = 'preserve-3d'
return cardBox
}
// 渲染每个卡片,先在Y轴上旋转,再向Z轴平移)
renderCardList(cardBox, r) {
const selector = '.box'
const cardList = [...cardBox.querySelectorAll(selector)]
cardList.forEach((el, index) => {
const angle = 360 / cardList.length * index
el.style.transform = `rotateY(${angle}deg) translateZ(${r}px)`
})
return cardList
}
}
// 示例化
new RotateBox(document.querySelector('.box-container'))
3、旋转:基础功能
需要旋转这个”圆柱体“,其实只要旋转”卡片盒子“即可,因为每个卡片的旋转角度都是基于”卡片盒子“的。我们丰富一些辅助的属性与方法,后期可以直接赋值或者调用方便处理。
- 构造函数里添加一个属性rotate,默认值为0,这个属性将表示当前我们“卡片盒子”旋转了多少度。
- 提供一个辅助方法,rotateTo,调用这个方法并传入角度,会改变”卡片盒子“的角度样式。
- 给”卡片盒子“添加css的过渡动画
transition
,也在构造器里设置一个过渡动画的动画时长
class RotateBox {
constructor(container) {
...
this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
this.cardList = this.renderCardList(this.cardBox, this.r)
this.rotate = 0 // 当前旋转角度
}
...
// 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
renderCardBox(container, transitionTimeout) {
const selector = '.box-wrapper'
const cardBox = container.querySelector(selector)
cardBox.style.transformStyle = 'preserve-3d'
cardBox.style.transition = `transform ${transitionTimeout}s linear`
return cardBox
}
...
// 旋转到指定角度
rotateTo(rotate) {
this.rotate = parseInt(rotate)
this.cardBox.style.transform = `rotateY(${this.rotate}deg)`
}
}
4、旋转:自动旋转
这是个很正常的需求,轮播图每隔一段时间会自动旋转一下,但我希望在轮播图这个类里面处理这个逻辑,我希望这是个可选择的附属功能,于是我想到了插件的概念,我对我这个插件有一下几点要求:
- 插件与主体的数据流向一定要保持清晰(单项数据流)。
- 插件尽量在内部处理逻辑,对外只暴露一些简单明了的事件或方法,供主体调用。
- 插件不会引起一些不必要的意外情况,插件不会修改主体的属性与行为(例如旋转)。
为了实现如下几个点,想到了观察者模式,就像js或者node中,很多功能都有on,请教了我们部门技术大佬马哥,很快找到一个叫EventEmitter3的js代码,粗略的研究了一番,自己写了一个简易的EventEmitter类
class EventEmitter {
constructor() {
this._events = {}
}
on(eventName, fn) {
if(typeof fn !== 'function') {
throw new TypeError('The listener must be a function');
}
if(!this._events[eventName]) {
this._events[eventName] = [fn]
return
}
this._events[eventName].push(fn)
}
emit(eventName, ...args) {
if(!this._events[eventName]) {
return
}
this._events[eventName].forEach(fn => {
fn(...args)
})
}
}
基于这个类,我们开始一步一步的处理自动轮播, 先从主类开始
- 主类RotateBox添加属性
angle
表示每次旋转的角度 - 主类RotateBox添加属性
direction
表示旋转方向(clockwise顺时针,counterclockwise逆时针) - 主类RotateBox添加属性
autoPlayRotateTime
表示每次自动旋转的间隔(秒) - 主类RotateBox添加方法
getPreviousAndNextRotate
用以获取下一个或上一个的旋转角度
class RotateBox {
constructor(container) {
...
this.rotate = 0 // 当前旋转角度
this.angle = 360 / this.cardList.length // 每次旋转的角度
this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
this.autoPlayRotateTimeout = 2000
}
// 获取前一个与后一个的坐标,当前角度(rotate), 每次旋转角度(angle),旋转方向(clockwise顺时针,counterclockwise逆时针)
getPreviousAndNextRotate(rotate, angle, direction = 'counterclockwise') {
let previous = 0
let next = 0
if(rotate === 0 || rotate % angle === 0) {
next = rotate + angle
previous = rotate - angle
} else {
if(rotate > 0) {
previous = rotate - (rotate % angle)
next = previous + angle
}
if(rotate < 0) {
next = rotate - (rotate % angle)
previous = next - angle
}
}
switch (direction) {
case 'counterclockwise':
return {next, previous}
case 'clockwise':
return {next: previous, previous: next}
}
}
}
实现自动旋转插件类,这个类,轮播图主体会提供必要的信息给我们,例如每次旋转的角度,旋转方向,每次自动旋转的间隔,我们内部处理定时器与旋转角度的逻辑,每次间隔都会获取当前轮播图所处角度,并告知轮播图下一个旋转的角度,至于真实的旋转就是轮播图自己处理的,插件不做任何干涉。
// 自动轮播插件
class RotateSwiperAutoPlugin extends EventEmitter {
constructor(rotateSwiper, angle, intervalTimeout = 2000, direction = 'counterclockwise') {
super();
this.rotateSwiper = rotateSwiper // 当前旋转实例
this.angle = angle // 每次旋转的角度
this.intervalTimeout = intervalTimeout // 每次旋转定时器的间隔
this.intervalNo = null // 旋转定时器序号
this.direction = direction // 旋转方向(clockwise顺时针,counterclockwise逆时针
}
// 告知旋转主体,要开始转到下一个了
next(rotate) {
const { next: nextRotate } = this.rotateSwiper.getPreviousAndNextRotate(rotate, this.angle, this.direction)
this.emit('rotate', nextRotate)
}
// 开始
start() {
this.stop()
this.intervalNo = setInterval(() => {
const currentRotate = this.rotateSwiper.rotate
this.next(currentRotate)
}, this.intervalTimeout)
}
// 停止
stop() {
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
}
主体只需要注册对应插件,调用插件实例的方法与监听插件的事件,就能做出很清晰的处理了
- 主类添加方法
initRotateSwiperAutoPlugin
- 在这个方法中实例化自动轮播插件。
- 监听
轮播
事件,在事件触发时,获取下一个要旋转的角度,并调用自身的rotateTo
方法。 - 最后返回这个自动轮播插件实例,获取到这个实例后,在调用这个实例的
start
启动方法。
class RotateBox {
constructor(container) {
this.container = this.renderContainer(container)
this.r = this.container.clientWidth / 2 // 半径
this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
this.cardList = this.renderCardList(this.cardBox, this.r)
this.rotate = 0 // 当前旋转角度
this.angle = 360 / this.cardList.length // 每次旋转的角度
this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
this.autoPlayRotateTimeout = 2000
this.rotateSwiperAutoPlugin = this.initRotateSwiperAutoPlugin()
this.rotateSwiperAutoPlugin.start() // 开始旋转
}
// 初始化自动轮播逻辑
initRotateSwiperAutoPlugin() {
const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
rotateSwiperAutoPlugin.on('rotate', rotate => {
this.rotateTo(rotate)
})
return rotateSwiperAutoPlugin
}
}
5、旋转:拖拽旋转(touch)
因为这是一个h5的需求,所以做的是一个touch的处理,也是类似于自动轮播似的做了一个插件,插件不做过多干涉,只对外暴露事件与信息。
- 实现一个拖拽旋转插件类,继承EventEmitter,插件会获取到dom,并在touch事件时,处理相关逻辑,并在合适的时机触发事件并提供必要参数信息。
- 在主类实例化拖拽插件类,并监听对应事件,处理自身的逻辑。
- 拖拽完成后,会有一个如果不处于某个卡片的角度,则进行“吸附”。
- 拖拽时暂停自动轮播。(这里处理其实并不好,插件之间存在了耦合)
// 触控旋转插件
class RotateSwiperTouchPlugin extends EventEmitter{
constructor(cardBox) {
super();
this.cardBox = cardBox
this.addTouchEvent()
}
addTouchEvent() {
this.cardBox.addEventListener('touchstart', event => {
const { clientX: originX, clientY: originY } = event.touches[0]
this.emit('touchstart', { originX, originY })
this.cardBox.ontouchmove = event => {
const { clientX: moveX, clientY: moveY } = event.touches[0]
this.emit('touchmove', { originX, originY, moveX, moveY })
return false
}
this.cardBox.ontouchend = () => {
const { clientX: endX, clientY: endY } = event.touches[0]
this.cardBox.ontouchmove = null
this.emit('touchend', {endX, endY})
return false
}
return false
})
}
}
class RotateBox {
constructor(container) {
...
this.rotateSwiperAutoPlugin.start() // 开始旋转
// 初始化touch插件
this.rotateSwiperTouchPlugin = this.initRotateSwiperTouchPlugin()
}
// 初始化自动轮播逻辑
initRotateSwiperAutoPlugin() {
const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
rotateSwiperAutoPlugin.on('rotate', rotate => {
this.rotateTo(rotate)
})
return rotateSwiperAutoPlugin
}
// 初始化拖拽旋转逻辑
initRotateSwiperTouchPlugin() {
const rotateSwiperTouchPlugin = new RotateSwiperTouchPlugin(this.cardBox)
let originRotate = 0
rotateSwiperTouchPlugin.on('touchstart', () => {
originRotate = this.rotate
this.rotateSwiperAutoPlugin.stop()
})
rotateSwiperTouchPlugin.on('touchmove', ({ originX, moveX }) => {
const diffX = originX - moveX
const rotateY = originRotate - diffX
this.rotateTo(rotateY)
})
rotateSwiperTouchPlugin.on('touchend', async () => {
await this.rotateToNextOrPrevious(this.rotate)
this.rotateSwiperAutoPlugin.start()
})
return rotateSwiperTouchPlugin
}
// 当拖拽松开时,如果处于两个卡片中间,则会判断,进行位置校准
rotateToNextOrPrevious(rotate) {
return new Promise(resolve => {
const { next, previous } = this.getPreviousAndNextRotate(rotate, this.angle, this.direction)
if(rotate / this.angle === 0) {
return
}
this.rotateTo(next - rotate > rotate - previous ? previous : next)
setTimeout(() => {
resolve()
}, this.transitionTimeout * 1000)
})
}
整体效果就算是完成了,去除边框贴上背景图。 整体代码如下:
<!doctype html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<style>
* {padding: 0; margin: 0}
.box-container {
width: 320px;
height: 300px;
position: relative;
display:flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
.box-wrapper {
width: 150px;
height: 200px;
position: relative;
/*transform-style: preserve-3d;*/
transition: transform linear .15s;
}
.box {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url('example/images/mouse.jpg') no-repeat center center;
background-size: cover;
}
</style>
<body>
<div class="box-container">
<div class="box-wrapper" style="transform:rotateY(0deg)">
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
</div>
</div>
<!--<script src="./math.min.js"></script>-->
</body>
<script>
class EventEmitter {
constructor() {
this._events = {}
}
on(eventName, fn) {
if(typeof fn !== 'function') {
throw new TypeError('The listener must be a function');
}
if(!this._events[eventName]) {
this._events[eventName] = [fn]
return
}
this._events[eventName].push(fn)
}
emit(eventName, ...args) {
if(!this._events[eventName]) {
return
}
this._events[eventName].forEach(fn => {
fn(...args)
})
}
}
// 触控旋转插件
class RotateSwiperTouchPlugin extends EventEmitter{
constructor(cardBox) {
super();
this.cardBox = cardBox
this.addTouchEvent()
}
addTouchEvent() {
this.cardBox.addEventListener('touchstart', event => {
const { clientX: originX, clientY: originY } = event.touches[0]
this.emit('touchstart', { originX, originY })
this.cardBox.ontouchmove = event => {
const { clientX: moveX, clientY: moveY } = event.touches[0]
this.emit('touchmove', { originX, originY, moveX, moveY })
return false
}
this.cardBox.ontouchend = () => {
const { clientX: endX, clientY: endY } = event.touches[0]
this.cardBox.ontouchmove = null
this.emit('touchend', {endX, endY})
return false
}
return false
})
}
}
// 自动轮播插件
class RotateSwiperAutoPlugin extends EventEmitter {
constructor(rotateSwiper, angle, intervalTimeout = 2000, direction = 'counterclockwise') {
super();
this.rotateSwiper = rotateSwiper // 当前旋转实例
this.angle = angle // 每次旋转的角度
this.intervalTimeout = intervalTimeout // 每次旋转定时器的间隔
this.intervalNo = null // 旋转定时器序号
this.direction = direction // 旋转方向(clockwise顺时针,counterclockwise逆时针
}
// 告知旋转主体,要开始转到下一个了
next(rotate) {
const { next: nextRotate } = this.rotateSwiper.getPreviousAndNextRotate(rotate, this.angle, this.direction)
this.emit('rotate', nextRotate)
}
// 开始
start() {
this.stop()
this.intervalNo = setInterval(() => {
const currentRotate = this.rotateSwiper.rotate
this.next(currentRotate)
}, this.intervalTimeout)
}
// 停止
stop() {
if(this.intervalNo) {
clearInterval(this.intervalNo)
this.intervalNo = null
}
}
}
class RotateBox {
constructor(container) {
this.container = this.renderContainer(container)
this.r = this.container.clientWidth / 2 // 半径
this.transitionTimeout = 0.15 // 过渡动画所需时长(s)
this.cardBox = this.renderCardBox(this.container, this.transitionTimeout)
this.cardList = this.renderCardList(this.cardBox, this.r)
this.rotate = 0 // 当前旋转角度
this.angle = 360 / this.cardList.length // 每次旋转的角度
this.direction = 'counterclockwise' // 旋转方向(clockwise顺时针,counterclockwise逆时针)
this.autoPlayRotateTimeout = 2000
this.rotateSwiperAutoPlugin = this.initRotateSwiperAutoPlugin()
this.rotateSwiperAutoPlugin.start() // 开始旋转
// 初始化touch插件
this.rotateSwiperTouchPlugin = this.initRotateSwiperTouchPlugin()
}
// 初始化自动轮播逻辑
initRotateSwiperAutoPlugin() {
const rotateSwiperAutoPlugin = new RotateSwiperAutoPlugin(this, this.angle, this.autoPlayRotateTimeout, this.direction)
rotateSwiperAutoPlugin.on('rotate', rotate => {
this.rotateTo(rotate)
})
return rotateSwiperAutoPlugin
}
// 容器添加3d效果看上去更明显一些
renderContainer(container) {
container.style.transformStyle = 'preserve-3d'
container.style.transform = 'rotateX(-15deg) rotateY(15deg)'
return container
}
// 卡片盒子需要添加transformStyle,才可以看出每个卡片在3d效果上的移动
renderCardBox(container, transitionTimeout) {
const selector = '.box-wrapper'
const cardBox = container.querySelector(selector)
cardBox.style.transformStyle = 'preserve-3d'
cardBox.style.transition = `transform ${transitionTimeout}s linear`
return cardBox
}
// 渲染每个卡片,先在Y轴上旋转,再向Z轴平移)
renderCardList(cardBox, r) {
const selector = '.box'
const cardList = [...cardBox.querySelectorAll(selector)]
cardList.forEach((el, index) => {
const angle = 360 / cardList.length * index
el.style.transform = `rotateY(${angle}deg) translateZ(${r}px)`
})
return cardList
}
// 初始化拖拽旋转逻辑
initRotateSwiperTouchPlugin() {
const rotateSwiperTouchPlugin = new RotateSwiperTouchPlugin(this.cardBox)
let originRotate = 0
rotateSwiperTouchPlugin.on('touchstart', () => {
originRotate = this.rotate
this.rotateSwiperAutoPlugin.stop()
})
rotateSwiperTouchPlugin.on('touchmove', ({ originX, moveX }) => {
const diffX = originX - moveX
const rotateY = originRotate - diffX
this.rotateTo(rotateY)
})
rotateSwiperTouchPlugin.on('touchend', async () => {
await this.rotateToNextOrPrevious(this.rotate)
this.rotateSwiperAutoPlugin.start()
})
return rotateSwiperTouchPlugin
}
// 当拖拽松开时,如果处于两个卡片中间,则会判断,进行位置校准
rotateToNextOrPrevious(rotate) {
return new Promise(resolve => {
const { next, previous } = this.getPreviousAndNextRotate(rotate, this.angle, this.direction)
if(rotate / this.angle === 0) {
return
}
this.rotateTo(next - rotate > rotate - previous ? previous : next)
setTimeout(() => {
resolve()
}, this.transitionTimeout * 1000)
})
}
// 获取前一个与后一个的坐标,当前角度(rotate), 每次旋转角度(angle),旋转方向(clockwise顺时针,counterclockwise逆时针)
getPreviousAndNextRotate(rotate, angle, direction = 'counterclockwise') {
let previous = 0
let next = 0
if(rotate === 0 || rotate % angle === 0) {
next = rotate + angle
previous = rotate - angle
} else {
if(rotate > 0) {
previous = rotate - (rotate % angle)
next = previous + angle
}
if(rotate < 0) {
next = rotate - (rotate % angle)
previous = next - angle
}
}
switch (direction) {
case 'counterclockwise':
return {next, previous}
case 'clockwise':
return {next: previous, previous: next}
}
}
// 旋转到指定角度
rotateTo(rotate) {
this.rotate = parseInt(rotate)
this.cardBox.style.transform = `rotateY(${this.rotate}deg)`
}
}
const rotateSwiper = new RotateBox(document.querySelector('.box-container'))
</script>
</html>
6、待续未完...
后期我们可能需要更多的功能,比如将touch替换成mouse相关的事件,比如可以进入到这个轮播图的内部,比如图片从平面变成曲面,我们可以继续增加开发更多的插件。 这里我只开发了进入到轮播图内部的插件:
曲面的逻辑可能会把每一个面也作为一个对象,新建一个Card类,在新增一个Card曲面插件类,来实现这一逻辑。
感谢大家的观看
2022年6月5日(端午节)