首页
/ antd Badge 动态计数变化:从官方示例到 ScrollNumber 滚轮动画原理

antd Badge 动态计数变化:从官方示例到 ScrollNumber 滚轮动画原理

2026-09-06 13:15:44作者:蔡丛锟

本篇以 ant-design(antd)官方 Badge 示例 change(展示动态变化的效果)为主体,完整拆解「徽标数字随状态变化而滚动、圆点随开关显隐」这一实战场景:既给出可直接运行的完整示例代码与参数行为说明,又结合 Badge 组件源码ScrollNumberSingleNumber 的实现,讲清数字逐位滚动的底层算法与动画样式来源,帮助你在业务中正确控制 countdotoverflowCount 等属性的显示逻辑。

官方示例:让数字随状态动态变化

示例文档 change.md 的定位只有一句话——「展示动态变化的效果」(The count will be animated as it changes.),其配套实现位于 change.tsx。示例的核心思路是:用一个可变状态 count 驱动 <Badge count={count}>,观察徽标在数字增减时的动画表现;同时用 dot 徽标配合 Switch 验证圆点的显隐过渡。完整代码如下:

import React, { useState } from 'react';
import { MinusOutlined, PlusOutlined, QuestionOutlined } from '@ant-design/icons';
import { Avatar, Badge, Button, Space, Switch } from 'antd';

const App: React.FC = () => {
  const [count, setCount] = useState(5);
  const [show, setShow] = useState(true);

  const increase = () => {
    setCount(count + 1);
  };

  const decline = () => {
    let newCount = count - 1;
    if (newCount < 0) {
      newCount = 0;
    }
    setCount(newCount);
  };

  const random = () => {
    const newCount = Math.floor(Math.random() * 100);
    setCount(newCount);
  };

  const onChange = (checked: boolean) => {
    setShow(checked);
  };

  return (
    <Space vertical>
      <Space size="large">
        <Badge count={count}>
          <Avatar shape="square" size="large" />
        </Badge>
        <Space.Compact>
          <Button onClick={decline} icon={<MinusOutlined />} />
          <Button onClick={increase} icon={<PlusOutlined />} />
          <Button onClick={random} icon={<QuestionOutlined />} />
        </Space.Compact>
      </Space>
      <Space size="large">
        <Badge dot={show}>
          <Avatar shape="square" size="large" />
        </Badge>
        <Switch aria-label="Show badge dot" onChange={onChange} checked={show} />
      </Space>
    </Space>
  );
};

export default App;

示例中有三处值得注意的业务细节:

  • decline 做了下限保护:数字减到 0 后不再继续递减。这里有一个隐藏行为——count 为 0 且未设置 showZero 时,数字徽标会整体隐藏(见下文「隐藏判定与状态缓存」),因此连续点减到最后,数字徽标会以缩放退场动画消失。
  • random 制造大幅跳变Math.floor(Math.random() * 100) 在 0–99 之间随机取值,用于观察多位数字同时滚动、以及从一位数跳到三位数时各数位的表现。
  • dotcount 是两种互斥形态<Badge dot={show}> 不显示数字,只显示红色圆点;Switch 切换 show 时,圆点同样伴随缩放动画出现/消失,而非直接生硬增删 DOM。

与示例相关的 Badge 关键属性行为

理解示例表现的前提,是弄清楚 Badge 几个属性在源码中的默认值与判定规则(属性声明见 Badge.tsx):

属性 默认值 在示例中的行为
count null 徽标要展示的数字,类型是 React.ReactNode,因此也可以传自定义节点
overflowCount 99 超过该值显示为 99+;示例的 random 最大只能到 99,恰好落在边界
showZero false falsecount === 0 隐藏数字徽标,这也是示例减到 0 后徽标消失的原因
dot false true 且非零时显示圆点;示例中 dot={show}Switch 控制
size medium small 时数字更小(-count-sm),default 已废弃并会在开发环境告警

源码中对「显示什么、显示多少」的计算链路如下(Badge.tsx):

const numberedDisplayCount = (
  (count as number) > (overflowCount as number) ? `${overflowCount}+` : count
) as string | number | null;

const isZero =
  numberedDisplayCount === '0' || numberedDisplayCount === 0 || text === '0' || text === 0;

const ignoreCount = count === null || (isZero && !showZero);

也就是说,overflowCount 的截断先于一切显示判定;而 countnull(未传)、或为 0 且未开 showZero 时,都会进入 ignoreCount 分支,最终由 isHidden 控制整个指示器的显隐(Badge.tsx):

const showAsDot = dot && !isZero;
const mergedCount = showAsDot ? '' : numberedDisplayCount;

const isHidden = useMemo(() => {
  const isEmpty = !isReactRenderable(mergedCount) && !isReactRenderable(text);
  return (isEmpty || (isZero && !showZero)) && !showAsDot;
}, [mergedCount, isZero, showZero, showAsDot, text]);

显隐缓存:为什么退场动画期间数字不会「变回」

示例中从 99 减到 0,或 Switch 关闭圆点时,徽标并不会瞬间消失,而是先播放退场动画。这个过程中如果组件直接读取当前 count,动画残留的旧 DOM 可能渲染出错误内容。源码用三个 ref 做了「存活状态缓存」(Badge.tsx):

// Count should be cache in case hidden change it
const countRef = useRef(count);
if (!isHidden) {
  countRef.current = count;
}
const livingCount = countRef.current;

// We need cache count since remove motion should not change count display
const displayCountRef = useRef(mergedCount);
if (!isHidden) {
  displayCountRef.current = mergedCount;
}
const displayCount = displayCountRef.current;

// We will cache the dot status to avoid shaking on leaved motion
const isDotRef = useRef(showAsDot);
if (!isHidden) {
  isDotRef.current = showAsDot;
}

三个缓存只在「可见」时更新:一旦进入 isHiddencountdisplayCountisDot 全部冻结在最后一帧的值上,保证退场动画期间徽标显示的是消失前的正确数字/形态,避免抖动。这对示例中「减到 0 时徽标带动画消失」的表现至关重要。

显隐过渡:CSSMotion 的 zoom 动画

数字与圆点的出现/消失统一包裹在 CSSMotion 中:

<CSSMotion
  visible={!isHidden}
  motionName={`${prefixCls}-zoom`}
  motionAppear={false}
  motionDeadline={1000}
>
  • motionAppear={false}:首次挂载不播入场动画,避免页面加载时的无意义闪烁;
  • motionDeadline={1000}:1 秒兜底,防止 transitionend 事件丢失导致类名残留;
  • 对应的动画样式定义在 style/index.ts
[`${componentCls}-zoom-appear, ${componentCls}-zoom-enter`]: {
  animationName: antZoomBadgeIn,
  animationDuration: token.motionDurationSlow,
  animationTimingFunction: token.motionEaseOutBack,
  animationFillMode: 'both',
},
[`${componentCls}-zoom-leave`]: {
  animationName: antZoomBadgeOut,
  // ... 同上
},

即徽标的显隐走的是「缩放 + easeOutBack 回弹」的 CSS 动画,时长与缓动由主题 token(motionDurationSlowmotionEaseOutBack)驱动,随全局 Motion 配置与暗色/定制主题自动适配。

数字滚动:ScrollNumber 的逐位渲染

示例中最核心的「数字会动」效果并不在 Badge 本体,而在 ScrollNumber——Badge 渲染数字徽标时始终通过它输出(Badge.tsxcount={displayCount} 传入 ScrollNumber)。其关键逻辑(ScrollNumber.tsx):

// Only integer need motion
let numberNodes: React.ReactNode = count;
if (count && Number(count) % 1 === 0) {
  const numberList = String(count).split('');

  numberNodes = (
    <bdi>
      {numberList.map((num, i) => (
        <SingleNumber
          prefixCls={prefixCls}
          count={Number(count)}
          value={num}
          key={numberList.length - i}
        />
      ))}
    </bdi>
  );
}

这里有两个决定动画成败的设计:

  1. 只有整数才启用滚动Number(count) % 1 === 0)。非整数(如 1.5)直接原样渲染,不做逐位动画;字符串计数同理直接展示。
  2. 每个数位是独立的 SingleNumber,用 <bdi> 包裹(配合样式中 bdi { unicode-bidi: plaintext } 处理 RTL 文本方向)。key={numberList.length - i} 按「从右往左的位次」生成 key——也就是说,个位数永远是同一个 React 节点,十位数也是同一个。这保证了 5 → 65 时,个位节点从「显示 5」变为「显示 6」,而新出现的十位节点是全新挂载的,各自独立滚动互不干扰。

另外 ScrollNumber.tsx 还兼容了旧的自定义边框写法:当用户通过 style={{ borderColor }} 给徽标指定边框色时,会转换成 box-shadow: 0 0 0 1px <color> inset 来模拟边框,因为徽标本身用 box-shadow 实现了白色描边效果,普通 border 会破坏圆角形态。

单数位滚动算法:SingleNumber 如何「滚」起来

SingleNumber 实现了类似老式翻牌钟的竖向滚动:每个数位维护「当前值 + 一列可滚动的数字」,通过 transform: translateY 切换显示哪一位。核心步骤(SingleNumber.tsx):

  1. 记录前后状态prevValue/prevCount 用 state 保存,直到过渡结束才同步:

    const onTransitionEnd: React.TransitionEventHandler<HTMLSpanElement> = () => {
      setPrevValue(value);
      setPrevCount(count);
    };
    
    // Fallback if transition events are not supported
    React.useEffect(() => {
      const timer = setTimeout(onTransitionEnd, 1000);
      return () => clearTimeout(timer);
    }, [value]);
    

    除了监听 transitionend,还设了 1 秒超时兜底,浏览器不支持过渡事件时状态也能收敛。

  2. 无变化则直接静态渲染prevValue === value 或出现 NaN 时只渲染单个 UnitNumber,并关闭 transition,避免首帧抖动。

  3. 构造数字列并决定方向

    const end = value + 10;
    const unitNumberList: number[] = [];
    for (let index = value; index <= end; index += 1) {
      unitNumberList.push(index);
    }
    const unit = prevCount < count ? 1 : -1;  // 数字变大向上滚,变小向下滚
    

    注意方向取自整体 count 的变化prevCount < count)而非单个数位。这是为了让 5 → 659 → 10 这类跨位跳变时,所有数位滚动方向一致,视觉上更协调。

  4. 裁剪与偏移计算:以旧值为起点向新值方向切出数字子列,每个 UnitNumbertop: ${offset}00% 绝对定位(SingleNumber.tsx);容器再用 translateY 平移,目标位移由 getOffsetSingleNumber.tsx)按 0–9 循环模运算求出「旧值到新值需要滚过几格」:

    offsetStyle = {
      transform: `translateY(${-getOffset(prevValue, value, unit)}00%)`,
    };
    
  5. 外层容器裁剪:滚出的数字不会溢出,因为样式里 .ant-scroll-number 设置了 overflow: hidden,且内层 .ant-scroll-number-only 的高度固定为 indicatorHeight,每个数字单元等高等距排列:

    [numberPrefixCls]: {
      overflow: 'hidden',
      transition: `all ${token.motionDurationMid} ${token.motionEaseOutBack}`,
      [`${numberPrefixCls}-only`]: {
        height: indicatorHeight,
        transition: `all ${token.motionDurationSlow} ${token.motionEaseOutBack}`,
        WebkitTransformStyle: 'preserve-3d',
        WebkitBackfaceVisibility: 'hidden',
      },
    },
    

    配合 WebkitBackfaceVisibility: hiddenpreserve-3d,可缓解部分浏览器在 transform 过渡时的锯齿与闪烁。

实际使用中的边界与注意点

结合以上源码,使用示例中的写法时需要注意几个「源码级」限制:

  • 减到 0 的默认行为showZero 默认为 falsecount 为 0 时数字徽标会整体走退场动画消失;若希望显示 0,需显式传 showZero。示例中 decline 特意 clamp 到 0,正是为了展示这一隐藏路径。
  • overflowCount 边界:超过上限显示为字符串 99+(默认上限),此时不再逐位滚动——99+ 是纯文本渲染。示例 random 的取值 0–99 刚好覆盖「一位数 ↔ 两位数」与 99 边界的切换。
  • 非整数/字符串不滚动count="99+"count={1.5} 这类非整数会直接静态展示,只有整数才进入逐位滚动链路。
  • count 可以是任意节点count 的类型为 React.ReactNode,传入自定义 React 元素时 Badge 会用 cloneElement 合并样式(Badge.tsx),此时滚动动画不生效,显隐缩放动画依然保留。
  • 动画时长可随主题调整:显隐 zoom 动画与数字滚动过渡分别使用 motionDurationSlowmotionDurationMid 等运动 token,在 ConfigProvider 的 theme.token 中全局调节 Motion 即可统一影响 Badge 的动效节奏。

小结

change 示例 用最少的代码覆盖了 Badge 的两类动效:数字徽标的逐位滚动(ScrollNumberSingleNumber 的 translateY 滚轮算法 + overflow: hidden 裁剪)与显隐的 zoom 缩放过渡(CSSMotion + badge-zoom 动画),并借 countRef/displayCountRef 等缓存机制保证退场动画期间内容不抖动。理解了这条链路,你就能在业务中准确预期 Badge 在 count 增减、归零、超上限、自定义节点等各种状态下的表现,并通过主题 Motion token 按需定制动效。

登录后查看全文
热门项目推荐
相关项目推荐