Skip to content

路由3D跳转

作者:Yuan Tang
更新于:5 个月前
字数统计:1.2k 字
阅读时长:5 分钟

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

效果图

经过UI的调整改版后效果如下图所示:

效果图

根据当前路由动态把对应路由名称放到最前面,背景为激活状态;点击其他按钮切换路由后切换激活状态,且会有动画过渡。其余非激活状态的路由背景透明度逐渐递减。

思考

根据效果图不难推测出,整体的旋转切换效果需要通过 3D动画 来实现,通过 rotate 来控制。现在来思考如何控制它们的背景及不透明度和旋转的角度。

激活状态

首先当前是否是激活状态需要通过路由来判断,数组中用一个字段 url 来标识当前项所对应的路由,只要当前路由匹配,则处于激活状态。

vue
<script setup>
const router = useRouter()

const list = ref([
  { url: '/home', name: 'xxx' },
  { url: '/about', name: 'yyy' },
  { url: '/info', name: 'zzz' },
])

function isActive(el) {
  return el.url && el.url === router.path
}
</script>

<template>
  <div v-for="(item, index) in list" :key="item.url" :class="{ active: isActive(item) }">
    <!-- ... -->
  </div>
</template>

不透明度

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

  1. 声明一个变量 activeIndex 表示当前激活项的索引,通过计算属性 findIndex 判断当前路由的索引项
  2. activeIndex 减去当前项的索引,取绝对值,再用数组长度减去该值,通过 Math.min 获取该值与前面得到的绝对值的最小值
  3. 1 除以当前数组长度并赋值给变量 unitOpacity
  4. 计算当前的索引的不透明度,计算方式为 1 减去第二步获取到的值乘 unitOpacity 乘 1.7
vue
<script setup>
// ...
const activeIndex = computed(() => list.findIndex(item => item.url === router.path))

const unitOpacity = computed(() => 1 / list.length)

function getGap(activeIndex, index) {
  const len = list.length
  const gap = Math.abs(activeIndex - index)
  return Math.min(gap, len - gap)
}
</script>

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

3d旋转

剩下 3D旋转 的功能需要实现,先实现 3d布局,就是为需要 3d旋转的盒子设置 transform-style: preserve-3d; 样式,通过 rotate 实现角度调整。

每个 div 偏移的角度可以通过当前的索引计算获得,代码如下:

vue
<script setup>
function getRotateY(i) {
  const len = list.length
  const l = len / 2 | 0
  const rotationY = unitDeg.value * i - 1
  const gap = getGap(activeIndex.value, i)
  const p = leftOrRightOrCenter(activeIndex.value, i)
  const 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>

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

整体代码

vue
<script setup>
import { computed, ref, 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`)

function getGap(l, r) {
  const len = props.systemList.length
  const gap = Math.abs(l - r)
  return Math.min(len - gap, gap)
}

function leftOrRightOrCenter(activeIndex, index) {
  const 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) {
  const len = props.systemList.length
  const l = len / 2 | 0
  const rotationY = unitDeg.value * i - 1
  const gap = getGap(activeIndex.value, i)
  const p = leftOrRightOrCenter(activeIndex.value, i)
  const 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) => {
  const 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>

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

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

Contributors

Yuan Tang