最近在开发移动端页面时遇到了一个给播放器做个圆环进度条的需求,没有什么花哨的功能,只绑定点击事件,并根据播放进度改变圆环弧长变化。下面是由需求展开的一些学习笔记,内容涉及canvas,svg以及vue的知识。

1.canvas实现
好嘛,年轻的我马上想到了用canvas,还有什么动画是canvas征服不了,一把梭干就对了。不消一会,日日搬砖娴熟如我,已经码好代码
1<template>
2 <div class="circle-wrapper" @click.stop="$emit('click')">
3 <canvas :style="{ height,width }" id="progressCircle"></canvas>
4 <img class="status-btn" v-show="!status" :src="playStatusImg[0]" />
5 <img class="status-btn" v-show="status" :src="playStatusImg[1]" />
6 </div>
7</template>
8
9<script>
10const play = require('@/assets/audioplayer/play-sm.png')
11const pause = require('@/assets/audioplayer/pause-sm.png')
12
13export default {
14 name: 'CircleProgress',
15 props: {
16 status: {
17 type: Boolean,
18 default: false
19 },
20 opts: {
21 type: Object,
22 default: () => ({
23 width: '27px',
24 height: '27px'
25 })
26 }
27 },
28 data () {
29 return {
30 progressCircle: '',
31 playStatusImg: [play, pause]
32 }
33 },
34 methods: {
35 /**
36 * 绘制canvas
37 */
38 draw ({ currentTime, duration }) {
39 window.requestAnimationFrame(() => this.circleAnime(currentTime, duration))
40 },
41 /**
42 * 根据播放进度绘制圆形进度条
43 * @param {number} currentTime 当前时间进度,单位ms
44 * @param {number} duration 总播放时长,单位ms
45 */
46 circleAnime (currentTime = 0, duration = 1) {
47 const ctx = this.progressCircle.getContext('2d')
48 const radius = 12
49 const lineWidth = 2
50 const { PI } = Math
51 const startAngle = -0.5 * PI
52 const endAngle = -0.5 * PI + (currentTime / duration) * 2 * PI
53 ctx.clearRect(0, 0, (radius + lineWidth) * 2, (radius + lineWidth) * 2)
54 ctx.lineWidth = lineWidth
55 ctx.strokeStyle = '#F4D4D7'
56 ctx.beginPath()
57 ctx.arc(radius + lineWidth, radius + lineWidth, radius, 0, 2 * PI)
58 ctx.stroke()
59 ctx.strokeStyle = '#BA191D'
60 ctx.beginPath()
61 ctx.arc(radius + lineWidth, radius + lineWidth, radius, startAngle, endAngle)
62 ctx.stroke()
63 },
64 },
65 mounted () {
66 this.progressCircle = document.querySelector('#progressCircle')
67 }
68}
69</script>
70
71<style lang="less" scoped>
72.status-btn {
73 position: absolute;
74 left: 50%;
75 top: 50%;
76 transform: translate(-40%, -50%);
77 height: 10px;
78 width: 10px;
79 object-fit: contain;
80}
81.circle-wrapper {
82 position: relative;
83}
84</style>于是我一把保存热更新,页面看到的真容是这样的——

What?这种突如其来的朦胧美是什么鬼,和理想中的样子差得可太远了啊,这可怎么见人。心急火燎的我马上想起了编程的基本思想之一——面向google编程,搜到的解答是这么说的:
因为 canvas 不是矢量图,而是像图片一样是位图模式的。高 dpi 显示设备意味着每平方英寸有更多的像素。也就是说二倍屏,浏览器就会以2个像素点的宽度来渲染一个像素,该 canvas 在 Retina 屏幕下相当于占据了2倍的空间,相当于图片被放大了一倍,因此绘制出来的图片文字等会变模糊。
因此,要做 Retina 屏适配,关键是知道当前屏幕的设备像素比,然后将 canvas 放大到该设备像素比来绘制,然后将 canvas 压缩到一倍来展示。
而在浏览器的 window 对象中有一个 devicePixelRatio 的属性,该属性表示了屏幕的设备像素比,即用几个(通常是2个)像素点宽度来渲染1个像素。
举例来说,假设 devicePixelRatio 的值为 2 ,一张 100×100 像素大小的图片,在 Retina 屏幕下,会用 2 个像素点的宽度去渲染图片的 1 个像素点,因此该图片在 Retina 屏幕上实际会占据 200×200 像素的空间,相当于图片被放大了一倍,因此图片会变得模糊。
类似的,在 canvas context 中也存在一个 backingStorePixelRatio 的属性,该属性的值决定了浏览器在渲染canvas之前会用几个像素来来存储画布信息。 backingStorePixelRatio 属性在各浏览器厂商的获取方式不一样,所以需要加上浏览器前缀来实现兼容。
总而言之,要想canvas在高清屏上清晰的展示还得计算屏幕像素比和浏览器渲染canvas的比值,将canvas画布大小先放大缩小。所以我还得来个适配函数:
1/**
2 * @desc 适配屏幕像素比,防止canvas模糊
3 */
4adaptScreen () {
5 const ratio = this.getPixelRatio(this.progressCircle)
6 const ctx = this.progressCircle.getContext('2d')
7 const { style } = this.progressCircle
8 this.progressCircle.width = style.width.replace('px', '') * ratio
9 this.progressCircle.height = style.height.replace('px', '') * ratio
10 ctx.scale(ratio, ratio)
11}
12/**
13 * 获取设备像素比
14 */
15getPixelRatio (context) {
16 const backingStore = context.backingStorePixelRatio ||
17 context.webkitBackingStorePixelRatio ||
18 context.mozBackingStorePixelRatio ||
19 context.msBackingStorePixelRatio ||
20 context.oBackingStorePixelRatio ||
21 context.backingStorePixelRatio || 1
22 return (window.devicePixelRatio || 1) / backingStore
23},嘿呀,对齐了像素比之后,终于把这么个简单的动画给折腾清晰了。

2.svg实现
尽管需求是实现了,但是我总觉得的就这么个小动画用到这么多代码(尤其是还需要适应不同屏幕像素比这点)未免有点小题大做的味道。而回看解释canvas模糊原因,有句话提醒了我:
因为 canvas 不是矢量图,而是像图片一样是位图模式的。
那既然canvas是因为不是矢量图导致了像素模糊,我换用矢量图不就可以避免这个问题了嘛?这个时候就轮到我们的svg(可缩放矢量图形)隆重登场了。作为一种矢量图像格式,SVG 图形可以无限地扩展,这使其在 responsive design 中非常有用,因为您可以创建可缩放到任意屏幕大小的界面元素和图形。SVG 还提供了一组有用的工具,例如裁剪,遮罩,过滤器和动画。这就意味着不管在什么像素比的高分辨屏幕上都不怕动画模糊了!再开始讲实现之前,不熟悉在html中svg写法的童鞋可能需要先出门左转看下mdn的svg介绍。
轨迹动画原理
结合这个动画的特性,我们可以用到svg轨迹动画来实现这个圆形进度条的需求。讲轨迹动画之前,需要先说明一下轨迹动画用到的stroke(描边)属性们:stroke、stroke-array、stroke-array、stroke-dashoffset。
stroke:描边颜色,其实翻译为描边,但是实际使用中并不是描述描边的通用属性,而是用来设定描边色值的
stroke-width:描边宽度,控制线条粗细
stroke-dasharray:这个属性是一组用逗号分割的数字组成的数列,每一组数字,第一个用来表示填色区域的长度,第二个用来表示非填色区域的长度。
stroke-dashoffset:表示虚线的起始偏移。可选值为:<percentage>, <length>, inherit。百分比值,长度值,继承。
fill:填充值,其实和轨迹动画关系不大,然而在做进度条还是需要用到,就提了一嘴
svg的轨迹动画实际上,就是先将stroke-dashoffset,stroke-dasharray的非填色区域长度设置的和路径一样长或者更长来让路径完全空白。接着慢慢增加填色区域的长度,制造出描边慢慢的按照路径画出轨迹达到动画的效果。需要更详细的讲解可以看下张鑫旭大佬写的svg轨迹动画讲解。针对这个需求,我按照轨迹动画的原理,写了svg的实现,代码相比起canvas可以说是十分简单.
template代码:
1<svg width="28" height="28" xmlns="http://www.w3.org/2000/svg">
2 <circle stroke="#000" stroke-width="2" r="12" cx="14" cy="14" fill="transparent" stroke-dasharray="999 0" stroke-dashoffset="0"></circle>
3 <circle id="svg-progress" stroke="#BA191D" stroke-width="2" r="12" cx="14" cy="14" fill="transparent" :stroke-dasharray="`${currentPosition} 999`" stroke-dashoffset="0" transform="rotate(-45)"></circle>
4</svg>svg的写法类似于xml,最外层用svg元素包裹,里面可以根据需要使用各种形状的元素和路径,这里用到了cicle圆元素,画出两个圆充当底色和进度,并将圆的填充设置为透明,做到圆环的效果
script代码:
1/**
2 * 绘制canvas
3 */
4drawProgressCircle ({ currentTime, duration }) {
5 const percent = currentTime / duration
6 this.currentPosition = (percent * Math.PI * 12 * 2).toFixed(2)
7},currentPosition是根据圆的周长公式和播放进度百分比计算出的弧长,用来控制描边的长度,那么随着播放进度的增加,就能看到描边慢慢的填满整个圆环。来一个svg和canvas实现的对比:

左边是svg实现,右边是canvas
可以看到原型进度通过canvas和svg实现并没有明显差异,然而代码比起canvas更为简洁。
svg结合CSS3动画
除了能够简单实现轨迹动画以外,由于svg归根结底可以算作html元素,因此还可以使用css animation让轨迹动画更加流畅!就想这个原型进度条,可以给缓慢出现的圆环加上transition过渡,达到更优雅的动画效果。
1#svg-progress {
2 transform: rotate(-90deg); //由于circle元素绘制起点是右顶点,需要旋转将起点改成上顶点
3 transform-origin: 14px 14px; //将旋转中心改成circle元素的圆心
4 transition: all 0.2s linear; //增加平滑过渡效果
5} 
可以看到了svg增加过渡后视觉效果比canvas更好
参考文章:
纯CSS实现帅气的SVG路径描边动画效果 www.zhangxinxu.com/wordpress/2…
解决canvas在高清屏中绘制模糊问题 www.html.cn/archives/92…