Progress circle 跟上一篇 Progress bar 一樣是能夠展示當前進度的元件。只是在外觀上面以圓形替代長條形,好處是在寬度不夠的排版空間當中能夠節省空間。
我們知道昨天提到的的 Antd progress bar 結構上長這樣:
<div className="ant-progress-inner">
  <div className="ant-progress-bg" />
</div>
那我們今天來偷看一下 progress circle 的結構:
<div className="ant-progress-inner">
  <svg className="ant-progress-circle">
    <path className="ant-progress-circle-trail" ...>
    <path className="ant-progress-circle-path" ...>
    ....(略)
  </svg>
</div>
看起來真的沒有 progress bar 那麼單純了是不是呢?
但至少我們找到了一個關鍵,就是他使用了 SVG 來畫甜甜圈。
如果甜甜圈要用 <div /> 來做,或許也不是做不到,但可能實現起來又更複雜,可以參考下面這篇。
https://www.oxxostudio.tw/articles/201503/css-pie-chart.html
主要的原理就是使用半圓型,然後根據不同的角度、進度,來遮蔽掉不需要的部分,只露出我們需要的角度範圍,就能夠做到進度條的效果。
但是進度條又更複雜一點,因為他不是純扇型,他是一個甜甜圈,中間的部分需要挖空,當然我們不可能真的把他挖空,所以能夠用到的方法也是想辦法把中間遮起來。
大家想想看,如果做一個元件需要常常這樣遮遮掩掩的,在變換一些使用情境的時候是不是很有可能會露出馬腳呢?就像小時候不懂事說了一個謊或掩蓋了一件秘密,未來在面對各種情境的時候就很容易露出破綻一樣,我們在使用這個元件的時後,使用情境上面就會變得比較侷限,或是比較容易破版被抓到。
因此,使用 SVG 來畫 Progress Circle 就成為我們這次的選擇。
SVG 簡介:
SVG 是可縮放向量圖形(Scalable Vector Graphics,SVG)。是基於 XML ,用於描述二維向量圖形的一種圖形格式,而 SVG 也是由 W3C 所制定的開放標準,老早就成為網頁標準。
要畫出一個 SVG 圖,我們需要先定義出圖片的可視區域大小,width=“300” height=“300” 就表示我們定義了一個 300x300 的視區,與 HTML 和 CSS 比較不同的地方,SVG 本身定義這些屬性是沒有單位的,不過基本上就是以「像素 px」為單位。
<svg width="300" height="300" ...>
  ...
</svg>
我們來看一下 SVG 要怎麼畫圓型,SVG 提供了 <circle /> 這個標籤來給我們使用,要給哪些參數才有辦法定義出一個圓形呢?其實我們只需要圓心的座標以及半徑長度就能夠定義出一個圓形了:
circle
cx, cy
r
<circle cx="80.141" cy="73.446" r="44" ...... />
上面的這個圓心座標 cx 與 cy 是在 SVG 可視範圍內的座標。

circle 上面有一些屬性是我們等一下會用到的。
首先要介紹的是 stroke,這個詞有點接近於 CSS 的 border,是用來描述描邊的屬性。
若直接對 stroke 指定一個色票,那就會是這個描邊的顏色,例如我們要讓 progress 的 rail 是淺灰色,我們可以這樣做:
.progress-circle__rail {
  stroke: #EEE;
}
描邊屬性除了顏色之外,我們也能夠指定他的寬度,這邊使用的是 stroke-width。
再來我們要介紹的是 stroke-dasharray,是用來把 stroke 做成虛線的效果,線段會被拆成線段、空白、線段、空白.....,如下面這樣:
<svg width="300" height="300" style="background: #FFF;">
  <circle
    r="50"
    cx="150"
    cy="150"
    fill="#FFF"
    stroke="#aaa"
    stroke-width="12"
    stroke-dasharray='20'
  />
</svg>

如上顯示,線段被拆成 20px 的線段,再空 20px 的空白,不斷的循環。
stroke-dasharray 除了可以放單一值之外,我們可以觀察到他的屬性命名是 *-array,表示他可以像是 array 一樣來使用,例如說,我們也可以給兩個值,其中第一個值是線段長度,第二個值是空白長度,如下:
stroke-dasharray='20 5'
那我們就能夠看到下面這樣的效果:
stroke-dasharray 還有其他更進階的用法,我們可以參考 MDN Web Docs 上面的定義,本篇因為不會用到,所以就不詳述。
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray
都已經破梗破到這裡了,大家是否有參透 progress circle 進度條的原理了呢?
如果還沒有參透,我再來給一個提示,如果我把 stroke-dasharray 指定為下面這樣如何:
stroke-dasharray='20 999999'
我指定第一個描邊線段為 20px,之後就是一個超級大的空白間距,我用 999999 來表示,那我們就會有下面這樣的效果:

上圖是我在 SVG 裡面再加上一個 circle 當作底色,方便我們觀察這個描邊線段。
如果我們能夠指定描邊的線段長度以及線段間距的空白長度,那就表示我們可以把第一個描邊線段當作 progress track,藉由改變這個線段的長度,我們就可以用來表示 progress 的進度了!
由於我們的進度是 0% ~ 100%,因此對應到這個線段的長度就是 0px ~ 圓周長px。
| 屬性 | 說明 | 類型 | 默認值 | 
|---|---|---|---|
| className | 客製化樣式 | string | |
| value | 進度 | number | 0 | 
| themeColor | 主題配色,primary、secondary 或是自己傳入色票 | primary, secondary, 色票 | primary | 
| strokeColor | 定義 track 漸層顏色 | { 'xx%': 'value' }[] | {} | 
| isClockwise | track 是否為順時針方向,若 false 則為逆時針方向 | boolean | true | 
透過上面的分析,我們知道 progress circle 首先需要定義出 SVG 的可視範圍,並且裡面有兩個元素,一個是 rail,一個是 track,因此我們的結構如下:
<svg width="..." height="...">
  <circle className="progress-circle__rail" />
  <circle className="progress-circle__track" />
</svg>
進度
跟前篇 ProgressBar 一樣,我們需要把 value 限制在 0% ~ 100%,避免不必要的困擾:
const formatValue = (value) => {
  if (value > 100) {
    return 100;
  }
  if (value < 0) {
    return 0;
  }
  return value;
};
前面分析有提到,進度 0% ~ 100%,是對應到 progress 虛線長度 0px ~ 圓周長px,因此我們需要計算圓周長,公式我就是使用國中小的數學來計算圓周長:
const perimeter = radius * 2 * Math.PI; // 圓周長
再來,我們的到圓周長之後,按照給定 progress 的 value,我們一比例算出該進度的弧長:
const argLength = perimeter * (formatValue(value) / 100); // 弧長
拿到弧長之後,我們就可以畫出進度條了:
const INFINITE = 999999;
.progress-circle__track {
  {...略}
  stroke-dasharray: ${(props) => props.$argLength} ${INFINITE};
}
那我們在 <ProgressCircle /> 傳入 value,經過上述步驟,就能夠得到下面的效果:
<ProgressCircle value={20} />

但這個進度條的起始點是從三點鐘方向,我們希望他是從十二點鐘方向開始,所以我們要對他做一點旋轉:
svg {
  transform: rotate(-90deg);
}
這樣看起來就正常多了:

數值資訊
我們常看到數值資訊被放在圈圈裡面,因此這邊做法也很簡單,直接用 position: absolute; 把數值資訊放在圓圈中心就可以了:
const Info = styled.div`
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  .progress-circle__value {
    font-size: ${(props) => props.$size / 4}px;
  }
  .progress-circle__percent-sign {
    font-size: ${(props) => props.$size / 6}px;
  }
`;
<Info
  className="progress-circle__info"
  $size={size}
>
  <span className="progress-circle__value">{value}</span>
  <span className="progress-circle__percent-sign">%</span>
</Info>
到目前為止,一個看起來模有樣的 ProgressCircle 就完成了:

在前面有提到,我們用 formatValue 這個 function 來把 progress track 的值限制在合理範圍內,但是數值資訊我們可以依照輸入的顯示沒關係,下面也展示一下各種數值的結果:
<ProgressCircle />
<ProgressCircle value={25} />
<ProgressCircle value={50} />
<ProgressCircle value={75} />
<ProgressCircle value={100} />
<ProgressCircle value={120} />

改變主題顏色
這邊的做法都跟之前一樣,我們就不再仔細說明,直接展示結果,表示我們用之前的方法一樣可以做到:

漸層顏色
記得我們前篇的 ProgressBar 的漸層處理,是直接使用 background 屬性:
background: linear-gradient(45deg, #FF8E53 30%, #FE6B8B 90%);
但是在 SVG 裡面,漸層的處理需要透過別的手段。
詳細的部分我們可以參考 MDN Web Docs 的說明:
https://developer.mozilla.org/zh-TW/docs/Web/SVG/Tutorial/Gradients
主要的意思是說, SVG 提供我們兩個屬性 fill 以及 stroke 有設置漸層的方法,漸層的類型有兩種,一個是「線形漸層(linearGradient)」,一個是「放射形漸層(radialGradient)」。
以線性漸層為例,我們首先需要在 <defs /> 元素裡面創建一個 <linearGradient /> 元素,然後在裡面定義要從什麼顏色漸層到什麼顏色:
<linearGradient id="linearGradient">
  <stop offset="0%" stop-color="red"/>
  <stop offset="100%" stop-color="blue"/>
</linearGradient>
接著有一個關鍵步驟,就是 <linearGradient /> 需要設置 id 屬性,這是為了讓 stroke 可以引用這個漸層,假設我們 id 的設置是這樣:
<linearGradient id="linearGradient">
  ...
</linearGradient>
那我們在 stroke 當中要引用這個漸層,就會需要這樣做:
stroke: url(#linearGradient);
那我們模仿 Antd 對於漸層顏色的設置,我們定義一個 props ,他可以接受下面這樣的物件:
const strokeColor = {
  '0%': '#108ee9',
  '100%': '#87d068',
};
<ProgressCircle strokeColor={strokeColor} value={25} />
在 strokeColor 當中,key 的部分就是漸層的 offset,value 的部分就是漸層的顏色 stop-color,因此透過迭代,我們就能夠實現透過 props 傳入來定義漸層的功能:
<svg width="..." height="...">
  {strokeColor && (
    <defs>
      <linearGradient
        id="linearGradient"
      >
        {
          Object.keys(strokeColor || {}).map((offset) => (
            <stop
              key={offset}
              offset={offset}
              stopColor={strokeColor[offset]}
            />
          ))
        }
      </linearGradient>
    </defs>
  )}
  <circle className="progress-circle__rail" ... />
  <circle className="progress-circle__track" ... />
</svg>
效果就會如下面這樣:

進度順時針、逆時針
今天我們想要設置一個參數可以讓我們決定我們的進度條需要是順時針生長,還是逆時針生長:
<ProgressCircle isClockwise={false} value={25} />
那這個方法也很簡單,如果原本預設是順時針生長,想要變成逆時針,只要水平翻轉就可以了,水平翻轉的方法我們是用 css transform 來做:
transform: rotateY(180deg);
整體作法如下示意:
const counterClockwiseStyle = css`
  .progress-circle__progress {
    transform: rotateY(180deg);
  }
`;
const StyledProgressCircle = styled.div`
  ...略
  ${(props) => (props.$isClockwise ? null : counterClockwiseStyle)}
`;
<StyledProgressCircle
  ...
  $isClockwise={isClockwise}
>
  <span className="progress-circle__progress">
    <svg width="..." height="...">
      <defs>...</defs>
      <circle className="progress-circle__rail" ... />
      <circle className="progress-circle__track" ... />
    </svg>
  </span>
</StyledProgressCircle>

改變 circle 大小
假設今天我們想要透過下面這樣的方式來改變 progress circle 的大小:
const ResizeProgressCircle = styled(ProgressCircle)`
  width: ${(props) => props.$size}px;
  height: ${(props) => props.$size}px;
`;
<ResizeProgressCircle $size={60} ... />
我們是用 css className 覆寫的方式來做,整體架構如下:
<StyledProgressCircle
  ref={progressCircleRef}
  className={className}
>
  <svg width="..." height="...">
    <defs>...</defs>
    <circle className="progress-circle__rail" ... />
    <circle className="progress-circle__track" ... />
  </svg>
</StyledProgressCircle>
所以簡單來說,我想要操作的是 <StyledProgressCircle /> 這個方形元素的大小,但是他的 children,也就是我們的 SVG 圖,我希望他可以自動跟著他的 parent 元素來變大變小。
那做法就是我透過 useRef 這個 hook 來操作 <StyledProgressCircle />,藉此取得他的大小,然後把它存成參數之後, SVG 圖的大小就要跟著這個參數來動:
const progressCircleRef = useRef();
const [size, setSize] = useState(0);
const handleUpdateSize = useCallback(() => {
  const currentElem = progressCircleRef.current;
  setSize(currentElem.clientWidth);
}, []);
useEffect(() => {
  handleUpdateSize();
  window.addEventListener('resize', handleUpdateSize);
  return () => {
    window.removeEventListener('resize', handleUpdateSize);
  };
}, [handleUpdateSize]);
我們用 size 這個 state 來記錄我們所取得的元件大小,接著,因為他是一個方形元件,所以我希望方形邊長的一半,就等於圓形的半徑,當然,我們要扣除 stroke-width 所佔用的寬度,因此我得到的半徑會是這樣:
const defaultStrokeWidth = size * 0.08;
const radius = (size - defaultBorderWidth) / 2;
由於我希望元件放大縮小的時候, progress stroke width 也會跟著改變,才不會造成元件變太大而 progress circle 看起來很細,或是元件變太小而 progres circle 看起來很粗的狀況。
那我們計算出 radius,就能夠帶入我們的 circle 上了:
<circle
  className="progress-circle__rail"
  r={radius}
  cx={size / 2}
  cy={size / 2}
/>
下面就是我們展示的成果:
<ResizeProgressCircle value={87} $size={60} />
<ResizeProgressCircle value={87} $size={100} />
<ResizeProgressCircle value={87} $size={200} />

ProgressCircle 元件原始碼:
Source code
Storybook:
ProgressCircle
https://codepen.io/JMChristensen/pen/Ablch?editors=1111
https://wcc723.github.io/svg/2014/06/15/svg-css-stroke-animation/
https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray