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

但行好事,莫问前程

前言

最近需要开发看板功能,涉及到给用户展示一些 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 = 10,0 -> 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 层:使用组件处理样式和布局,确保视觉稳定

结语

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

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

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

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

相关推荐
lovingsoft1 小时前
Chrome Lighthouse 详解
前端·chrome
Qlly1 小时前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝1 小时前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦1 小时前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡2 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI2 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95272 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人2 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载2 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队2 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端