![[為你自己寫 Vue Component] AtomicProgress](https://ithelp.ithome.com.tw/upload/images/20240918/20120484G1ybUOq8dk.png)
進度條(Progress)是一個用於展示任務完成進度的元件。它廣泛應用於需要顯示工作、任務或操作完成度的地方,例如大型檔案上傳時,我們可以將上傳的進度回饋給使用者知道。這樣比起乾等,在感覺上給使用者一種真實有在前進的視覺回饋,等待的人心理也會比較踏實,整體感受也會更好。
除了檔案上傳外,像是頁面資料載入、工作完成進度、表單完成比例等,也可以應用 Progress 元件來呈現。
在許多 UI Library 中,會分別提供長條的(Linear)和圓形的(Circular)兩種 Progress 元件供使用者挑選。這樣一方面拆成兩個不同的元件在元件內部能更專注於自己本身的特性表現,維護度也會比較好;另一方面,站在使用者的角度,拆成兩個元件能更好地按需引入所需要的元件,不會載入多餘的結構與樣式。
但在這裡我們會將兩種不同的變體整合為同一個元件。如果在自己的專案上明確只會使用其中一種,可以只保留需要的部分或是拆成兩個元件來維護。


在開始實作前,我們先研究各個 UI Library 的 Progress 元件是如何設計的。
Element Plus

<template>
<div class="demo-progress">
<ElProgress :percentage="50" />
<ElProgress :percentage="100" :format="format" />
<ElProgress :percentage="100" status="success" />
<ElProgress :percentage="100" status="warning" />
<ElProgress :percentage="50" status="exception" />
</div>
</template>
Element Plus 在使用上接收一個 percentage 參數來設定進度條的進度,format 可以自訂進度條的顯示格式,status 則是用來設定進度條的狀態,有 success、warning、exception 三種狀態。
status 本身自帶顏色與 Icon,但如果只是想要修改顏色或是想要自訂顏色,可以透過 color 來設定。
另外,如果想自定義在最後面的提示文字,可以在 format 傳入一個 function 來回傳想要顯示的文字。
const format = (percentage) => percentage !== 100
? `${percentage}%`
: 'Full'
如果想要使用圓形的 Progress,我們可以透過 type="circle" 來設定並且透過 width 改變元件的大小。

<template>
<div class="demo-progress">
<ElProgress type="circle" :percentage="0" />
<ElProgress type="circle" :percentage="25" />
<ElProgress type="circle" :percentage="100" status="success" />
<ElProgress type="circle" :percentage="70" status="warning" />
<ElProgress type="circle" :percentage="50" status="exception" />
</div>
</template>
Vuetify

<template>
<div>
<VProgressLinear
bg-color="pink-lighten-3"
color="pink-lighten-1"
model-value="15"
/>
<VProgressLinear
bg-color="blue-grey"
color="lime"
model-value="30"
/>
<VProgressLinear
bg-color="success"
color="error"
model-value="45"
/>
</div>
</template>
Vuetify 在顏色設定上有更多的彈性,可以透過 bg-color 設定背景顏色,color 設定前景顏色,model-value 來設定進度條的進度。

<template>
<div class="text-center">
<VProgressCircular :size="50" color="primary" />
<VProgressCircular :width="3" color="red" />
<VProgressCircular :size="70" :width="7" color="purple" />
<VProgressCircular :width="3" color="green" />
<VProgressCircular :size="50" color="amber" />
</div>
</template>
在 Vuetify 中,圓形的 Progress 元件可以透過 <VProgressCircular> 來使用,size 用來設定元件的大小(圓的直徑),width 用來設定進度條的寬度。
Nuxt UI

<template>
<UProgress :value="2" :max="5" />
</template>
Nuxt UI 目前只有提供長條狀的 Progress 元件,但 Nuxt UI 可以透過 value 來設定進度條的進度,max 來設定進度條的最大值,例如範例中 value 為 2,但 max 只有 5,最後算出來進度條則是 40%。
在前兩個 UI 框架中,基本上都是以 100 為最大值,因此在使用時就得自己把進度值轉換成百分比,雖然這不是很難的計算但還是方便很多呢!
HTML 原生 <progress>
<progress max="100" value="70">70%</progress>
MDN 上對 <progress> 的 max 與 value 屬性說明如下:
max
此屬性描述由 <progress> 元素指示的任務所需的工作量。如果存在 max 屬性,則其值必須大於 0 且為有效的浮點數。默認值為 1。
value
此屬性指定已完成的任務量。它必須是介於 0 和 max 之間的有效浮點數,如果省略 max,則必須介於 0 和 1 之間。
綜合以上並結合自身經驗,我們統整出 <AtomicProgress> 的功能:
variants 來設定進度條的變體,可以填入 liner 或是 circular。value 設定進度條當前的進度,預設為 0。max 來設定進度條的最大值,預設為 100。size 設定圓形進度條的直徑。color 來設定進度條的顏色。thickness 來設定進度條的寬度。indicator 來決定是否顯示進度條的百分比或數字。首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 名稱 | 型別 | 預設值 | 說明 |
|---|---|---|---|
| variants | liner, circular |
liner |
進度條的變體 |
| value | number |
0 |
進度條的進度 |
| max | number |
100 |
進度條的最大值 |
| size | number, ${number} |
64 |
圓形進度條的直徑 |
| color | primary, success, warning, danger, info |
primary |
進度條的顏色 |
| thickness | number, ${number} |
8 |
進度條的寬度 |
| indicator | boolean, percentage, value |
false |
是否顯示進度條的百分比或數字 |
interface AtomicProgressProps {
variants?: 'liner' | 'circular';
value?: Numberish;
max?: Numberish;
size?: Numberish;
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
thickness?: Numberish;
indicator?: boolean | 'percentage' | 'value';
}
const props = withDefaults(defineProps<AtomicProgressProps>(), {
variants: 'liner',
value: 0,
max: 100,
size: 64,
color: 'primary',
thickness: 8,
indicator: false,
});
接著我們先實作 Linear Progress 元件,後面再研究如何實現 Circular Progress 的部分。
我們先算最重要的進度百分比,這樣我們就可以根據進度百分比來設定進度條的寬度。
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
}
const percentage = computed(() => {
const percent = (props.value / props.max) * 100;
const clamped = clamp(percent, 0, 100)
return clamped;
})
在我們計算百分比的最後,我們使用 clamp 這個 function 來確保如果使用者傳了不合理的 value 或 max 不會造成奇怪的結果。
HTML 部分很單純,我們只要給定一組 Trail 和 Track 兩個主要結構並且依照條件顯示 Indicator 就可以了:
<template>
<div class="atomic-progress">
<div class="atomic-progress__trail">
<div class="atomic-progress__track" />
</div>
<span
v-if="indicator"
class="atomic-progress__indicator"
>
{{ indicator === 'value' ? value : `${percentage}%` }}
</span>
</div>
</template>
我們在 Root 上加上 CSS 變數並補上簡單的 CSS 設定。
<div
class="atomic-progress"
:style="{
'--progress-thickness': `${thickness}px`,
}"
>
<!-- 略 -->
</div>
.atomic-progress {
@each $color, $value in $color-map {
&--#{$color} {
--progress-color: #{$value};
}
}
&__trail {
position: relative;
display: block;
overflow: hidden;
width: 100%;
height: var(--progress-thickness);
background-color: #f1f1f1;
border-radius: 99999px;
}
&__track {
position: absolute;
inset: 0;
background-color: var(--progress-color);
border-radius: 99999px;
transition: all 0.3s;
}
}
在前面的元件我們已經多次使用 CSS 變數來幫助我們用比較精簡的設定來達到我們想要的效果,這篇就不再多做解釋。
我們這邊要討論如何設定 Track 元件來呈現進度條成長的效果。前面我們已經計算出進度的百分比為 percentage,我們要如何反應到 Track 上呢?
使用 width
這是最直覺的作法,我們可以透過 style 變數來設定 Track 的寬度:
<div
class="atomic-progress__track"
:style="{
width: `${percentage}%`
}"
/>
使用 transform
我們可能不會直接想到這個方法,但這個做法效果也還不錯,實際上可以這樣實作:
<div
class="atomic-progress__track"
:style="{
transform: `translateX(${percentage - 100}%)`
}"
/>
但如果還記得 AtomicPopover 的實作,我們就會知道 transform 不會觸發 Reflow 與 Repaint,在效能表現上會比修改 width 好那麼一點點。
我們可以瀏覽器的從開發人員工具應證使用 transform 是否效能真的比較好。
Atomic Progress 透過 width 實作 Track

Atomic Progress 透過 transform 實作 Track

在 Circular Progress 的部分,我們要使用 <svg> 與 SVG 的 <circle> 來完成。
在使用 SVG 時我們可以想像它本身是一張大畫布,而我們需要給定這個畫布的座標範圍。例如我們可以開一張 x 軸從 0 點開始寬 300 與 y 軸從 0 開始高 300 的畫布。
<!-- <svg viewBox="x-min y-min width height"> -->
<svg viewBox="0 0 100 100">
<!-- 畫布 -->
</svg>
在 SVG 中,我們可以使用 <circle> 來畫圓。我們可以透過 cx 與 cy 來設定圓心的位置,r 來設定圓的半徑。
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" />
</svg>
這樣我們就可以畫出一個半徑為 50 的圓。

接著我們要使用 stroke 與 stroke-width 來設定圓的邊框,stroke 來設定邊框的顏色,stroke-width 來設定邊框的寬度。
<svg viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="50"
fill="none"
stroke="#1976D2"
stroke-width="8"
/>
</svg>

看來加上 stroke 會被畫布切掉,這時我們可以調整半徑大小,我們觀察畫面後發現只要減去 stroke-width 一半就會正常。
接著我們用 stroke-dasharray 繪製出虛線的效果。
<svg viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="#1976D2"
stroke-width="8"
stroke-dasharray="10"
/>
</svg>

上面的 stroke-dasharray 給了 10,我們可以看到畫面畫出了一個線段與空白皆為 20px 的虛線。
如果我們調整一下設定。
<svg viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="46"
fill="none"
stroke="#1976D2"
stroke-width="8"
stroke-dasharray="20 290"
stroke-linecap="round"
/>
</svg>

為了方便觀察,我們多疊了一層 <circle> 來當作背景。到這一步是不是已經與 Circular Progress 的基本樣式很接近了呢!
我們只要使用 CSS 將 <circular> 逆時針轉 90 度,就可以讓進度條從我們預期的位置開始長。
到這裡對於應該如何實作 Circular Progress 元件應該有些眉目了,整合 props 上的資訊我們可以實作出 Circular Progress 的模板。
<template>
<div class="atomic-progress">
<svg
:viewBox="`0 0 ${size} ${size}`"
:height="size"
:width="size"
>
<circle
class="atomic-progress__circular-trail"
:cx="halfSize"
:cy="halfSize"
fill="none"
:r="radius"
stroke="#F1F1F1"
stroke-linecap="round"
:stroke-width="thickness"
/>
<circle
class="atomic-progress__circular-track"
:cx="halfSize"
:cy="halfSize"
fill="none"
:r="radius"
stroke="#1976D2"
:stroke-dasharray="`${length} ${circumference}`"
stroke-linecap="round"
:stroke-width="thickness"
/>
</svg>
</div>
</template>
我們已知的有從 props 來的 size,這作為畫布的大小(SVG 的大小)與 thickness,這作為 Circular Progress 的寬度。
接著盤點一下我們還需要的資料:
halfSize:Circular Progress 在 SVG 中的圓心位置。
const halfSize = computed(() => Number(props.size) / 2);
radius:Circular Progress 的半徑。
const radius = computed(() => {
return (Number(props.size) - Number(props.thickness)) / 2;
});
circumference:Circular Progress 的周長。
const circumference = computed(() => 2 * Math.PI * radius.value);
length:進度條的長度。
進度條的長度我們可以將圓周長乘上進度百分比。
const length = computed(() => {
return circumference.value * (percentage.value / 100)
})
這樣我們就可以完成 Circular Progress 的實作了!
當 Progress 的進度是一個不明確或是無法被估計的值時,我們可以在 Progress 上加上 Indeterminate 的效果,這樣使用者就可以知道工作還是有在進行中,而不會是一片空白,一片茫然。
在 Element UI 與 Vuetify 中都有提供 Indeterminate 的功能。在 Element Plus 中,我們可以透過 indeterminate 來設定進度條是否為不確定的。

<template>
<ElProgress :percentage="50" :indeterminate="true" />
</template>
為了讓我們的 <AtomicProgress> 有更好的使用者體驗,我們也可以嘗試加入這個功能。
這時我們可以使用 CSS 的 animation 來處理進度條的動畫。
@keyframes indeterminate-liner {
0% {
transform: translateX(-100%);
}
60% {
transform: translateX(0%);
}
100% {
transform: translateX(100%);
}
}
接著我們加上一個表示不確定狀態的 CSS Class:atomic-progress--indeterminate
.atomic-progress {
&--indeterminate &__track {
animation: indeterminate-liner 2s ease infinite running;
}
}
最後,在 indeterminate 狀態時,我們把原本拿用來顯示進度條設定拿掉。
<template>
<div
class="atomic-progress atomic-progress--indeterminate"
:style="{
'--progress-thickness': `${thickness}px`,
'--progress-size': `${size}px`
}"
>
<div class="atomic-progress__trail">
<div
class="atomic-progress__track"
:style="!indeterminate
? {
transform: `translateX(${percentage - 100}%)`,
}
: undefined
"
/>
</div>
</div>
</template>
這樣我們的 Linear Progress 就有了 Indeterminate 的效果了!

Progress Circular 的 Indeterminate 也可以透過 CSS 的 animation 來處理。
@keyframes indeterminate-circular {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.atomic-progress {
&--indeterminate &__circular-track {
animation: indeterminate-circular 500ms linear infinite;
}
}
最後我們讓 Circular Progress 在 indeterminate 時有一個固定的進度條長度,看起來才真的有東西在轉。
const length = computed(() => {
return !props.indeterminate
? circumference.value * (percentage.value / 100)
: circumference.value * 0.33;
});
這樣我們的 Circular Progress 就也有了 Indeterminate 的效果了!

為了讓使用輔助技術,如螢幕閱讀器的使用者可以清楚知道出現在畫面上的元素是一個 Progress,我們可以透過 role="progressbar" 屬性來設定元素的角色。
<template>
<div
class="atomic-progress"
role="progressbar"
>
<!-- 略 -->
</div>
</template>
除了知道這個元素是一個 Progress 外,我們也可以透過 aria-valuenow、aria-valuemin 與 aria-valuemax 來進一步設定進度條的進度。
<template>
<div
:aria-valuemax="max"
aria-valuemin="0"
:aria-valuenow="!indeterminate ? value : undefined"
class="atomic-progress"
role="progressbar"
>
<!-- 略 -->
</div>
</template>
在這裡我們帶入的為使用者自定義的 value 與 max,這樣螢幕閱讀器就可以告訴使用輔助技術的使用者總共有多少任務要完成,現在完成了多少,讓這些使用者也能享有更好的網頁使用體驗。
在這篇文章中,我們實作了 <AtomicProgress> 元件,裡面包含了 Linear 與 Circular 兩種不同的 Progress UI 變體。
在 Linear Progress 的部分,我們使用 CSS 變數來幫助我們設定進度條的寬度,並且分享了除了 width 之外,也可以考慮使用 transform 來設定進度條的長度,並比較了之間的渲染效能差異。
在 Circular Progress 的部分,我們建立了些許的 SVG 知識,並使用這些知識畫出圓形的進度條,最後透過 stroke-dasharray 來設定進度條的長度。
除了基本的功能外,我們也加入了 Indeterminate 的效果,讓我們的 <AtomicProgress> 元件可以在進度不明確的情境下,依然達到提升使用者體驗的效果。
動畫部分可以隨喜好調整,我們也可以在不同的 UI Library 中參考不同的動畫效果的作法,並且加入自己的元件實作中,打造出專屬於自己的 Progress 元件。
<AtomicProgress> 原始碼:AtomicProgress.vue