diff --git a/src/components/animations/AnimationEngine.tsx b/src/components/animations/AnimationEngine.tsx index 10c80aa7..a3cf7129 100644 --- a/src/components/animations/AnimationEngine.tsx +++ b/src/components/animations/AnimationEngine.tsx @@ -1,4 +1,4 @@ -import { scheduleFrame, stepSpring, SpringConfig, SpringState } from '../../utils/animationUtils'; +import { stepSpring, SpringConfig, SpringState } from '../../utils/animationUtils'; type OnUpdate = (value: number) => void; @@ -9,15 +9,44 @@ export type AnimationController = { export class AnimationEngine { private active: Set = new Set(); + private frameId: number | null = null; spring(start: number, target: number, onUpdate: OnUpdate, config: SpringConfig = {}) { const impl = new AnimationControllerImpl(start, target, onUpdate, config); this.active.add(impl); - impl.onStop = () => this.active.delete(impl); - impl.start(); + + impl.onStop = () => { + this.active.delete(impl); + if (this.active.size === 0 && this.frameId !== null) { + cancelAnimationFrame(this.frameId); + this.frameId = null; + } + }; + + impl.start(this.ensureLoop.bind(this)); return impl; } + private ensureLoop() { + if (this.frameId !== null) return; + + const loop = (now: number) => { + if (this.active.size === 0) { + this.frameId = null; + return; + } + + // Process all calculations and DOM writes + for (const anim of this.active) { + anim.step(now); + } + + this.frameId = requestAnimationFrame(loop); + }; + + this.frameId = requestAnimationFrame(loop); + } + stopAll() { for (const c of Array.from(this.active)) c.stop(); this.active.clear(); @@ -41,35 +70,39 @@ class AnimationControllerImpl implements AnimationController { this.config = config; } - start() { + start(triggerLoop: () => void) { if (this.running) return; this.running = true; this.last = performance.now(); - const loop = () => { - if (!this.running) return; - const now = performance.now(); - const dt = Math.min(32, now - this.last) / 1000; - this.last = now; - const res = stepSpring( - this.state.position, - this.state.velocity, - this.state.target, - dt, - this.config, - ); - this.state.position = res.position; - this.state.velocity = res.velocity; - this.onUpdate(this.state.position); - const vel = Math.abs(this.state.velocity); - const dist = Math.abs(this.state.position - this.state.target); - if (vel < (this.config.restVelocity ?? 0.02) && dist < (this.config.precision ?? 0.001)) { - this.onUpdate(this.state.target); - this.stop(); - return; - } - scheduleFrame(loop); - }; - scheduleFrame(loop); + triggerLoop(); + } + + step(now: number) { + if (!this.running) return; + + const dt = Math.min(32, now - this.last) / 1000; + this.last = now; + + const res = stepSpring( + this.state.position, + this.state.velocity, + this.state.target, + dt, + this.config, + ); + + this.state.position = res.position; + this.state.velocity = res.velocity; + + this.onUpdate(this.state.position); + + const vel = Math.abs(this.state.velocity); + const dist = Math.abs(this.state.position - this.state.target); + + if (vel < (this.config.restVelocity ?? 0.02) && dist < (this.config.precision ?? 0.001)) { + this.onUpdate(this.state.target); + this.stop(); + } } stop() { @@ -79,7 +112,7 @@ class AnimationControllerImpl implements AnimationController { setTarget(t: number) { this.state.target = t; - if (!this.running) this.start(); + if (!this.running) this.start(() => {}); } } diff --git a/src/components/animations/InteractiveAnimations.tsx b/src/components/animations/InteractiveAnimations.tsx index 61bad3b9..ec61784d 100644 --- a/src/components/animations/InteractiveAnimations.tsx +++ b/src/components/animations/InteractiveAnimations.tsx @@ -26,39 +26,47 @@ export default function InteractiveAnimations({ let dragging = false; const resetCompositorHints = () => { - el.style.willChange = ''; + if (el) el.style.willChange = ''; }; const onPointerDown = (e: PointerEvent) => { dragging = true; startRef.current = axis === 'x' ? e.clientX : e.clientY; el.setPointerCapture(e.pointerId); + if (ctrlRef.current) ctrlRef.current.stop(); + el.style.willChange = 'transform'; - el.style.transform = axis === 'x' ? 'translate3d(0,0,0)' : 'translate3d(0,0,0)'; }; const onPointerMove = (e: PointerEvent) => { if (!dragging) return; const cur = axis === 'x' ? e.clientX : e.clientY; const delta = cur - startRef.current; - // apply transform directly for immediate response - const transform = - axis === 'x' ? `translate3d(${delta}px,0,0)` : `translate3d(0,${delta}px,0)`; - el.style.transform = transform; - el.style.willChange = 'transform'; + + // Use requestAnimationFrame for the manual move to keep it in sync with refresh rate + requestAnimationFrame(() => { + if (!dragging) return; + el.style.transform = + axis === 'x' ? `translate3d(${delta}px,0,0)` : `translate3d(0,${delta}px,0)`; + }); }; const onPointerUp = (e: PointerEvent) => { if (!dragging) return; dragging = false; + const cur = axis === 'x' ? e.clientX : e.clientY; const delta = cur - startRef.current; const abs = Math.abs(delta); + + // READ outside the loop to avoid thrashing + const exitDistance = axis === 'x' ? window.innerWidth : window.innerHeight; + if (abs > threshold && onDismiss) { // fling away const dir = delta > 0 ? 1 : -1; - ctrlRef.current = engine.spring(delta, dir * (window.innerWidth || 1200), (v) => { + ctrlRef.current = engine.spring(delta, dir * exitDistance, (v) => { el.style.transform = axis === 'x' ? `translate3d(${v}px,0,0)` : `translate3d(0,${v}px,0)`; }); ctrlRef.current.onStop = () => onDismiss(); @@ -90,7 +98,11 @@ export default function InteractiveAnimations({
{children}