封装了一个会 "Duang" 的果冻标签栏组件

115 阅读2分钟

「好的动画是用户感觉不到存在的,但缺少它时一切都会显得生硬」

最近在工作中需要实现一个底部标签栏切换效果,参考了 掘金上的优秀实践,结合 Vue3 封装了一个具有「果冻弹性」的 Tab 组件。点击切换时,背景块会像果冻一样拉伸变形并丝滑移动,本文将详细拆解实现思路。

一、为什么需要「双层结构」?

这是最核心的设计思路。如果只用一层元素,我们会遇到一个技术矛盾:

  • 位移需要 transform: translateX() 来实现 GPU 加速
  • 形变需要 transform: scale() 来实现果冻拉伸
  • 冲突:CSS 的 transform 属性不能同时应用两个不同类型的变换(后设置的会覆盖前者)

💡 解决方案:分层职责

外层 indicator(只负责位移)
└── 内层 indicator-blob(只负责形变)
  • 外层:使用 transition 监听 transform 变化,实现丝滑平移
  • 内层:使用 animation 定义关键帧动画,实现弹性形变 这种"关注点分离"的模式,让两个动画互不干扰,同时运行。

二、核心代码解析

<template>
    <div class="nav-container">
        <div class="indicator" ref="indicatorRef">
            <div class="indicator-blob" :style="styleCore"></div>
        </div>

        <div
            v-for="(tab, index) in tabs"
            :key="index"
            class="icon-item"
            @click="moveIndicator(tab, index)"
            :ref="
                el => {
                    if (el) tabRefs[index] = el;
                }
            "
        >
            <slot name="title" :tab="tab[props.source.id]">
                {{ tab[props.source.title] }}
            </slot>
        </div>
    </div>
</template>

注意:这里使用了 Vue3 的模板 ref 绑定方式,通过函数式绑定将每个 tab 的 DOM 引用存入数组。

2. 逻辑层:计算位移与触发动画

<script setup>
const ANIMATION_DURATION = 500;
const TRANSITION_DURATION = 300;
const DEBOUNCE_DELAY = 200;
const RESIZE_DEBOUNCE_DELAY = 200;

const debounce = (func, delay) => {
    let timeoutId;
    return (...args) => {
        if (timeoutId) {
            clearTimeout(timeoutId);
        }
        timeoutId = setTimeout(() => {
            func.apply(this, args);
        }, delay);
    };
};

let resizeObserver;

const props = defineProps({
    modelValue: { default: 0 },
    tabs: { type: Array, default: () => [] },
    styleCore: { type: Object, default: () => ({}) },
    styleOuter: { type: Object, default: () => ({}) },
    source: { type: Object, default: () => ({ title: 'title', id: 'id' }) },
});

const emit = defineEmits(['update:modelValue', 'change']);

const tabRefs = ref([]);
const indicatorRef = ref(null);
const currentIndex = ref(props.modelValue);

const moveIndicator = (tab, index, silent = false) => {
    const indicator = indicatorRef.value;
    const targetTab = tabRefs.value[index];

    if (!indicator || !targetTab) return;

    const containerRect = indicator.parentElement.getBoundingClientRect();
    const tabRect = targetTab.getBoundingClientRect();

    const translateX = tabRect.left - containerRect.left;
    const width = tabRect.width;
    const height = tabRect.height;

    indicator.style.width = `${width}px`;
    indicator.style.height = `${height}px`;
    indicator.style.transform = `translateX(${translateX}px)`;

    triggerJellyAnimation(indicator);

    if (!silent) {
        emit('change', tab[props.source.id]);
        emit('update:modelValue', tab[props.source.id]);
        currentIndex.value = index;
    }
};

const triggerJellyAnimation = element => {
    const blob = element.querySelector('.indicator-blob');
    blob.classList.remove('stretching');
    void blob.offsetWidth;
    blob.classList.add('stretching');
};

const updateIndicatorPosition = () => {
    const index = props.tabs.findIndex(item => item[props.source.id] === currentIndex.value);
    if (index !== -1) {
        const tab = props.tabs[index];
        moveIndicator(tab, index, true);
    }
};

onMounted(() => {
    nextTick(() => {
        if (props.tabs.length > 0) {
            const index = props.tabs.findIndex(item => item[props.source.id] === props.modelValue);
            if (index !== -1) {
                const tab = props.tabs[index];
                moveIndicator(tab, index, true);
            }
        }
    });

    resizeObserver = new ResizeObserver(debounce(updateIndicatorPosition, RESIZE_DEBOUNCE_DELAY));
    resizeObserver.observe(indicatorRef.value.parentElement);
});

onUnmounted(() => {
    resizeObserver?.disconnect();
});
</script>

🔑 关键技术点:

  1. void blob.offsetWidth:这是实现重复触发动画的核心。浏览器优化机制会批量处理样式变更,通过读取元素的 offsetWidth 强制触发重排(Reflow),确保动画类被移除后重新添加能生效。
  2. nextTick:确保 DOM 渲染完成后再计算几何位置,避免获取到错误的 getBoundingClientRect 数据。

3. 样式层:CSS 动画与贝塞尔曲线

</script>
<style lang="scss" scoped>
$nav-padding: 8px;
$nav-gap: 8px;
$nav-border-radius: 24px;
$indicator-border-radius: 16px;
$tab-padding: 12px;
$tab-font-size: 14px;

.nav-container {
    position: relative;
    display: flex;
    padding: $nav-padding;
    border-radius: $nav-border-radius;
    gap: $nav-gap;
}

.indicator {
    position: absolute;
    top: $nav-padding;
    left: 0;
    z-index: 1;
    pointer-events: none;

    transition:
        transform 0.5s cubic-bezier(0.68, -0.2, 0.265, 1.2),
        width 0.3s ease;

    will-change: transform, width;
}

.indicator-blob {
    width: 100%;
    height: 100%;
    background: #fff;
    border-radius: $indicator-border-radius;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}

.stretching {
    animation: jelly-anim 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}

@keyframes jelly-anim {
    0% {
        transform: scale(1, 1);
    }
    30% {
        transform: scale(1.25, 0.75);
    }
    50% {
        transform: scale(1.25, 0.75);
    }
    70% {
        transform: scale(0.85, 1.15);
    }
    100% {
        transform: scale(1, 1);
    }
}

.icon-item {
    padding: $tab-padding;
    text-align: center;
    cursor: pointer;
    z-index: 2;
    position: relative;

    .tab-text {
        font-size: $tab-font-size;
        font-weight: 600;
        transition: color 0.3s;
    }

    &.active .tab-text {
        color: #2c2c2c;
    }
}
</style>

🎨 CSS 技巧解读:

  1. Cubic Bezier 回弹曲线cubic-bezier(0.68, -0.2, 0.265, 1.2) 中的 y 值超出 0-1 范围(-0.2 和 1.2),实现了"过冲"效果——果冻块会稍微超过目标位置再回弹,这就是物理世界中的弹性表现。
  2. Will-Change 属性:提前告知浏览器哪些属性会变化,让浏览器提前创建合成层,避免动画开始时的卡顿。
  3. Mix-Blend-Mode(可选黑科技) :如果你将文字颜色设为白色并开启 mix-blend-mode: difference,当白色背景块移动到文字下方时,文字会自动变成黑色,无需 JS 干预颜色切换。