圆环进度条

一.场景

需要实现一个渐变色的圆环进度条,进度条长这样子:

circle

circle

0进度时显示一个灰色的槽,加载过程中彩色条顺时针走完一圈

二.经典解法“两扇门”

圆环进度条有一种经典解法,原理很有意思,HTML结构如下:

<div class="circle progress-basic">
    <div class="left">
        <div class="circle left-circle"></div>
    </div>
    <div class="right">
        <div class=" circle right-circle"></div>
    </div>
</div>

先不考虑圆,想象一个双开门,progress-basic是门框,left是左半扇门,right是右半扇门,如下:

.progress-basic {
    position: relative;
}
.left, .right {
    position: absolute;
    width: 100px; height: 200px;
    top: 0;
    overflow: hidden;
}
.left {
    left: 0;
}
.right {
    right: 0;
}

然后考虑如何实现0-180deg的效果:进度条需要从0点位置一点点出现,填满右边半圆环,180deg的时候左边半圆环灰槽,右边半圆环进度条

想象正方形纸上左边画了一个半圆环,用左手遮住左边,然后顺时针旋转纸,半圆环就会从左手指尖一点点出现,转够180度时,正好左边是空白,右边是半圆环。此时不能再转了,会露馅(半圆环进度条刚好走完,再转就断了)

180-360deg的方法与之类似,先把半圆环进度条藏好,再一点一点转出来。完整CSS如下:

.circle {
    width: 200px; height: 200px;
    box-sizing: border-box;
    border-radius: 50%;
    border: 10px solid silver;
}

.progress-basic {
    position: relative;
}
.left, .right {
    position: absolute;
    width: 100px; height: 200px;
    /*塞进父级border*/
    top: -10px;
    overflow: hidden;
}
.left {
    /*塞进父级border*/
    left: -10px;
}
.right {
    /*塞进父级border*/
    right: -10px;
}
.left-circle, .right-circle {
    border-color: #077df8;
}
.left-circle {
    margin-right: -100px;
    border-left-color: transparent;
    border-top-color: transparent;
    transform: rotateZ(-45deg);
}
.right-circle {
    margin-left: -100px;
    border-right-color: transparent;
    border-bottom-color: transparent;
    transform: rotateZ(-45deg);
}

门框自带灰色槽,两扇门overflow: hidden,左半扇门藏好右半圆环进度条,右半扇门藏好左半圆环进度条。先转右边的纸,纸上左边的半圆环一点点出现,到180度后去转左边的纸,纸上右边的半圆环一点点出现,360度时刚好拼接出完整圆环。如下:

var $ = document.querySelector.bind(document);

void function() {
    var $rightCircle = $('.right-circle');
    var $leftCircle = $('.left-circle');
    var prog = 0;
    var deg = 0;
    var initialDeg = -45;
    var halfDone;
    var timer = setInterval(function() {
        prog += 0.01;
        deg = 360 * prog;
        if (deg < 180) {
            $rightCircle.style.transform = 'rotateZ(' + (initialDeg + deg) + 'deg)';
        }
        else if (deg < 360) {
            if (!halfDone) {
                console.log('half done');
                $rightCircle.style.transform = 'rotateZ(' + (initialDeg + 180) + 'deg)';
                halfDone = true;
            }
            $leftCircle.style.transform = 'rotateZ(' + (initialDeg + deg - 180) + 'deg)';
        }
        else {
            console.log('done');
            $leftCircle.style.transform = 'rotateZ(' + (initialDeg + 180) + 'deg)';
            clearInterval(timer);
        }
    }, 100);
}();

很巧妙的方法,不知道有没有学名,暂且称之为“两扇门”

P.S.通过clip也能实现,原理一样

三.尝试经典解法

“两扇门”原理好像通用,照搬到渐变进度条上,那么需要把进度条图片水平翻转,再分为左右两半,如图:

circle-left-right

circle-left-right

具体如下:

<div class="circle circle-image progress-basic">
    <div class="left">
        <div class="circle left-circle"></div>
    </div>
    <div class="right">
        <div class=" circle right-circle"></div>
    </div>
</div>

/*用渐变图片左右拼*/
.circle-image .left-circle {
    margin-right: -100px;
    border: none;
    background: url(circle-right.png) no-repeat 100px 0;
    -webkit-background-size: 100px 200px;
    background-size: 100px 200px;
    transform: rotateZ(0deg);
}
.circle-image .right-circle {
    margin-left: -100px;
    border: none;
    background: url(circle-left.png) no-repeat 0 0;
    -webkit-background-size: 100px 200px;
    background-size: 100px 200px;
    transform: rotateZ(0deg);
}

存在一个致命问题:180度时候另一半接不上,纯色场景下左右两半圆环颜色一样,看不到180-360deg时的差异,不存在接不上的问题,但渐变圆环很明显能看到180度后左边圆环是整个转着出现的,而不是一点点出现。尝试失败,考虑其它方式

四.白块遮罩

另一种不同的思路是把进度条圆环先铺在下面,左右都用白块遮住,然后顺时针旋转右边白块,底下的右半圆环一点点出现,180度换左边白块转动,底下的左半圆环一点点出现

这样就不存在左右接不上的问题,因为圆环本来就是完整的。解决了条的问题,还要考虑槽,可以把槽画在左右白块上,旋转白块槽跟着转,进度条一点点出现,完美。具体如下:

<div class="circle circle-block progress-basic">
    <div class="left">
        <div class="block left-block">
            <div class="circle"></div>
        </div>
    </div>
    <div class="right">
        <div class="block right-block">
            <div class="circle"></div>
        </div>
    </div>
    <div class="circleBar"></div>
</div>

/*用白块遮住*/
.circle-block .block {
    width: 100%; height: 100%;
    background: #fff;
}
.circle-block .right-block {
    -webkit-transform-origin: 0 50%;
    /*盖住露出的边缘*/
    border-right: 1px solid #fff;
}
.right-block .circle {
    margin-left: -100px;
    border-left-color: transparent;
    border-top-color: transparent;
    transform: rotateZ(-45deg);
}
.circle-block .left-block {
    -webkit-transform-origin: 100% 50%;
    /*盖住露出的边缘*/
    border-left: 1px solid #fff;
    margin-left: -1px;
}
.left-block .circle {
    border-right-color: transparent;
    border-bottom-color: transparent;
    transform: rotateZ(-45deg);
}
.circle-block .circleBar {
    width: 200px; height: 200px;
    margin-top: -10px; margin-left: -10px;
    background: url(circle.png) no-repeat 0 0;
    -webkit-background-size: 200px 200px;
    background-size: 200px 200px;
}

在高分辨率设备上,白块遮得不够完美,因此需要:

/*盖住露出的边缘*/
border-right: 1px solid #fff;

效果还不错,但存在很明显的限制:只适用于纯色背景,因为白块的背景色要和页面背景色一致,否则就露馅了

五.canvas描边

条件允许的话,可以用canvas画一个,所有问题迎刃而解:

<canvas class="canvas">不支持canvas</canvas>

void function() {
    var options = {
        size: 200,
        lineWidth: 10,
        rotate: 0
    };

    var canvas = $('.canvas');
    var ctx = canvas.getContext('2d');
    canvas.width = canvas.height = options.size;

    // 重置原点到中心点
    ctx.translate(options.size / 2, options.size / 2);
    // 旋转-90度,让x轴与-y方向重合
    ctx.rotate((-1 / 2 + options.rotate / 180) * Math.PI);

    var radius = (options.size - options.lineWidth) / 2;

    var drawCircle = function(color, lineWidth, percent) {
            percent = Math.min(Math.max(0, percent || 1), 1);
            ctx.beginPath();
            ctx.arc(0, 0, radius, 0, Math.PI * 2 * percent, false);
            ctx.strokeStyle = color;
            // butt, round or square
            // 线条两端的形状
            // butt 默认,两端不加装饰,截断感
            // round 给线条两端包上半圆
            // square 给线条两端包上半方,看起来和butt一样,但比butt长一个lineWidth
            ctx.lineCap = 'butt';
            ctx.lineWidth = lineWidth;
            ctx.stroke();
    };

    // 灰槽
    drawCircle('#efefef', options.lineWidth, 100 / 100);

    var prog = 0;
    // 色条渐变
    var gradient = ctx.createLinearGradient(0, 100, 0, -100);
    gradient.addColorStop(0, '#ff0000');
    gradient.addColorStop(0.15, '#ff00ff');
    gradient.addColorStop(0.33, '#0000ff');
    gradient.addColorStop(0.49, '#00ffff');
    gradient.addColorStop(0.67, '#00ff00');
    gradient.addColorStop(0.84, '#ffff00');
    gradient.addColorStop(1, '#ff0000');
    // '#077df8'
    var timer = setInterval(function() {
        prog += 1;
        // 色条
        drawCircle(gradient, options.lineWidth, prog / 100);
        if (prog >= 360) {
            clearInterval(timer);
        }
    }, 100);
}();

注意渐变的定义:

var gradient = ctx.createLinearGradient(0, 100, 0, -100);

这样定义的其实是从右向左的线性渐变,因为坐标系经过了两次变换:

// 重置原点到中心点
// 原点平移到(100, 100)
ctx.translate(options.size / 2, options.size / 2);
// 旋转-90度,让x轴与-y方向重合
// x轴y轴转置
ctx.rotate((-1 / 2 + options.rotate / 180) * Math.PI);

很容易让进度条变成圆头的:

ctx.lineCap = 'round';
// 线条两端的形状
// butt 默认,两端不加装饰,截断感
// round 给线条两端包上半圆
// square 给线条两端包上半方,看起来和butt一样,但比butt长一个lineWidth

但canvas描边在高分辨率设备上存在锯齿的问题,需要一点小技巧:

var width = canvas.width, height=canvas.height;
if (window.devicePixelRatio) {
    canvas.style.width = width + "px";
    canvas.style.height = height + "px";
    canvas.height = height * window.devicePixelRatio;
    canvas.width = width * window.devicePixelRatio;
    ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}

几倍屏就用几倍图,这样可以解决锯齿问题

六.svg描边动画

同样,如果条件允许,还可以用svg描边动画来实现,如下:

<div class="progress-svg">
    <!-- 条 -->
    <svg viewBox="-10 -10 220 220">
    <g fill="none" stroke-width="10" transform="translate(100,100)">
    <path d="M 0,-100 A 100,100 0 0,1 86.6,-50" stroke="url(#cl1)"/>
    <path d="M 86.6,-50 A 100,100 0 0,1 86.6,50" stroke="url(#cl2)"/>
    <path d="M 86.6,50 A 100,100 0 0,1 0,100" stroke="url(#cl3)"/>
    <path d="M 0,100 A 100,100 0 0,1 -86.6,50" stroke="url(#cl4)"/>
    <path d="M -86.6,50 A 100,100 0 0,1 -86.6,-50" stroke="url(#cl5)"/>
    <path d="M -86.6,-50 A 100,100 0 0,1 0,-100" stroke="url(#cl6)"/>
    </g>
    </svg>
    <!-- 槽 -->
    <svg viewBox="-10 -10 220 220">
    <path d="M200,100 C200,44.771525 155.228475,0 100,0 C44.771525,0 0,44.771525 0,100 C0,155.228475 44.771525,200 100,200 C155.228475,200 200,155.228475 200,100 Z" stroke-dashoffset="629"></path>
    </svg>
</div>
<!--  Defining Angle Gradient Colors  -->
<svg width="0" height="0">
<defs>
<linearGradient id="cl1" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="1" y2="1">
    <stop stop-color="#618099"/>
    <stop offset="100%" stop-color="#8e6677"/>
</linearGradient>
<linearGradient id="cl2" gradientUnits="objectBoundingBox" x1="0" y1="0" x2="0" y2="1">
    <stop stop-color="#8e6677"/>
    <stop offset="100%" stop-color="#9b5e67"/>
</linearGradient>
<linearGradient id="cl3" gradientUnits="objectBoundingBox" x1="1" y1="0" x2="0" y2="1">
    <stop stop-color="#9b5e67"/>
    <stop offset="100%" stop-color="#9c787a"/>
</linearGradient>
<linearGradient id="cl4" gradientUnits="objectBoundingBox" x1="1" y1="1" x2="0" y2="0">
    <stop stop-color="#9c787a"/>
    <stop offset="100%" stop-color="#817a94"/>
</linearGradient>
<linearGradient id="cl5" gradientUnits="objectBoundingBox" x1="0" y1="1" x2="0" y2="0">
    <stop stop-color="#817a94"/>
    <stop offset="100%" stop-color="#498a98"/>
</linearGradient>
<linearGradient id="cl6" gradientUnits="objectBoundingBox" x1="0" y1="1" x2="1" y2="0">
    <stop stop-color="#498a98"/>
    <stop offset="100%" stop-color="#618099"/>
</linearGradient>
</defs>
</svg>

对应的CSS如下:

.progress-svg {
  display: inline-block;
  position: relative;
  text-align: center;
}
.progress-svg svg {
  width: 200px; height: 200px;
}
.progress-svg svg:nth-child(2) {
  position: absolute;
  left: 0;
  top: 0;
  -webkit-transform: rotate(-90deg);
          transform: rotate(-90deg);
}
.progress-svg svg:nth-child(2) path {
  fill: none;
  stroke-width: 25;
  stroke-dasharray: 629;
  stroke: #fff;
  opacity: .9;
  -webkit-animation: load 10s;
          animation: load 10s;
}
@keyframes load {
  0% {
    stroke-dashoffset: 0;
  }
}

svg描边动画与path的两个东西有关:

  • stroke-dasharray:虚线每小段长度

  • stroke-dashoffset:虚线初始位置偏移量(向左偏移

向左偏移很关键,否则想不通为什么要递减到0,而不是从0递增:

A dashed stroke with a non-zero dash offset. The dashing pattern is 20,10 and the dash offset is 15. The red line shows the actual path that is stroked.

stroke-dashoffset

stroke-dashoffset

详细见SVG规范

原理是让stroke-dasharray长度大于path,并让stroke-dashoffset等于stroke-dasharray,此时没有描边(向左偏移把虚线的第一段实线弄没了),然后让偏移量递减到0,视觉效果就是虚线的第一段实线从左边一点点露出来,为0时恰好覆盖path

反过来想,如果让stroke-dashoffset为0,stroke-dasharray从极小值递增至等于path,虽然虚线第一段确实是一点点变长的,但后续小段没办法隐藏掉,所以不能用来实现描边动画

但是,svg渐变描边存在拼接的问题,仔细看能发现圆环上有4处断开,就是因为两段渐变接不上,虽然定义的渐变色值没有问题,理论上能完美接起来,但实际效果不理想

同样用CSS做渐变边框也存在左右拼接的问题,如下:

<div class="colorfulBorder"></div>

.colorfulBorder {
    width:100px; height:100px; -webkit-transform:rotate(90deg);
}
.colorfulBorder:before {
    content:"";
    display:block;
    width:100px; height:50px;
    margin-top:10px; padding:10px; padding-bottom:0; box-sizing:border-box;
    border-top-left-radius:50px;
    border-top-right-radius:50px;
    background:-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#fff)
    ),-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#077df8),
        color-stop(1,#74baff)
    );
    background-clip:content-box,padding-box;
}
.colorfulBorder:after {
    content:"";
    display:block;
    width:100px; height:50px;
    padding:10px; padding-top:0;
    box-sizing:border-box;
    border-bottom-left-radius:50px;
    border-bottom-right-radius:50px;
    background:-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#fff)
    ),-webkit-gradient(
        linear,
        left top,
        right top,
        color-stop(0,#fff),
        color-stop(1,#74baff)
    );
    background-clip:content-box,padding-box;
}

这种瑕疵导致整个方案不可行

七.在线Demo

Demo地址:http://www.ayqy.net/temp/progress/circle.html

八.总结

对于纯色圆环进度条,建议采用经典“两扇门”解法,能够适应非纯色背景

如果是渐变圆环进度条,方案选择如下:

  • 条件允许,优选canvas,能够适应非纯色背景

  • 不行就用白块遮罩,但只适用于纯色背景

svg描边动画可以实现自定义形状进度条,比如一条弯弯的小河,但不适用于渐变描边的情况(渐变相接处有缝隙)

CSS通过多background实现渐变边框不可行(因为渐变相接处有很明显缝隙)

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code