跳转到内容

路由3D跳转

刀刀

12/31/2024

0 字

0 分钟

项目最开始采用的是 element-uitab 组件实现路由跳转,用户看了之后很不满意,觉得没有科幻风,要求修改。

效果图

经过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>

不透明度

接着处理不透明度,根据效果图激活状态的是完全显行的,开始慢慢往两侧递减。因此可以用如下步骤来实现:

  1. 声明一个变量 activeIndex 表示当前激活项的索引,通过计算属性 findIndex 判断当前路由的索引项
  2. activeIndex 减去当前项的索引,取绝对值,再用数组长度减去该值,通过 Math.min 获取该值与前面得到的绝对值的最小值
  3. 1 除以当前数组长度并赋值给变量 unitOpacity
  4. 计算当前的索引的不透明度,计算方式为 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>