268 lines
7.3 KiB
TypeScript
268 lines
7.3 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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<TransitionState>({
|
|||
|
|
from: null,
|
|||
|
|
to: null,
|
|||
|
|
isAnimating: false,
|
|||
|
|
slug: null,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 存储离开页面时的元素位置
|
|||
|
|
const exitPositions = new Map<string, DOMRect>()
|
|||
|
|
|
|||
|
|
// 创建全局遮罩层用于转场
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
}
|