84 lines
2.3 KiB
TypeScript
84 lines
2.3 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useState, useEffect, useRef } from 'react';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 数字增长动画 Hook - 进入视口触发,离开视口重置
|
|||
|
|
* 使用 requestAnimationFrame 保证与浏览器刷新同步
|
|||
|
|
*/
|
|||
|
|
export function useCountUp(end: number, duration: number = 1500, startRangePercent: number = 0.75) {
|
|||
|
|
const [mounted, setMounted] = useState(false);
|
|||
|
|
const [count, setCount] = useState(end); // 初始值设为目标值,避免 hydration 错误
|
|||
|
|
const elementRef = useRef<HTMLDivElement>(null);
|
|||
|
|
const startValueRef = useRef<number>(end);
|
|||
|
|
const rafRef = useRef<number | null>(null);
|
|||
|
|
|
|||
|
|
// 客户端挂载后设置随机起始值
|
|||
|
|
useEffect(() => {
|
|||
|
|
setMounted(true);
|
|||
|
|
startValueRef.current = Math.floor(end * (startRangePercent + Math.random() * 0.15));
|
|||
|
|
setCount(startValueRef.current);
|
|||
|
|
}, [end, startRangePercent]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
|
|||
|
|
const cancelAnimation = () => {
|
|||
|
|
if (rafRef.current !== null) {
|
|||
|
|
cancelAnimationFrame(rafRef.current);
|
|||
|
|
rafRef.current = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const startAnimation = () => {
|
|||
|
|
cancelAnimation();
|
|||
|
|
|
|||
|
|
const start = startValueRef.current;
|
|||
|
|
const startTime = performance.now();
|
|||
|
|
|
|||
|
|
const tick = (currentTime: number) => {
|
|||
|
|
const elapsed = currentTime - startTime;
|
|||
|
|
const progress = Math.min(elapsed / duration, 1);
|
|||
|
|
|
|||
|
|
// easeOutCubic 缓动
|
|||
|
|
const easeOutCubic = 1 - Math.pow(1 - progress, 3);
|
|||
|
|
const currentCount = Math.floor(start + (end - start) * easeOutCubic);
|
|||
|
|
|
|||
|
|
setCount(currentCount);
|
|||
|
|
|
|||
|
|
if (progress < 1) {
|
|||
|
|
rafRef.current = requestAnimationFrame(tick);
|
|||
|
|
} else {
|
|||
|
|
setCount(end); // 确保最终值准确
|
|||
|
|
rafRef.current = null;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
rafRef.current = requestAnimationFrame(tick);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const observer = new IntersectionObserver(
|
|||
|
|
(entries) => {
|
|||
|
|
if (entries[0].isIntersecting) {
|
|||
|
|
startAnimation();
|
|||
|
|
} else {
|
|||
|
|
cancelAnimation();
|
|||
|
|
setCount(startValueRef.current);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ threshold: 0.1 }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (elementRef.current) {
|
|||
|
|
observer.observe(elementRef.current);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return () => {
|
|||
|
|
observer.disconnect();
|
|||
|
|
cancelAnimation();
|
|||
|
|
};
|
|||
|
|
}, [end, duration, mounted]);
|
|||
|
|
|
|||
|
|
return { count, elementRef };
|
|||
|
|
}
|