周末好, 今天给大家带来一款接地气的环形进度条组件 vue-awesome-progress. 近日被设计小姐姐要求实现这么一个环形进度条效果, 大体由四部分组成, 分别是底色圆环, 进度弧, 环内文字, 进度圆点. 设计稿截图如下:
我的第一反应还是找现成的组件, 市面上很多组件都实现了前 3 点, 独独没找到能画进度圆点的组件, 不然稍加定制也能复用. 既然没有现成的组件, 只有自己用 vue + canvas 撸一个了.
效果图
先放个效果图, 然后再说下具体实现过程, 各位看官且听我慢慢道来.
安装与使用
源码地址 https://github.com/cumt-robin/vue-awesome-progress , 欢迎 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 段, 利用 Windows.requestAnimationFrame 依次执行动画.
比如从 30° 到 90°, 我给它分为 6 段, 每次画 10°. 要注意 canvas 画这种动画过程一般是要重复地清空画布并重绘的, 所以第一次我画的弧线范围就是 30°~40°, 第二次我画的弧线范围就是 30°~50°, 以此类推......
基本的代码结构如下, 具体代码请参考 https://github.com/cumt-robin/vue-awesome-progress v1.1.0 版本, 如果顺手帮忙点个 star 也是极好的.
- animateDrawArc(canvas, ctx, startDeg, endDeg, nextDeg, step) {
- Windows.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, 增强了可定制性, 使用体验更好. 这里不列出实现代码了, 请前往 https://github.com/cumt-robin/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 时, 动画效果各异, 完全取决于使用者自己.
其他效果
当然根据组件支持的属性, 我们也可以定制出其他效果, 比如不显示文字, 不显示圆点, 弧线线宽与圆环线宽一样, 不使用渐变色, 不需要动画, 等等. 我们后续也会考虑支持更多能力, 比如控制进度, 数字动态增长等! 具体使用方法, 请参考 https://github.com/cumt-robin/vue-awesome-progress .
结语
写完这个组件有让我感觉到, 程序员最终不是输给了代码和技术的快速迭代, 而是输给了自己的逻辑思维能力和数学功底. 就 https://github.com/cumt-robin/vue-awesome-progress 这个组件而言, 根据这个思路, 我们也能迅速开发出适用于 React,Angular 以及其他框架生态下的组件. 工作三年有余, 接触了不少框架和技术, 经历了 MVVM,Hybrid, 小程序, 跨平台, 大前端, serverless 的大火, 也时常感慨 "学不动了", 在这个快速演进的代码世界里常常感到失落. 好在自己还没有丢掉分析问题的能力, 而不仅仅是调用各种 API 和插件, 这可能是程序员最宝贵的财富吧. 前路坎坷, 我辈当不忘初心, 愿你出走半生, 归来仍是少年!
首发链接 https://juejin.im/post/5dc626125188253aec025a60