react 实现环形Progress

68 阅读2分钟
import React, { useMemo, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';

/** 容器:用于定位百分比文本和设置整体尺寸 */
const ProgressWrapper = styled.div`
  position: relative;
  width: ${props => props.$size}px;
  height: ${props => props.$size}px;
`;

/** 环形路径样式:通用于主环和外环 */
const Circle = styled.circle`
  transition: stroke-dashoffset 0.3s ease;
  stroke: ${props => props.$color};
  stroke-width: ${props => props.$strokeWidth}px;
  fill: none;
  ${props => props.$continuous && 'stroke-linecap: round;'}  // 连续外圈需要圆头线帽
`;

/** SVG包裹层:设置3D倾斜效果 */
const SvgWrapper = styled.div`
  perspective: 500px;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;

  svg {
    transform: rotateX(60deg) rotateZ(0deg);  // 倾斜角度创建3D视觉
    transform-style: preserve-3d;
  }
`;

/** 中心文本样式,可通过 prop 定制 */
const PercentText = styled.div`
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 24px;
  font-weight: bold;
  color: #fff;
  ${props => props.$customStyle || ''}
`;

const StepProgressCircle = ({
  percent = 0,                           // 外部传入的目标百分比
  steps = { count: 12, gap: 2 },        // 主环分段数量和间隔
  trailColor = 'rgba(0, 0, 0, 0.06)',   // 未激活颜色
  strokeWidth = 10,                     // 主环线宽
  activeColor = '#1677ff',             // 激活颜色
  size = 120,                           // 整体尺寸
  outerStrokeWidth = 6,                // 外圈线宽
  gapBetween = 8,                      // 主环与外圈的间距
  showPercent = true,                  // 是否显示数值
  percentTextStyle = '',               // 百分比文本样式
}) => {
  const [animatedPercent, setAnimatedPercent] = useState(0); // 动画值
  const requestRef = useRef(); // 用于记录 requestAnimationFrame 的引用

  /** 触发百分比动画(初始或更新) */
  useEffect(() => {
    let start = null;
    const duration = 1000; // 动画持续时间

    const animate = (timestamp) => {
      if (!start) start = timestamp;
      const progress = timestamp - start;
      const eased = Math.min(progress / duration, 1); // 线性补间
      setAnimatedPercent(percent * eased);            // 更新动画百分比

      if (progress < duration) {
        requestRef.current = requestAnimationFrame(animate);
      } else {
        setAnimatedPercent(percent);                  // 确保最后为完整值
        cancelAnimationFrame(requestRef.current);
      }
    };

    // 重启动画
    cancelAnimationFrame(requestRef.current);
    setAnimatedPercent(0);
    requestRef.current = requestAnimationFrame(animate);

    return () => cancelAnimationFrame(requestRef.current);
  }, [percent]);

  /** 圆的半径与总长计算(基于视图Box 100x100) */
  const mainRadius = 50 - strokeWidth / 2 - gapBetween - outerStrokeWidth;
  const outerRadius = 50 - outerStrokeWidth / 2;

  const mainCircumference = 2 * Math.PI * mainRadius;
  const outerCircumference = 2 * Math.PI * outerRadius;

  // 外圈偏移(用于环形动画)
  const outerDashoffset = outerCircumference * (1 - animatedPercent / 100);
  const outerDasharray = outerCircumference;

  /** 主环分段信息:计算每段 dasharray 和 dashoffset */
  const stepInfo = useMemo(() => {
    const { count, gap } = steps;
    const gapLength = (mainCircumference * gap) / 100; // 间隔长度
    const visibleLength = mainCircumference / count - gapLength;

    return Array.from({ length: count }).map((_, index) => ({
      dasharray: `${visibleLength} ${mainCircumference - visibleLength}`,
      dashoffset: -index * (visibleLength + gapLength),
    }));
  }, [steps, mainCircumference]);

  // 当前应高亮的段数
  const activeIndex = useMemo(() => {
    return Math.floor((animatedPercent / 100) * steps.count);
  }, [animatedPercent, steps.count]);

  /** 百分比格式化:整数保留整型,小数保留一位 */
  const formatPercent = (value) =>
    Number.isInteger(value) ? value : value.toFixed(1);

  return (
    <ProgressWrapper $size={size}>
      <SvgWrapper>
        <svg viewBox="0 0 100 100" width={size} height={size}>
          {/* 外圈背景 */}
          <Circle
            cx="50"
            cy="50"
            r={outerRadius}
            $strokeWidth={outerStrokeWidth}
            $color={trailColor}
            strokeDasharray="100%"
          />

          {/* 外圈渐变进度环 */}
          <defs>
            <linearGradient id="outerGradient" x1="0%" y1="0%" x2="100%" y2="0%">
              <stop offset="0%" stopColor={activeColor} />
              <stop offset="100%" stopColor="#66ccff" />
            </linearGradient>
          </defs>
          <Circle
            cx="50"
            cy="50"
            r={outerRadius}
            $strokeWidth={outerStrokeWidth}
            $color="url(#outerGradient)"
            $continuous
            strokeDasharray={outerDasharray}
            strokeDashoffset={outerDashoffset}
          />

          {/* 主环背景段 */}
          {stepInfo.map((info, index) => (
            <Circle
              key={`trail-${index}`}
              cx="50"
              cy="50"
              r={mainRadius}
              $strokeWidth={strokeWidth}
              $color={trailColor}
              strokeDasharray={info.dasharray}
              strokeDashoffset={info.dashoffset}
            />
          ))}

          {/* 主环激活段 */}
          {stepInfo.map(
            (info, index) =>
              index < activeIndex && (
                <Circle
                  key={`active-${index}`}
                  cx="50"
                  cy="50"
                  r={mainRadius}
                  $strokeWidth={strokeWidth}
                  $color={activeColor}
                  strokeDasharray={info.dasharray}
                  strokeDashoffset={info.dashoffset}
                />
              )
          )}
        </svg>
      </SvgWrapper>

      {/* 中心文本(可隐藏和自定义样式) */}
      {showPercent && (
        <PercentText $customStyle={percentTextStyle}>
          {formatPercent(animatedPercent)}
        </PercentText>
      )}
    </ProgressWrapper>
  );
};

export default StepProgressCircle;
// 使用
  <StepProgressCircle
    percent={62.4}
      size={140}
    steps={{ count: 16, gap: 2 }}
    strokeWidth={8}
    gapBetween={8}
    outerStrokeWidth={1}
    trailColor={"#1a254d"}
  />

效果: image.png