svg实现环形进度条

490 阅读4分钟

环形进度条虽然很常用,但基本都是直接用各种组件库来搞,所以在此之前也没有自己真正实现过。偶然思考了以下这个问题,直接用CSS的话,好像还不太好实现,所以看了下svg实现的方案,也趁机熟悉一下svg——svg实现环形进度环出乎意料的简单。

一、效果预览

可在线体验效果:wintc.top/laboratory/…

该效果代码大概如下:

<div class="progress-container progress" style="width: 100px; height: 100px;">
  <svg viewBox="0 0 100 100">
    <path d="M 50 2 A 48 48 0 0 1 98 50 A 48 48 0 1 1 50 2" fill="none" stroke-width="4" stroke="#d5d5d6"></path>
    <path
      d="M 50 2 A 48 48 0 0 1 95.55661495953414 34.880647056545364"
      fill="none"
      stroke-width="4"
      stroke-linecap="round"
      stroke="red"
      class="path"
      style="stroke-dasharray: 60.3186, 241.274;">
    </path>
  </svg>
  <div class="inner-content">20%</div>
</div>

可以看到,其中圆环图形部分对应一个svg元素,svg元素内有两个path标签,分别画了灰色背景圆环和红色进度环。接下来简单介绍一下这几个元素内的几个重要属性。

二、SVG坐标

svg的坐标系是一个直角坐标系,不过与初高中数学中的坐标系y轴正方向相反:

svg坐标系(图片来源MDN)通过坐标系,可以约束svg图形从哪里开始画,画一个什么形状,到哪里结束等等。默认情况下,1个坐标单位和1屏幕像素对应。为了让我们实现的进度条组件复用性更高,我们可以在根级svg元素设置viewBox属性,用于约束缩放。比如:

<svg viewBox="0 0 100 100"></svg>

这样不管svg实际宽度对应多少像素,其内部可见的坐标点x、y坐标都在0~100之间。

三、元素

path元素有一个最重要的属性就是d属性,d属性的值可以是一连串的指令拼接而成。一个指令由指令字母+参数组成。已上述例子中的第一个path为例:

<path d="M 50 2 A 48 48 0 0 1 98 50 A 48 48 0 1 1 50 2" fill="none" stroke-width="4" stroke="#d5d5d6"></path>

d属性中用到了两种指令,M指令和A指令,完整的指令列表为:

    • M = moveto
    • L = lineto
    • H = horizontal lineto
    • V = vertical lineto
    • C = curveto
    • S = smooth curveto
    • Q = quadratic Belzier curve
    • T = smooth quadratic Belzier curveto
    • A = elliptical Arc
    • Z = closepath

以上所有命令均允许小写字母。大写表示绝对定位,小写表示相对定位。

这里仅仅介绍M指令和A指令,因为仅用这两种指令就可以完成进度环的绘制。

M指令很简单,后面跟随的两个数字x,y作为参数,表示移动画笔到x,y坐标,这时候还没有开始画线;如果使用小写字母m后面跟随数字dx, dy,则表示画笔从当前位置水平、垂直方向各移动d、dy个坐标(相对当前位置而不是原点进行移动)。

A指令稍微复杂,因为参数实在太多了。A指令作用是画椭圆弧形,指令格式为:

A rx ry x-axis-rotation large-arc-flag sweep-flag x y。

椭圆有长轴和短轴两个轴线,在A指令里通过前两个参数rx、ry来表示,这里我们为了画个圆,所以rx、ry设置为一致即可。试想,通过M指令规定了起点,通过rx、ry规定了弧线的长短轴,很显然我们还需要其它约束条件来得到一条圆弧。A指令的最后两个参数x、y约束了椭圆弧线的终点。给定起点A、终点B、长短轴rx/ry,可以得到4条弧线(如下图)。

要想确定下图中的某一条弧线,还需要另外两个参数large-arc-flag和sweep-flag,两个参数的可选值都是0和1。sweep-flag表示起点到终点是顺时针还是逆时针,下图中前两种情况即为顺时针(sweep-flag设置为1),后两种情况为逆时针(sweep-flag设置为0)。对于顺时针的两条弧线,large-arc-flag控制取长弧还是短弧,1为长弧,0为短弧。

A指令还有一个参数是x-axis-rotation,可以用于控制弧线旋转的角度。

通过path元素,我们已经可以刻画任意角度的圆弧,配上一个完整的背景圆弧环,就可以完成0 ~ 100%任意进度的进度环了。

四、0 ~ 100%任意进度进度环

任意进度的进度环,假设起点固定为圆环最顶部的点,无非是根据进度计算上述path中的各个参数的值。根据角度和圆弧半径计算终点坐标,就是高中所学的“极坐标转换为直角坐标”,利用三角函数直接转换即可。而使用长弧还是短弧,我们只用判断进度是否大于一半即可。原理很简单,Vue组件代码如下:

<template>
  <div
    :style="{
      width: size,
      height: size
    }"
    class="progress-container">
    <svg :viewBox="`0 0 ${svgWidth} ${svgWidth}`">
      <path
        :d="backPath"
        fill="none"
        :stroke-width="lineWidth"
        stroke="#d5d5d6">
      </path>
      <path
        class="path"
        :d="path"
        fill="none"
        :stroke-width="lineWidth"
        stroke-linecap="round"
        :style="{
          'stroke-dasharray': dashArray,
        }"
        stroke="red">
      </path>
    </svg>
    <div
      class="inner-content">
      <slot>
        {{ progressText }}
      </slot>
    </div>
  </div>
</template>

<script>
const LINE_WIDTH = 4
const SVG_WIDTH = 100

export default {
  props: {
    progress: {
      type: Number,
      default: 50
    },
    size: {
      type: String,
      default: '100px'
    }
  },
  computed: {
    lineWidth () {
      return LINE_WIDTH
    },
    svgWidth () {
      return SVG_WIDTH
    },
    radius () {
      return (this.svgWidth - this.lineWidth) / 2
    },
    fixedProgress () {
      return Math.max(Math.min(100, this.progress), 0)
    },
    progressText () {
      return this.fixedProgress + '%'
    },
    deg () {
      return 2 * Math.PI * (this.fixedProgress - 0.1) / 100
    },
    backPath () {
      let sx = this.svgWidth / 2, sy = this.lineWidth / 2
      let dx = this.svgWidth - this.lineWidth / 2, dy = this.svgWidth / 2
      let r = this.radius
      return `M ${sx} ${sy} A ${r} ${r} 0 0 1 ${dx} ${dy} A ${r} ${r} 0 1 1 ${sx} ${sy}`
    },
    path () {
      let r = this.radius
      let sx = this.svgWidth / 2, sy = this.lineWidth / 2
      let dx = this.svgWidth / 2 + Math.sin(this.deg) * r
      let dy = this.svgWidth / 2 - Math.cos(this.deg) * r
      let arc = this.fixedProgress > 50 ? 1 : 0
      return `M ${sx} ${sy} A ${r} ${r} 0 ${arc} 1 ${dx} ${dy}`
    },
    dashArray () {
      let ratio = this.fixedProgress / 100
      let c = Math.PI * 2 * this.radius
      return `${c * ratio / this.svgWidth * 100}, ${c * (1 - ratio) / this.svgWidth * 100}`
    }
  }
}
</script>

<style lang="stylus" scoped>
.progress-container
  position relative
  .inner-content
    position absolute
    left 5%
    right 5%
    top 5%
    bottom 5%
    z-index 2
    display flex
    align-items center
    justify-content center
.path
  transition stroke-dasharray .4s ease

</style>

进度变化的时候,如果直接改变可能会表现得很生硬,这里使用了stroke-dasharray这个CSS属性做过渡效果,这样在进度增加的时候,有一个动画增加的效果。这个CSS属性用一个偶数个数字(如果数字为奇数个,则重复一遍变为偶数)来描述线条的虚实相间效果,第一个数字表示线条实线长度,第二个数字表示空白线条长度,依次类推循环。

至此,一个圆形svg进度环⭕️就完成了。