circular progress bar component
9/9/2024

圆环进度条

宽度自适应外层容器、自定义颜色、线条宽度、圆角、起始label

yarn add react-motion

import React, { useRef, useEffect, useState, useMemo } from 'react';
import { Motion, spring } from 'react-motion';
 
interface SemiCircleProgressProps {
  /* 进度 (0 - 100) */
  progress?: number;
  color?: string;
  /* 线条宽度 */
  strokeWidth?: number;
  /* 是否两端圆角 */
  rounded?: boolean;
  /* 标题 */
  title: string | React.ReactNode;
  /* 起始label - 位于左右两侧起点 - 内容尽量不要超过 radius */
  startLabel?: string | React.ReactNode;
  endLabel?: string | React.ReactNode;
}
 
const SemiCircleProgress: React.FC<SemiCircleProgressProps> = props => {
  const {
    progress = 50,
    color = 'black',
    strokeWidth,
    rounded = false,
    title,
    startLabel,
    endLabel,
  } = props;
  const containerRef = useRef<HTMLDivElement>(null);
  const [radius, setRadius] = useState(50);
  /* 线条宽度 */
  const _strokeWidth = strokeWidth ?? radius / 4;
 
  useEffect(() => {
    const updateRadius = () => {
      if (containerRef.current) {
        const width = containerRef.current.offsetWidth;
        setRadius(width / 4); // 因为外层容器宽度是 diameter * 2,所以这里除以4
      }
    };
 
    updateRadius();
    window.addEventListener('resize', updateRadius);
    return () => window.removeEventListener('resize', updateRadius);
  }, []);
 
  const diameter = radius * 2;
  const center = radius;
  /* 计算内圈半径 */
  const arcRadius = center - _strokeWidth / 2;
  /* 半圆周长 */
  const circumference = Math.PI * arcRadius;
  /* 计算进度长度 */
  const progressLength = (progress / 100) * circumference;
 
  return (
    <div
      ref={containerRef}
      style={{
        position: 'relative',
        width: '100%',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
      }}
    >
      <svg width={diameter} height={radius} viewBox={`0 0 ${diameter} ${radius}`}>
        {/* 背景轨道 */}
        <path
          d={`M ${_strokeWidth / 2},${center} A ${arcRadius},${arcRadius} 0 0,1 ${diameter -
            _strokeWidth / 2},${center}`}
          fill="none"
          stroke="lightgray"
          strokeWidth={_strokeWidth}
          strokeLinecap={rounded ? 'round' : 'butt'}
        />
 
        {/* 进度条动画 */}
        <Motion defaultStyle={{ t: 0 }} style={{ t: spring(progressLength) }}>
          {({ t }) => (
            <path
              d={`M ${_strokeWidth / 2},${center} A ${arcRadius},${arcRadius} 0 0,1 ${diameter -
                _strokeWidth / 2},${center}`}
              fill="none"
              stroke={color}
              strokeWidth={_strokeWidth}
              strokeLinecap={rounded ? 'round' : 'butt'}
              strokeDasharray={`${t}, ${circumference}`}
              strokeDashoffset={0}
            />
          )}
        </Motion>
      </svg>
      {/* title & progress */}
      <div
        style={{
          position: 'absolute',
          margin: 'auto',
          top: radius / 2,
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
        }}
      >
        <div style={{ fontSize: 'max(12px, 1.5vw)', fontWeight: 'bold' }}>{progress}%</div>
        {title && <div style={{ color: '#868484', fontSize: 'max(10px, 0.8vw)' }}>{title}</div>}
      </div>
      {/* startLabel && endLabel */}
      <div
        style={{
          width: '100%',
          display: 'flex',
          justifyContent: 'space-around',
          color: '#868484',
          marginTop: 2,
        }}
      >
        <div style={{ width: '40%', marginLeft: _strokeWidth, fontSize: 'max(10px, 0.9vw)' }}>
          {startLabel}
        </div>
        <div style={{ width: '40%', marginRight: _strokeWidth, fontSize: 'max(10px, 0.9vw)' }}>
          {endLabel}
        </div>
      </div>
    </div>
  );
};
 
export default SemiCircleProgress;