周末好,今天给大家带来一款接地气的环形进度条组件vue-awesome-progress
。近日被设计小姐姐要求实现这么一个环形进度条效果,大体由四部分组成,分别是底色圆环,进度弧,环内文字,进度圆点。设计稿截图如下:
我的第一反应还是找现成的组件,市面上很多组件都实现了前 3 点,独独没找到能画进度圆点的组件,不然稍加定制也能复用。既然没有现成的组件,只有自己用 vue + canvas
撸一个了。
效果图
先放个效果图,然后再说下具体实现过程,各位看官且听我慢慢道来。
安装与使用
源码地址,欢迎 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")
]
}
静态展示
任何事都不是一蹴而就的,我们首先来实现一个静态的效果,然后再实现动画效果,甚至是复杂的控制逻辑。
确定画布大小
第一步是确定画布大小。从设计稿我们可以直观地看到,整个环形进度条的最外围是由进度圆点确定的,而进度圆点的圆心在圆环圆周上。
因此我们得出伪代码如下:
// 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
网页中的坐标系是这样的,从绝对定位的设置上其实就能看出来(top
,left
设置正负值会发生什么变化),而且原点 (0, 0)
是在盒子(比如说canvas
)的左上角哦。
对于角度而言,0°
是 x
轴正向,默认是顺时针方向旋转。
圆环的圆心就是 canvas
的中心,所以 x
, y
取outerRadius
的值就可以了。
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}
画文字
调用 fillText
绘制文字,利用 canvas.clientWidth / 2
和canvas.clientWidth / 2
取得中点坐标,结合控制文字对齐的两个属性 textAlign
和textBaseline
,我们可以将文字绘制在画布中央。文字的值由 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);
}
画进度弧
支持普通颜色和渐变色,withGradient
默认为 true
,代表使用渐变色绘制进度弧,渐变方向我默认给的从上到下。如果希望使用普通颜色,withGradient
传false
即可,并可以通过 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();
其中 lineWidth
是弧线的宽度,由父组件传入
lineWidth: {
type: Number,
default: 8
}
画进度圆点
最后我们需要把进度圆点补上,我们先写死一个角度90°
,显而易见,圆点坐标为(this.outerRadius, this.outerRadius + this.circleRadius)
画圆点的代码如下:
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
}
角度自定义
当然,进度条的角度是灵活定义的,包括开始角度,结束角度,都应该由调用者随意给出。因此我们再定义一个属性angleRange
,用于接收起止角度。
angleRange: {
type: Array,
default: function() {return [270, 90]
}
}
有了这个属性,我们就可以随意地画进度弧和圆点了,哈哈哈哈。
老哥,这种圆点坐标怎么求?
噗 …… 看来高兴过早了,最重要的是根据不同角度求得圆点的圆心坐标,这让我顿时犯了难。
经过冷静思考,我脑子里闪过了一个利用正余弦公式求坐标的思路,但前提是坐标系原点如果在圆环外接矩形的左上角才好算。仔细想想,冇问题啦,我先给坐标系平移一下,最后求出来结果,再补个平移差值不就行了嘛。
???? 画图工具不是很熟练,这里图没画好,线歪了,请忽略细节。
好的,我们先给坐标系向右下方平移 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));
这样,一个基本的 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)
}
}
}
缓动效果
线性动画显得有点单调,可操作性不大,因此我考虑引入贝塞尔缓动函数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"
/>
可以看到,当传入不同的动画周期 duration
和缓动参数 easing
时,动画效果各异,完全取决于使用者自己。
其他效果
当然根据组件支持的属性,我们也可以定制出其他效果,比如不显示文字,不显示圆点,弧线线宽与圆环线宽一样,不使用渐变色,不需要动画,等等。我们后续也会考虑支持更多能力,比如控制进度,数字动态增长等!具体使用方法,请参考 vue-awesome-progress。
结语
写完这个组件有让我感觉到,程序员最终不是输给了代码和技术的快速迭代,而是输给了自己的逻辑思维能力和数学功底。就 vue-awesome-progress 这个组件而言,根据这个思路,我们也能迅速开发出适用于 React
,Angular
以及其他框架生态下的组件。工作三年有余,接触了不少框架和技术,经历了 MVVM
,Hybrid
, 小程序
, 跨平台
, 大前端
, serverless
的大火,也时常感慨“学不动了”,在这个快速演进的代码世界里常常感到失落。好在自己还没有丢掉分析问题的能力,而不仅仅是调用各种 API
和插件,这可能是程序员最宝贵的财富吧。前路坎坷,我辈当不忘初心,愿你出走半生,归来仍是少年!
首发链接
扫一扫下方小程序码或搜索Tusi 博客
,即刻阅读最新文章!