小程序canvas仪表盘,动画效果(带注释)

937 阅读5分钟

大致的效果是这样

image.png

废话多说 直接看代码 HTML

<view class="progress-container">
    <canvas class="canvas" type="2d" id="myCanvas" style="width: 750rpx;height: 460rpx;"></canvas>
</view>

JS

需要两个参数,已完成的数量和总量。

totalCount: {
      type: Number, //总数
      value: 0,
    },
    completedNum:{
      type: Number, //完成的数量
      value: 0,
    }

在组件的attached生命周期中定义初始值

  attached() {
    this.value = 0 ; //完成数量的起始值
    this.endValue = 0; //未完成的初始值,如果不需要可将此部分的代码逻辑删除
    this.r = 80 ; //圆的半径
    this.init(); //执行canvas
  },

OK 然后开始创建画布了 采用了canvas2d的类型,由于画这个仪表盘的时候写了挺多的demo 自我感觉2d比webgl清晰且更加流畅

 init() {
      const query = this.createSelectorQuery();
      const {totalCount,completedNum} = this.data;
      query
        .select('#myCanvas')
        .fields({
          node: true,
          size: true
        })
        .exec((res) => {
          const canvas = res[0].node;
          const ctx = canvas.getContext('2d');
          const system = wx.getSystemInfoSync();
          const dpr = system.pixelRatio;
          const ratio = system.windowWidth / 375; //计算屏幕比列 更好的适配不同机型
          canvas.width = res[0].width * dpr;//canvas狂傲
          canvas.height = res[0].height * dpr;
          ctx.scale(dpr, dpr); //同比缩放
          ctx.translate(system.windowWidth/2,230*ratio/2) //将圆心偏移到画布中心,230是canvas的高度 这样更好计算往后的数据
          // 设置圆环的宽度
          ctx.lineWidth = parseInt(12 * ratio);
          this.drawBottomColor(ctx, ratio);
          let renderLoop = null;
          //设置动画执行 主要使用了小程序canvas的requestAnimationFrame
          //此处ifelse的判断是已完成的数量多还是未完成的数量多,要执行数量比较多那部分的逻辑,否则动画执行不完全 如果不需要未完成部分 ,无需执行此判断。
          if(completedNum>=totalCount/2){
            renderLoop = ()=>{
              if (this.value <= this.data.completedNum) {
                this.render(ctx, ratio);
                canvas.requestAnimationFrame(renderLoop);
                if(this.endValue >= totalCount-completedNum){ //如果数量已经超过本身未完成的数量,就让他一直等于这个数值。
                  this.endValue = totalCount-completedNum 
                }
                this.drawEndExtendLine(ctx, ratio,this.endValue++) //每次执行的时候value ++ 下方代码逻辑同理
              }
            }
          }else{
            renderLoop = () =>{
              if(this.endValue<=totalCount-completedNum){
                if(this.value>=completedNum){
                  this.value = completedNum
                }
                this.render(ctx, ratio);
                this.drawEndExtendLine(ctx, ratio,this.endValue++)
                canvas.requestAnimationFrame(renderLoop);
              }
            }
          }
          canvas.requestAnimationFrame(renderLoop);
        });
    },

执行render函数

render(ctx, ratio) {
      ctx.clearRect(-375/2*ratio, -230/2*ratio, 375 * ratio, 230 * ratio); //清除画布
      this.draw(ctx, ratio);//开始画图
},
    
draw(ctx, ratio) {
  // 画底色
  this.drawBottomColor(ctx, ratio);
  // 画高亮色
  this.drawActiveColor(ctx, ratio, this.value++);
  ctx.stroke();

},

首先执行的是底部那个淡灰色的圆弧和圆弧内部的虚线

drawBottomColor(ctx, ratio) {
      let startAngle = 120/ 180 * Math.PI ; //开始角度  这样更容易计算出角度  
      const endAngle = 60/ 180 * Math.PI ; //结束角度   总角度是300 
      ctx.beginPath();
      ctx.lineCap = 'round' //线条接口处形状
      ctx.lineWidth = 12*ratio //宽度
      ctx.strokeStyle = '#71aefe';
      ctx.arc(0,0,this.r*ratio,startAngle,endAngle); 
      ctx.stroke(); //填充底部灰色圆弧
      ctx.beginPath() //开始绘制底部虚线
      ctx.lineCap = 'butt' //线条接口处形状
      ctx.strokeStyle = 'rgba(255,255,255,0.5)'
      ctx.lineWidth = 3 * ratio;
      // ctx.lineDashOffset = 5;
      ctx.setLineDash([2, 2.5]);
      ctx.arc(0, 0, this.r*ratio- 15, startAngle, endAngle) //圆弧的半径 - 15 位置可按照自己的需求计算
      ctx.stroke() 
},

然后开始画高亮的圆弧

    // 高亮进度条
drawActiveColor(ctx, ratio,nowValue) {
  const {totalCount} = this.data ; 和上面的灰色圆弧的不同的就是结束位置,需要计算一下
  let startAngle = 120 * Math.PI / 180; //开始角度
  const finishedValue = nowValue / totalCount * 300 //已完成部分结束角度 300总角度
  const finishedAngle = (120 + finishedValue) * Math.PI / 180
  ctx.beginPath();
  if(this.data.completedNum>0){
    ctx.lineCap = 'round' //线条接口处形状
  }
  ctx.lineWidth = 12*ratio //宽度
  ctx.strokeStyle = '#fff'; // 设置圆环的颜色
  ctx.arc(0,0,this.r * ratio,startAngle,finishedAngle);
  ctx.stroke();
  this.drawCenterFont(ctx, ratio,nowValue) //绘制中心文字
  this.drawExtendLine(ctx, ratio,nowValue) //延长线
},

开始写中间的文字比较简单,唯一需要注意的一点就是需要获取一下文字的宽度,避免1位数2位数3位数的时候覆盖到别的文字,或者距离又太远

drawCenterFont(ctx, ratio,nowValue){
  ctx.font = "36px normal";
  ctx.textAlign = 'center'
  ctx.fillStyle = '#fff'
  const textWidth = ctx.measureText(this.data.totalCount); //获取文字宽度
  ctx.fillText(this.data.totalCount, 0, 0)
  ctx.font = "14px normal";
  ctx.fillText('个', textWidth.width / 2 + 10, 0)
  ctx.fillText('任务', 0, 22 * ratio)
},

然后到了最麻烦的一个部分,如果没有延长线的需求到这个地方就可以结束了。

//延长线
    drawExtendLine(ctx,ratio,nowValue){
      const {totalCount} = this.data ;
      const extendLineValue = nowValue/totalCount*300
      // 我们首先要确定延长线的各个地方的位置,然后再去根据三角函数去计算x,y的位置。这样的好处就是延长线可以在圆弧的不同位置有不同的展示,避免了延长线和圆弧有重叠 如果不想用这个方法,可以固定写死 
      //这个函数计算出位置
      const postionArr = this.computedPosition(extendLineValue,ratio,totalCount,nowValue)
      
      ctx.beginPath()
      ctx.lineWidth = 2*ratio //宽度
      ctx.fillStyle = '#fff'
      ctx.setLineDash([]); //将线条切回实线
      ctx.arc(postionArr[0].x,postionArr[0].y,3,0,2*Math.PI) //先画延长线顶端的小圆球
      ctx.stroke();
      ctx.beginPath()
      ctx.lineCap = 'butt' //线条接口处形状
      ctx.lineWidth = 2*ratio
      ctx.setLineDash([3, 2]);
      ctx.moveTo(postionArr[1].x ,postionArr[1].y)  //然后开始画延长线
      ctx.lineTo(postionArr[2].x,postionArr[2].y)
      ctx.lineTo(postionArr[3].x,postionArr[3].y)
      ctx.stroke();
      ctx.font = "18px normal";
      //文字的位置计算的不是太准确,如果有需求可以自行计算一下
      ctx.fillText(Math.round(nowValue/totalCount*100),postionArr[4].x - 3,postionArr[4].y - 5)
      ctx.font = "12px normal"
      ctx.fillText("%", postionArr[5].x -7*ratio,postionArr[5].y - 5*ratio)
      ctx.fillText("已完成", postionArr[4].x +5*ratio, postionArr[4].y + 12*ratio)
    },


//计算已完成延长线各点xy的位置
    computedPosition(extendLineValue,ratio,totalCount,nowValue){
    //各个角度的位置 可自信更改
      const angleArr = [
        { x:92, y:92 },
        { x:95, y:95 },
        { x:105, y:105 },
        { x:135, y: 105 },
        { x:160, y: 110 },
        { x:135, y: 110 },
      ];
      let postionArr = [];
      //这个延长线如果超过一半的位置 就让他停下 所以计算这部分
      if(nowValue>=parseInt(totalCount * 0.4)){
        for(let item of angleArr){
          postionArr.push({
            x :Math.cos((120 + totalCount/totalCount*300* 0.2) * Math.PI / 180) * (item.x * ratio),
            y: Math.sin((120 + totalCount/totalCount*300* 0.2) * Math.PI / 180) * (item.y * ratio)
          })
        }
      }else{
        for(let item of angleArr){
          postionArr.push({
            x :Math.cos((120 + extendLineValue / 2 ) * Math.PI / 180) * (item.x * ratio),
            y: Math.sin((120 + extendLineValue / 2 ) * Math.PI / 180) * (item.y * ratio)
          })
        }
      }
      return postionArr
    },

未完成的线同理,这部分有很大的优化空间,毕竟重复的代码有点多。

//尾部的延长线
    drawEndExtendLine(ctx,ratio,endvalue){
      const {totalCount} = this.data;
      const unFinishdValue = (endvalue)/totalCount*300;
      const postionArr = this.computedEndPosition(unFinishdValue,ratio,endvalue,totalCount)
      ctx.beginPath();
      ctx.setLineDash([]); //将线条切回实线
      ctx.arc(postionArr[0].x,postionArr[0].y,3*ratio,0,2*Math.PI,true)
      ctx.stroke();
      ctx.beginPath()
      ctx.setLineDash([3, 2]);
      ctx.moveTo(postionArr[1].x ,postionArr[1].y)
      ctx.lineTo(postionArr[2].x,postionArr[2].y)
      ctx.lineTo(postionArr[3].x,postionArr[3].y)
      ctx.stroke();
      ctx.font = "18px normal";
      ctx.fillText(Math.round(endvalue/totalCount*100),postionArr[4].x ,postionArr[4].y - 5)
      ctx.font = "12px normal"
      ctx.fillText("%", postionArr[5].x ,postionArr[5].y - 5*ratio)
      ctx.fillText("未完成", postionArr[4].x + 5*ratio, postionArr[4].y + 12*ratio)
    },
//计算尾部延长线各x,y的位置
    computedEndPosition(unFinishdValue,ratio,endValue,totalCount){
      const angleArr = [
        { x:92, y:92 },
        { x:95, y:95 },
        { x:105, y:105 },
        { x:135, y: 105 },
        { x:150, y: 100 },
        { x:170, y: 100 },
      ];
      let postionArr = [];
      if(endValue>parseInt(totalCount * 0.4)){
        for(let item of angleArr){
          postionArr.push({
            x:Math.cos((60-totalCount/totalCount*300* 0.2) * Math.PI /180) * (item.x* ratio),
            y: Math.sin((60-totalCount/totalCount*300* 0.2) * Math.PI/180) * (item.y*ratio)
          })
        }
      }else{
        for(let item of angleArr){
          postionArr.push({
            x:Math.cos((60-unFinishdValue/2) * Math.PI /180) * (item.x* ratio),
            y: Math.sin((60-unFinishdValue/2) * Math.PI/180) * (item.y*ratio)
          })
        }
      }
      
      return postionArr
    }

到这里这个仪表盘就算完成了,第一次写canvas写的不是很好。请各位大佬轻喷...