svg实现渐变环形进度条

541 阅读1分钟

``

  <div class="circle-process">
    <svg :key="useSvgRender" class="svg-ele" :viewBox="`0, 0, ${pointX * 2}, ${pointX * 2}`">
      <defs>
        <linearGradient x1="1" y1="0" x2="0" y2="0" :id="`outGradient` + useSvgRender">
          <stop offset="0%" :stop-color="circleAminationOption.outStarColor" />
          <stop offset="100%" :stop-color="circleAminationOption.outEndColor" />
        </linearGradient>
        <linearGradient x1="1" y1="0" x2="0" y2="0" :id="`innerGradient` + useSvgRender">
          <stop offset="0%" :stop-color="circleAminationOption.innerStarColor" />
          <stop offset="100%" :stop-color="circleAminationOption.innerEndColor" />
        </linearGradient>
      </defs>
      <circle
        class="circle-bg"
        :cx="pointX"
        :cy="pointX"
        :r="circleConfig.cr"
        :stroke="circleConfig.bgColor"
        :stroke-width="circleConfig.bgWidth"
      />
      <circle
        ref="circleRef"
        class="circle-color"
        :cx="pointX"
        :cy="pointX"
        :r="circleConfig.cr"
        :stroke="`url(#innerGradient${useSvgRender})`"
        :stroke-width="circleConfig.cWidth"
        :stroke-dasharray="`0, 1000000`"
      >
        <animate
          :to="`${circleAminationOption.innerArcLength},1000000`"
          :begin="circleAminationOption.outDurtion"
          :dur="circleAminationOption.innerDurtion"
          :from="`${circleAminationOption.innerInitArcLength},1000000`"
          calcMode="linear"
          keyTimes="0;0.5;1"
          attributeName="stroke-dasharray"
          fill="freeze"
        />
      </circle>

      <circle
        ref="circleRef"
        class="circle-color"
        :cx="pointX"
        :cy="pointX"
        :r="circleConfig.cr"
        :stroke="`url(#outGradient${useSvgRender})`"
        :stroke-width="circleConfig.cWidth"
        :stroke-dasharray="`${circleAminationOption.outArcLength},1000000`"
      >
        <animate
          :to="`${circleAminationOption.outArcLength},1000000`"
          begin="0s"
          :dur="circleAminationOption.outDurtion"
          from="0,1000000"
          attributeName="stroke-dasharray"
          calcMode="linear"
          keyTimes="0;0.5;1"
          fill="freeze"
        />
      </circle>
      <g>
        <line
          fill="none"
          stroke="#ffffff"
          stroke-width="2"
          stroke-dasharray="null"
          stroke-linejoin="null"
          stroke-linecap="round"
          :x1="pointX"
          y1="0"
          :x2="pointX"
          :y2="circleConfig.cWidth * 2"
          :transform="`rotate(${360 * Number(count)} ${pointX} ${pointX})`"
        />
        <animateTransform
          attributeName="transform"
          begin="0s"
          :dur="circleConfig.duration"
          type="rotate"
          calcMode="linear"
          keyTimes="0;0.5;1"
          :from="`${360 - 360 * Number(count)} ${pointX} ${pointX}`"
          :to="`360 ${pointX} ${pointX}`"
        />
      </g>
    </svg>
    <div class="circle-count">
      <slot>{{ count * 100 }}</slot>
    </div>
  </div>
</template>
<script setup>
/**
 * fill: none; // 圆的填充色
 * stroke: #7c83fd;
 * stroke-width: 10; // 画笔的宽度
 * stroke-dasharray: 314; // 圆的周长
 * stroke-dashoffset: 314;
 * stroke-linecap: round; // 圆的头部
 * cx: '80', // 圆环在svg中的坐标x
 * cy: '80', // 圆环在svg中的坐标y
 * cr: '50', // 圆环的半径
 *         :transform="`rotate(${360 * Number(count)})`"
 */

const props = defineProps({
  circleConfig: {
    type: Object,
    default: () => {
      return {
        cr: '30',
        cWidth: '4', // 进度圆弧的宽度
        color: ['rgba(244,112,33)', 'rgba(255,207,93)'], // 进度圆弧的颜色,可以为字符,数组,fn, 颜色支持rbga和十六进制
        bgColor: 'rgba(255, 255, 255, 0.4)', //背景圆的颜色,颜色支持rbga和十六进制
        bgWidth: '2', // 背景圆的宽度
        lineSize: '20', // 指针的高度大小:暂时没有用到
        duration: '2s' // 动画间隔
      }
    }
  },
  // repeat: {
  //   type: String,
  //   default: 'indefinite'
  // },
  count: {
    type: [Number, String],
    default: 0.9
  }
})

// 环的周长
const circleLength = computed(() => {
  return Math.floor(2 * Math.PI * props.circleConfig.cr)
})

// // 环的stroke-dasharray的值
// const circleProgressLen = computed(() => {
//   let progressLength = Number(props.count) * circleLength.value
//   return `${progressLength},1000000`
// })

const pointX = computed(() => {
  return Number(props.circleConfig.cr) + Number(props.circleConfig.cWidth)
})

// 计算圆环动画相关的值
// const circleAminationOption = ref({
//   outArcLength: 0, // 外层圆弧渲染长度
//   outDurtion: 0, // 外层动画执行时间
//   innerArcLength: 0, //  内层圆弧渲染长度
//   innerInitArcLength: 0, //  内层圆弧开始渲染的其实位置
//   innerDurtion: 0, // 内层动画执行时间
//   outStarColor: '', // 外层圆弧渲染起始颜色
//   outEndColor: '', // 外层圆弧渲染结束颜色
//   innerStarColor: '', // 内层圆弧渲染起始颜色
//   innerEndColor: '' // 内层圆弧渲染结束颜色
// })
const circleAminationOption = computed(() => {
  /**
   *  1、如果进度小于一半,渐变色不需要两个圆实现
   *  2、如果进度条不需要渐变色,也可以不需要两个圆实现
   */
  // 一个环分为两半,inner— 上部分半环的数据(内层圆弧), out- 下部分半环的数据(最外层圆弧)

  let circleAminationOption = {
    outArcLength: 0, // 外层圆弧渲染长度
    outDurtion: 0, // 外层动画执行时间
    innerArcLength: 0, //  内层圆弧渲染长度
    innerInitArcLength: 0, //  内层圆弧开始渲染的其实位置
    innerDurtion: 0, // 内层动画执行时间
    outStarColor: '', // 外层圆弧渲染起始颜色
    outEndColor: '', // 外层圆弧渲染结束颜色
    innerStarColor: '', // 内层圆弧渲染起始颜色
    innerEndColor: '' // 内层圆弧渲染结束颜色
  }

  let circleProgressColor = props.circleConfig.color || ''
  if (!circleProgressColor) return circleAminationOption

  if (typeof circleProgressColor === 'function') {
    circleProgressColor = circleProgressColor()
  }

  // 单个色值环不用分上下半部分
  if (
    typeof circleProgressColor === 'string' ||
    (circleProgressColor instanceof Array && circleProgressColor.length === 1)
  ) {
    circleAminationOption.outArcLength = Number(props.count) * circleLength.value
    circleAminationOption.outDurtion = props.circleConfig.duration
    circleAminationOption.innerArcLength = 0
    circleAminationOption.innerInitArcLength = 0 // 为动画做准备
    circleAminationOption.innerDurtion = 0
    circleAminationOption.outEndColor = circleAminationOption.outStarColor =
      typeof circleProgressColor === 'string' ? circleProgressColor : circleProgressColor[0]
    return circleAminationOption
  }

  // 进度小于0.5时不用分上下半部分
  if (Number(props.count) < 0.5) {
    circleAminationOption.outArcLength = Number(props.count) * circleLength.value
    circleAminationOption.outDurtion = props.circleConfig.duration
    circleAminationOption.outStarColor = circleProgressColor[0]
    circleAminationOption.outEndColor = circleProgressColor[1]
  } else {
    // 动画:
    // 由下部分颜色值从0s开始渲染,下部分的渲染时长为 ((0.5 / 进度值) * 总渲染时长);渲染长度为 0.5 * 进度值 * 环的周长
    // 下部分的渲染位置从 0 到 下半部分总渲染长度:渲染长度为 0.5 * 进度值 * 环的周长

    // 上半部分 从 ((0.5 / 进度值) * 总渲染时长) 开始,即上半部分渲染完成才开始,结束为设置的总渲染时长
    // 上部分的渲染位置从  0.5 * 进度值 * 环的周长   到 总渲染长度   进度值 * 环的周长

    const time = props.circleConfig.duration.split('s')[0]
    if (!time) return circleAminationOption

    const outAnimationTime = (0.5 / Number(props.count)) * time
    circleAminationOption.outArcLength = 0.5 * circleLength.value
    circleAminationOption.outDurtion = outAnimationTime + 's'
    circleAminationOption.innerArcLength = Number(props.count) * circleLength.value
    circleAminationOption.innerInitArcLength = 0.5 * circleLength.value // 为动画做准备 此时从中间开始
    circleAminationOption.innerDurtion = time - outAnimationTime + 's' // 为动画做准备 此时从中间开始

    const halfColor = gradientColor(circleProgressColor[0], circleProgressColor[1], 2)[0]
    circleAminationOption.outStarColor = circleProgressColor[0]
    circleAminationOption.outEndColor = halfColor
    circleAminationOption.innerStarColor = circleProgressColor[1]
    circleAminationOption.innerEndColor = halfColor
  }

  return circleAminationOption
})

// 获取每步长结束时的rgba值
const gradientColor = (startcolor, endColor, step) => {
  if (step < 2) return false
  if (startcolor.includes('#')) startcolor = stringToRgb(startcolor)
  if (endColor.includes('#')) endColor = stringToRgb(endColor)
  let startColorList = startcolor.slice(5, -1).split(',')
  let endColorList = endColor.slice(5, -1).split(',')

  let startR = Number(startColorList[0])
  let startG = Number(startColorList[1])
  let startB = Number(startColorList[2])
  let endR = Number(endColorList[0])
  let endG = Number(endColorList[1])
  let endB = Number(endColorList[2])
  let sR = (endR - startR) / step // 总差值
  let sG = (endG - startG) / step
  let sB = (endB - startB) / step

  let colorArr = []
  for (let i = 1; i < step; i++) {
    let color =
      'rgb(' + parseInt(sR * i + startR) + ',' + parseInt(sG * i + startG) + ',' + parseInt(sB * i + startB) + ')'
    colorArr.push(color)
  }

  return colorArr
}

// 16进制色值转换为rgba
const stringToRgb = (str, mode = 'string') => {
  const template = str.toLowerCase()
  let result = ''
  if (template.indexOf('rgb(') === 0) {
    result = template
  } else if (template.indexOf('rgba(') === 0) {
    const colors = template
      .replace(/rgba\(/g, '')
      .replace(/\)/g, '')
      .split(',')
    const r = colors[0]
    const g = colors[1]
    const b = colors[2]
    result = `rgb(${r},${g},${b})`
  } else if (template.indexOf('#') === 0) {
    let colors = template.replace(/#/g, '')
    let resultArr = []
    if (colors.length === 3) {
      colors = colors.replace(/[0-9a-f]/g, (str) => {
        return str + str
      })
    }
    for (let i = 0; i < colors.length; i += 2) {
      resultArr.push(parseInt(colors[i] + colors[i + 1], 16))
    }
    result = `rgb(${resultArr.join(',')})`
  }
  if (mode === 'string') {
    return result
  } else if (mode === 'array') {
    return result.replace(/rgb\(/g, '').replace(/\)/g, '').split(',')
  }
}

const useSvgRender = ref(0)
watch(
  () => props.count,
  () => {
    useSvgRender.value = Math.random()
  }
)
</script>

<style lang="scss" scoped>
.circle-process {
  width: 200px;
  height: 200px;
  position: relative;

  .svg-ele {
    width: 100%;
    height: 100%;
  }

  .circle-count {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    cursor: default;
    font-size: 20px;
  }
}

.circle-bg {
  fill: none;
}

.circle-color {
  fill: none;
  // stroke-linecap: round;
  transform: rotate(-90deg);
  transform-origin: center;
  transform-box: fill-box;
  position: relative;

  ::after {
    content: '|';
    width: 1px;
    height: 20px;
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    background-color: #fff;
  }
}
</style>