大屏、看板必备的丝滑技巧 — 数字滚动

但行好事,莫问前程

前言

最近需要开发看板功能,涉及到给用户展示一些 number 数据的场景。

作为看板页面,我们要 避免突兀的数值变化,让数字的变化更加自然、更有视觉吸引力,提升用户体验。

最后我采用了 数字滚动 动效,并封装为 hooks 方便复用。

效果如下,其中有不少有趣的设计思路值得复盘。

预览:我的后台 -> Editor

源文件:github.com/XIwE1/react...

tsx 复制代码
<NumberScroll value={numberValue} options={{ decimals: 0 }} className="justify-center self-center" />

文中已附上源代码和思路,如果对你有所帮助,还望点赞、收藏、关注三连😽。

方案

整理一下思路,如果要实现 0 -> 100 或者 100 -> 0,会想到什么方法?

  • 最简单的是直接更新对应 state,但这样一闪而过十分突兀
  • 其次是步进,即value / time = step,例如step = 100 -> 10 -> 20 ... -> 100
    • 这样不错,但这种线性的变化还是略显生硬
  • 最后我们可以利用缓动函数 来控制过程
    • 0 -> ... -> 50 -> 70 -> 80 -> 85 -> ... -> 98 -> 99 -> 100

实现

  1. 迅速完成大部分数值变化,视觉上快速地接近最终值 +
  2. 剩余小部分差值平滑变化,平稳、自然减速并抵达终点

变量分为:更新频率 和 更新步幅(数值)

原理是结合变量与场景,使用缓动函数(贝塞尔曲线数学公式)。

程序逻辑

ini 复制代码
例:
初始值: 0 → 目标值: 1000,
duration = 3500ms,1 / 3 = 1167ms 时间用于快速的线性变化,剩下时间用于平滑滚动
线性阈值 = 1000 * 2 / 3 = 666.67,滑动值 = 1000 / 3 = 333.33
│
├─ determine(): 在不同节点,判断需要变化的数值A和线性阈值B,设置新的数值变化目标C
│  ├─ 第一阶段:剩余变化量A 1000 > 阈值B 666.67(大数值线性变化),0 → 目标C 666.67(线性,3500 / 3 = 1167ms)
│  └─ 第二阶段:剩余变化量A 333.33 < 阈值B 666.67(小数值平滑滚动),666.67 → 目标C 1000(缓动,3500 - 1167 = 2333ms)
│
└─ count() 循环执行:循环改变计数值
   ├─ 第一阶段:
   │  ├─ 线性变化
   │  ├─ 目标数值 = 666.67
   │  ├─ 当前数值 = 0 -> 666.67
   │  ├─ 耗时 = 3500 / 3 = 1167ms
   │  └─ 动画结束,调用 determine() 重新计算
   │
   └─ 第二阶段:
   │  ├─ 平滑滚动
   │  ├─ ...类上
      └─ 动画结束,currentValue = 1000

还有很多场景要考虑,数值为负数,减操作,数字被减为0...

缓动函数

简单来说,是 "通过数学公式来 控制 变化的进度 或者 运动的轨迹"

它使数据和动画摆脱生硬、呆板的线性变化,它让事物的变化 更加符合视觉常识

详情可见 贝塞尔曲线:实现更好的动画效果和图形

tsx 复制代码
// utils/index.ts
// 生成对应三次贝塞尔曲线的js代码
export function createBezierFunction(p1x: number, p1y: number, p2x: number, p2y: number) {
  return function (t: number) {
    return 3 * Math.pow(1 - t, 2) * t * p1y + 3 * (1 - t) * Math.pow(t, 2) * p2y + Math.pow(t, 3);
  };
}

实践

我们封装成hooks来重复使用

/src/hooks/useNumberDuration.ts

tsx 复制代码
import { useEffect, useRef, useState, useMemo, useCallback } from "react";

// 缓动函数
const easeOutExpo = (t: number) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const linear = (t: number) => t;

export interface UseNumberDurationProps {
  value: number;
  duration?: number;
  decimals?: number;
  split?: string;
}

const useNumberScroll = ({
  value,
  duration = 5500,
  decimals = 2,
  split = ",",
}: UseNumberDurationProps) => {
  const rafRef = useRef<number>();
  const currentRef = useRef<number>(0);
  const durationRef = useRef<number>(duration);

  const startTime = useRef<number>();
  const startValue = useRef<number>(0);
  /** 线性变化的值 */
  const EasingThreshold = useMemo(() => {
    const diff = value - startValue.current;
    const threshold = (Math.abs(diff) * 2) / 3;
    return startValue.current + (diff > 0 ? threshold : -threshold);
  }, [value]);

  /** 滑动变化的值 */
  const EasingAmount = useMemo(() => {
    const amount = value - EasingThreshold;
    return amount;
  }, [value, EasingThreshold]);

  // 当前的实际值
  const [currentValue, setCurrentValue] = useState(0);
  // 格式化后用于展示的值
  const _current = useMemo(
    () =>
      currentValue.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [currentValue, decimals, split]
  );

  // 当前目标的结束值
  const endValue = useRef<number>(value);
  // 最终值
  const finalValue = useRef<number | null>(null);
  const result = useMemo(
    () => value.toFixed(decimals).replace(/\B(?=(\d{3})+(?!\d))/g, split),
    [value, decimals, split]
  );

  // determine - 判断变化采用线性还是缓动,设置最终值final和当前目标结束值end
  const determine = useCallback(() => {
    const end =
      finalValue.current !== null ? finalValue.current : endValue.current;
    const animateAmount = Math.abs(end - startValue.current);

    if (animateAmount > Math.abs(EasingThreshold)) {
      finalValue.current = end;
      endValue.current = end - EasingAmount;
      // 拿出小部分时间用于线性变化
      durationRef.current = duration / 3;
    } else {
      finalValue.current = null;
      endValue.current = end;
      durationRef.current = duration; // 这样动画滑动更明显一点
      // durationRef.current = (duration * 2) / 3;
    }
  }, [duration, EasingThreshold, EasingAmount]);

  const count = useCallback(
    (timestamp: number) => {
      if (!startTime.current) startTime.current = timestamp;
      // 根据时间差计算当前进度
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / durationRef.current, 1);
      const eased =
        finalValue.current !== null ? linear(progress) : easeOutExpo(progress);

      const currentValue =
        startValue.current + (endValue.current - startValue.current) * eased;

      currentRef.current = currentValue;
      setCurrentValue(currentValue);

      if (progress < 1) {
        rafRef.current = requestAnimationFrame(count);
      } else {
        startValue.current = currentValue;
        // 最终值不为空 = 还未到最终值 = 剩下的值要平滑增加
        if (finalValue.current !== null) {
          cancelAnimationFrame(rafRef.current!);
          startTime.current = undefined;
          endValue.current = finalValue.current;
          finalValue.current = null;
          determine();
          rafRef.current = requestAnimationFrame(count);
        }
      }
    },
    [determine]
  );

  useEffect(() => {
    if (rafRef.current) cancelAnimationFrame(rafRef.current);

    // 变化时重置状态
    endValue.current = value;
    finalValue.current = null;
    durationRef.current = duration;
    startValue.current = currentRef.current;
    startTime.current = undefined;
    // 判断当前的变化方式,并开始动画循环
    determine();
    rafRef.current = requestAnimationFrame(count);

    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
      startTime.current = undefined;
    };
  }, [value, duration]);

  return { current: _current, result };
};

export default useNumberScroll;

数字滚动

上述的hooks已经实现数字增长的效果了,但还有样式上的问题需要解决,

  1. 数据变化的过程中,元素占宽也在不断变化
  2. 非等宽字体的数字占宽不同

以上因素会导致 数据抖动

我采用的解决方案:

  • if 可以使用等宽字体
    • 预先使用 result 渲染真实元素获取所占宽度 estimateWidth
  • else
    • result 内的数字替换为占宽最大数字来渲染预估值

最后我们封装一个ui组件进行复用

/src/ui/NumberScroll/index.tsx

tsx 复制代码
import React, { useRef, useLayoutEffect, useState } from "react";
import useNumberScroll, { UseNumberDurationProps } from "../hooks/useNumberScroll";

interface NumberScrollProps {
  value: number;
  options?: Omit<UseNumberDurationProps, 'value'>;
  className?: string;
  suffix?: string;
  style?: React.CSSProperties;
}

const NumberScroll: React.FC<NumberScrollProps> = ({
  value,
  options,
  suffix = "",
  className,
  style,
}) => {
  const { current, result } = useNumberScroll({
    value,
    ...options
  });

  const measureRef = useRef<HTMLSpanElement>(null);
  const [fixedWidth, setFixedWidth] = useState<number | undefined>(undefined);

  useLayoutEffect(() => {
    if (measureRef.current) {
      const { width } = measureRef.current.getBoundingClientRect();
      setFixedWidth(width);
    }
  }, [result]);

  return (
    <>
      {/* estimate width */}
      <span
        className={className}
        ref={measureRef}
        style={{
          position: "absolute",
          visibility: "hidden",
          height: "auto",
          width: "auto",
          whiteSpace: "nowrap",
          ...style,
        }}
      >
        {result}
        {suffix}
      </span>

      <span
        className={className}
        style={{
          display: "inline-block",
          width: fixedWidth,
          ...style,
        }}
      >
        {current}
        {suffix}
      </span>
    </>
  );
};

export default NumberScroll;

总结

我们可以看出数字滚动动画的核心:

1. 分阶段策略

  • 大数值快速线性变化:使用 1/3 的时间快速完成 2/3 的数值变化
  • 小数值平滑缓动:使用 2/3 的时间平滑完成剩余的 1/3 变化
  • 关键参数:线性阈值(2/3)、滑动值(1/3)、时间分配(1/3 vs 2/3)

2. 缓动函数选择

  • 第一阶段 :使用 linear 线性函数,快速接近目标
  • 第二阶段 :使用 easeOutExpo 指数缓出函数,自然减速到达

3. 样式优化

  • 固定宽度:避免动画过程中的布局抖动
  • 等宽字体优先:如果可以使用等宽字体,直接测量最终宽度
  • 预估宽度:非等宽字体时,用最宽数字预估宽度

4. 封装复用

  • 逻辑层 :使用 hooks 处理动画逻辑,返回当前值和最终值
  • UI 层:使用组件处理样式和布局,确保视觉稳定

结语

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教~

相关推荐
前端工作日常2 小时前
我学习到的AG-UI的功能:全面的交互支持
前端
LawrenceLan2 小时前
Flutter 零基础入门(十三):late 关键字与延迟初始化
开发语言·前端·flutter·dart
深耕AI2 小时前
【wordpress系列教程】03 网站页面的编辑
开发语言·前端
前端达人2 小时前
2026年React数据获取的第六层:从自己写缓存到用React Query——减少100行代码的秘诀
前端·javascript·react.js·缓存·前端框架
2501_948122632 小时前
React Native for OpenHarmony 实战:Steam 资讯 App 通知设置实现
javascript·react native·react.js·游戏·ecmascript·harmonyos
—Qeyser2 小时前
Flutter 生命周期完全指南:从出生到死亡的全过程
前端·javascript·flutter
2501_948122632 小时前
React Native for OpenHarmony 实战:Steam 资讯 App 帮助中心实现
javascript·react native·react.js·游戏·ecmascript·harmonyos
YAY_tyy2 小时前
Turfjs 性能优化:大数据量地理要素处理技巧
前端·3d·arcgis·cesium·turfjs