最近在开发的时候遇到了一个环形进度条的需求,设计师希望这个进度条是渐变色的,并且能有对应的动画。具体效果如图
因为git图的缘故所以看起来有点卡,但是实测帧数还是可以稳定到50到60之间的。
并且将其封装成了一个Vue组件,只需要传入对应的参数就可以快速的生成内容了,如果你有类似的要求可以参考以下链接
aboyl的github
切换分支到svg-circle-progress就可以查看对应的源代码以及相关文档
下面开始讲解整体的思路
我们需要什么?
- 一个环形进度条
- 渐变色
- 动画
细节点
环形精度条的首尾都是圆形
我们先来实现环形进度条。
实现思路很简单
使用svg画两个圆
一个圆作为底色,画满
另一个圆作为进度条,此时应该只画一段弧形
怎么画圆
参考svg的文档我们可以知道得出以下代码
<svg :height="200" :width="200" x-mlns="http://www.w3.org/200/svg">
<circle
:r="50"
:cx="100"
:cy="100"
:stroke="'red'"
:stroke-width="10"
fill="none"/>
</svg>
效果如图
我们得到了一个红色的圆环
其中
r为半径
cx,cy为在svg中的坐标
stroke为颜色
stroke-width为画笔的宽度
fill为none表示不进行填充,不然我们看到的将是一整个圆而不是圆环
接下来我们需要画一段圆弧
画圆弧我们可以通过stoke-dasharray,他的本意是画实线跟虚线交替的线段,我们设置参数为 '弧长,极大值'
那么在显示上因为虚线空的部分很长,所以我们将看不到第二段的实线
对于我们需要的圆的头部 可以设置stroke-linecap为round
最终效果如图
代码如下
<circle
:r="50"
:cx="100"
:cy="100"
:stroke="'red'"
:stroke-width="10"
fill="none"
/>
<circle
:r="50"
:cx="100"
:cy="100"
:stroke="'yellow'"
:stroke-dasharray="`100,100000`"
:stroke-width="10"
fill="none"
stroke-linecap="round"
/>
</svg>
此时我们观测到起始方向是在左边的中间位置,因此我们进行旋转,在第二个圆上加上旋转
transform="rotate(-90)"
transform-origin="center"
因为我们需要封装成一个组件,那么他应该接收
- 进度
- 底色
- 弧度的颜色
- 内圆的半径
- 圆弧的宽度
同时对于 - svg的宽高
- 外圆的半径
- 弧长
这些应该是我们进行计算得出来的值
当然为了使用上的方便 我们应该给出一些默认值
组件代码如下
<template>
<svg
:height="option.size"
:width="option.size"
x-mlns="http://www.w3.org/200/svg"
>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.backColor"
:stroke-width="option.strokeWidth"
fill="none"
/>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.progressColor"
:stroke-dasharray="arcLength"
:stroke-width="option.strokeWidth"
fill="none"
transform="rotate(-90)"
transform-origin="center"
stroke-linecap="round"
/>
</svg>
</template>
<script>
export default {
name: 'Progress',
props: {
progress: {
type: Number,
required: true,
},
progressOption: {
type: Object,
default: () => { },
},
},
data () {
return {
}
},
computed: {
arcLength () {
let circleLength = Math.floor(2 * Math.PI * this.option.radius)
let progressLength = this.progress * circleLength
return `${progressLength},100000000`
},
option () {
// 所有进度条的可配置项
let baseOption = {
radius: 100,
strokeWidth: 20,
backColor: 'red',
progressColor: 'yellow',
}
Object.assign(baseOption, this.progressOption)
// 中心位置自动生成
baseOption.cy = baseOption.cx = baseOption.radius + baseOption.strokeWidth
baseOption.size = (baseOption.radius + baseOption.strokeWidth) * 2
return baseOption
},
},
}
</script>
其实现在修改一下这让人吐槽的配色已经可以用来使用了~
接下来我们来实现渐变色
第一个思路当然是去搜索svg怎么实现渐变色了,我一开始也是进行了搜索,最终写出来了如下的代码
<defs>
<linearGradient id="gradient">
<stop
offset="0%"
style="stop-color: red;"
/>
<stop
offset="100%"
style="stop-color: yellow"
/>
</linearGradient>
</defs>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.backColor"
:stroke-width="option.strokeWidth"
fill="none"
/>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="'url(#gradient)'"
:stroke-dasharray="arcLength"
:stroke-width="option.strokeWidth"
fill="none"
transform="rotate(-90)"
transform-origin="center"
stroke-linecap="round"
/>
</svg>
效果如图
为什么跟我们期望的效果不一样?
我们期望的是从最上方顺时针方向开始画圆弧,顶部颜色是红色,而到了结尾的时候是黄色的,为什么会这样呢?
因为其实我们的观点是错的。假如我们不做其他的处理,单纯的给一个圆加上渐变是什么样子的呢?
<defs>
<linearGradient id="gradient">
<stop
offset="0%"
style="stop-color: green;"
/>
<stop
offset="100%"
style="stop-color: yellow"
/>
</linearGradient>
</defs>
<!-- <circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.backColor"
:stroke-width="option.strokeWidth"
fill="none"
/> -->
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="'url(#gradient)'"
:stroke-width="option.strokeWidth"
/>
</svg>
如图
可见
- 线性渐变是从左到右边
- 刚刚的位置不正确是因为设置了旋转的原因
那么我们可以推翻我们前面不靠谱的推测转回来继续思考怎么实现渐变的办法,对于有没有办法依靠svg的渐变元素这些来实现渐变的互动,鉴于CSS依靠渐变可以做出很多玩法,所以我不能打包票说没有,只不过我觉得可能实现思路会比较的麻烦,所以就换了一种思路来进行实现。
我们只需要手动计算渐变就ok了
也就是说只需要实现算法,计算出从颜色a到颜色b之间的各个渐变色是多少,依靠不同弧长不同颜色的圆进行重叠,那么我们就可以模拟渐变的实现
需要注意的是渐变的实现并不是有的时候相信的那样,从000000到ffffff进行累加,而是需要先转化成为rgb再进行计算
通过搜索引擎我们可以找到一些实现好的算法,对于具体的原理就没有再深究了,
参考文章
里面还实现了rgb转16进制的算法这些,不过核心的渐变算法大致如下,其他的算法如果有需要得话可以参考一下
function gradientColor (startRGB, endRGB, step) {
let startR = startRGB[0]
let startG = startRGB[1]
let startB = startRGB[2]
let endR = endRGB[0]
let endG = endRGB[1]
let endB = endRGB[2]
let sR = (endR - startR) / step // 总差值
let sG = (endG - startG) / step
let sB = (endB - startB) / step
var colorArr = []
for (var i = 0; i < step; i++) {
let color = 'rgb(' + parseInt((sR * i + startR)) + ',' + parseInt((sG * i + startG)) + ',' + parseInt((sB * i + startB)) + ')'
colorArr.push(color)
}
return colorArr
}
可以看到是对rgb颜色的三位分别进行步进以达到渐变的效果
接下来的问题便是如何计算步数,并且根据我们前面的分析,渐变色跟圆弧应该是对应的,经过一些测试,在步数为100的情况下,肉眼不是很能分辨出存在渐变色的空隙(ps:本来在写这篇文字之前自己的看法是进行一些计算,在进度高的情况下,步进次数会高于进度低的步进次数的说,不过写到这里的时候突然想到了过低会导致不连贯的问题。。。于是可以回去修方案了,事实证明确实进行总结确实会有更多的发现)
步数我们设定为100再对原来的弧长进行一百等分,就可以得到一个数组了,而原来的svg中circl元素我们则使用v-for根据前面生成的数组进行生成,代码如下
<template>
<svg
:height="option.size"
:width="option.size"
x-mlns="http://www.w3.org/200/svg"
>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.backColor"
:stroke-width="option.strokeWidth"
fill="none"
/>
<circle
v-for="(item, index) in arcArr"
:key="index"
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="item.color"
:stroke-dasharray="item.arcLength"
:stroke-width="option.strokeWidth"
fill="none"
transform="rotate(-90)"
transform-origin="center"
stroke-linecap="round"
/>
</svg>
</template>
<script>
export default {
name: 'Progress',
props: {
progress: {
type: Number,
required: true,
},
progressOption: {
type: Object,
default: () => { },
},
},
computed: {
arcArr () {
let circleLength = Math.floor(2 * Math.PI * this.option.radius)
let progressLength = this.progress * circleLength
const step = 100 // 设置到100则已经比较难看出来颜色断层
const gradientColor = (startRGB, endRGB, step) => {
let startR = startRGB[0]
let startG = startRGB[1]
let startB = startRGB[2]
let endR = endRGB[0]
let endG = endRGB[1]
let endB = endRGB[2]
let sR = (endR - startR) / step // 总差值
let sG = (endG - startG) / step
let sB = (endB - startB) / step
let colorArr = []
for (let i = 0; i < step; i++) {
let color = `rgb(${sR * i + startR},${sG * i + startG},${sB * i + startB})`
colorArr.push(color)
}
return colorArr
}
let colorArr = gradientColor(this.option.startColor, this.option.endColor, step)
// 计算每个步进中的弧长
let arcLengthArr = colorArr.map((color, index) => ({
arcLength: `${index * (progressLength / 100)},100000000`,
color: color
}))
arcLengthArr.reverse()
return arcLengthArr
},
option () {
// 所有进度条的可配置项
let baseOption = {
radius: 100,
strokeWidth: 20,
backColor: '#E6E6E6',
startColor: [249, 221, 180],
endColor: [238, 171, 86], // 用于渐变色的开始
}
Object.assign(baseOption, this.progressOption)
// 中心位置自动生成
baseOption.cy = baseOption.cx = baseOption.radius + baseOption.strokeWidth
baseOption.size = (baseOption.radius + baseOption.strokeWidth) * 2
return baseOption
},
},
}
</script>
需要注意的是我们最后的时候对生成的数组进行了一次颠倒,不然弧度最长的圆弧会挂在最后面,导致我们看不到渐变效果
效果如图
这里我稍微调整了一下颜色让他符合我们的预期
最后为其加上动画效果
这部分不是一个很复杂的事情,需要注意在circle上的stroke-dasharray我们需要去掉,避免出现弧长在一开始的时候就渲染了出来,然后又马上消失进入动画效果的情况,虽然影响不大,但是还是需要注意一下。
代码如下
<circle
v-for="(item, index) in arcArr"
:key="index"
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="item.color"
:stroke-width="option.strokeWidth"
fill="none"
transform="rotate(-90)"
transform-origin="center"
stroke-linecap="round"
>
<animate
:to="item.arcLength"
begin="0s"
:dur="option.durtion"
from="0,1000000"
attributeName="stroke-dasharray"
fill="freeze"
/>
</circle>
总结
确实在学到东西以后需要做一些输出才能达到真正的学会,其实整个组件设计一开始的时候跟现在的区别还蛮大的,中间走了很多的弯路,关于svg的渐变从一开始就想错了,即使在我换思路的时候,我还以为是svg的渐变在作用于弧型的时候是顺时针画圆,并且在百分之五十的时候从结尾的颜色切换到开始的颜色达到循环渐变,直到自己写文章的时候才发现自己的错误,比如一开始的时候加入渐变效果后因为步长的计算有问题,导致出现了精度的概念,只能做到0.01的进度,而一旦切换到高精度就会导致动画非常卡顿,后来换了思路就清晰很多了
以下需要进行补充
- 因为重点在于渐变,所以对接受的参数就没有过多的需求了,附带的参考文章里面自己看一下改一改就能让startColor跟endColor接受正常的颜色值了
- 这里只做了两个渐变色,如果需要多个渐变感觉修正一下算法也不会很难
- 关于步进次数为100,这里自己也做过一些测试,在我试验的颜色值下面感觉在20这些数字差距也不会很大,不过性能看起在100的情况下跟20的情况下差距不大,就没有再进行修正了,而是成为了参数默认值默认值
另一种实现方式:
果然是学无止境,搜索网上的文章的时候突然发现了张大神的一篇漏网之鱼,居然只使用了两个circle元素就实现了渐变效果
于是我也从中吸取了一些帮助对这个进度条进行了进一步的优化
参考链接
张鑫旭渐变进度条实现
不过确定貌似是不能做到最后的尾部颜色是设置的结尾色,不过这样也可以作为一个补充,具体效果如图
可以看到右边的末端颜色略微淡色
具体实现方式看代码,个人认为需要注意的点
- 实现方式,本质上是两张circle的叠加,然后进行了旋转得到的,从颜色colorA到颜色colorB,算出中间值colorC,然后第一个从colorA到colorC,第二个从colorB到colorC,旋转到从上到下,那么进行叠加,看起来就是从colorA到colorC再到colorB了
- 要注意动画的实现,需要按照比例对动画时长进行切割,这样才不会导致在最后的时候原本流畅的动画过了最底部的时候速度突然骤降,注意动画的无缝衔接
具体参考代码如下
<template>
<svg :height="option.size" :width="option.size" x-mlns="http://www.w3.org/200/svg">
<defs>
<linearGradient x1="1" y1="0" x2="0" y2="0" id="outGradient">
<stop offset="0%" :stop-color="arcOption.outArcStartColor" />
<stop offset="100%" :stop-color="arcOption.outArcEndColor" />
</linearGradient>
<linearGradient x1="1" y1="0" x2="0" y2="0" id="innerGradient">
<stop offset="0%" :stop-color="arcOption.innerArcStartColor" />
<stop offset="100%" :stop-color="arcOption.innerArcEndColor" />
</linearGradient>
</defs>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
:stroke="option.backColor"
:stroke-width="option.strokeWidth"
fill="none"
/>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
stroke="url('#innerGradient')"
:stroke-width="option.strokeWidth"
transform="rotate(-90)"
transform-origin="center"
fill="none"
stroke-linecap="round"
:stroke-dasharray="`0,1000000`"
>
<animate
:to="`${arcOption.innerArcLength},1000000`"
:begin="arcOption.outDurtion"
:dur="arcOption.innerDurtion"
:from="`${arcOption.innerInitArcLength},1000000`"
attributeName="stroke-dasharray"
fill="freeze"
/>
</circle>
<circle
:r="option.radius"
:cx="option.cx"
:cy="option.cy"
stroke="url('#outGradient')"
:stroke-width="option.strokeWidth"
:stroke-dasharray="`${arcOption.outArcLength},1000000`"
fill="none"
transform="rotate(-90)"
transform-origin="center"
stroke-linecap="round"
>
<animate
:to="`${arcOption.outArcLength},1000000`"
begin="0s"
:dur="arcOption.outDurtion"
from="0,1000000"
attributeName="stroke-dasharray"
fill="freeze"
/>
</circle>
</svg>
</template>
<script>
export default {
name: 'Progress2',
props: {
progress: {
type: Number,
required: true,
},
progressOption: {
type: Object,
default: () => { },
},
},
computed: {
arcOption () {
let arcConfig = {}
let circleLength = Math.floor(2 * Math.PI * this.option.radius)
// 如果此时小于0.5 则只需要显示最外层的圆弧 里面的圆弧不需要画了
// 时间计算 因为第二段的长度不见得等于第一段 所以不能平分时间 不然会导致第二端的速度出现骤降
// 因此需要按照比例进行时间计算
if (this.progress < 0.5) {
arcConfig.outArcLength = this.progress * circleLength
arcConfig.outDurtion = this.option.durtion // 为初始设置的动画值
arcConfig.innerArcLength = 0
arcConfig.innerInitArcLength = 0 // 为动画做准备
arcConfig.innerDurtion = 0
} else {
const time = this.option.durtion.split('s')[0]
arcConfig.outArcLength = 0.5 * circleLength
arcConfig.outDurtion = (0.5 / this.progress) * time + 's' //
arcConfig.innerArcLength = this.progress * circleLength
arcConfig.innerInitArcLength = 0.5 * circleLength // 为动画做准备 此时从中间开始
arcConfig.innerDurtion = ((this.progress - 0.5) / this.progress) * time + 's' // 为动画做准备 此时从中间开始
}
const tansfromColor = arr => `rgb(${arr[0]},${arr[1]},${arr[2]})`
arcConfig.outArcStartColor = tansfromColor(this.option.startColor)
arcConfig.outArcEndColor = tansfromColor(this.option.startColor.map((color, index) => color + (this.option.endColor[index] - color) / 2))
arcConfig.innerArcStartColor = tansfromColor(this.option.endColor)
arcConfig.innerArcEndColor = tansfromColor(this.option.startColor.map((color, index) => color + (this.option.endColor[index] - color) / 2))
return arcConfig
},
option () {
// 所有进度条的可配置项
let baseOption = {
radius: 100,
strokeWidth: 20,
backColor: '#E6E6E6',
startColor: [249, 221, 180],
endColor: [238, 171, 86],
durtion: '1s',
step: 100,
}
Object.assign(baseOption, this.progressOption)
// 中心位置自动生成
baseOption.cy = baseOption.cx = baseOption.radius + baseOption.strokeWidth
baseOption.size = (baseOption.radius + baseOption.strokeWidth) * 2
return baseOption
},
},
}
</script>