圆弧倒计时进度条的实现

2,418 阅读11分钟

一、前言

最近的项目中,需要实现一个圆弧形倒计时进度条,对于本来css知识薄弱的我当场就懵逼,脑海里总是不断思考如何实现,不幸的是脑袋里没能蹦出半个想法。然后立马百度查看网上是否有相似的解决方案,百度下来初步知道如何来实现了,那我们就一步一步从0到有开始这段旅程。 首先展示一下最终的成果,最终效果图如下:

实现要点:浅色圆弧需要分成左右两边,左右两边都需要用一个同心原来实现,亮色圆弧也需要左右分开,各自用一个同心圆来实现。让我们开始吧!

二、实现步骤

添加容器

让整个容器是position: fixed方便可以在整个页面上随意放置

html代码:

<div class="task-container"></div>

css代码:

.task-container {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    width: 65px;
    height: 65px;
    display: flex;
    justify-content: center;
    align-items: center;
}

画底盘

加点阴影,让它看起来有点立体的感觉

html代码:

<div class="task-container">
    <div class="task-cicle"></div>
</div>

css代码:

.task-container {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    width: 65px;
    height: 65px;
    display: flex;
    justify-content: center;
    align-items: center;
	
    .task-cicle {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 53px;
        height: 53px;
        border-radius: 50%;
        background: #FFFFFF;
        box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.05);
    }
}

效果:

重点来了,接下来实现圆弧

我们先画右圆弧,我们用右半边矩形来实现,右半圆只设置上方和右边的边框颜色

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
        </div>
    </div>
</div>

css代码:

.task-container {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    width: 65px;
    height: 65px;
    display: flex;
    justify-content: center;
    align-items: center;


    .task-cicle {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 53px;
        height: 53px;
        border-radius: 50%;
        background: #FFFFFF;
        box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.05);
    }

    .task-inner {
        position: relative;
        width: 46px;
        height: 46px;
    }

    .right-cicle {
        width: 23px;
        height: 46px;
        position: absolute;
        top: 0;
        right: 0;
        overflow: hidden;
    }

    .cicle-progress {
        position: absolute;
        top: 0;
        width: 46px;
        height: 46px;
        border: 3px solid transparent;
        box-sizing: border-box;
        border-radius: 50%;
    }

    .cicle1-inner {
        left: -23px;
        border-right: 3px solid #e0e0e0;
        border-top: 3px solid #e0e0e0;
        transform: rotate(-15deg);
    }
}

right-cicle需要设置overflow: hidden;对子元素超出的部分进行裁剪。cicle1-inner中的旋转-15度,其实可以根据设计稿来调整你需要展示的弧度

如果父节点,没有进行裁剪,右半圆就会延伸到左边

裁剪之后的效果

画左边的弧

接下来根据同样的原理画左边的弧。左边的圆,只设置上方和左边的边框颜色

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle2-inner"></div>
            </div>
        </div>
    </div>
</div>

css代码:

.left-cicle {
    width: 23px;
    height: 46px;
    position: absolute;
    top: 0;
    left: 0;
    overflow: hidden;
}

.cicle2-inner {
    left: 0;
    border-left: 3px solid #e0e0e0;
    border-top: 3px solid #e0e0e0;
    transform: rotate(15deg);
}

效果如下:

ok,圆弧的基本轮廓已经完成,接下来实现亮色进度条,进度条也是分左右边各自实现

画右半边进度条

右半边圆只设置上方和右边的边框颜色

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle2-inner"></div>
            </div>
            <div class="right-cicle">
                <div class="cicle-progress cicle3-inner" id="rightCicle"></div>
            </div>
        </div>
    </div>
</div>

css代码:

.cicle3-inner {
    left: -23px;
    border-right: 3px solid #feca02;
    border-top: 3px solid #feca02;
    transform: rotate(-135deg);
}

效果如下:

为什么是旋转-135度?进度条是从左边蔓延到右边的,让亮色进度条旋转到左右两边的临界点,也就是初始角度是-135度,随着时间推移增加旋转角度,进度条就蔓延到右边了

转到哪个角度为止呢?转到亮色边框和右边灰色边框重合,也就是-15度,那么右边亮色进度条的旋转角度范围就是-135度到-15度,共120度的。

右半边进度条已经完成,初始角度是-135度,随着时间的推移,慢慢旋转到-15度的位置

画左半边的进度条

左半圆只设置上方和左边的边框颜色

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle2-inner"></div>
            </div>
            <div class="right-cicle">
                <div class="cicle-progress cicle3-inner" id="rightCicle"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle4-inner" id="leftCicle"></div>
            </div>
        </div>
    </div>
</div>

css代码:

.cicle4-inner {
    left: 0;
    border-left: 3px solid #feca02;
    border-top: 3px solid #feca02;
    transform: rotate(195deg);
}

效果如下(为了演示,父节点为设置了overflow: inherit;不裁剪,能更清楚来龙去脉):

为什么要旋转195度?进度条是从左边开始由无到有的,我们让亮色进度条旋转到左边灰色圆弧起始点的临界点位置,随着时间的推移增加旋转角度。左边进度条要转120度,所以左边进度条旋转角度范围:195到315度

我们把父节点的overflow设置回原来的hidden,对子节点超出的部分进行裁剪。

what?裁剪之后还露出了一个小尾巴,如何把这个小尾巴给掩盖掉?这时候我们需要在左边再画一个同心圆来遮盖掉它

画遮盖圆

注意:遮罩圆边框宽度要比左边亮色进度条圆的边框宽度要大,不然会遮盖不完全,会出现金色余晖,且要和亮色进度条是同心圆

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle2-inner"></div>
            </div>
            <div class="right-cicle">
                <div class="cicle-progress cicle3-inner" id="rightCicle"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle4-inner" id="leftCicle"></div>
            </div>
            <div class="left-cicle">
                <div class="mask-inner"></div>
            </div>
        </div>
    </div>
</div>

css代码(为了展示遮罩圆是完全覆盖的,我把父节点的overflow: inherit;不裁剪,圆的边框颜色设置为蓝色):

.mask-inner {
    position: absolute;
    left: 0;
    top: 0;
    width: 39px;
    height: 39px;
    // border: 4px solid transparent;
    border: 4px solid blue;
    border-radius: 50%;
    // border-left: 4px solid #FFFFFF;
    // border-top: 4px solid #FFFFFF;
    // transform: rotate(195deg);
}

看,我们的遮罩圆已经完全遮罩了其他圆,遮盖圆和左边进度条圆一样,都是旋转195度,只设置上方和左边的边框颜色,边框颜色是和底盘颜色一样,我们把父节点overflow设置为hidden裁剪

css代码:

.mask-inner {
    position: absolute;
    left: 0;
    top: 0;
    width: 39px;
    height: 39px;
    border: 4px solid transparent;
    border-radius: 50%;
    border-left: 4px solid blue;
    border-top: 4px solid blue;
    transform: rotate(197deg);
}

蓝色部分就是我们的小尾巴的位置,我们用白色替换蓝色边框

.mask-inner {
    position: absolute;
    left: 0;
    top: 0;
    width: 39px;
    height: 39px;
    border: 4px solid transparent;
    border-radius: 50%;
    border-left: 4px solid #FFFFFF;
    border-top: 4px solid #FFFFFFl
    transform: rotate(197deg);
}

效果:

哇,看看,小尾巴已经不见了。

如果遮盖圆和左边亮色进度条设置一样的边框大小,会出现金色边

好吧,样式方面已经基本完成,其他点缀的样式就不在这里列出了,可以看看下面的源码。要让进度条动起来,需要通过js来操作,js里的源码我已经写了比较清楚的注释,方便理解。

html代码:

<div class="task-container">
    <div class="task-cicle">
        <div class="task-inner">
            <div class="right-cicle">
                <div class="cicle-progress cicle1-inner"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle2-inner"></div>
            </div>
            <div class="right-cicle">
                <div class="cicle-progress cicle3-inner" id="rightCicle"></div>
            </div>
            <div class="left-cicle">
                <div class="cicle-progress cicle4-inner" id="leftCicle"></div>
            </div>
            <div class="left-cicle">
                <div class="mask-inner"></div>
            </div>
            <div class="inner">
                <img src="https://img12.360buyimg.com/img/jfs/t1/150018/30/1001/2042/5eec2f8eEfd3c853a/e7982308423ce71a.png" alt="" srcset="">
                <div class="water-count">10</div>
            </div>
        </div>
        <div class="task-bottom">
            <div class="task-btn" id="time"></div>
        </div>
    </div>
</div>


<script>
    const rightCicle = document.getElementById('rightCicle');
    const leftCicle = document.getElementById('leftCicle');
    const timeDom = document.getElementById('time');
    let isStop = false;
    let timer;
    const totalTime = 10; // 总时间
    const halfTime = totalTime / 2; // 总时间的一半
    const initRightDeg = -135; // 右半边进度条初始角度
    const initLeftDeg = 195; // 左半边进度条初始角度
    const halfCicle = 120; // 左右连边各要转的总角度
    const perDeg = 120 / halfTime; // 每秒转的角度
    let inittime = 10;
    let begTime; // 倒计时开始时间戳
    let stopTime; // 倒计时停止时间戳

    function run() {
        const time = inittime;
        let animation;
        if (time > halfTime) {
            // 左半边还没转完
            // 左半边:动画的初始角度=左半边进度条初始角度+已经转的角度,最终角度=初始角度+120度,动画持续时间=左半边还剩需要转的时间
            // 右半边:动画的初始角度=右半边进度条初始角度,最终角度=初始角度+120度,动画持续时间=一半的时间,动画延迟=左半边还剩需要转的时间
            animation = `
                @keyframes task-left {
                    0% {
                        transform: rotate(${initLeftDeg + (totalTime - time) * perDeg}deg);
                    }
                    100% {
                        transform: rotate(${initLeftDeg + halfCicle}deg);
                    }
                }
                .task-left {
                    animation-name: task-left;
                    animation-duration: ${time - halfTime}s;
                    animation-timing-function: linear;
                    animation-delay: 0s;
                    animation-fill-mode: forwards;
                    animation-direction: normal;
                    animation-iteration-count: 1;
                }
                @keyframes task-right {
                    0% {
                        transform: rotate(${initRightDeg}deg);
                    }
                    100% {
                        transform: rotate(${initRightDeg + halfCicle}deg);
                    }
                }
                .task-right {
                    animation-name: task-right;
                    animation-duration: ${halfTime}s;
                    animation-timing-function: linear;
                    animation-delay: ${time - halfTime}s;
                    animation-fill-mode: forwards;
                    animation-direction: normal;
                    animation-iteration-count: 1;
                }
            `;
        } else {
            // 左半边已经转完
            // 左半边动画:起始帧和重点帧都=左半边进度条初始角度+120度
            // 右半边动画:动画的初始角度=右半边进度条初始角度+右半边已经角度,最终角度=初始角度+120度,动画持续时间=剩余时间
            animation = `
                @keyframes task-left {
                    0% {
                        transform: rotate(${initLeftDeg + halfCicle}deg);
                    }
                    100% {
                        transform: rotate(${initLeftDeg + halfCicle}deg);
                    }
                }
                .task-left {
                    animation-name: task-left;
                    animation-duration: 0s;
                    animation-timing-function: linear;
                    animation-delay: 0s;
                    animation-fill-mode: forwards;
                    animation-direction: normal;
                    animation-iteration-count: 1;
                }
                @keyframes task-right {
                    0% {
                        transform: rotate(${initRightDeg + (halfTime - time) * perDeg}deg);
                    }
                    100% {
                        transform: rotate(${initRightDeg + halfCicle}deg);
                    }
                }
                .task-right {
                    animation-name: task-right;
                    animation-duration: ${time}s;
                    animation-timing-function: linear;
                    animation-delay: 0s;
                    animation-fill-mode: forwards;
                    animation-direction: normal;
                    animation-iteration-count: 1;
                }
            `;
        }
        // 增加动画暂停和开始类
        animation += `.stop {animation-play-state: paused;} .run {animation-play-state: running;}`
        const styleDom = document.createElement('style');
        styleDom.type = 'text/css';
        styleDom.innerHTML = animation;
        document.getElementsByTagName('head').item(0).appendChild(styleDom);
        leftCicle.classList.add('task-left');
        rightCicle.classList.add('task-right');
        begTime = Date.now();
        countDown();
    }

    function countDown() {
        if (begTime && stopTime) {
            // 从1秒到1.6秒后暂停,动画一直在走,而倒计时因为未到2秒,定时器就清除了,下次还是会从1开始计时,
            // 这就会导致倒计时和动画的不同步,之类稍微校正一下,如果结束时间和开始时间取余数大于500,就把倒计时-1秒
            const runtime = stopTime - begTime;
            console.log(runtime % 1000);
            if (runtime % 1000 > 500) {
                inittime -= 1;
            }
        }
        begTime = Date.now();
        timeDom.innerText = `${inittime}秒后获得`;
        timer = setInterval(() => {
            inittime -= 1;
            timeDom.innerText = `${inittime}秒后获得`;
            if (inittime <= 0) {
                clearInterval(timer);
            }
        }, 1000);
    }
    // 点击可暂停倒计时和动画
    timeDom.addEventListener('click', () => {
        if (isStop) {
            isStop = false;
            countDown();
            leftCicle.classList.remove('stop');
            leftCicle.classList.add('run');
            rightCicle.classList.remove('stop');
            rightCicle.classList.add('run');
        } else {
            stopTime = Date.now();
            isStop = true;
            clearInterval(timer);
            leftCicle.classList.remove('run');
            leftCicle.classList.add('stop');
            rightCicle.classList.remove('run');
            rightCicle.classList.add('stop');
        }
    }, false);

    run();
</script>

css代码:

.task-container {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    margin: auto;
    width: 65px;
    height: 65px;
    display: flex;
    justify-content: center;
    align-items: center;


    .task-cicle {
        display: flex;
        justify-content: center;
        align-items: center;
        width: 53px;
        height: 53px;
        border-radius: 50%;
        background: #FFFFFF;
        box-shadow: 0px 0px 12px 0px rgba(0, 0, 0, 0.05);
    }

    .task-inner {
        position: relative;
        width: 46px;
        height: 46px;
    }

    .right-cicle {
        width: 23px;
        height: 46px;
        position: absolute;
        top: 0;
        right: 0;
        overflow: hidden;
    }

    .cicle-progress {
        position: absolute;
        top: 0;
        width: 46px;
        height: 46px;
        border: 3px solid transparent;
        box-sizing: border-box;
        border-radius: 50%;
    }

    .cicle1-inner {
        left: -23px;
        border-right: 3px solid #e0e0e0;
        border-top: 3px solid #e0e0e0;
        transform: rotate(-15deg);
    }

    .left-cicle {
        width: 23px;
        height: 46px;
        position: absolute;
        top: 0;
        left: 0;
        overflow: hidden;
    }

    .cicle2-inner {
        left: 0;
        border-left: 3px solid #e0e0e0;
        border-top: 3px solid #e0e0e0;
        transform: rotate(15deg);
    }

    .cicle3-inner {
        left: -23px;
        border-right: 3px solid #feca02;
        border-top: 3px solid #feca02;
        transform: rotate(-135deg);
    }

    .cicle4-inner {
        left: 0;
        border-left: 3px solid #feca02;
        border-top: 3px solid #feca02;
        transform: rotate(195deg);
    }

    .mask-inner {
        position: absolute;
        left: 0;
        top: 0;
        width: 39px;
        height: 39px;
        border: 4px solid transparent;
        border-radius: 50%;
        border-left: 4px solid #FFFFFF;
        border-top: 4px solid #FFFFFF;
        transform: rotate(195deg);
    }

    .inner {
        position: absolute;
        left: 0;
        top: -2px;
        right: 0;
        bottom: 0;
        width: 22px;
        height: 26px;
        margin: auto;

        img {
            width: 100%;
            height: 100%;
        }
    }

    .water-count {
        position: absolute;
        top: 8px;
        left: 50%;
        transform: translateX(-50%);
        font-family: "JDZhengHei-01-Regular";
        font-size: 12px;
        color: #FFFFFF;
    }

    .task-bottom {
        display: flex;
        justify-content: center;
        align-items: center;
        position: absolute;
        width: 60px;
        height: 15px;
        left: 50%;
        transform: translateX(-50%);
        bottom: 2px;
    }

    .task-btn {
        display: flex;
        justify-content: center;
        align-items: center;
        height: 15px;
        border-radius: 7px;
        background-image: linear-gradient(-45deg, #FEB402 0%, #FF8407 100%);
        font-size: 8px;
        color: #FFFFFF;
        line-height: 15px;
        padding: 0 4px;
    }
}

三、总结

浅色圆弧和亮色进度条的实现比较绕,一眼看过去不太好理解,我们可以把每一步拆分开。4个圆弧的实现,父节点都进行了裁剪,裁剪之后很难看出子元素原本的样子,我们可以先把裁剪去掉,看看未裁剪时,各个圆的表现。