网络日志

可线性渐变的环形进度条的实现探究

本文主要介绍基于 NutUI Vue3 的 circleProgress 组件的设计与实现原理,是一个圆环形的进度条组件,用来展示操作的当前进度,支持修改进度以及渐变色。环形进度条是很常用的一个组件,特别是在管理后台数据统计的页面上或是一些需要用户等待的任务。

实现效果

效果如下图

实现思路

首先我们来理一下我们的需求,我们需要一个圆形的可以改变进度,有动画的,可以支持渐变色的进度条

目前是有三种常见的实现方案:

  • CSS 实现
  • SVG 实现
  • Canvas 实现

而 SVG 又分为两种,一种是直接用 circle 实现,另外一种是用 path 来画出来。

然后我们来看下一些国内出名的组件库实现方式:

Antd DesignTdesignvarletElement UIvant
SVGCircleCircleCirclePathPath

Antd Design和TDesign,varlet 采用svg的Circle实现,但是小编个人觉得Antd Design的渐变色的开始和结束是有问题的,TDesign和varlet暂时不支持环形进度条线性渐变Element UI和Vant 采用svg的Path实现,Vant是支持线性渐变的,而且不存在渐变色的开始和结束不对应问题,Element暂时不支持线性渐变

目前主流组件库的环形进度条基本上都是用svg绘出来的,因为实现思路简单,使用 SVG 画两个圆 一个圆作为底色,另一个圆作为进度展示,使用时候的问题也很少。NutUI也选择使用SVG来实现进度条。

下边详细介绍一下两种实现方式

circle实现

首先来说下 SVG 的 circle (Antd,Tdesign的实现方式),下边也会介绍到Antd的进度条线性渐变问题

首先第一步 我们先来画个最简单的圆

<svg height="100" width="100" x-mlns="http://www.w3.org/200/svg">
  <circle r="40" cx="50" cy="50" stroke="'red'" stroke-width="10" fill="none" />
</svg>

上边的属性我就不多介绍了 不了解的同学其实也可以读出来大概意思,r 是半径,cx,cy 为圆点位置,以及颜色和弧度的宽度。这里可能有的同学要问了 这不是一个 circle 就可以实现嘛,可不要忘记了我们需要的效果,

那我们再来画一个不是100%进度的圆,如何画呢这里就用到了 stroke-dasharray 属性,可以将图形的描边进行点状化,这里需要理解的是,点状化的点,其大小是可以设置的,并不真的就是那么一个·,可以变长或者变短。所以如果 circle 的点的长度正好等于 circle边长,那么点看上去就是 circle 的边。我们计算下圆环的周长就可以了,参数也就是弧长,极大值,极大值就是周长,弧长就是进度值。大家应该也发现问题了,这样的话在不是100%进度的时候,部分弧度是没有颜色的,所以我们还需要一个底色。也就是另外一个circle

<svg>
  <circle
    r="40"
    cx="50"
    cy="50"
    stroke="#d9d9d9"
    stroke-width="10"
    fill="none"
  />
  />
  <circle
    r="40"
    cx="50"
    cy="50"
    stroke="red"
    stroke-width="10"
    stroke-dasharray="200,251"
    fill="none"
    stroke-linecap="round"
  />
</svg>

因为这里我们需要它从左边的中间位置开始,所以还需要加上旋转。

至于接下来就很简单了 让它动起来,那么如何动起来呢 动态改变 stroke-dasharray 的值就可以了,下方介绍path的时候会讲到如何改变。来讲一下遇到的小坑 ,就是我们在做渐变色的时候,会发现我们的渐变色并不是从我们的进度开始地方开始渐变的。这里我们也可以看下Antd的环形进度条渐变,我用一个红到黑来给大家看一下。(Tdesign这里小编在在线编辑器里尝试了一下,线性渐变没有生效。)

这里小编个人觉得Antd的这个渐变也是不对的(仅代表个人想法),当然大家有别的想法也可以提出来一起探讨。

其实是因为线性渐变是从左往右的,并且上边为圆环加了旋转的原因,解决方法大家可以自行搜索引擎搜索一下,这里不多做阐述。

path实现

下边主要介绍一下用 pathVant,Element实现方法)实现吧 可以简单并完美的解决上边的渐变色对应不上(Element暂时不支持线性渐变)的问题。

circle 实现思路是一样的,画两个圆,一个用来表示底色,一个用来表示弧度,主要是我们如何来画一个圆呢

  1. 了解viewBox属性,在SVG标签中添加该属性,这个属性是用来设置画布的大小,但是大家注意,它是一个相对大小,会根据我们的父元素改变而动态适配。比如我们将其属性设置为 viewBox="0,0,100,100",其实它是将我们的整个画布的宽和高分为100份,其中SVG元素是在这个分割以后的画布上摆放展示。

    我们不需要再关注 SVG 的宽高,它现在已经实现了自适应,会自动根据外层父元素的宽高进行 适配,我们最外层给用户一个 Props 来设置环形进度条的大小。

    <div :style="{ height: radius * 2 + 'px', width: radius * 2 + 'px' }">
        <svg viewBox="0 0 100 100"></svg>
     </div>
  2. 了解path的d属性,既然我们要用path来画圆,那我们当然得熟悉一下🐂🍺的d属性来,它可以画出各种各样的线来。d 属性用来定义路径数据,我们首先来了解下我们需要用到的参数:

      M = moveto(M X,Y) :将画笔移动到指定的坐标位置
    
      A = elliptical Arc(A RX,RY,XROTATION,FLAG1,FLAG2,X,Y): 椭圆弧

    请注意 ⚠️ ,这些参数是区分大小的,当它为大写的命令时表明它的参数是绝对位置,小写的命令表明它的擦含糊相对于当前位置的点。我们也可以使用负数值来作为我们命令的参数。负相对 x 值将会往左移,而负相对 y 值将会向上移。

    下边来详解下我们的这两个参数 moveto属性其实很好理解,画笔的指定位置,x 轴和y 轴,M(绝对) m(相对)

    elliptical Arc 椭圆弧的记录以下:指令:A (绝对) a (相对)

    椭圆弧的参数形式:(rx ry x-axis- rotation large-arc-flag sweep-flag x y)详解参数:rx ry 是椭圆的两个半轴的长度。x-axis-rotation是椭圆相对于坐标系的旋转角度,角度数而非弧度数。large-arc-flag 是标记绘制大弧(1)仍是小弧(0)部分。sweep-flag 是标记向顺时针(1)仍是逆时针(0)方向绘制。x y 是圆弧终点的坐标。

    因为我们这里需要将圆开始的绘制方向交给用户来控制,所以这里来接受一个 props 来控制绘制方向。根据上述描述我们可以写出 pathd 属性,在绝对位置 50,50 的位置(圆心),先从圆心上方位置 45 的位置绘图(45 即为半径)然后接下来是椭圆弧的参数,rxry,半径为 45,旋转角度为 0,绘制一个大弧,然后是绘制方向,顺时针还是逆时针旋转,最后是圆弧终点的坐标。可以得出下边的代码

    const path = computed(() => {
      const isWise = props.clockwise ? 1 : 0;
      return `M 50 50 m 0 -45 a 45 45 0 1 ${isWise} 0 90 a 45 45 0 1, ${isWise} 0 -90`;
    });

    这样的话就会绘制出一个圆来,其他用到的属性就和上边的 cirlce一样了

stroke:描边颜色

stroke-width:描边宽度

fill:填充颜色

stroke-dasharray:间隔多少像素绘制一次

既然 path 的路径我们已经完成了,接下来就是正常的给这个圆环上色和加上动态进度变化了。

首先我们来写底部的背景圆环,用户可以自定义去传背景圆环弧度的色值以及圆弧的宽度。

 <path class="nut-circleprogress-path" :style="pathStyle" :d="path" fill="none" :stroke-width="strokeWidth">/>

 const pathStyle = computed(() => {
      return {
        stroke: props.pathColor
      };
});

接下来是展示进度条的圆环,因为我们这里还需要一个渐变色,所以还需要在 SVG中加入一些代码查阅 SVG 的文档我们找到了一个叫做 <linearGradient> 的 SVG 元素,通过使用该元素我们可以达成颜色渐变的目的。

  1. 创建 linearGradient 在创建这个元素之前,我们需要知道<linearGradient>标签必须嵌套在<defs>的内部。<defs>标签是 definitions的缩写,它可对诸如渐变之类的特殊元素进行定义。而且我们必须给渐变内容指定一个 id 属性,否则文档内的其他元素就不能引用它。为了让渐变能被重复使用,渐变内容需要定义在<defs>标签内部,而不是定义在形状上面。
  2. 设定颜色渐变方向现在<linearGradient>元素创建成功了,下面我们可以为其赋值属性以达到按需求修改渐变颜色变向的需求。渐变的方向可以通过两个点来控制,它们分别是属性 x1x2y1y2,这些属性定义了渐变路线走向。渐变色默认是水平方向的,但是通过修改这些属性,就可以旋转该方向。
  3. 设定渐变色

    <linearGradient>中理论上添加的颜色是无上限的,但若想有渐变效果最少要添加两种颜色。因此需要在<linearGradient>中创建最少两个<stop>元素,以添加你需要的颜色属性。所以我们这里采用一个循环来处理多个颜色属性

    <stop>元素有三个属性:

    stop-color: 想要设定的渐变颜色

    offset: 在你定义的方向向量上,定义该颜色的生效位置,使用百分比来设置具体的存在位置。

    stop-opacity: 设定 stop-color 颜色的透明度(暂时用不到)

    <defs>
     <linearGradient :id="refRandomId" x1="100%" y1="0%" x2="0%" y2="0%">
           <stop v-for="(item, index) in stopArray" :key="index" :offset="item.key" :stop-color="item.value"></stop>
     </linearGradient>
    </defs>
    const stopArray = computed(() => {
       if (!isObject(props.color)) {
         return;
       }
       let color = props.color;
       const colorArr = Object.keys(color).sort((a, b) => parseFloat(a) - parseFloat(b));
       let stopArr: object[] = [];
       colorArr.map((item, index) => {
         let obj = {
           key: '',
           value: ''
         };
         obj.key = item;
         obj.value = color[item];
         stopArr.push(obj);
       });
       return stopArr;
     });

    渐变色加完以后,我们来处理一下进度条(如何让进度条绑定上这个渐变色),其实很简单,将圆环的 stroke 变成<linearGradient>的唯一 id 就可以啦;接着处理一下圆环的进度展示,和上边 circle 的处理方式一致,使用 stroke-dasharray 就可以啦。

 <path
    class="nut-circleprogress-hover"
    :style="hoverStyle"
    :d="path"
    fill="none"
    :stroke-linecap="strokeLinecap"
    :stroke-width="strokeWidth"
  ></path>

const hoverStyle = computed(() => {
    let perimeter = 283;
    let offset = (perimeter * Number(props.progress)) / 100;
    return {
       stroke: isObject(props.color) ? `url(#${refRandomId})` : props.color,
        strokeDasharray: `${offset}px ${perimeter}px`
    };
});

上方的stroke-linecap属性是一个表示属性,定义了在打开子路径被描边时要在其末尾使用的形状,可以用在style中。至于上方的283是如何来的,其实很简单 就是我们的圆环周长,2πr=2 3.1415926 45。其实到了这里,我们正常h5环境下的环形进度条就大功告成了。

Taro下的SVG

因为NutUI是可以配合Taro用来开发微信小程序的,所以这里的环形进度条在小程序环境下当然也要拥有相同的功能了。

因为我们普通h5环境下使用的SVG实现的,所以想一套代码适用,结果发现在小程序环境中暂不支持使用SVG。

这是小程序官方文档中回答的,所以我这里采用了将其作为背景图来展示。我们通过一些转换SVG为base64的网站发现,<其实是%3C>%3E#替换成%23就可以啦,因为我们里边还存在一些变量,所以我这边将他们拆分开了,分成几个变量来写。

<div :style="style"></div>

    const style = computed(() => {
      let { strokeWidth } = props;

      let stopArr: Array<object> = stop();
      let stopDom: string[] = [];
      if (stopArr) {
        stopArr.map((item: Item) => {
          let obj = '';
          obj = `%3Cstop offset='${item.key}' stop-color='${transColor(item.value)}'/%3E`;
          stopDom.push(obj);
        });
      }
      let perimeter = 283;
      let progress = +currentRate.value;
      let offset = (perimeter * Number(format(parseFloat(progress.toFixed(1))))) / 100;
      const isWise = props.clockwise ? 1 : 0;
      const color = isObject(props.color) ? `url(%23${refRandomId})` : transColor(props.color);
      let d = `M 50 50 m 0 -45 a 45 45 0 1 ${isWise} 0 90 a 45 45 0 1, ${isWise} 0 -90`;
      const pa = `%3Cdefs%3E%3ClinearGradient id='${refRandomId}' x1='100%25' y1='0%25' x2='0%25' y2='0%25'%3E${stopDom}%3C/linearGradient%3E%3C/defs%3E`;
      const path = `%3Cpath d='${d}' stroke-width='${strokeWidth}' stroke='${transColor(
        props.pathColor
      )}' fill='none'/%3E`;
      const path1 = `%3Cpath d='${d}' stroke-width='${strokeWidth}' stroke-dasharray='${offset},${perimeter}' stroke-linecap='round' stroke='${color}' fill='none'/%3E`;

      return {
        background: `url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100'  xmlns='http://www.w3.org/2000/svg'%3E${pa}${path}${path1}%3C/svg%3E")`,
        width: '100%',
        height: '100%'
      };
    });

你以为这就大功告成了吗 不不不 你会发现你在动态改变环形进度条的进度时,没有动画,显得生硬刻板

所以我们这里给它去增加一个动画效果,我们这里去用setTimeout代替一下requestAnimationFrame(不了解的同学可以了解下这个属性,很好用的!),因为小程序环境下不支持。

const requestAnimationFrame = function (callback: Function) {
      var currTime = new Date().getTime();
      var timeToCall = Math.max(0, 16.7 - (currTime - lastTime));
      lastTime = currTime + timeToCall;
      var id = setTimeout(function () {
        callback(currTime + timeToCall, lastTime);
      }, timeToCall);
      lastTime = currTime + timeToCall;
      return id;
    };
const cancelAnimationFrame = function (id: any) {
      clearTimeout(id);
    };

然后我们就可以去动态变化进度条啦。

好啦 到这里我们的taro适配也就完成啦,让我们来看一看效果吧:

gif图可能不太明显,大家可以微信搜索NutUI小程序尝试一下。

结语

本文介绍了 NutUI 中 circleProgress 组件的设计思路与实现原理,与大家共勉。最后再提一下我们的 NutUI 组件库,长期以来,团队的小伙伴都在尽心尽力地维护着 NutUI。在之后的日子里,这种坚持也不会放弃,我们依然会积极地维护与迭代,为有需要的同学提供技术支持,也会不定时地发布一些相关的文章帮助大家更好地理解与使用我们的组件库。

来点个 Star ❤️ 支持我们一下吧 ~