/** * Shared Element Transition Composable * 实现卡片到详情页的共享元素转场动画(FLIP 技术) */ import { reactive, readonly } from 'vue' interface TransitionElement { el: HTMLElement rect: DOMRect } interface TransitionState { from: TransitionElement | null to: TransitionElement | null isAnimating: boolean slug: string | null } const state = reactive({ from: null, to: null, isAnimating: false, slug: null, }) // 存储离开页面时的元素位置 const exitPositions = new Map() // 创建全局遮罩层用于转场 let overlayEl: HTMLElement | null = null function createOverlay() { if (overlayEl) return overlayEl overlayEl = document.createElement('div') overlayEl.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.95); z-index: 9999; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; ` document.body.appendChild(overlayEl) return overlayEl } function showOverlay() { const overlay = createOverlay() requestAnimationFrame(() => { overlay.style.opacity = '1' }) } function hideOverlay() { if (!overlayEl) return overlayEl.style.opacity = '0' setTimeout(() => { if (overlayEl && overlayEl.parentNode) { overlayEl.parentNode.removeChild(overlayEl) overlayEl = null } }, 300) } export function useSharedTransition() { /** * 标记源元素(列表卡片) */ function markSource(slug: string, el: HTMLElement) { if (!el || !slug) return const rect = el.getBoundingClientRect() state.from = { el, rect } state.slug = slug exitPositions.set(slug, rect) console.log('📌 标记源元素:', { slug, rect: { x: rect.left, y: rect.top, w: rect.width, h: rect.height } }) } /** * 标记目标元素(详情页) */ function markTarget(slug: string, el: HTMLElement) { if (!el || !slug) return const rect = el.getBoundingClientRect() state.to = { el, rect } } /** * 执行进入动画(列表 → 详情) */ function animateEnter(slug: string, targetEl: HTMLElement) { if (!targetEl) return console.log('🎬 animateEnter 被调用:', { slug, targetEl, isAnimating: state.isAnimating }) const fromRect = exitPositions.get(slug) console.log('📍 源位置信息:', fromRect) // 显示遮罩层 showOverlay() if (!fromRect) { // 没有源位置,使用简单淡入 console.log('⚠️ 没有找到源位置,使用降级动画') targetEl.style.opacity = '0' targetEl.style.transform = 'scale(0.9)' targetEl.style.position = 'relative' targetEl.style.zIndex = '10000' requestAnimationFrame(() => { requestAnimationFrame(() => { targetEl.style.transition = 'opacity 1s ease, transform 1s ease' targetEl.style.opacity = '1' targetEl.style.transform = 'scale(1)' setTimeout(() => { targetEl.style.transition = '' targetEl.style.transform = '' targetEl.style.opacity = '' targetEl.style.position = '' targetEl.style.zIndex = '' hideOverlay() }, 1000) }) }) return } state.isAnimating = true const toRect = targetEl.getBoundingClientRect() // 计算 FLIP 变换 const scaleX = fromRect.width / toRect.width const scaleY = fromRect.height / toRect.height const translateX = fromRect.left - toRect.left const translateY = fromRect.top - toRect.top console.log('🎬 共享元素转场开始:', { slug, from: { x: fromRect.left, y: fromRect.top, w: fromRect.width, h: fromRect.height }, to: { x: toRect.left, y: toRect.top, w: toRect.width, h: toRect.height }, transform: { translateX, translateY, scaleX, scaleY } }) // First: 设置初始状态(从源位置) targetEl.style.position = 'relative' targetEl.style.zIndex = '10000' targetEl.style.transformOrigin = 'top left' targetEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})` targetEl.style.opacity = '0.3' targetEl.style.willChange = 'transform, opacity' // Last: 动画到最终位置(使用双重 RAF 确保初始状态被应用) requestAnimationFrame(() => { requestAnimationFrame(() => { targetEl.style.transition = 'transform 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 1.5s ease' targetEl.style.transform = 'translate(0, 0) scale(1, 1)' targetEl.style.opacity = '1' setTimeout(() => { targetEl.style.transition = '' targetEl.style.transform = '' targetEl.style.transformOrigin = '' targetEl.style.opacity = '' targetEl.style.willChange = '' targetEl.style.position = '' targetEl.style.zIndex = '' state.isAnimating = false hideOverlay() console.log('✅ 共享元素转场完成') }, 1500) }) }) } /** * 执行退出动画(详情 → 列表) */ function animateExit(slug: string, sourceEl: HTMLElement, onComplete?: () => void) { if (!sourceEl || state.isAnimating) { onComplete?.() return } const toRect = exitPositions.get(slug) if (!toRect) { // 没有目标位置,使用简单淡出 sourceEl.style.transition = 'opacity 0.6s ease, transform 0.6s ease' sourceEl.style.opacity = '0' sourceEl.style.transform = 'scale(0.95)' setTimeout(() => { onComplete?.() }, 600) return } state.isAnimating = true const fromRect = sourceEl.getBoundingClientRect() // 计算反向 FLIP 变换 const scaleX = toRect.width / fromRect.width const scaleY = toRect.height / fromRect.height const translateX = toRect.left - fromRect.left const translateY = toRect.top - fromRect.top console.log('🔙 共享元素退出动画开始:', { slug, from: { x: fromRect.left, y: fromRect.top, w: fromRect.width, h: fromRect.height }, to: { x: toRect.left, y: toRect.top, w: toRect.width, h: toRect.height }, transform: { translateX, translateY, scaleX, scaleY } }) // 动画到目标位置(加长到 800ms) sourceEl.style.transformOrigin = 'top left' sourceEl.style.transition = 'transform 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.8s ease' sourceEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scaleX}, ${scaleY})` sourceEl.style.opacity = '0.7' setTimeout(() => { state.isAnimating = false console.log('✅ 共享元素退出动画完成') onComplete?.() }, 800) } /** * 清理状态 */ function cleanup() { state.from = null state.to = null state.slug = null state.isAnimating = false } /** * 清理过期的位置缓存(保留最近 20 个) */ function cleanupOldPositions() { if (exitPositions.size > 20) { const keys = Array.from(exitPositions.keys()) const toDelete = keys.slice(0, keys.length - 20) toDelete.forEach(key => exitPositions.delete(key)) } } return { state: readonly(state), markSource, markTarget, animateEnter, animateExit, cleanup, cleanupOldPositions, } }