用 CSS+SVG 做一个优雅的环形进度条

其中进度、尺寸、环宽和颜色都可以非常方便地进行控制。

核心原理:

利用两个重叠的圆环形,通过对上层圆环弧长的控制来表示进度,下层圆环则作为辅助,呈现环形进度条剩余的部分。

核心知识点:

SVG circlestroke-dasharray

弧长公式l = πrα/180°

CSS 变量

CSS 计数器

下面分享下具体实现过程。

实现环形

要实现环形,有多种技术可供选择,包括 Canvas、SVG,甚至 CSS + HTML 的组合。在本文中,我们使用了 SVG 方案,一来是因为 SVG 的 API 丰富,图形表现力非常强大,二来是 SVG 可以与 CSS 无缝搭配使用,实现更加强大的功能。

用 SVG 中的 circle 标签可以毫无压力的绘出一个直径 200px 的圆环:


```

<svg width="400" height="400">

  <circle

    cx="200"

    cy="200"

    r="100"

    fill="none"

    stroke="blue"

    stroke-width="10" />

</svg>

```


我们再调整下代码,让圆环图形完美适应 SVG 容器的 200×200 大小:

```

<svg width="200" height="200">

  <circle

    cx="100"

    cy="100"

    r="95"

    fill="none"

    stroke="blue"

    stroke-width="10" />

</svg>

```

小技巧:为了完美适应容器尺寸,我们可以将半径 r 的值设置为容器宽度的一半减去 stroke-width 大小的一半,这样做可以确保圆环不会因为溢出而被容器剪裁掉。此处 r = 200 / 2 - 10 / 2,即 (200 - 10) / 2 = 95


接着使用相同的方法绘制另一个圆环,作为辅助圆环。为了视觉效果,进度圆环应该在上层,因此在代码中,进度圆环的标签应该放在辅助圆环的后面。完成后的代码如下:

```

<svg width="200" height="200">

  <!-- 辅助圆环 -->

  <circle

    cx="100"

    cy="100"

    r="95"

    fill="none"

    stroke="#ccc"

    stroke-width="10" />


  <!-- 进度圆环 -->

  <circle

    cx="100"

    cy="100"

    r="95"

    fill="none"

    stroke="blue"

    stroke-width="10" />

</svg>

```

可以发现代码中有很多重复的属性,为了让代码更加简洁和高效,我们可以使用 CSS 将这些重复的部分提取出来,统一声明,让代码变得更加“干”(DRY):

```

.progress-circle {

  width: 200px;

  height: 200px;

}

.progress-circle > circle {

  cx: 100px;

  cy: 100px;

  r: 95px;

  fill: none;

  stroke-width: 10px;

}

```


```

<svg class="progress-circle">

  <circle stroke="#ccc" />

  <circle stroke="blue" />

</svg>

```

实现圆环进度

上下两层的圆环已经准备好了,现在的重点是如何实现上层圆环上的进度,这个问题可以分为 2 个关键点:

1.如何实现圆环的弧长?

2.如何将进度百分比转换为圆环的弧长?

要解决第 1 个问题,这里要用到 SVG 中的stroke-dasharray属性,这是一个用来控制路径虚线疏密程度的属性,其值是一组描述虚线的短划线与空白间隙长度的数列。例如,如果设置stroke-dasharray="5 2",则路径将以 5 个像素的短划线和 2 个像素的空白间隙交替显示,其中第一个数控制短划线长度,第二个数控制空白间隙长度。

stroke-dasharray 的参数值还支持多个数列,详情见 MDN 文档

很明显,这里我们需要控制圆弧的长度(即虚线中的短划线长度)来呈现进度。然而,由于虚线中的短划线是多个且重复的,仅仅改变短划线长度并不能满足我们的需求,具体情况如下图所示:


根据需求,我们只需要圆环的一段圆弧即可,那如何实现呢?经过多次尝试,我们发现当改变虚线中空白间隙的长度(即stroke-dasharray的第二个数),当这个长度超过圆环的周长时,视觉效果上圆环只剩下一条独立的圆弧了,此时我们可以通过调整第一个数来改变圆弧的长度,从而解决了上面第 1 个问题。


上面第 2 个问题暂时看起来比较棘手,因为我们很难看出 0%~100%之间的进度到底对应多长的弧长。我们继续探究,寻找规律。

根据需求,当进度百分比是 100%时,进度条的圆弧要呈现出一个圆环,此时圆弧夹角是 360°,当进度百分比是 50%时,圆弧是个半圆环,夹角是 180° 。反过来也成立:180°表示 50%,360°表示 100% 。可以看出进度百分比和角度是存在等量关系的,同时根据弧长公式l = πrα/180°,带入角度就可以求出弧长了,至此“进度百分比 - 角度 - 弧长”三者的规律就清晰了,思路马上要通了。

l = πrα/180°

其中 l 表示弧长,π 是圆周率,r 表示半径,α 表示夹角


在实际使用中,我们是用百分数来控制弧长的,而不是用角度,所以接下来把进度百分比和弧长关联起来。

我们把弧长公式变动下,分子分母同时乘以 2,则有:

l = 2πrα/180°*2,即l = 2πr * α/360°。

同时根据前面可知进度百分比和角度存在等量关系(360° 等于 100%),所以可以得到:

l = 2πr * p/100,其中p为当前进度百分数。

现在,我们就可以根据进度百分比来计算出对应的弧长了。


优化细节

环形进度条通常都是从 12 点钟方向开始的,而 SVG 中默认是 3 点钟方向作为起点,所以我们给它偏转-90°进行修正:

```

.progress-circle {

  ...

  transform: rotate(-90deg);

}

```


另外我们发现圆弧的端点处过于生硬,给个圆角效果修饰下是个好主意。这里我们用了 SVG 中的属性stroke-linecap:

```

.progress-circle > circle {

  ...

  stroke-linecap: round;

}

```


当然了,动画过渡效果也可以安排上,让进度条的变化更加丝滑:

```

.progress-circle > circle {

  ...

  transition: stroke-dasharray 0.4s linear, stroke .3s;

}

```

组件化

环形进度条的效果虽然已经实现了,但光靠上面的那些代码还是很难复用。环形进度条的宽度、高度、半径、颜色等都是写死的,另外stroke-dasharray的值还得靠 JS 进行计算,再赋值给<circle>元素。说好的纯 CSS 实现呢?

要解决这些问题,我们需要将环形进度条组件化。

先定义一些组件全局要用到的 CSS 变量:

```

/* 容器 */

.progress-circle {

  --percent: 0;  /* 百分数 */

  --size: 180px;  /* 尺寸大小 */

  --border-width: 15px;  /* 环形宽度(粗细) */

  --color: #7856d7;  /* 主色 */

  --inactive-color: #ccc;  /* 辅助色 */

}

```

然后利用calc将写死的数值改为根据 CSS 变量动态计算:

```

/* 容器 */

.progress-circle {

  width: var(--size);

  height: var(--size);

  transform: rotate(-90deg);

  border-radius: 50%;

}

/* 进度条环形图形 */

.progress-circle > circle {

  cx: calc(var(--size) / 2);

  cy: calc(var(--size) / 2);

  r: calc((var(--size) - var(--border-width)) / 2);

  fill: none;

  stroke-width: var(--border-width);

  stroke-linecap: round;

  transition: stroke-dasharray 0.4s linear, stroke .3s;

}

```

SVG 中的stroke-dasharray也调整为根据 CSS 变量动态计算:

```

<svg class="progress-circle">

  <circle stroke="var(--inactive-color)" />

  <circle stroke="var(--color)"

    style="stroke-dasharray: calc(

      2 * 3.1415 * (var(--size) - var(--border-width)) / 2 *

      (var(--percent) / 100)

    ), 1000"

  />

</svg>

```

这样我们通过改变父容器的--percent变量就能直接控制进度条的百分比显示了。


同理,也可以改变其他内部变量来方便地控制组件的外观和尺寸。例如可以根据阈值来动态调整进度条颜色:

```

function changeProgress(percent) {

  progressEl.style.setProperty('--percent', percent);

  [

    { value: 90, color: '#7c5' },

    { value: 70, color: '#65c' },

    { value: 50, color: '#fc3' },

    { value: 0, color: '#f66' }

  ].find(it => {

    if (percent >= it.value) {

      progressEl.style.setProperty('--color', it.color);

      return true;

    }

  });

}

```


显示百分比文本

可以把百分比的文本显示在环形进度条的中央,增强可视化效果。有了前面的铺垫,这里只需用伪元素 + CSS 计数器就能轻松实现百分比文本显示。

由于 SVG 中不支持伪元素,所以我们加一层 HTML 标签作为主容器:

```

<div class="progress-circle">

  <svg>

    ...

  </svg>

</div>

```

然后为主容器增加伪元素,居中定位,再利用 CSS 计数器接收--percent变量的值:

```

/* 百分数文本 */

.progress-circle::before {

  position: absolute;

  top: 50%;

  left: 50%;

  transform: translate(-50%, -50%);

  counter-reset: progress var(--percent);

  content: counter(progress) '%';

  white-space: nowrap;

  font-size: 18px;

}

```

效果如下图:


完善边界场景

上面为圆环加了stroke-linecap: round让描边的端点圆角化,不过却引出了一个小问题:当真实进度为 0% 时,由于端点圆角的存在,使得进度条在视觉效果上明显大于 0%,正如下图所示:


《纯CSS实现未读消息超过100自动显示为99+》一文的启发,我们为进度圆环加一个值为 --percent 的 opacity 属性即可解决这个问题:

```

<div class="progress-circle">

  <svg>

    <circle ... />

    <circle class="progress-value" ... />

  </svg>

</div>

```


```

.progress-value {opacity: var(--percent);}

```

当 --percent 的值为 0 时,此处 opacity 为 0,则进度圆环完全透明不显示;当 --percent 的值大于 0 时,opacity 的值按 1 处理,则进度圆环正常显示。


扩展:仪表盘式进度条

环形进度条还有一种变体——仪表盘式进度条,顾名思义就是以仪表盘的形态显示进度,有着视觉吸引力强和易于理解的优点。下图是来自AntDesign的仪表盘式进度条示例:


在前面知识的基础上,我们趁热打铁再做一个仪表盘式进度条。

生成缺口

在视觉上,仪表盘式进度条相比圆环进度条最大的不同就是前者有个空白“缺口”,且缺口朝下,整体左右对称。

我们很容易能够想到用stroke-dasharray的第 1 个参数来生成可见的圆弧(即“缺口”以外的部分),用第 2 个参数来生成间隙(即“缺口”)。以中心夹角 90°的缺口为例,代入弧长公式(l = πrα/180°)则有:

```

<div class="progress-circle">

  <svg>

    <circle stroke="var(--inactive-color)"

            style="stroke-dasharray: calc(3.1415 * var(--r) * (360 - 90) / 180),

                                    calc(3.1415 * var(--r) * 90 / 180)"

    />

  </svg>

</div>

```

效果如下图所示:


确定svg旋转角度

在不对容器做任何旋转的情况下,这个 90°缺口朝向右上角,而我们实际想要的是让缺口朝下,且整体左右对称。要达到这个目的,我们需要将svg容器顺时针旋转 135°,效果如下:


在实际场景中,这个“缺口”的大小是可配置的,所以我们把缺口夹角封装到主容器的 CSS 变量里,方便后续的动态计算。

```

.progress-circle {

  ...

  --gap-degree: 90;  /* 缺口夹角 */

}

```

当缺口夹角变成动态时,svg容器到底需要旋转多少度才能使缺口的开口朝下,且整体左右对称呢?我们接着以 90°夹角的缺口为例来分析。从前面实践可知想要让 90°缺口朝下,且整体左右对称,则需要将svg容器顺时针旋转 135°,旋转后的示意图如下:


其中∠A = ∠B = 135°,仔细观察可发现,这里的 135°等于缺口自身夹角 90°加上 ∠A 与 ∠B 重合的夹角 45°. 显然,当缺口夹角发生变化时,此处的重合夹角需要动态计算,结合上图我们不难看出这里存在一个等式:

2 × 重合夹角 + 缺口夹角 = 180°

那么则有:

重合夹角 = (180° - 缺口夹角) / 2

结合 CSS 变量我们就可以确定svg容器的旋转角度,代码如下:

```

.progress-circle > svg {

  ...

  transform: rotate(

    calc((var(--gap-degree) + (180 - var(--gap-degree)) / 2) * 1deg)

  );

}

```

这样我们的进度条就能适配不同的缺口夹角了:


修正进度换算

当我们按环形进度条的方式来测试仪表盘式进度条时,发现仪表盘式进度条的进度显示有问题——实际进度百分比和进度显示不匹配,如下图所示:


其实也不难理解,我们先看一下现有进度圆环的代码部分:

```

<circle stroke="var(--color)"

  class="progress-value"

  style="stroke-dasharray: calc(2 * 3.1415 * var(--r) * (var(--percent) / 100)), 1000"

/>

```

在圆环进度条中,这里的var(--percent) / 100与α / 360°是等价的,360°圆弧表示 100%进度,但在仪表盘式进度条中,360°减去缺口夹角后对应的圆弧才真正表示 100%进度。所以我们得把缺口夹角的因素换算进去,修正实际的进度显示。

我们从这个公式入手:l = 2πr * α/360°

在圆环进度条中,50%的进度对应 180°的圆弧夹角,代入公式则有:

l = 2πr * 180 / 360

如换成仪表盘式进度条,当缺口夹角为 90°时,这里的分母就是360° - 90° = 270°。小学数学教会我们:分子分母等比变化时,其值不变,所以我们给分子也乘以分母变化的值,则有:

l = 2πr * 180 * (270 / 360) / 270

进一步分解下可得:l = πr * 180 * (270 / 180) / 270

我们把除缺口夹角外的圆弧换成 CSS 变量--active-degree: calc(360 - var(--gap-degree));,写到进度圆环的代码中:

```

<circle stroke="var(--color)"

  class="progress-value"

  style="stroke-dasharray: calc(3.1415 * var(--r) * 180 * var(--active-degree) / 180 / var(--active-degree)), 1000"

/>

```

其中(180 * var(--active-degree) / 180) / var(--active-degree)等价于(var(--percent) * var(--active-degree) / 180) / 100,答案已经呼之欲出了:

```

<circle stroke="var(--color)"

  class="progress-value"

  style="stroke-dasharray: calc(3.1415 * var(--r) * var(--percent) * var(--active-degree) / 180 / 100), 1000"

/>

```

最终效果:Demo 传送门


最后

利用 CSS 和 SVG 技术,我们成功地实现了一个优雅的环形进度条,这个进度条不仅能够展示进度,还能够通过 CSS 变量来调整样式满足不同的需求。希望这篇分享对大家使用 CSS 和 SVG 有所帮助,也希望大家能够在实践中不断探索和创新,做出更加实用和美观的作品。


转载自《用 CSS+SVG 做一个优雅的环形进度条》

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容