Files
what-to-eat/apps/web/app/composables/animation.ts
T

100 lines
2.9 KiB
TypeScript

import type { MaybeElementRef } from '@vueuse/core'
import { isClient, unrefElement } from '@vueuse/core'
const FLIGHT_DURATION = 560
const FLIGHT_STEPS = 16
/** 抛物线顶点相对起点额外抬高的距离 */
const FLIGHT_LIFT = 72
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function center(rect: DOMRect) {
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
/**
* 篮子按钮“接住”食材的回弹
*/
function bump(el: Element) {
el.animate([
{ transform: 'scale(1)' },
{ transform: 'scale(1.18)', offset: 0.35 },
{ transform: 'scale(0.94)', offset: 0.7 },
{ transform: 'scale(1)' },
], { duration: 420, easing: 'ease-out' })
}
/**
* 选中食材时,emoji 沿抛物线飞进篮子按钮
*/
export function useEmojiAnimation(basket: MaybeElementRef) {
const playAnimation = (emoji: string, origin?: Element | null) => {
if (!isClient)
return
const basketEl = unrefElement(basket)
// 落点优先用标记了 data-flight-target 的元素,其次是容器里真正的按钮
const target = basketEl?.querySelector('[data-flight-target], ion-fab-button, button') ?? basketEl
if (!target?.isConnected)
return
const to = target.getBoundingClientRect()
if (!to.width || !to.height)
return
if (!origin || prefersReducedMotion()) {
if (!prefersReducedMotion())
bump(target)
return
}
const start = center(origin.getBoundingClientRect())
const end = center(to)
const dx = end.x - start.x
const dy = end.y - start.y
// 二次贝塞尔控制点:水平居中,竖直方向高于起点与终点
const cx = dx / 2
const cy = Math.min(0, dy) - FLIGHT_LIFT
const keyframes: Keyframe[] = []
for (let i = 0; i <= FLIGHT_STEPS; i++) {
const t = i / FLIGHT_STEPS
const x = 2 * (1 - t) * t * cx + t * t * dx
const y = 2 * (1 - t) * t * cy + t * t * dy
const scale = t < 0.2 ? 1 + t * 1.5 : 1.3 - (t - 0.2) * 1.1
keyframes.push({
transform: `translate(-50%, -50%) translate(${x}px, ${y}px) rotate(${t * (dx >= 0 ? 30 : -30)}deg) scale(${scale})`,
opacity: t > 0.85 ? 1 - (t - 0.85) / 0.15 * 0.7 : 1,
})
}
const emojiEl = document.createElement('span')
emojiEl.textContent = emoji
emojiEl.setAttribute('aria-hidden', 'true')
Object.assign(emojiEl.style, {
position: 'fixed',
left: `${start.x}px`,
top: `${start.y}px`,
zIndex: '1000',
fontSize: '20px',
lineHeight: '1',
pointerEvents: 'none',
willChange: 'transform, opacity',
})
document.body.appendChild(emojiEl)
const flight = emojiEl.animate(keyframes, {
duration: FLIGHT_DURATION,
easing: 'cubic-bezier(0.45, 0, 0.75, 1)',
fill: 'forwards',
})
flight.finished.then(() => bump(target), () => {}).finally(() => emojiEl.remove())
}
return {
playAnimation,
}
}