feat(header): 重构导航栏动画逻辑并优化入场效果
将导航栏动画控制从首页迁移到组件内部,实现更流畅的从底部飞入效果 添加自定义事件监听和保底机制确保导航栏正常显示 优化导航链接样式和交互效果
This commit is contained in:
@@ -37,22 +37,81 @@ const setBottomHeaderVisible = (visible: boolean) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
|
||||
gsap.set(bottomHeaderRef.value, {
|
||||
yPercent: 140,
|
||||
autoAlpha: 0,
|
||||
})
|
||||
// 移除之前这里强制设置透明度的代码,直接依靠 css 控制
|
||||
// gsap.set(topHeaderRef.value, { y: '100vh', opacity: 0 })
|
||||
|
||||
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
|
||||
topHeaderTween = gsap.to(topHeaderRef.value, {
|
||||
yPercent: -140,
|
||||
autoAlpha: 0,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
start: 0,
|
||||
end: 200,
|
||||
scrub: true,
|
||||
},
|
||||
paused: true, // 初始不执行,等进场动画完毕再绑定到 ScrollTrigger
|
||||
})
|
||||
|
||||
// 定义一个供外部或延迟调用的入场动画方法
|
||||
const playHeaderIntro = () => {
|
||||
if (!topHeaderRef.value) return
|
||||
|
||||
gsap.fromTo(topHeaderRef.value,
|
||||
{
|
||||
y: '100vh',
|
||||
opacity: 0 // 恢复初始透明度为0,让它在屏幕最底部时是隐形的
|
||||
},
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1, // 伴随飞入的过程逐渐完全显示
|
||||
duration: 1.4,
|
||||
ease: 'power4.out',
|
||||
delay: 0,
|
||||
clearProps: 'transform',
|
||||
onComplete: () => {
|
||||
// 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死
|
||||
if (topHeaderRef.value) {
|
||||
topHeaderRef.value.style.opacity = ''
|
||||
// 进场动画完毕后,再将滚动消失动画挂载到 ScrollTrigger
|
||||
if (topHeaderTween) {
|
||||
ScrollTrigger.create({
|
||||
animation: topHeaderTween,
|
||||
start: 0,
|
||||
end: 200,
|
||||
scrub: true,
|
||||
})
|
||||
ScrollTrigger.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场
|
||||
const handleHeroReady = () => {
|
||||
playHeaderIntro()
|
||||
}
|
||||
|
||||
// 如果是在客户端执行,判断是否在首页
|
||||
if (typeof window !== 'undefined') {
|
||||
// 使用 setTimeout 宏任务确保所有 GSAP 设置生效,并且跳过初始渲染卡顿
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname === '/') {
|
||||
// 首页:等待自定义事件
|
||||
window.addEventListener('hero-intro-done', handleHeroReady, { once: true })
|
||||
|
||||
// 增加一个保底的 setTimeout,防止首页的动画事件因为某种原因未能触发(例如用户快速切换路由)
|
||||
setTimeout(() => {
|
||||
// 不再判断太复杂的 opacity 计算,只要 3.5s 到了,且 topHeaderRef 还有,直接执行,防止各种 bug 导致锁死
|
||||
playHeaderIntro()
|
||||
}, 3500) // 等待 3.5 秒(足够首页 intro 动画执行完)
|
||||
} else {
|
||||
// 非首页的保底进场
|
||||
playHeaderIntro()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
|
||||
gsap.set(bottomHeaderRef.value, {
|
||||
yPercent: 140,
|
||||
autoAlpha: 0,
|
||||
})
|
||||
|
||||
bottomHeaderScrollTrigger = ScrollTrigger.create({
|
||||
@@ -93,6 +152,7 @@ onBeforeUnmount(() => {
|
||||
<header
|
||||
ref="topHeaderRef"
|
||||
class="site-header-top fixed left-0 right-0 top-0 z-50 mx-auto flex h-[100px] w-page-container max-w-[calc(100%-2rem)] items-center justify-between px-4 py-5"
|
||||
style="opacity: 0; transform: translateY(100vh);"
|
||||
>
|
||||
<!-- 左侧:Logo 和 标题 -->
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -112,18 +172,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- 中间:导航链接 -->
|
||||
<nav class="hidden md:flex items-center gap-20 lg:gap-28">
|
||||
<a
|
||||
<NuxtLink
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:href="item.path"
|
||||
class="group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
|
||||
:to="item.path"
|
||||
class="nav-link-item group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
|
||||
style="font-size: 1.125rem; justify-content: flex-start; position: relative;"
|
||||
>
|
||||
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-3">
|
||||
<span class="mr-1.5 h-1.5 w-1.5 rounded-full bg-black"></span>
|
||||
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]">
|
||||
<span class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- 右侧:菜单图标 (使用 div 绘制) -->
|
||||
|
||||
+28
-11
@@ -252,15 +252,35 @@ onMounted(() => {
|
||||
yPercent: 0,
|
||||
duration: 1.05,
|
||||
ease: 'power4.inOut',
|
||||
onUpdate: function() {
|
||||
// 当 Hero (白色背景) 的 yPercent 运动到 40% 的时候(即大部分背景已经上浮到了屏幕内,还没完全贴顶),触发导航栏出场
|
||||
const progress = this.progress()
|
||||
// 使用一个标记防止重复触发
|
||||
if (progress > 0.6 && !this._headerTriggered) {
|
||||
this._headerTriggered = true
|
||||
window.dispatchEvent(new Event('hero-intro-done'))
|
||||
}
|
||||
},
|
||||
onStart: function() {
|
||||
this._headerTriggered = false
|
||||
}
|
||||
}, 'introOut')
|
||||
if (topHeader) {
|
||||
introTimeline.to(topHeader, {
|
||||
yPercent: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.55,
|
||||
ease: 'power3.out',
|
||||
}, 'introOut+=0.45')
|
||||
}
|
||||
// 取消原本通过 index.vue 直接控制导航栏 yPercent 的行为
|
||||
// 因为我们要让 Header 自己执行从屏幕底部(y: 100vh)飞入的高级动画
|
||||
// if (topHeader) {
|
||||
// introTimeline.to(topHeader, {
|
||||
// yPercent: 0,
|
||||
// autoAlpha: 1,
|
||||
// duration: 0.55,
|
||||
// ease: 'power3.out',
|
||||
// }, 'introOut+=0.45')
|
||||
// }
|
||||
|
||||
// 在动画即将完成或者 Hero 入场时触发自定义事件,通知导航栏执行自身的从下往上飞入动画
|
||||
// 将触发时机再延后,等白色背景开始渲染的瞬间就触发
|
||||
// introTimeline.add(() => {
|
||||
// window.dispatchEvent(new Event('hero-intro-done'))
|
||||
// }, 'introOut')
|
||||
}
|
||||
|
||||
const releasePageScroll = () => {
|
||||
@@ -283,9 +303,6 @@ onMounted(() => {
|
||||
gsap.set(introCharElements, { y: 0, yPercent: 110 })
|
||||
gsap.set(tmElement, { autoAlpha: 0 })
|
||||
gsap.set(heroElement, { yPercent: 100 })
|
||||
if (topHeader) {
|
||||
gsap.set(topHeader, { yPercent: -140, autoAlpha: 0 })
|
||||
}
|
||||
setupHeroScroll()
|
||||
|
||||
loaderTween = gsap.to(loaderBarElement, {
|
||||
|
||||
Reference in New Issue
Block a user