Files
arcticnewone/app/components/layouts/ArcticNewOneHeader.vue
T
virtheart 78439ba069 feat(header): 重构导航栏动画逻辑并优化入场效果
将导航栏动画控制从首页迁移到组件内部,实现更流畅的从底部飞入效果
添加自定义事件监听和保底机制确保导航栏正常显示
优化导航链接样式和交互效果
2026-04-26 22:57:41 +08:00

204 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
// 导航菜单数据配置
const navItems = [
{ name: 'Home', path: '/' },
{ name: 'Studio', path: '/studio' },
{ name: 'Work', path: '/work' },
{ name: 'News', path: '/news' },
{ name: 'Contact', path: '/contact' }
]
const topHeaderRef = ref<HTMLElement | null>(null)
const bottomHeaderRef = ref<HTMLElement | null>(null)
let topHeaderTween: gsap.core.Tween | null = null
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
let isBottomHeaderVisible = false
const setBottomHeaderVisible = (visible: boolean) => {
if (!bottomHeaderRef.value || isBottomHeaderVisible === visible) {
return
}
isBottomHeaderVisible = visible
gsap.to(bottomHeaderRef.value, {
yPercent: visible ? 0 : 140,
autoAlpha: visible ? 1 : 0,
duration: 0.35,
ease: 'power2.out',
overwrite: 'auto',
})
}
onMounted(() => {
// 移除之前这里强制设置透明度的代码,直接依靠 css 控制
// gsap.set(topHeaderRef.value, { y: '100vh', opacity: 0 })
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
topHeaderTween = gsap.to(topHeaderRef.value, {
yPercent: -140,
autoAlpha: 0,
ease: 'none',
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({
start: 0,
end: 'max',
onUpdate: (self) => {
const scrollY = self.scroll()
// 深度超过 2500px 后向上滚,显示未来用于深层内容的底部导航。
if (scrollY > 2500 && self.direction === -1) {
setBottomHeaderVisible(true)
return
}
// 回到 2300px 以内,或继续向下滚时,底部导航收回。
if (scrollY <= 2300 || self.direction === 1) {
setBottomHeaderVisible(false)
}
},
})
ScrollTrigger.refresh()
})
onBeforeUnmount(() => {
topHeaderTween?.scrollTrigger?.kill()
topHeaderTween?.kill()
bottomHeaderScrollTrigger?.kill()
topHeaderTween = null
bottomHeaderScrollTrigger = null
isBottomHeaderVisible = false
gsap.killTweensOf([topHeaderRef.value, bottomHeaderRef.value])
})
</script>
<template>
<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">
<div class="logo-icon flex h-10 w-10 items-center justify-center rounded-full bg-black text-white">
<!-- 简单的极简风格 SVG Logo -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-6 w-6">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
<path d="M2 12h20" />
</svg>
</div>
<div class="flex flex-col justify-center">
<span class="text-base font-bold leading-none tracking-wider text-black">北极新一</span>
<span class="mt-1 text-xs leading-none text-gray-500">ArcticNewOne</span>
</div>
</div>
<!-- 中间导航链接 -->
<nav class="hidden md:flex items-center gap-20 lg:gap-28">
<NuxtLink
v-for="item in navItems"
:key="item.name"
: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-[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>
</NuxtLink>
</nav>
<!-- 右侧菜单图标 (使用 div 绘制) -->
<div class="menu-icon group flex h-12 w-12 cursor-pointer flex-col items-end justify-center gap-[8px]">
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:translate-y-[2.5px]"></div>
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:-translate-y-[2.5px]"></div>
</div>
</header>
<header
ref="bottomHeaderRef"
class="fixed bottom-0 left-0 right-0 z-50 mx-auto h-[100px] w-page-container max-w-[calc(100%-2rem)] bg-blue-200 px-4 py-5"
style="visibility: hidden; opacity: 0;"
>
<nav>Bottom Navigation</nav>
</header>
</template>