【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. 效果预览

相关推荐
BBB努力学习程序设计11 小时前
CSS Sprite技术:用“雪碧图”提升网站性能的魔法
前端·html
BBB努力学习程序设计11 小时前
CSS3渐变:用代码描绘色彩的流动之美
前端·html
冰暮流星11 小时前
css之动画
前端·css
jump68011 小时前
axios
前端
spionbo11 小时前
前端解构赋值避坑指南基础到高阶深度解析技巧
前端
用户40993225021212 小时前
Vue响应式声明的API差异、底层原理与常见陷阱你都搞懂了吗
前端·ai编程·trae
开发者小天12 小时前
React中的componentWillUnmount 使用
前端·javascript·vue.js·react.js
永远的个初学者12 小时前
图片优化 上传图片压缩 npm包支持vue(react)框架开源插件 支持在线与本地
前端·vue.js·react.js
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ12 小时前
npm i / npm install 卡死不动解决方法
前端·npm·node.js
Kratzdisteln12 小时前
【Cursor _RubicsCube Diary 1】Node.js;npm;Vite
前端·npm·node.js