Files
arcticnewone/app/components/home/HomeHero.vue
T
virtheart 76e7b4b817 refactor(components): 优化动画组件代码结构并移除冗余样式
style(pages): 使用实用类替换内联样式
refactor(composables): 简化页面标题模板逻辑
docs(components): 添加详细注释说明动画逻辑
2026-04-27 02:28:49 +08:00

155 lines
5.6 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 { onMounted, onBeforeUnmount, ref } from 'vue'
import { useNuxtApp } from '#app'
import gsap from 'gsap'
// 定义背景画廊与主视图绑定的总滚动距离(像素),数值越大,用户向下滚动时 Hero 面板缩放的过程就越长
const heroPinDistance = 1200
const heroGalleryColumns = [
{ side: 'left', images: ['1018', '1015', '1024', '1039'].map(id => `https://picsum.photos/id/${id}/900/1200`) },
{ side: 'right', images: ['1043', '1050', '1067', '1074'].map(id => `https://picsum.photos/id/${id}/900/1200`) },
] as const
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
let cleanupHeroScroll: (() => void) | null = null
// 暴露供外部(如 index.vue)使用的异步播放方法
const play = () => new Promise<void>((resolve) => {
if (!heroRef.value) return resolve()
let eventDispatched = false
// 执行入场动画:将 Hero 整体容器从屏幕下方(yPercent: 100)滑入屏幕(yPercent: 0
gsap.to(heroRef.value, {
yPercent: 0,
duration: 1.2,
ease: 'power3.out',
onUpdate() {
// 当上滑动画进度超过 60% 时,触发全局事件,通知导航栏等其他组件可以开始它们自己的进场动画了
if (!eventDispatched && this.progress() > 0.6) {
eventDispatched = true
window.dispatchEvent(new CustomEvent('hero-intro-done'))
}
},
onComplete: resolve,
})
})
// 设置页面向下滚动时的视差缩放逻辑(绑定到 Lenis 的平滑滚动数值上)
const setupHeroScroll = () => {
if (!heroStageRef.value || !heroPanelRef.value) return
const { $lenis } = useNuxtApp() as { $lenis?: { scroll: number } }
const stageTop = heroStageRef.value.offsetTop
let renderedScale = -1
// 初始化 Hero 面板的缩放状态和 3D 硬件加速
gsap.set(heroPanelRef.value, { scale: 1, force3D: true, transformOrigin: 'center center' })
// 核心的每帧更新逻辑
const updateHeroPanelScale = () => {
const exitDistance = window.innerHeight
const totalDistance = heroPinDistance + exitDistance
// 如果启用了 Lenis,则读取其平滑的插值滚动值,否则回退到浏览器原生的 window.scrollY
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
// 计算当前元素在滚动容器内走过的有效距离
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - stageTop)
// 根据滚动距离,分两个阶段计算 Hero 面板的缩放比例 (scale):
// 阶段一:在设置的 1200px 距离内,面板从 1.0 缩小到 0.65
// 阶段二:超过 1200px 后,跟随页面继续向下滚出的过程,面板从 0.65 继续缩小到 0.18
const newScale = currentDistance <= heroPinDistance
? gsap.utils.interpolate(1, 0.65, currentDistance / heroPinDistance)
: gsap.utils.interpolate(0.65, 0.18, (currentDistance - heroPinDistance) / exitDistance)
// 只有当缩放值的变化超过肉眼/渲染的有效阈值(0.001)时,才真正触发 DOM 更新,节省性能
if (Math.abs(renderedScale - newScale) > 0.001) {
renderedScale = newScale
gsap.set(heroPanelRef.value, { scale: newScale })
}
}
// 立即执行一次计算初始状态
updateHeroPanelScale()
// 将更新函数绑定到 gsap 的全局 ticker(类似于 requestAnimationFrame),保证在每一帧平滑读取数据并更新
gsap.ticker.add(updateHeroPanelScale)
// 返回清理函数
cleanupHeroScroll = () => gsap.ticker.remove(updateHeroPanelScale)
}
onMounted(() => {
// 组件挂载时,先将整个 Hero 强行置于屏幕下方,等待外部调用 `play()` 才滑入
if (heroRef.value) gsap.set(heroRef.value, { yPercent: 100 })
// 初始化滚动监听逻辑
setupHeroScroll()
})
onBeforeUnmount(() => {
cleanupHeroScroll?.()
})
defineExpose({ heroStageRef, heroRef, play })
</script>
<template>
<section
ref="heroStageRef"
class="relative bg-black"
:style="{ height: `calc(100svh + ${heroPinDistance}px)` }"
>
<section ref="heroRef" class="sticky top-0 h-[100svh] overflow-hidden bg-[#111]" v-once>
<div class="absolute inset-0 overflow-hidden bg-black" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="absolute -bottom-10 -top-10 w-[49vw] overflow-hidden"
:class="column.side === 'left' ? 'left-0' : 'right-0'"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track will-change-transform"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="grid gap-9 pb-9">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="block w-full rounded-none object-cover h-[clamp(260px,42vh,430px)]"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="absolute inset-0 grid place-items-center bg-white text-5xl will-change-transform">
<p>Hero Section</p>
</div>
</section>
</section>
</template>
<style scoped>
.hero-gallery-track {
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
</style>