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

相关推荐
蓝瑟忧伤12 小时前
前端技术新十年:从工程体系到智能化开发的全景演进
前端
Baklib梅梅12 小时前
员工手册:保障运营一致性与提升组织效率的核心载体
前端·ruby on rails·前端框架·ruby
IT_陈寒13 小时前
Redis性能翻倍的5个冷门技巧,90%开发者都不知道第3个!
前端·人工智能·后端
jingling55514 小时前
vue | 在 Vue 3 项目中集成高德地图(AMap)
前端·javascript·vue.js
油丶酸萝卜别吃14 小时前
Vue3 中如何在 setup 语法糖下,通过 Layer 弹窗组件弹出自定义 Vue 组件?
前端·vue.js·arcgis
J***Q29221 小时前
Vue数据可视化
前端·vue.js·信息可视化
ttod_qzstudio1 天前
深入理解 Vue 3 的 h 函数:构建动态 UI 的利器
前端·vue.js
_大龄1 天前
前端解析excel
前端·excel
一叶茶1 天前
移动端平板打开的三种模式。
前端·javascript
前端大卫1 天前
一文搞懂 Webpack 分包:async、initial 与 all 的区别【附源码】
前端