[React] 手动实现CountTo 数字滚动效果

发布于:2024-05-04 ⋅ 阅读:(34) ⋅ 点赞:(0)

这个CountTo组件npmjs里当然有大把的依赖存在,不过今天我们不需要借助任何三方依赖,造个轮子来手动实现这个组件。

通过研究其他count to插件我们可以发现,数字滚动效果主要依赖于requestAnimationFrame 通过js帧来让数字动起来,数字变化则是依赖于内部的easingFn函数来每次计算。

首先声明组件props类型

interface Props {
  /**
   * 动画开始的值
   */
  start?: number;
  /**
   * 目标值
   */
  end: number;
  /**
   * 持续时间
   */
  duration?: number;
  /**
   * 是否自动播放
   */
  autoPlay?: boolean;
  /**
   * 精度
   */
  decimals?: number;
  /**
   * 小数点
   */
  decimal?: string;
  /**
   * 千分位分隔符
   */
  separator?: string;
  /**
   * 数字前 额外信息
   */
  prefix?: string;
  /**
   * 数字后 额外信息
   */
  suffix?: string;
  /**
   * 是否使用变速函数
   */
  useEasing?: boolean;
  /**
   * 计算函数
   */
  easingFn?: (t: number, b: number, c: number, d: number) => number;
  /**
   * 动画开始后传给父组件的回调
   */
  started?: () => void;
  /**
   * 动画结束传递给父组件的回调
   */
  ended?: () => void;
}

除了end 是必要的,其他都是可选参数。
所以我们需要给组件默认值,防止没有参数时会报错。
同时写几个工具函数便于后面使用

export default function Index({
  end,
  start = 0,
  duration = 3000,
  autoPlay = true,
  decimals = 0,
  decimal = '.',
  separator = ',',
  prefix = '',
  suffix = '',
  useEasing = true,
  easingFn = (t, b, c, d) => (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b,
  started = () => {},
  ended = () => {},
}: Props) {
	  const isNumber = (val: string) => {
	    return !isNaN(parseFloat(val));
	  };
	  // 格式化数据,返回想要展示的数据格式
	  const formatNumber = (n: number) => {
	    let val = '';
	    if (n % 1 !== 0) val = n.toFixed(decimals);
	    const x = val.split('.');
	    let x1 = x[0];
	    const x2 = x.length > 1 ? decimal + x[1] : '';
	    const rgx = /(\d+)(\d{3})/;
	    if (separator && !isNumber(separator)) {
	      while (rgx.test(x1)) {
	        x1 = x1.replace(rgx, '$1' + separator + '$2');
	      }
	    }
	    return prefix + x1 + x2 + suffix;
	  };
	  ...
}

初始化数据

  const [state, setState] = useState<State>({
    start: 0,
    paused: false,
    duration,
  });
  const startTime = useRef(0);
  const _timestamp = useRef(0);
  const remaining = useRef(0);
  const printVal = useRef(0);
  const rAf = useRef(0);
  const endRef = useRef(end);
  const endedCallback = useRef(ended);
  const [displayValue, setValue] = useState(formatNumber(start));
  // 定义一个计算属性,当开始数字大于结束数字时返回true
  const stopCount = useMemo(() => start > end, [start, end]);

动画的关键函数

  const count = (timestamp: number) => {
    if (!startTime.current) startTime.current = timestamp;
    _timestamp.current = timestamp;
    const progress = timestamp - startTime.current;
    remaining.current = state.duration - progress;

    // 是否使用速度变化曲线
    if (useEasing) {
      if (stopCount) {
        printVal.current = state.start - easingFn(progress, 0, state.start - end, state.duration);
      } else {
        printVal.current = easingFn(progress, state.start, end - state.start, state.duration);
      }
    } else {
      if (stopCount) {
        printVal.current = state.start - (state.start - endRef.current) * (progress / state.duration);
      } else {
        printVal.current = state.start + (endRef.current - state.start) * (progress / state.duration);
      }
    }
    if (stopCount) {
      printVal.current = printVal.current < endRef.current ? endRef.current : printVal.current;
    } else {
      printVal.current = printVal.current > endRef.current ? endRef.current : printVal.current;
    }
    setValue(formatNumber(printVal.current));
    if (progress < state.duration) {
      rAf.current = requestAnimationFrame(count);
    } else {
      endedCallback.current?.();
    }
  };

执行动画的函数

  const startCount = () => {
    setState({ ...state, start, duration, paused: false });
    rAf.current = requestAnimationFrame(count);
    startTime.current = 0;
  };

挂载时监听是否有autoPlay 来选择是否开始动画,同时组件销毁后清除requestAnimationFrame动画;

  useEffect(() => {
    if (autoPlay) {
      startCount();
      started?.();
    }
    return () => {
      cancelAnimationFrame(rAf.current);
    };
  }, []);

一些相关依赖的监听及处理

useEffect(() => {
    if (!autoPlay) {
      cancelAnimationFrame(rAf.current);
      setState({ ...state, paused: true });
    }
  }, [autoPlay]);
  useEffect(() => {
    if (!state.paused) {
      cancelAnimationFrame(rAf.current);
      startCount();
    }
  }, [start]);

最后返回displayValue就可以了;

好了 我要开启五一假期了!
最后附上完整代码 –

'use client';

import { useEffect, useMemo, useRef, useState } from 'react';

interface Props {
  /**
   * 动画开始的值
   */
  start?: number;
  /**
   * 目标值
   */
  end: number;
  /**
   * 持续时间
   */
  duration?: number;
  /**
   * 是否自动播放
   */
  autoPlay?: boolean;
  /**
   * 精度
   */
  decimals?: number;
  /**
   * 小数点
   */
  decimal?: string;
  /**
   * 千分位分隔符
   */
  separator?: string;
  /**
   * 数字前 额外信息
   */
  prefix?: string;
  /**
   * 数字后 额外信息
   */
  suffix?: string;
  /**
   * 是否使用变速函数
   */
  useEasing?: boolean;
  /**
   * 计算函数
   */
  easingFn?: (t: number, b: number, c: number, d: number) => number;
  /**
   * 动画开始后传给父组件的回调
   */
  started?: () => void;
  /**
   * 动画结束传递给父组件的回调
   */
  ended?: () => void;
}
interface State {
  start: number;
  paused: boolean;
  duration: number;
}
export default function Index({
  end,
  start = 0,
  duration = 3000,
  autoPlay = true,
  decimals = 0,
  decimal = '.',
  separator = ',',
  prefix = '',
  suffix = '',
  useEasing = true,
  easingFn = (t, b, c, d) => (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b,
  started = () => {},
  ended = () => {},
}: Props) {
  const isNumber = (val: string) => {
    return !isNaN(parseFloat(val));
  };
  // 格式化数据,返回想要展示的数据格式
  const formatNumber = (n: number) => {
    let val = '';
    if (n % 1 !== 0) val = n.toFixed(decimals);
    const x = val.split('.');
    let x1 = x[0];
    const x2 = x.length > 1 ? decimal + x[1] : '';
    const rgx = /(\d+)(\d{3})/;
    if (separator && !isNumber(separator)) {
      while (rgx.test(x1)) {
        x1 = x1.replace(rgx, '$1' + separator + '$2');
      }
    }
    return prefix + x1 + x2 + suffix;
  };

  const [state, setState] = useState<State>({
    start: 0,
    paused: false,
    duration,
  });
  const startTime = useRef(0);
  const _timestamp = useRef(0);
  const remaining = useRef(0);
  const printVal = useRef(0);
  const rAf = useRef(0);
  const endRef = useRef(end);
  const endedCallback = useRef(ended);
  const [displayValue, setValue] = useState(formatNumber(start));
  // 定义一个计算属性,当开始数字大于结束数字时返回true
  const stopCount = useMemo(() => start > end, [start, end]);
  const count = (timestamp: number) => {
    if (!startTime.current) startTime.current = timestamp;
    _timestamp.current = timestamp;
    const progress = timestamp - startTime.current;
    remaining.current = state.duration - progress;

    // 是否使用速度变化曲线
    if (useEasing) {
      if (stopCount) {
        printVal.current = state.start - easingFn(progress, 0, state.start - end, state.duration);
      } else {
        printVal.current = easingFn(progress, state.start, end - state.start, state.duration);
      }
    } else {
      if (stopCount) {
        printVal.current = state.start - (state.start - endRef.current) * (progress / state.duration);
      } else {
        printVal.current = state.start + (endRef.current - state.start) * (progress / state.duration);
      }
    }
    if (stopCount) {
      printVal.current = printVal.current < endRef.current ? endRef.current : printVal.current;
    } else {
      printVal.current = printVal.current > endRef.current ? endRef.current : printVal.current;
    }
    setValue(formatNumber(printVal.current));
    if (progress < state.duration) {
      rAf.current = requestAnimationFrame(count);
    } else {
      endedCallback.current?.();
    }
  };

  const startCount = () => {
    setState({ ...state, start, duration, paused: false });
    rAf.current = requestAnimationFrame(count);
    startTime.current = 0;
  };

  useEffect(() => {
    if (!autoPlay) {
      cancelAnimationFrame(rAf.current);
      setState({ ...state, paused: true });
    }
  }, [autoPlay]);
  useEffect(() => {
    if (!state.paused) {
      cancelAnimationFrame(rAf.current);
      startCount();
    }
  }, [start]);
  useEffect(() => {
    if (autoPlay) {
      startCount();
      started?.();
    }
    return () => {
      cancelAnimationFrame(rAf.current);
    };
  }, []);
  return displayValue;
}


网站公告

今日签到

点亮在社区的每一天
去签到