最近闲着无聊研究起掘金的UI,在会员界面看到一幅轮播图深得我心,刚好最近在学习Vue3,想着用Vue3来复刻一波。如下图所示:
UI结构
首先需要确定的是UI结构,从里到外看,单个图片为一个层级,其包裹的父元素为一个层级;左右按钮为一个层级;轮播图和按钮需要父元素来进行父相子绝定位;最后最外层的div作为一个整体宽度限制,最终结构如下:
<template>
<div class="base">
<div class="container">
<div class="content">
<div>
<img class="img" />
</div>
</div>
<div class="butt">
<a>{{ '<' }}</a
>
<a>{{ '>' }}</a
>
</div>
</div>
</div>
</template>
样式实现
首先需要将图片像金字塔一样重叠起来,如下图所示:
.current {
transform: none !important;
z-index: 999;
}
.left_1 {
transform: translateX(-533px);
z-index: 997;
}
之后,我们为所有图片添加上样式,为了方便我们定义一个styleArray数组来存储样式名称,在迭代图片时进行一一对应:
<script lang="ts" setup>
import { swiperImages } from '../../contants/swiper-images';
const styleArray = ['current', 'right_2', 'right_1', 'left_1', 'left_2'];
</script>
<div class="content">
<div
:class="`div ${styleArray[returnSwiperItem(index)]}`"
v-for="(item, index) in swiperImages"
:key="index"
>
<img :src="item" alt="出错" class="img" />
</div>
</div>
<style scoped>
.content {
position: relative;
margin: 0 auto;
width: 400px;
height: 212px;
}
.current {
transform: none !important;
z-index: 999;
}
.left_1 {
transform: translateX(-533px);
z-index: 997;
}
.left_2 {
transform: translateX(-266px);
z-index: 998;
}
.right_1 {
transform: translateX(533px);
z-index: 997;
}
.right_2 {
transform: translateX(266px);
z-index: 998;
}
</style>
问题来了,我们上面所实现的效果类似平面图,但是掘金上是立体3D的,要如何实现?这时候需要用到CSS中的transform-style和perspective属性,我们在content中加入这两个属性,并设置每个图片z轴上的偏移量以及y轴的旋转角度:
.content {
position: relative;
margin: 0 auto;
transform-style: preserve-3d;
perspective: 1000px;
width: 400px;
height: 212px;
}
.left_1 {
transform: translateX(-533px) translateZ(-500px) rotateY(35deg);
z-index: 997;
}
.left_2 {
transform: translateX(-266px) translateZ(-400px) rotateY(35deg);
z-index: 998;
}
.right_1 {
transform: translateX(533px) translateZ(-500px) rotateY(-35deg);
z-index: 997;
}
.right_2 {
transform: translateX(266px) translateZ(-400px) rotateY(-35deg);
z-index: 998;
}
这时候就可以看到图片呈现立体效果:
轮播图切换
以上是静态页面的构建,这时候我们需要让图片动起来,实际上就是对以上定义的样式顺序循环设置到图片上。在切换的时候我们需要注意两点:
- 图片在切换到最后一张或者第一张后需要将索引重置为第一张或者最后一张
- 图片对应的当前样式索引需要进行动态计算得出,因为图片的放置是从左到右一个循环,假设第一张图片在最上方,则第二张图片在右2,第三张图片在右1,第四张图片在左1,最后一张图片在左2,所以需要保证当前索引在切换时正确顺序,算法如下:
const returnSwiperItem = (index: number) => {
// index为当前样式索引 styleIndex为切换时索引
let value = index + styleIndex.value;
// 如果当前值超出数组边界值则说明当前图片为最上方展示图片
if (value > 4) {
return value - 5;
}
return value;
};
自动切换
自动切换我们需要在watchEffect中注册定时器间隔2.5s调用切换下一张方法:
watchEffect(() => {
timer = setInterval(() => {
handleNext();
}, 2500);
});
onUnmounted(() => {
// 离开时清除定时器防止内存泄漏
timer && clearInterval(timer);
});
点击按钮切换
点击按钮切换我们需要监听a标签的click方法并通过注意点1实现上下切换:
<script>
const styleIndex = ref(0);
const handlePrev = () => {
if (styleIndex.value === 4) {
styleIndex.value = 0;
} else {
styleIndex.value += 1;
}
};
const handleNext = () => {
if (styleIndex.value === 0) {
styleIndex.value = 4;
} else {
styleIndex.value -= 1;
}
};
</script>
<div class="butt">
<a
:class="`${isResize ? 'pre_min common_min' : 'pre common'}`"
href="#"
@click="handlePrev"
>{{ '<' }}</a
>
<a
:class="`${isResize ? 'next_min common_min' : 'next common'}`"
href="#"
@click="handleNext"
>{{ '>' }}</a
>
</div>
结语
最后成功实现掘金效果,还做了补充,包括鼠标滑动切换,点击图片切换,屏幕适配等。最终代码如下:
<script lang="ts" setup>
import { ref, onUnmounted, watchEffect, onMounted } from 'vue';
import { swiperImages } from '../../contants/swiper-images';
const styleArray = ['current', 'right_2', 'right_1', 'left_1', 'left_2'];
const styleIndex = ref(0);
const isEnter = ref(false);
const isMounted = ref(false);
const isResize = ref(false);
let isMouseDown = false;
let isClick = false;
let startPoint = 0;
let timer: NodeJS.Timeout | null = null;
const handlePrev = () => {
if (styleIndex.value === 4) {
styleIndex.value = 0;
} else {
styleIndex.value += 1;
}
};
const handleNext = () => {
if (styleIndex.value === 0) {
styleIndex.value = 4;
} else {
styleIndex.value -= 1;
}
};
watchEffect(() => {
timer = setInterval(() => {
if (isMounted.value) {
if (!isEnter.value) handleNext();
}
isMounted.value = true;
}, 2500);
});
onMounted(() => {
window.addEventListener('resize', () => {
isResize.value = window.innerWidth < 1024;
});
});
onUnmounted(() => {
timer && clearInterval(timer);
window.removeEventListener('resize', () => {});
});
const returnSwiperItem = (index: number) => {
if (!isMounted.value) return index;
let value = index + styleIndex.value;
if (value > 4) {
return value - 5;
}
return value;
};
const mouseEnter = (value: boolean) => {
isEnter.value = value;
};
const handleClick = (index: number) => {
const value = returnSwiperItem(index);
if (value !== 0 && isClick) {
index ? (styleIndex.value = 5 - index) : (styleIndex.value = 0);
}
};
const mouseDown = (payload: MouseEvent) => {
isMouseDown = true;
isClick = true;
startPoint = payload.clientX;
};
const mouseMove = (payload: MouseEvent) => {
payload.preventDefault();
if (isMouseDown) {
const moveInstance = payload.clientX - startPoint;
if (moveInstance > 20) {
handlePrev();
isMouseDown = false;
isClick = false;
return;
}
if (moveInstance < -20) {
handleNext();
isMouseDown = false;
isClick = false;
return;
}
}
};
const mouseUp = () => {
isMouseDown = false;
};
</script>
<template>
<div class="base">
<div class="container" @mouseenter="mouseEnter(true)" @mouseleave="mouseEnter(false)">
<div class="content">
<div
:class="`div ${styleArray[returnSwiperItem(index)]}`"
v-for="(item, index) in swiperImages"
:key="index"
@mousedown="mouseDown"
@mousemove="mouseMove"
@mouseup="mouseUp"
@click="handleClick(index)"
>
<img :src="item" alt="出错" class="img" />
</div>
</div>
<div class="butt">
<a
:class="`${isResize ? 'pre_min common_min' : 'pre common'}`"
href="#"
@click="handlePrev"
>{{ '<' }}</a
>
<a
:class="`${isResize ? 'next_min common_min' : 'next common'}`"
href="#"
@click="handleNext"
>{{ '>' }}</a
>
</div>
</div>
</div>
</template>
<style scoped>
.base {
text-align: center;
max-width: 1076px;
min-width: 1024px;
margin: 0 auto 128px;
position: relative;
}
.container {
margin: 20 auto;
width: 100%;
overflow: visible;
height: 212px;
z-index: 1000;
}
.content {
position: relative;
margin: 0 auto;
transform-style: preserve-3d;
perspective: 1000px;
width: 400px;
height: 212px;
}
.div {
border-width: 0px;
width: 400px;
height: 212px;
transition:
transform 500ms ease,
opacity 500ms ease,
visibility 500ms ease;
box-shadow: 0 2px 10px rgba(235, 222, 205, 0.5);
border: 1px solid rgba(228, 230, 235, 0.5);
border-radius: 8px;
position: absolute;
}
.img {
width: 100%;
height: 100%;
}
.butt {
width: 100%;
height: 0;
z-index: 1000;
position: absolute;
top: 50%;
margin-top: -30px;
left: 0;
}
.common {
position: absolute;
width: 50px;
height: 50px;
border-radius: 25px;
border: 1px solid #d6b885;
background-color: #fffbf2;
color: #9b7e49;
font-weight: 100;
box-sizing: border-box;
text-align: center;
user-select: none;
text-decoration: none;
line-height: 50px;
}
.common_min {
position: absolute;
width: 50px;
height: 50px;
font-weight: 300;
box-sizing: border-box;
text-align: center;
user-select: none;
text-decoration: none;
line-height: 50px;
background-color: transparent;
border-color: transparent;
color: #fff;
font-size: 30px;
background-color: rgba(0, 0, 0, 0.4);
transform: scale(0.5);
}
.pre {
left: -62px;
}
.pre_min {
left: -3px;
border-radius: 0 4px 4px 0;
}
.next {
right: -62px;
}
.next_min {
right: -3px;
border-radius: 4px 0 0 4px;
}
.current {
transform: none !important;
z-index: 999;
}
.left_1 {
transform: translateX(-533px) translateZ(-500px) rotateY(35deg);
z-index: 997;
}
.left_2 {
transform: translateX(-266px) translateZ(-400px) rotateY(35deg);
z-index: 998;
}
.right_1 {
transform: translateX(533px) translateZ(-500px) rotateY(-35deg);
z-index: 997;
}
.right_2 {
transform: translateX(266px) translateZ(-400px) rotateY(-35deg);
z-index: 998;
}
</style>