【React.js】渐变环形进度条

背景

最近页面需求开发遇到了一个场景 - 需要一个渐变的环形进度条! 当前项目是基于 antd-mobile 进行开发, 其物料不能满足当前的需求!

收益

  1. 封装一个公共的环形进度条,在以后需要展示渐变进度场景的能够用到

  2. write once,run anywhere ! - 所有的进度条场景都能使用这个组件

设计

具体用法

  1. 基本使用
tsx 复制代码
import GredientCircleProgress from '@/components/GredientCircleProgress'

export default () => <GredientCircleProgress percent={50} />
  1. 自定义进度条整体的尺寸
tsx 复制代码
import GredientCircleProgress from '@/components/GredientCircleProgress'

export default () => <GredientCircleProgress percent={50} width={200} height={200} />
  1. 自定义进度条颜色
tsx 复制代码
import GredientCircleProgress from '@/components/GredientCircleProgress'

export default () => <GredientCircleProgress percent={50} fromColor="#08C284" toColor="#86DC27" />
  1. 自定义进度条填充厚度
tsx 复制代码
import GredientCircleProgress from '@/components/GredientCircleProgress'

export default () => <GredientCircleProgress percent={50} strokeWidth={10} />

可用 API

props 属性:

属性 说明 类型 是否必传?
percent 当前的百分比 number
trackColor 轨道颜色 React.CSSProperties['color']
strokeWidth 圆环的宽度 `number string`
fromColor 渐变开始的颜色 React.CSSProperties['color']
toColor 渐变结束的颜色 React.CSSProperties['color']
animationTime 动画时间 (单位:ms) number
width 宽度 (默认单位:px, 如果需要传 rem需要使用字符串) `number string`
height 高度 (默认单位:px, 如果需要传 rem需要使用字符串) `number string`
showText 是否展示中间的文字 ? boolean
style svg 行内样式 React.CSSProperties

ref 暴露:

属性 说明 类型
getContainer 获取 Svg 元素 `() => SVGSVGElement null`
setProgress 设置圆环进度(注意,此方法不会有动画效果!) (percent: number) => void
animateProgress 设置圆环精度(带动画效果)1. targetPercent: 目标百分比 (targetPercent: number, duration?: number) => void
  1. duration: 动画持续时间(毫秒) | (targetPercent: number, duration?: number) => void

代码实现:

1. 类型定义

tsx 复制代码
import type { CSSProperties } from 'react'

// props 定义
export interface ICircleProgressProps {
  /**
   * 当前的百分比
   */
  percent?: number

  /**
   * 轨道颜色
   */
  trackColor?: CSSProperties['color']

  /**
   * 圆环的宽度
   */
  strokeWidth?: number | string

  /**
   * 渐变开始的颜色
   */
  fromColor?: CSSProperties['color']

  /**
   * 渐变结束的颜色
   */
  toColor?: CSSProperties['color']

  /**
   * 动画时间 (单位:`ms`)
   */
  animationTime?: number

  /**
   * 宽度 (单位:`px`)
   */
  width?: number | string

  /**
   * 高度 (单位:`px`)
   */
  height?: number | string

  /**
   * 是否展示中间的文字 ?
   */
  showText?: boolean

  /**
   * svg 行内样式
   */
  style?: CSSProperties
}

// expose 定义
export interface ICircleProgressExpose {
  /**
   * 获取 Svg 元素
   */
  getContainer: () => SVGSVGElement | null

  /**
   * 设置圆环进度(无动画)
   * @param {number} percent 进度百分比(0-100)
   */
  setProgress: (percent: number) => void

  /**
   * 带动画的设置圆环进度
   * @param {number} targetPercent - 目标百分比 (0-100)
   * @param {number} duration - 动画持续时间(毫秒)
   */
  animateProgress: (targetPercent: number, duration?: number) => void
}

2. 数据定义

2.1. id 定义

为了保证组件渲染的独立性,我们需要一些 id 来限制 GredientCircleProgress内部的绘制

tsx 复制代码
/* 这里的 id 必须要写在组件函数的内部! */

// 整体实例 id
const instanceId = `circleProgress_${Date.now()}_${Math.floor(Math.random() * 10000)}`
// 容器 id
const containerId = `${instanceId}_container`
// 绘制圆形进度条的 id
const progressCircleId = `${instanceId}_progress`
// 内部文案渲染的 id
const percentTextId = `${instanceId}_text`

2.2. 外层元素定义

tsx 复制代码
const elRef: MutableRefObject<SVGSVGElement | null> = useRef<SVGSVGElement>(null)

2.3. 渲染所需变量定义

在渲染之前,我们需要计算出一些必要的变量 (比如:宽度,高度,圆半径 etc.)

提示

parseSize 方法可以参考下面的实现

tsx 复制代码
// 宽度数字
const numWidth = parseSize(width)
// 高度数字
const numHeight = parseSize(height)
// 填充内容数字
const numStrokeWidth = parseSize(strokeWidth)
// 计算圆的半径 - 考虑 strokeWidth 并确保在容器内
const radius = Math.min(numWidth, numHeight) / 2 - numStrokeWidth / 2
// 计算中心点 - x 坐标
const centerX = numWidth / 2
// 计算中心点 - y 坐标
const centerY = numHeight / 2

2.4. svg 样式定义

合并默认样式和用户自定义样式,确保行内块展示不换行

ts 复制代码
const mergedStyle: CSSProperties = useMemo(() => {
  return {
    ...style,
    display: 'inline-block', // 设置为行内块
  }
}, [style])

3. 核心方法实现

3.1. parseSize - 适配尺寸的方法

tsx 复制代码
// 解析尺寸值,支持 px 和 rem 单位
function parseSize(size: number | string): number {
  if (typeof size === 'number') {
    return size
  }

  // 处理 rem 单位
  if (typeof size === 'string' && size.endsWith('rem')) {
    // 获取根元素的字体大小,用于将 rem 转换为 px
    const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
    const remValue = parseFloat(size)
    return remValue * rootFontSize
  }

  // 默认处理(px 或纯数字字符串)
  return parseInt(size)
}

3.2. animateProgress定义

tsx 复制代码
function animateProgress(targetPercent: number, duration = 1500) {
  // 限制百分比范围在 0-100
  targetPercent = Math.max(0, Math.min(100, targetPercent))

  const startPercent = 0
  const startTime = performance.now()

  const animate = (currentTime) => {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1) // 0 到 1

    // 使用缓动函数(ease-out)使动画更自然
    const easeOutProgress = 1 - Math.pow(1 - progress, 3)

    const currentPercent = startPercent + (targetPercent - startPercent) * easeOutProgress

    setProgress(currentPercent)

    // 如果动画未完成,继续下一帧
    if (progress < 1) {
      requestAnimationFrame(animate)
    } else {
      // 确保最后设置为精确的目标值
      setProgress(targetPercent)
    }
  }

  requestAnimationFrame(animate)
}

3.3. setProgress 定义

tsx 复制代码
function setProgress(percent: number) {
  // 限制百分比范围在 0-100
  percent = Math.max(0, Math.min(100, percent))

  // 使用唯一ID获取当前组件实例的圆环元素
  const circle = document.getElementById(progressCircleId)

  // 只在 circle 存在时设置进度条
  if (!circle) return

  // 计算圆周长: 2πr
  const circumference = 2 * Math.PI * radius

  // 计算显示的长度
  const dashLength = (circumference * percent) / 100
  const gapLength = circumference - dashLength

  // 设置 stroke-dasharray
  circle.setAttribute('stroke-dasharray', `${dashLength} ${gapLength}`)

  // 只在 showText 为 true 且 text 元素存在时更新文字
  if (showText) {
    // 使用唯一ID获取当前组件实例的文本元素
    const text = document.getElementById(percentTextId)
    if (text) {
      text.textContent = `${Math.round(percent)}%`
    }
  }
}

4. 视图渲染

使用 svg 技术并绑定上面的数据和方法即可进行渲染

tsx 复制代码
<svg
  ref={elRef}
  className={styles['container']}
  style={mergedStyle}
  width={width}
  height={height}
>
  <defs>
    <linearGradient id={containerId} x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" stopColor={fromColor} />
      <stop offset="100%" stopColor={toColor} />
    </linearGradient>
  </defs>

  <circle
    cx={centerX}
    cy={centerY}
    r={radius}
    fill="none"
    stroke={trackColor}
    strokeWidth={strokeWidth}
  />

  {percent !== 0 && (
    <circle
      id={progressCircleId}
      cx={centerX}
      cy={centerY}
      r={radius}
      fill="none"
      stroke={`url(#${containerId})`}
      strokeWidth={strokeWidth}
      strokeLinecap="round"
      transform={`rotate(-90 ${centerX} ${centerY})`}
    />
  )}

  {showText && (
    <text
      id={percentTextId}
      x={centerX}
      y={centerY + 10}
      textAnchor="middle"
      fontSize={Math.min(numWidth, numHeight) / 8} // 动态计算文字大小
      fontWeight="bold"
      fill="#333"
    >
      {Math.round(percent)}%
    </text>
  )}
</svg>

5. 绑定监听并启动组件

添加侦听并动态执行 animateProgress

tsx 复制代码
// 1. 使用 forwardRef 包裹组件
const GredientCircleProgress = forwardRef<ICircleProgressExpose, ICircleProgressProps>(
  (props, ref) => {
    // ...
    
    // 2. 在 ref 上面扩展这些方法
    useImperativeHandle(ref, () => {
      return {
        getContainer: () => elRef.current,
        setProgress,
        animateProgress,
      }
    })
  }
)

6. 效果预览

相关推荐
wordbaby3 分钟前
TanStack Router 基于文件的路由
前端
wordbaby7 分钟前
TanStack Router 路由概念
前端
wordbaby10 分钟前
TanStack Router 路由匹配
前端
cc蒲公英11 分钟前
vue nextTick和setTimeout区别
前端·javascript·vue.js
程序员刘禹锡15 分钟前
Html中常用的块标签!!!12.16日
前端·html
我血条子呢26 分钟前
【CSS】类似渐变色弯曲border
前端·css
DanyHope26 分钟前
LeetCode 两数之和:从 O (n²) 到 O (n),空间换时间的经典实践
前端·javascript·算法·leetcode·职场和发展
hgz071027 分钟前
企业级多项目部署与Tomcat运维实战
前端·firefox
用户18878710698428 分钟前
基于vant3的搜索选择组件
前端
zhoumeina9928 分钟前
懒加载图片
前端·javascript·vue.js