34

用初中数学知识撸一个canvas环形进度条

 4 years ago
source link: https://segmentfault.com/a/1190000020953625
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

周末好,今天给大家带来一款接地气的环形进度条组件 vue-awesome-progress 。近日被设计小姐姐要求实现这么一个环形进度条效果,大体由四部分组成,分别是底色圆环,进度弧,环内文字,进度圆点。设计稿截图如下:

iYF3aua.png!web

我的第一反应还是找现成的组件,市面上很多组件都实现了前3点,独独没找到能画进度圆点的组件,不然稍加定制也能复用。既然没有现成的组件,只有自己用 vue + canvas 撸一个了。

ve6zQrY.jpg!web

效果图

先放个效果图,然后再说下具体实现过程,各位看官且听我慢慢道来。

aM3qyyB.gif

安装与使用

源码地址 ,欢迎 star 和提 issue

安装

npm install --save vue-awesome-progress

使用

全局注册

import Vue from 'vue'
import VueAwesomeProgress from "vue-awesome-progress"
Vue.use(VueAwesomeProgress)

局部使用

import VueAwesomeProgress from "vue-awesome-progress"

export default {
    components: {
        VueAwesomeProgress
    },
    // 其他代码
}

webpack配置

由于当前版本发布时,未进行 babel 编译,因此使用时需要自行将 vue-awesome-progress 纳入 babel-loader 的解析范围。示例如下:

// resolve函数是连接路径的,方法体是path.join(__dirname, "..", dir)
{
  test: /\.js$/,
  loader: "babel-loader",
  include: [
    resolve("src"),
    resolve("node_modules/vue-awesome-progress")
  ]
}

静态展示

任何事都不是一蹴而就的,我们首先来实现一个静态的效果,然后再实现动画效果,甚至是复杂的控制逻辑。

确定画布大小

第一步是确定画布大小。从设计稿我们可以直观地看到,整个环形进度条的最外围是由进度圆点确定的,而进度圆点的圆心在圆环圆周上。

jIvaMbq.png!web

因此我们得出伪代码如下:

// canvasSize: canvas宽度/高度
// outerRadius: 外围半径
// pointRadius: 圆点半径
// pointRadius: 圆环半径
canvasSize = 2 * outerRadius = 2 * (pointRadius + circleRadius)

据此我们可以定义如下组件属性:

props: {
  circleRadius: {
    type: Number,
    default: 40
  },
  pointRadius: {
    type: Number,
    default: 6
  }
},
computed: {
  // 外围半径
  outerRadius() {
    return this.circleRadius + this.pointRadius
  },
  // canvas宽/高
  canvasSize() {
    return 2 * this.outerRadius + 'px'
  }
}

那么 canvas 大小也可以先进行绑定了

<template>
    <canvas ref="canvasDemo" :width="canvasSize" :height="canvasSize" />
</template>

获取绘图上下文

getContext('2d') 方法返回一个用于在 canvas 上绘图的环境,支持一系列 2d 绘图 API

mounted() {
  // 在$nextTick初始化画布,不然dom还未渲染好
  this.$nextTick(() => {
    this.initCanvas()
  })
},
methods: {
  initCanvas() {
    var canvas = this.$refs.canvasDemo;
    var ctx = canvas.getContext('2d');
  }
}

画底色圆环

完成了上述步骤后,我们就可以着手画各个元素了。我们先画圆环,这时我们还要定义两个属性,分别是圆环线宽 circleWidth 和圆环颜色 circleColor

circleWidth: {
  type: Number,
  default: 2
},
circleColor: {
  type: String,
  default: '#3B77E3'
}

canvas 提供的画圆弧的方法是 ctx.arc() ,需要提供圆心坐标,半径,起止弧度,是否逆时针等参数。

ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise);

我们知道, Web 网页中的坐标系是这样的,从绝对定位的设置上其实就能看出来( topleft 设置正负值会发生什么变化),而且原点 (0, 0) 是在盒子(比如说 canvas )的左上角哦。

Mzmqeyv.jpg!web

对于角度而言, x 轴正向,默认是顺时针方向旋转。

圆环的圆心就是 canvas 的中心,所以 x , youterRadius 的值就可以了。

ctx.strokeStyle = this.circleColor;
ctx.lineWidth = this.circleWidth;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, 0, this.deg2Arc(360));
ctx.stroke();

注意 arc 传的是弧度参数,而不是我们常理解的 360° 这种概念,因此我们需要将我们理解的 360° 转为弧度。

// deg转弧度
deg2Arc(deg) {
  return deg / 180 * Math.PI
}

qIFJraB.png!web

画文字

调用 fillText 绘制文字,利用 canvas.clientWidth / 2canvas.clientWidth / 2 取得中点坐标,结合控制文字对齐的两个属性 textAligntextBaseline ,我们可以将文字绘制在画布中央。文字的值由 label 属性接收,字体大小由 fontSize 属性接收,颜色则取的 fontColor

if (this.label) {
  ctx.font = `${this.fontSize}px Arial,"Microsoft YaHei"`
  ctx.fillStyle = this.fontColor;
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(this.label, canvas.clientWidth / 2, canvas.clientWidth / 2);
}

QVZjqm6.png!web

画进度弧

支持普通颜色和渐变色, withGradient 默认为 true ,代表使用渐变色绘制进度弧,渐变方向我默认给的从上到下。如果希望使用普通颜色, withGradientfalse 即可,并可以通过 lineColor 自定义颜色。

if (this.withGradient) {
  this.gradient = ctx.createLinearGradient(this.circleRadius, 0, this.circleRadius, this.circleRadius * 2);
  this.lineColorStops.forEach(item => {
    this.gradient.addColorStop(item.percent, item.color);
  });
}

其中 lineColorStops 是渐变色的颜色偏移断点,由父组件传入,可传入任意个颜色断点,格式如下:

colorStops2: [
  { percent: 0, color: '#FF9933' },
  { percent: 1, color: '#FF4949' }
]

画一条从上到下的进度弧,即 270°90°

ctx.strokeStyle = this.withGradient ? this.gradient : this.lineColor;
ctx.lineWidth = this.lineWidth;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius, this.circleRadius, this.deg2Arc(270), this.deg2Arc(90));
ctx.stroke();

UBJBnqy.png!web

其中 lineWidth 是弧线的宽度,由父组件传入

lineWidth: {
  type: Number,
  default: 8
}

画进度圆点

最后我们需要把进度圆点补上,我们先写死一个角度 90° ,显而易见,圆点坐标为 (this.outerRadius, this.outerRadius + this.circleRadius)

ueQnUvm.png!web

画圆点的代码如下:

ctx.fillStyle = this.pointColor;
ctx.beginPath();
ctx.arc(this.outerRadius, this.outerRadius + this.circleRadius, this.pointRadius, 0, this.deg2Arc(360));
ctx.fill();

其中 pointRadius 是圆点的半径,由父组件传入:

pointRadius: {
  type: Number,
  default: 6
}

7BriMjZ.png!web

角度自定义

当然,进度条的角度是灵活定义的,包括开始角度,结束角度,都应该由调用者随意给出。因此我们再定义一个属性 angleRange ,用于接收起止角度。

angleRange: {
  type: Array,
  default: function() {
    return [270, 90]
  }
}

有了这个属性,我们就可以随意地画进度弧和圆点了,哈哈哈哈。

eaQbQzJ.jpg!web

老哥,这种圆点坐标怎么求?

V3mEZjF.png!web

噗......看来高兴过早了,最重要的是根据不同角度求得圆点的圆心坐标,这让我顿时犯了难。

e2Uzaqq.gif

经过冷静思考,我脑子里闪过了一个利用正余弦公式求坐标的思路,但前提是坐标系原点如果在圆环外接矩形的左上角才好算。仔细想想,冇问题啦,我先给坐标系平移一下,最后求出来结果,再补个平移差值不就行了嘛。

3iIjEfR.png!web

:point_up_2:画图工具不是很熟练,这里图没画好,线歪了,请忽略细节。

好的,我们先给坐标系向右下方平移 pointRadius ,最后求得结果再加上 pointRadius 就好了。伪代码如下:

// realx:真实的x坐标
// realy:真实的y坐标
// resultx:平移后求取的x坐标
// resultx:平移后求取的y坐标
// pointRadius 圆点半径
realx = resultx + pointRadius
realy = resulty = pointRadius

求解坐标的思路大概如下,分四个范围判断,得出求解公式,应该还可以化简,不过我数学太菜了,先这样吧。

getPositionsByDeg(deg) {
    let x = 0;
    let y = 0;
    if (deg >= 0 && deg <= 90) {
        // 0~90度
        x = this.circleRadius * (1 + Math.cos(this.deg2Arc(deg)))
        y = this.circleRadius * (1 + Math.sin(this.deg2Arc(deg)))
    } else if (deg > 90 && deg <= 180) {
        // 90~180度
        x = this.circleRadius * (1 - Math.cos(this.deg2Arc(180 - deg)))
        y = this.circleRadius * (1 + Math.sin(this.deg2Arc(180 - deg)))
    } else if (deg > 180 && deg <= 270) {
        // 180~270度
        x = this.circleRadius * (1 - Math.sin(this.deg2Arc(270 - deg)))
        y = this.circleRadius * (1 - Math.cos(this.deg2Arc(270 - deg)))
    } else {
        // 270~360度
        x = this.circleRadius * (1 + Math.cos(this.deg2Arc(360 - deg)))
        y = this.circleRadius * (1 - Math.sin(this.deg2Arc(360 - deg)))
    }
    return { x, y }
}

最后再补上偏移值即可。

const pointPosition = this.getPositionsByDeg(nextDeg);
ctx.arc(pointPosition.x + this.pointRadius, pointPosition.y + this.pointRadius, this.pointRadius, 0, this.deg2Arc(360));

NBrQBzi.png!web

这样,一个基本的 canvas 环形进度条就成型了。

动画展示

静态的东西逼格自然是不够的,因此我们需要再搞点动画效果装装逼。

基础动画

我们先简单实现一个线性的动画效果。基本思路是把开始角度和结束角度的差值分为 N 段,利用 window.requestAnimationFrame 依次执行动画。

比如从 30°90° ,我给它分为6段,每次画 10° 。要注意 canvas 画这种动画过程一般是要重复地清空画布并重绘的,所以第一次我画的弧线范围就是 30°~40° ,第二次我画的弧线范围就是 30°~50° ,以此类推......

基本的代码结构如下,具体代码请参考 vue-awesome-progress v1.1.0 版本,如果顺手帮忙点个 star 也是极好的。

animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step) {
  window.requestAnimationFrame(() => {
    // 清空画布
    ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
    // 求下一个目标角度
    nextDeg = this.getTargetDeg(nextDeg || startDeg, endDeg, step);
    // 画圆环
    // 画文字
    // 画进度弧线
    // 画进度圆点
    if (nextDeg !== endDeg) {
      // 满足条件继续调用动画,否则结束动画
      this.animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step)
    }
  }
}

U7fM3aB.gif

缓动效果

线性动画显得有点单调,可操作性不大,因此我考虑引入贝塞尔缓动函数 easing ,并且支持传入动画执行时间周期 duration ,增强了可定制性,使用体验更好。这里不列出实现代码了,请前往 vue-awesome-progress 查看。

<vue-awesome-progress label="188人" :duration="10" easing="0,0,1,1" />

<vue-awesome-progress
  label="36℃"
  circle-color="#FF4949"
  :line-color-stops="colorStops"
  :angle-range="[60, 180]"
  :duration="5"
/>

// 省略部分...

<vue-awesome-progress label="188人" easing="1,0.28,0.17,0.53" :duration="10" />

<vue-awesome-progress
  label="36℃"
  circle-color="#FF4949"
  :line-color-stops="colorStops"
  :angle-range="[60, 180]"
  :duration="5"
  easing="0.17,0.67,0.83,0.67"
/>

nmmUzav.gif

可以看到,当传入不同的动画周期 duration 和缓动参数 easing 时,动画效果各异,完全取决于使用者自己。

其他效果

当然根据组件支持的属性,我们也可以定制出其他效果,比如不显示文字,不显示圆点,弧线线宽与圆环线宽一样,不使用渐变色,不需要动画,等等。我们后续也会考虑支持更多能力,比如控制进度,数字动态增长等!具体使用方法,请参考 vue-awesome-progress

FbM7naA.gif

结语

写完这个组件有让我感觉到,程序员最终不是输给了代码和技术的快速迭代,而是输给了自己的逻辑思维能力和数学功底。就 vue-awesome-progress 这个组件而言,根据这个思路,我们也能迅速开发出适用于 ReactAngular 以及其他框架生态下的组件。工作三年有余,接触了不少框架和技术,经历了 MVVMHybrid小程序跨平台大前端serverless 的大火,也时常感慨“学不动了”,在这个快速演进的代码世界里常常感到失落。好在自己还没有丢掉分析问题的能力,而不仅仅是调用各种 API 和插件,这可能是程序员最宝贵的财富吧。前路坎坷,我辈当不忘初心,愿你出走半生,归来仍是少年!

首发链接

扫一扫下方小程序码或搜索 Tusi博客 ,即刻阅读最新文章!

n6Br6jj.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK