「好的动画是用户感觉不到存在的,但缺少它时一切都会显得生硬」
最近在工作中需要实现一个底部标签栏切换效果,参考了 掘金上的优秀实践,结合 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>
🔑 关键技术点:
void blob.offsetWidth:这是实现重复触发动画的核心。浏览器优化机制会批量处理样式变更,通过读取元素的offsetWidth强制触发重排(Reflow),确保动画类被移除后重新添加能生效。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 技巧解读:
- Cubic Bezier 回弹曲线:
cubic-bezier(0.68, -0.2, 0.265, 1.2)中的 y 值超出 0-1 范围(-0.2 和 1.2),实现了"过冲"效果——果冻块会稍微超过目标位置再回弹,这就是物理世界中的弹性表现。 - Will-Change 属性:提前告知浏览器哪些属性会变化,让浏览器提前创建合成层,避免动画开始时的卡顿。
- Mix-Blend-Mode(可选黑科技) :如果你将文字颜色设为白色并开启
mix-blend-mode: difference,当白色背景块移动到文字下方时,文字会自动变成黑色,无需 JS 干预颜色切换。