本文主要介紹基於 NutUI Vue3 的circleProgress組件的設計與實現原理,是一個圓環形的進度條組件,用來展示操作的當前進度,支持修改進度以及漸變色。環形進度條是很常用的一個組件,特別是在管理後台數據統計的頁面上或是一些需要用戶等待的任務。
實現效果
效果如下圖
實現思路
首先我們來理一下我們的需求,我們需要一個圓形的可以改變進度,有動畫的,可以支持漸變色的進度條。
目前是有三種常見的實現方案:
CSS 實現
SVG 實現
Canvas 實現
而 SVG 又分為兩種,一種是直接用circle實現,另外一種是用path來畫出來。
然後我們來看下一些國內出名的組件庫實現方式:
Antd Design 和 TDesign,varlet 採用 svg 的 Circle 實現,但是小編個人覺得 Antd Design 的漸變色的開始和結束是有問題的,TDesign 和 varlet 暫時不支持環形進度條線性漸變
Element UI 和 Vant 採用 svg 的 Path 實現,Vant 是支持線性漸變的,而且不存在漸變色的開始和結束不對應問題,Element 暫時不支持線性漸變
目前主流組件庫的環形進度條基本上都是用 svg 繪出來的,因為實現思路簡單,使用 SVG 畫兩個圓 一個圓作為底色,另一個圓作為進度展示,使用時候的問題也很少。NutUI 也選擇使用 SVG 來實現進度條。
下邊詳細介紹一下兩種實現方式
circle 實現
首先來說下 SVG 的circle(Antd,Tdesign 的實現方式),下邊也會介紹到Antd 的進度條線性漸變問題。
首先第一步 我們先來畫個最簡單的圓
<svg height="100" width="100" x-mlns="http://www.w3.org/200/svg"> <circle r="40" cx="50" cy="50" stroke="'red'" stroke-width="10" fill="none" /> </svg>
上邊的屬性我就不多介紹了 不了解的同學其實也可以讀出來大概意思,r 是半徑,cx,cy 為圓點位置,以及顏色和弧度的寬度。
這裡可能有的同學要問了 這不是一個circle就可以實現嘛,可不要忘記了我們需要的效果,
那我們再來畫一個不是 100% 進度的圓,如何畫呢
這裡就用到了stroke-dasharray屬性,可以將圖形的描邊進行點狀化,這裡需要理解的是,點狀化的點,其大小是可以設置的,並不真的就是那麼一個・,可以變長或者變短。
所以如果circle的點的長度正好等於circle邊長,那麼點看上去就是circle的邊。
我們計算下圓環的周長就可以了,參數也就是弧長,極大值,極大值就是周長,弧長就是進度值。
大家應該也發現問題了,這樣的話在不是 100% 進度的時候,部分弧度是沒有顏色的,所以我們還需要一個底色。也就是另外一個circle。
<svg> <circle r="40" cx="50" cy="50" stroke="#d9d9d9" stroke-width="10" fill="none" /> /> <circle r="40" cx="50" cy="50" stroke="red" stroke-width="10" stroke-dasharray="200,251" fill="none" stroke-linecap="round" /> </svg>
因為這裡我們需要它從左邊的中間位置開始,所以還需要加上旋轉。
至於接下來就很簡單了 讓它動起來,那麼如何動起來呢 動態改變stroke-dasharray的值就可以了,下方介紹path的時候會講到如何改變。來講一下遇到的小坑 ,就是我們在做漸變色的時候,會發現我們的漸變色並不是從我們的進度開始地方開始漸變的。
這裡我們也可以看下Antd的環形進度條漸變,我用一個紅到黑來給大家看一下。(Tdesign這裡小編在在線編輯器里嘗試了一下,線性漸變沒有生效。)
這裡小編個人覺得 Antd 的這個漸變也是不對的(僅代表個人想法),當然大家有別的想法也可以提出來一起探討。
其實是因為線性漸變是從左往右的,並且上邊為圓環加了旋轉的原因,解決方法大家可以自行搜索引擎搜索一下,這裡不多做闡述。
path 實現
下邊主要介紹一下用path(Vant,Element 實現方法)實現吧 可以簡單並完美的解決上邊的漸變色對應不上(Element 暫時不支持線性漸變)的問題。
和circle實現思路是一樣的,畫兩個圓,一個用來表示底色,一個用來表示弧度,主要是我們如何來畫一個圓呢
了解 viewBox 屬性,在 SVG 標籤中添加該屬性,這個屬性是用來設置畫布的大小,但是大家注意,它是一個相對大小,會根據我們的父元素改變而動態適配。比如我們將其屬性設置為viewBox="0,0,100,100", 其實它是將我們的整個畫布的寬和高分為 100 份,其中 SVG 元素是在這個分割以後的畫布上擺放展示。
我們不需要再關注 SVG 的寬高,它現在已經實現了自適應,會自動根據外層父元素的寬高進行適配,我們最外層給用戶一個 Props 來設置環形進度條的大小。
<div :style="{ height: radius * 2 + 'px', width: radius * 2 + 'px' }"> <svg viewBox="0 0 100 100"></svg> </div>
了解 path 的 d 屬性,既然我們要用path來畫圓,那我們當然得熟悉一下🐂🍺的d屬性來,它可以畫出各種各樣的線來。d屬性用來定義路徑數據,我們首先來了解下我們需要用到的參數:
M = moveto(M X,Y) :將畫筆移動到指定的坐標位置 A = elliptical Arc(A RX,RY,XROTATION,FLAG1,FLAG2,X,Y):橢圓弧
請注意 ⚠️ ,這些參數是區分大小的,當它為大寫的命令時表明它的參數是絕對位置,小寫的命令表明它的擦含糊相對於當前位置的點。我們也可以使用負數值來作為我們命令的參數。負相對 x 值將會往左移,而負相對 y 值將會向上移。
下邊來詳解下我們的這兩個參數
moveto屬性其實很好理解,畫筆的指定位置,x軸和y軸,M(絕對)m(相對)
elliptical Arc橢圓弧的記錄以下:指令:A(絕對)a(相對)
橢圓弧的參數形式:(rx ry x-axis- rotation large-arc-flag sweep-flag x y)詳解參數:rxry是橢圓的兩個半軸的長度。x-axis-rotation是橢圓相對於坐標系的旋轉角度,角度數而非弧度數。large-arc-flag是標記繪製大弧 (1) 仍是小弧 (0) 部分。sweep-flag是標記向順時針 (1) 仍是逆時針 (0) 方向繪製。xy是圓弧終點的坐標。
因為我們這裡需要將圓開始的繪製方向交給用戶來控制,所以這裡來接受一個 props 來控制繪製方向。根據上述描述我們可以寫出path的d屬性,
在絕對位置 50,50 的位置(圓心),先從圓心上方位置 45 的位置繪圖(45 即為半徑)然後接下來是橢圓弧的參數,rx,ry,半徑為 45,旋轉角度為 0,繪製一個大弧,然後是繪製方向,順時針還是逆時針旋轉,最後是圓弧終點的坐標。可以得出下邊的代碼
const path = computed(() => { const isWise = props.clockwise ? 1 : 0; return `M 50 50 m 0 -45 a 45 45 0 1 ${isWise} 0 90 a 45 45 0 1, ${isWise} 0 -90`; });
這樣的話就會繪製出一個圓來,其他用到的屬性就和上邊的cirlce一樣了
stroke:描邊顏色
stroke-width: 描邊寬度
fill:填充顏色
stroke-dasharray: 間隔多少像素繪製一次
既然path的路徑我們已經完成了,接下來就是正常的給這個圓環上色和加上動態進度變化了。
首先我們來寫底部的背景圓環,用戶可以自定義去傳背景圓環弧度的色值以及圓弧的寬度。
<path class="nut-circleprogress-path" :style="pathStyle" :d="path" fill="none" :stroke-width="strokeWidth">/> const pathStyle = computed(() => { return { stroke: props.pathColor }; });
接下來是展示進度條的圓環,因為我們這裡還需要一個漸變色,所以還需要在 SVG 中加入一些代碼查閱 SVG 的文檔我們找到了一個叫做<linearGradient>的 SVG 元素,通過使用該元素我們可以達成顏色漸變的目的。
1、創建 linearGradient
在創建這個元素之前,我們需要知道<linearGradient>標籤必須嵌套在<defs>的內部。<defs>標籤是definitions的縮寫,它可對諸如漸變之類的特殊元素進行定義。而且我們必須給漸變內容指定一個id屬性,否則文檔內的其他元素就不能引用它。為了讓漸變能被重複使用,漸變內容需要定義在<defs>標籤內部,而不是定義在形狀上面。
2、設定顏色漸變方向
現在<linearGradient>元素創建成功了,下面我們可以為其賦值屬性以達到按需求修改漸變顏色變向的需求。
漸變的方向可以通過兩個點來控制,它們分別是屬性x1、x2、y1和y2,這些屬性定義了漸變路線走向。漸變色默認是水平方向的,但是通過修改這些屬性,就可以旋轉該方向。
3、設定漸變色
在<linearGradient>中理論上添加的顏色是無上限的,但若想有漸變效果最少要添加兩種顏色。因此需要在<linearGradient>中創建最少兩個<stop>元素,以添加你需要的顏色屬性。
所以我們這裡採用一個循環來處理多個顏色屬性
<stop>元素有三個屬性:
stop-color:想要設定的漸變顏色
offset:在你定義的方向向量上,定義該顏色的生效位置,使用百分比來設置具體的存在位置。
stop-opacity:設定stop-color顏色的透明度(暫時用不到)
<defs> <linearGradient :id="refRandomId" x1="100%" y1="0%" x2="0%" y2="0%"> <stop v-for="(item, index) in stopArray" :key="index" :offset="item.key" :stop-color="item.value"></stop> </linearGradient> </defs> const stopArray = computed(() => { if (!isObject(props.color)) { return; } let color = props.color; const colorArr = Object.keys(color).sort((a, b) => parseFloat(a) - parseFloat(b)); let stopArr: object[] = []; colorArr.map((item, index) => { let obj = { key: '', value: '' }; obj.key = item; obj.value = color[item]; stopArr.push(obj); }); return stopArr; });
漸變色加完以後,我們來處理一下進度條(如何讓進度條綁定上這個漸變色),其實很簡單,將圓環的stroke變成<linearGradient>的唯一id就可以啦;接着處理一下圓環的進度展示,和上邊circle的處理方式一致,使用stroke-dasharray就可以啦。
<path class="nut-circleprogress-hover" :style="hoverStyle" :d="path" fill="none" :stroke-linecap="strokeLinecap" :stroke-width="strokeWidth" ></path> const hoverStyle = computed(() => { let perimeter = 283; let offset = (perimeter * Number(props.progress)) / 100; return { stroke: isObject(props.color) ? `url(#${refRandomId})` : props.color, strokeDasharray: `${offset}px ${perimeter}px` }; });
上方的stroke-linecap屬性是一個表示屬性,定義了在打開子路徑被描邊時要在其末尾使用的形狀,可以用在style中。
至於上方的 283 是如何來的,其實很簡單 就是我們的圓環周長,2πr=23.141592645。其實到了這裡,我們正常 h5 環境下的環形進度條就大功告成了。
Taro 下的 SVG
因為 NutUI 是可以配合 Taro 用來開發微信小程序的,所以這裡的環形進度條在小程序環境下當然也要擁有相同的功能了。
因為我們普通 h5 環境下使用的 SVG 實現的,所以想一套代碼適用,結果發現在小程序環境中暫不支持使用 SVG。
這是小程序官方文檔中回答的,所以我這裡採用了將其作為背景圖來展示。
我們通過一些轉換 SVG 為 base64 的網站發現,<其實是%3C,>是%3E;#替換成%23就可以啦,因為我們裡邊還存在一些變量,所以我這邊將他們拆分開了,分成幾個變量來寫。
<div :style="style"></div> const style = computed(() => { let { strokeWidth } = props; let stopArr: Array<object> = stop(); let stopDom: string[] = []; if (stopArr) { stopArr.map((item: Item) => { let obj = ''; obj = `%3Cstop offset='${item.key}' stop-color='${transColor(item.value)}'/%3E`; stopDom.push(obj); }); } let perimeter = 283; let progress = +currentRate.value; let offset = (perimeter * Number(format(parseFloat(progress.toFixed(1))))) / 100; const isWise = props.clockwise ? 1 : 0; const color = isObject(props.color) ? `url(%23${refRandomId})` : transColor(props.color); let d = `M 50 50 m 0 -45 a 45 45 0 1 ${isWise} 0 90 a 45 45 0 1, ${isWise} 0 -90`; const pa = `%3Cdefs%3E%3ClinearGradient id='${refRandomId}' x1='100%25' y1='0%25' x2='0%25' y2='0%25'%3E${stopDom}%3C/linearGradient%3E%3C/defs%3E`; const path = `%3Cpath d='${d}' stroke-width='${strokeWidth}' stroke='${transColor( props.pathColor )}' fill='none'/%3E`; const path1 = `%3Cpath d='${d}' stroke-width='${strokeWidth}' stroke-dasharray='${offset},${perimeter}' stroke-linecap='round' stroke='${color}' fill='none'/%3E`; return { background: `url("data:image/svg+xml,%3Csvg viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E${pa}${path}${path1}%3C/svg%3E")`, width: '100%', height: '100%' }; });
你以為這就大功告成了嗎 不不不 你會發現你在動態改變環形進度條的進度時,沒有動畫,顯得生硬刻板
所以我們這裡給它去增加一個動畫效果,我們這裡去用setTimeout代替一下requestAnimationFrame(不了解的同學可以了解下這個屬性,很好用的!), 因為小程序環境下不支持。
const requestAnimationFrame = function (callback: Function) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16.7 - (currTime - lastTime)); lastTime = currTime + timeToCall; var id = setTimeout(function () { callback(currTime + timeToCall, lastTime); }, timeToCall); lastTime = currTime + timeToCall; return id; }; const cancelAnimationFrame = function (id: any) { clearTimeout(id); };
然後我們就可以去動態變化進度條啦。
好啦 到這裡我們的 taro 適配也就完成啦,讓我們來看一看效果吧:
gif 圖可能不太明顯,大家可以微信搜索 NutUI 小程序嘗試一下。
結語
本文介紹了 NutUI 中circleProgress組件的設計思路與實現原理,與大家共勉。最後再提一下我們的 NutUI 組件庫,長期以來,團隊的小夥伴都在盡心盡力地維護着 NutUI。在之後的日子裡,這種堅持也不會放棄,我們依然會積極地維護與迭代,為有需要的同學提供技術支持,也會不定時地發布一些相關的文章幫助大家更好地理解與使用我們的組件庫。
來點個 Star ❤️ 支持我們一下吧~:https://github.com/jdf2e/nutui