你会用SVG绘制时间进度条嘛?
前言
SVG想必大家都非常熟悉,即使没有写过,也多多少少听过。写一些简单的动画或者交互,是绕不开的一种语言,但是它最大的问题就是上手比较陡峭,API比较多,使用方式也比较多,很容易在中间迷失。今天带大家学习几种圆形倒计时的开发思路。
正文
文章导读
- 成果在先
- 基础认识
- 环形倒计时
- 扇形倒计时
- 环形渐隐倒计时
- 圆角矩形渐隐倒计时
成果在先
为了不浪费大家时间,成果在先,让我们先看一下,阅读完本篇文章,可以学会绘制哪些动效。
基础认识
为了照顾从未使用过SVG的小伙伴,我先在这里给SVG做一个自我介绍。
SVG 意为可缩放矢量图形,使用 XML 格式定义图像。SVG 图像在放大或改变尺寸的情况下其图形质量不会有所损失。可以绘制包括(不仅限于):矩形、圆形、椭圆、线条、多边形、路径、文本等形状。可以说只有你想不到的,没有你画不出来的。 我收集了几个地址,大家有兴趣可以进去学习一下SVG基础知识。
环形倒计时
首先是第一个,也是最简单的,环形进度条。
实现思路:
- 绘制一个圆
- 内部填充透明色
- 边框使用虚线,虚实线按倒计时比例绘制
- 根据时间更改虚实线的长短
代码实现:
<!-- html -->
<svg class="countCircle" viewBox="0 0 20 20">
<circle
class="circleA"
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="rgba(255,255,255,.5)"
stroke-linecap="round"
stroke-width="0.8"
:stroke-dasharray="strokeDasharray"
/>
</svg>
// js
export default {
data(){
return {
strokeDasharray:'',
sum:10, // 倒计时总数
num:10, // 当前倒计时
timer:null,
}
},
mounted(){
this.startCountDown()
},
methods:{
startCountDown(){
const {sum,num} = this
this.strokeDasharray = `0 calc(10 * ${Math.PI} * ${sum - num}/${sum}) calc(10 * ${Math.PI} * ${num}/${sum}) 0`
this.timer && clearInterval(this.timer);
this.timer = setInterval(() => {
this.num --
if(this.num<0){
this.timer&&clearInterval(this.timer);
return
}
const {sum,num} = this
this.strokeDasharray = `0 calc(10 * ${Math.PI} * ${sum - num}/${sum}) calc(10 * ${Math.PI} * ${num}/${sum}) 0`
}, 1000);
}
}
}
.countCircle{
width: 200px;
height: 200px;
transform: rotate(-90deg);
}
.circleA{
transition: stroke-dasharray 1s linear;
}
代码解析:
- viewBox
用于表示当前SVG的实际比例,定义画布上可以显示的区域,比如上面的代码,元素的实际宽高是
200*200(px)
,viewBox的值为0 0 20 20
,表示从(0,0)
开始渲染,到(20,20)
结束。SVG中每一个单位代表200/20 = 10
个像素。stroke-width="0.8"
表示边框宽度为8px
。 - circle
可以看到SVG中绘制了
circle
元素。这是SVG中的形状(圆形),可以设置该元素的宽、高、填充、边框等属性。cx cy
属性代表圆的中心位置,r
属性代表圆的半径。 - stroke-linecap
定义不同类型线条的开始结束方式,对应类型如下。这里我使用了
round
,使倒计时看起来更加圆滑。 - stroke-dasharray 定义虚线的虚实比例组成,这也是最关键的属性。对应效果如下。
stroke-dasharray=""
stroke-dasharray="4"
stroke-dasharray="4 1"
stroke-dasharray="4 1 2"
stroke-dasharray="4 1 2 3"
可以看到如果只设置一个值`4`,它会根据四实四空的方式展示虚线,如果设置成`4 1`时,线段会根据`4 1 4 1 4 1 ...`(即四实一空)的方式展示整条线段;如果有三个值`4 1 2`,那它的虚实分布就是:`4 1 2 4 1 2 ...`。
根据该属性特性,我们只需要将倒计时已经过去的部分变为空即可。比如时间总长为6,倒计时过去了1,该属性应该为:`0 1 5 0`;倒计时过去了4,该属性应该为:`0 4 2 0`。直接根据周长等比划分:`0 calc(10 * ${Math.PI} * ${sum - num}/${sum}) calc(10 * ${Math.PI} * ${num}/${sum}) 0`。
最后再给属性添加过渡属性:transition: stroke-dasharray 1s linear;
,完美实现动画效果。
扇形倒计时
第二个,进阶版。
比较聪明的读者就要说了:“这个和第一个很像哎,是不是可以复用代码呀,把边框的属性加宽到半径的宽度,不就变成这样了吗?”
说的不错,让我们来看一下效果:
这?这转的是什么呀,怎么和我想象的结果不一样?
哈哈哈,大家发现了什么,属性stroke-linecap
还是round
呢。
代码实现:
// 保留第一版代码,修改两个属性。
stroke-linecap="buff"
stroke-width="10"
环形渐隐倒计时
前期调研: 看到这个效果,我的第一感觉就是,这不就是一个渐变吗?简单,我只要给边框添加一个径向渐变颜色,我边框变化的时候就会跟着我的边框一起移动了。研究了一下API,实现代码如下:
添加
linearGradient
径向渐变元素,其中一边是透明色,使用offset控制开始渐变的位置。去掉stroke
属性的颜色填充,填充该径向渐变元素。大功告成!
<svg class="svgCCountCircle" viewBox="0 0 20 20">
<linearGradient id="loopGradient">
<stop offset="60%" style="stop-color: #a9a2fe" />
<stop offset="90%" style="stop-color: rgba(130, 119, 255, 0)" />
</linearGradient>
<circle
class="circle"
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="url(#loopGradient)"
stroke-linecap="buff"
stroke-width="0.5"
:stroke-dasharray="strokeDasharray"
/>
</svg>
然而,我还是把问题想简单了,最终还是出现了问题。
- 径向渐变是基于形状本身的,而不会基于边框,也就是说,渐变的方向是根据圆形的方向进行渲染的,不在乎你边框是否消失。而且边框并不是消失了, 只是利用
dasharray
属性,填充了虚线,看上去消失了而已,想想倒也合理。
- 因为我这个是圆形,当径向渐变渲染时,会把开始和结束部分的颜色都渲染成透明色。
难道这条路就是死胡同了吗?
并没有,经过我苦思冥想,终于想到了问题的解决办法。
问题1:我确实无法让渐变知道我边框消失的方向,但是我自己知道方向呀,山不转我转,我直接动态更改渐变的方向不就可以了吗? 问题2:既然他会同时渲染开始结束部分,那当它开始渲染时,我先用一个半圆挡住结束区域,转走之后我再显示。
赶紧给自己点个赞👍。
实现思路:
- 添加径向渐变,作为边框颜色进行填充
- 渐变方向根据时间动态变化
- 添加1/4圆,倒计时前遮挡结束区域,知道旋转完成
代码实现:
<svg class="svgCCountCircle" viewBox="0 0 20 20">
<linearGradient id="loopGradient" :x1="initDirection.x1" :y1="initDirection.y1"
:x2="initDirection.x2" :y2="initDirection.y2">
<stop offset="60%" style="stop-color: #a9a2fe" />
<!-- <stop offset="90%" style="stop-color: red" /> -->
<stop offset="90%" style="stop-color: rgba(130, 119, 255, 0)" />
<animate attributeName="x1" values="0%;0%;100%;100%;0%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="y1" values="100%;0%;0%;100%;100%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="x2" values="100%;0%;0%;100%;100%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="y2" values="100%;100%;0%;0%;100%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
</linearGradient>
<circle
class="circle"
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="url(#loopGradient)"
stroke-linecap="buff"
stroke-width="0.5"
:stroke-dasharray="strokeDasharray"
/>
<circle
v-if="num>sum/2+2"
r="5"
cx="10"
cy="10"
fill="transparent"
stroke="#a9a2fe"
stroke-linecap="buff"
stroke-width="0.5"
:stroke-dasharray="`0 calc(10 * ${Math.PI} * 3/4) calc(10 * ${Math.PI} * 1/4) 0`"
/>
</svg>
export default {
data(){
return {
strokeDasharray:'',
sum:30,
num:28,
timer:null,
animateDuration: 0, // 旋转渐变所需动画时间
keyTimes: '0;0;0;0;0', // 运动时间分割
// 初始化渐变方向
initDirection: {
x1: '0%',
y1: '100%',
x2: '100%',
y2: '100%',
},
}
},
mounted(){
this.startCountdown()
},
methods:{
startCountdown(){
const {sum,num} = this
this.animateDuration = sum
this.strokeDasharray = `0 calc(10 * ${Math.PI} * ${sum - num}/${sum}) calc(10 * ${Math.PI} * ${num}/${sum}) 0`
// 计算渐变的旋转角度
this.parseDir()
this.timer&&clearInterval(this.timer);
this.timer = setInterval(() => {
this.num --
const {sum,num} = this
if(num<0){
this.timer&&clearInterval(this.timer);
}
this.strokeDasharray = `0 calc(10 * ${Math.PI} * ${sum - num}/${sum}) calc(10 * ${Math.PI} * ${num}/${sum}) 0`
}, 1000);
},
parseDir(){
const {num,sum} = this
const part = sum / 4
const times = [0, 0.25, 0.5, 0.75, 1]
this.initDirection = {
x1: '0%',
y1: '100%',
x2: '100%',
y2: '100%',
}
if (num !== sum && num > part * 3) {
times[1] = (num - part * 3) / part * 0.25
times[2] = times[1] + (1 - times[1]) / 3
times[3] = times[2] * 2 - times[1]
} else if (num <= part * 3 && num > part * 2) {
times[1] = 0
times[2] = (num - part * 2) / part * 0.25
times[3] = times[2] + (1 - times[2]) / 2
this.initDirection = {
x1: '0%',
y1: '0%',
x2: '0%',
y2: '100%',
}
} else if (num <= part * 2 && num > part) {
times[1] = 0
times[2] = 0
times[3] = (num - part) / part * 0.5
this.initDirection = {
x1: '100%',
y1: '0%',
x2: '0%',
y2: '0%',
}
} else if (num <= part) {
times[1] = 0
times[2] = 0
times[3] = 0
this.initDirection = {
x1: '100%',
y1: '100%',
x2: '100%',
y2: '0%',
}
}
this.keyTimes = times.join(';')
}
}
}
代码解析:
-
方向变化
linearGradient
元素的方向变化是根据x1 x2 y1 y2
四个属性控制的。如图所示,如果我想绘制从上到下的渐变,那么我的属性值应该是
x1=100%、 y1=0%、x2=100%、y2=100%
。 那么,我想要的径向渐变的轨迹其实就是:把图形变换成数据:
x1 y1 x2 y2 100% 0% 100% 100% 100% 100% 0% 100% 0% 100% 0% 0% 0% 0% 100% 0% 这也就是上面代码的实现思路,根据时间控制这几个属性的变化。
圆角矩形渐隐倒计时
接下来让我们来看最难的一个,类椭圆环形进度条。
前期调研 首先简单分析一下这个倒计时,很像一个椭圆但是弧度又不连贯,像一个矩形加了圆角,咦?那我们是不是可以绘制一个矩形,修改圆角,然后用上面旋转渐变的方式进行绘制呢? 说干就干!经过一些时间的编写,实现代码如下:
<svg class="countRect" viewBox="0 0 20 40">
<rect
x="10"
y="20"
rx="5"
ry="5"
width="9"
height="14"
fill="transparent"
stroke="#fff"
stroke-linecap="buff"
stroke-width="0.5"
/>
</svg>
嗯,效果还不错,给它加上渐变试试旋转效果。
......效果不好表现,辛苦读者脑补一下。😚 不对呀,怎么虚线渲染的速度和读秒的速度不一样呢。60秒的总时间,我读秒已经到20了,虚线还在30那里慢吞吞的走。
什么原因? 仔细研究了一波,找到了原因,我是根据周长去控制的虚线规律,而周长是根据原来的元素计算的,我给他加了圆角,导致图形畸变,矩形-->圆角矩形。得不到最终的周长了,导致无法清晰计算虚线的规律。
原来如此,看来rect
方案不行了怎么解决呢?
啥也不说了,用path
(一座大山)。
- path
用于定义一个路径。可以根据计算绘制任何形状的图形。路径的属性还是比较多的,经过我长时间的研究和努力,终于把大部分属性都掌握了。大家时间宝贵,在这里就不详细展开了,如果感兴趣,我会再写一篇只针对SVG
path
属性的讲解。
我们对倒计时进行一个简单的拆解,可以看到只需要绘制六条路径,其中2条直线,4条弧线。那我们需要用到哪些属性呢?
-
M move to 移动到,开始点 。参数(x y)
-
Z closepath 关闭路径,无参数
-
L line to 画直线。参数(x y)
-
Q quadratic Bézier curveto二次贝塞尔曲线到 参数(x1 y1,x y)
经过多次调试和计算,最终绘制出了这个类圆角矩形的倒计时路径。
path=M2 27 Q4 50, 27 52 L42 52 Q67 50, 68 27 Q67 4, 42 2 L27 2 Q4 4, 2 27 Z
使用之前的渐变思路,最终实现圆角矩形渐隐倒计时。
代码实现:
<svg class="loop" viewBox="0 0 70 54">
<linearGradient id="loopGradient" :x1="initDirection.x1" :y1="initDirection.y1"
:x2="initDirection.x2" :y2="initDirection.y2">
<stop offset="60%" style="stop-color: #a9a2fe" />
<!-- <stop offset="90%" style="stop-color: red" /> -->
<stop offset="90%" style="stop-color: rgba(130, 119, 255, 0)" />
<animate attributeName="x1" values="100%;100%;0%;0%;100%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="y1" values="0%;100%;100%;0%;0%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="x2" values="0%;100%;100%;0%;0%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
<animate attributeName="y2" values="0%;0%;100%;100%;0%" :keyTimes="keyTimes" :dur="animateDuration + 's'"
fill="freeze" />
</linearGradient>
<path v-show="num > sum / 2 + 2"
d="M2 27 Q4 50, 27 52 L35 52" fill="none" stroke="#a9a2fe" stroke-linecap="round" stroke-width="2"></path>
<path ref="svgEllipse" class="loopPath"
d="M2 27 Q4 50, 27 52 L42 52 Q67 50, 68 27 Q67 4, 42 2 L27 2 Q4 4, 2 27 Z" fill="none"
stroke="url(#loopGradient)" stroke-linecap="round" stroke-width="2" :stroke-dasharray="ellipseDasharray">
</path>
</svg>
export default {
data(){
return {
sum:10,
num:5,
timer:null,
animateDuration: 0, // 旋转渐变所需动画时间
keyTimes: '0;0;0;0;0', // 运动时间分割
// 初始化渐变方向
initDirection: {
x1: '100%',
y1: '0%',
x2: '0%',
y2: '0%'
},
ellipseDasharray:""
}
},
mounted(){
this.startCountdown()
},
computed:{
// 椭圆周长
ellipseLen () {
return this.$refs.svgEllipse ? this.$refs.svgEllipse.getTotalLength() : 0
},
},
methods:{
startCountdown(){
// 计算渐变的旋转角度
this.parseDir()
const {sum,num} = this
this.animateDuration = num
const ellipseSolid = this.ellipseLen * num / sum
this.ellipseDasharray = `${ellipseSolid} ${this.ellipseLen - ellipseSolid}`
this.timer&&clearInterval(this.timer);
this.timer = setInterval(() => {
this.num --
if(this.num<0){
this.timer&&clearInterval(this.timer);
return
}
const {sum,num} = this
const ellipseSolid = this.ellipseLen * num / sum
this.ellipseDasharray = `${ellipseSolid} ${this.ellipseLen - ellipseSolid}`
}, 1000);
},
parseDir(){
const {num,sum} = this
// 计算渐变的旋转角度
const part = sum / 4
const times = [0, 0.25, 0.5, 0.75, 1]
this.initDirection = {
x1: '100%',
y1: '0%',
x2: '0%',
y2: '0%',
}
if (num !== sum && num > part * 3) {
times[1] = (num - part * 3) / part * 0.25
times[2] = times[1] + (1 - times[1]) / 3
times[3] = times[2] * 2 - times[1]
} else if (num <= part * 3 && num > part * 2) {
times[1] = 0
times[2] = (num - part * 2) / part * 0.25
times[3] = times[2] + (1 - times[2]) / 2
this.initDirection = {
x1: '100%',
y1: '100%',
x2: '100%',
y2: '0%',
}
} else if (num <= part * 2 && num > part) {
times[1] = 0
times[2] = 0
times[3] = (num - part) / part * 0.5
this.initDirection = {
x1: '0%',
y1: '100%',
x2: '100%',
y2: '100%',
}
} else if (num <= part) {
times[1] = 0
times[2] = 0
times[3] = 0
this.initDirection = {
x1: '0%',
y1: '0%',
x2: '0%',
y2: '100%',
}
}
this.keyTimes = times.join(';')
}
}
}
结语
好啦,文章到这里就结束了,感谢您观看到这里,您的观看就是对我的认可!如果文章对您有一点点帮助,也可以给笔者点个赞!♥️♥️♥️
转载自:https://juejin.cn/post/7162127536820862983