路由3D跳转
刀刀
12/31/2024
0 字
0 分钟
项目最开始采用的是 element-ui
的 tab
组件实现路由跳转,用户看了之后很不满意,觉得没有科幻风,要求修改。
效果图
经过UI的调整改版后效果如下图所示:
根据当前路由动态把对应路由名称放到最前面,背景为激活状态;点击其他按钮切换路由后切换激活状态,且会有动画过渡。其余非激活状态的路由背景透明度逐渐递减。
思考
根据效果图不难推测出,整体的旋转切换效果需要通过 3D动画 来实现,通过 rotate
来控制。现在来思考如何控制它们的背景及不透明度和旋转的角度。
激活状态
首先当前是否是激活状态需要通过路由来判断,数组中用一个字段 url
来标识当前项所对应的路由,只要当前路由匹配,则处于激活状态。
vue
<template>
<div
v-for="(item, index) in list"
:key="item.url"
:class="{'active': isActive(item)}">
<!-- ... -->
</div>
</template>
<script setup>
const router = useRouter()
const list = ref([
{ url: '/home', name: 'xxx' },
{ url: '/about', name: 'yyy' },
{ url: '/info', name: 'zzz' },
])
const isActive = (el) => {
return el.url && el.url === router.path
}
</script>
不透明度
接着处理不透明度,根据效果图激活状态的是完全显行的,开始慢慢往两侧递减。因此可以用如下步骤来实现:
- 声明一个变量
activeIndex
表示当前激活项的索引,通过计算属性findIndex
判断当前路由的索引项 - 用
activeIndex
减去当前项的索引,取绝对值,再用数组长度减去该值,通过Math.min
获取该值与前面得到的绝对值的最小值 - 1 除以当前数组长度并赋值给变量
unitOpacity
- 计算当前的索引的不透明度,计算方式为 1 减去第二步获取到的值乘
unitOpacity
乘 1.7
vue
<template>
<div
v-for="(item, index) in list"
:key="item.url"
:class="{'active': isActive(item)}"
:style="{'--opacity': 1 - Math.abs(getGap(activeIndex, index)) * unitOpacity * 1.7}">
<!-- ... -->
</div>
</template>
<script setup>
// ...
const activeIndex = computed(() => list.findIndex(item => item.url === router.path))
const unitOpacity = computed(() => 1 / list.length)
const getGap = (activeIndex, index) => {
const len = list.length
const gap = Math.abs(activeIndex - index)
return Math.min(gap, len - gap)
}
</script>
3d旋转
剩下 3D旋转 的功能需要实现,先实现 3d布局,就是为需要 3d旋转的盒子设置 transform-style: preserve-3d;
样式,通过 rotate
实现角度调整。
每个 div
偏移的角度可以通过当前的索引计算获得,代码如下:
vue
<template>
<div
v-for="(item, i) in list"
:key="item.name"
class="item"
:class="{active: el.item && isActive(item)}"
:style="{
'--ry': `${getRotateY(i)}Deg`,
'--opacity': 1 - Math.abs(getGap(activeIndex, i)) * unitOpacity * 1.7,
}"
>
<a
:href="isActive(item) ? 'javascript:void(0)' : el.url"
:style="{transform: `rotateY(${-getRotateY(i) - rotationY}Deg)`}"
>
<span>{{ item.name }}</span>
</a>
</div>
</template>
<script setup>
// ...
function getRotateY(i) {
let len = list.length;
const l = len / 2 | 0;
let rotationY = unitDeg.value * i - 1;
const gap = getGap(activeIndex.value, i);
const p = leftOrRightOrCenter(activeIndex.value, i);
let a = Math.ceil(l / 2);
if (p === 'center' || gap === 0 || gap > a) {
return rotationY;
}
else if (p === 'left') {
const unit = unitDeg.value / (gap + 1);
return rotationY + gap * unit;
}
const unit = unitDeg.value / (gap + 1);
return rotationY - gap * unit;
}
</script>
整体代码
点击查看完整代码
vue
<template>
<div class="system-entry-box">
<div class="perspective-box">
<div
v-for="(el, i) in systemList"
:key="el.name"
class="item"
:class="{active: el.url && isActive(el)}"
:style="{
'--ry': `${getRotateY(i)}Deg`,
'--opacity': 1 - Math.abs(getGap(activeIndex, i)) * unitOpacity * 1.7,
}"
>
<a
:href="isActive(el) ? 'javascript:void(0)' : el.url"
:style="{transform: `rotateY(${-getRotateY(i) - rotationY}Deg)`}"
>
<span>{{ el.name }}</span>
</a>
</div>
</div>
</div>
</template>
<script setup>
import {ref, computed, watch} from 'vue';
import {useRoute} from '@/utils';
const props = defineProps({
systemList: {
type: Array,
default: () => [],
},
});
const route = useRoute();
const unitDeg = computed(() => 360 / (props.systemList.length || 1));
const unitOpacity = computed(() => 1 / (props.systemList.length || 1));
const activeIndex = computed(e => props.systemList?.findIndex?.(el => el.url && isActive(el)) ?? 0);
const rotationY = ref(0);
const rotationComputed = computed(() => rotationY.value + 'deg');
const getGap = (l, r) => {
let len = props.systemList.length;
const gap = Math.abs(l - r);
return Math.min(len - gap, gap);
};
const leftOrRightOrCenter = (activeIndex, index) => {
let len = props.systemList.length;
if (Math.abs(getGap(activeIndex, index)) === len / 2) {
return 'center';
}
if (activeIndex < index) {
if (index - activeIndex < len / 2) {
return 'right';
}
}
else if (index < activeIndex) {
if (index + len - activeIndex < len / 2) {
return 'right';
}
}
return 'left';
};
function getRotateY(i) {
let len = props.systemList.length;
const l = len / 2 | 0;
let rotationY = unitDeg.value * i - 1;
const gap = getGap(activeIndex.value, i);
const p = leftOrRightOrCenter(activeIndex.value, i);
let a = Math.ceil(l / 2);
if (p === 'center' || gap === 0 || gap > a) {
return rotationY;
}
else if (p === 'left') {
const unit = unitDeg.value / (gap + 1);
return rotationY + gap * unit;
}
const unit = unitDeg.value / (gap + 1);
return rotationY - gap * unit;
}
function isActive(el) {
return route.path === el.realUrl || route.path === el.url;
}
watch(activeIndex, (n, o = 0) => {
let len = props.systemList.length;
const gap = getGap(n, o);
const sign = ((o + gap) % len) === n ? -1 : 1;
rotationY.value += sign * unitDeg.value * gap;
}, {
immediate: true,
});
</script>
<style lang="less" scoped>
.system-entry-box {
width: 966px;
height: 276px;
background: url('@/assets/images/entry/bg.png') no-repeat;
background-size: 100% 149%;
// overflow: hidden;
perspective: 1200px;
perspective-origin: 48% 0%;
position: relative;
.perspective-box {
width: 100%;
height: 100%;
position: absolute;
left: 0%;
top: -20%;
transform-style: preserve-3d;
transition: transform 1s;
transform: translateZ(0) rotateY(v-bind(rotationComputed));
}
.item {
position: absolute;
left: calc(50% - 50px);
top: 40%;
display: flex;
width: 100px;
height: 100px;
border-radius: 50%;
transform-style: preserve-3d;
transform: rotateY(var(--ry)) translateZ(400px);
&:hover {
a {
opacity: 1;
margin-top: -50px;
}
}
a {
width: 100%;
height: 100%;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: url('@/assets/images/entry/sys-bg.png') no-repeat;
background-size: 100% 100%;
transition: transform 1s, opacity 1s, margin-top .5s;
opacity: var(--opacity);
span {
max-width: 3em;
max-height: 56px;
line-height: 24px;
text-align: center;
text-shadow: 0 3px 2px #054477;
}
}
&.active {
a {
margin-top: 0 !important;
background: url('@/assets/images/entry/sys-bg-active.png') no-repeat;
background-size: 100% 100%;
}
}
}
}
</style>