注意:這其實(shí)是一篇CustomPaint的使用教程!!
源碼地址: github.com/yumi0629/Fl…
在Flutter中, CustomPaint
就像是 Android 中的Paint一樣,可以用它繪制出各種各樣的自定義圖形。確實(shí),Paint的使用比較復(fù)雜,我覺(jué)得直接講API的話(huà)也太無(wú)聊了,要記住Paint的用法,還是自己動(dòng)手畫(huà)一個(gè)比較實(shí)在。
那為什么是畫(huà)一個(gè)CircleProgressBar呢?其實(shí)這個(gè)控件本來(lái)是為了交作業(yè)的,之前在講Hero的時(shí)候留了一個(gè)小練習(xí),里面有一個(gè)頁(yè)面,有一個(gè)很炫酷的圓形ProgressBar選擇器,當(dāng)時(shí)為了偷懶我就沒(méi)寫(xiě)(不要打我),所以現(xiàn)在來(lái)補(bǔ)交來(lái)。在寫(xiě)這個(gè)CircleProgressBar的時(shí)候發(fā)現(xiàn), CustomPaint
中基本的API都使用到了,畫(huà)圓、畫(huà)弧線(xiàn)、畫(huà)布旋轉(zhuǎn)、Paint的各種屬性的意義等等知識(shí)點(diǎn)都有涉及到。所以說(shuō),看完這篇文章,你絕對(duì)可以自己動(dòng)手嘗試畫(huà)一些炫酷的UI控件來(lái)!
國(guó)際慣例,先上效果圖:
什么是CustomPaint
CustomPaint
是一個(gè)繼承自 SingleChildRenderObjectWidget
的控件,所以注意,不能用setState的方式來(lái)刷新它??! painter
就是我們的主繪制工具,它是一個(gè) CustomPainter
; foregroundPainter
是用來(lái)繪制前景的工具; size
為畫(huà)布大小,這個(gè)size會(huì)傳遞給 Painter
; isComplex
和 willChange
是告訴Flutter你的 CustomPaint
是否復(fù)雜到需要使用cache相關(guān)的功能; child
屬性我們一般不填,即使你是想要在你的 CustomPaint
上添加一些其他的布局,也不建議放在child屬中性,因?yàn)槟銜?huì)發(fā)現(xiàn)你并不會(huì)得到你想要的結(jié)果。
所有的繪制都是發(fā)生在Painter里面的,繪制的代碼寫(xiě)在我們的自定義 CustomPainter
中:
我們需要重寫(xiě) paint()
和 shouldRepaint()
這兩個(gè)方法,一個(gè)是繪制流程,一個(gè)是在刷新布局的時(shí)候告訴Flutter是否需要重繪。注意下 paint
方法中的size參數(shù),就是我們?cè)?CustomPaint
中定義的size屬性,它包含了基本的畫(huà)布大小信息。
真正地繪制則是通過(guò) canvas
和 Paint
來(lái)實(shí)現(xiàn)的,我們將定義好了的Paint畫(huà)筆傳遞給 canvas.drawXXX()
方法,這個(gè)方法會(huì)告訴Flutter我們需要繪制一個(gè)什么東西,是一個(gè)圓呢、還是一條線(xiàn)呢?
一些常用的 canvas
繪制API:
一些常用的 Paint
屬性:
繪制步驟分析
首先是靜態(tài)進(jìn)度條的繪制,我們先拆解這個(gè)CircleProgressBar為三部分:底部圓環(huán)、進(jìn)度條和顯示當(dāng)前進(jìn)度的小圓點(diǎn)。因?yàn)?Canvas的繪制順序是按代碼順序一層一層往上疊加的 ,所以我們的繪制步驟應(yīng)該是:繪制底部圓環(huán)——>繪制進(jìn)度條——>繪制小圓點(diǎn)。
然后是手勢(shì)拖動(dòng)的實(shí)現(xiàn),我們選用 GestureDetector
來(lái)實(shí)現(xiàn)就可以了,在 onPanUpdate
回調(diào)中實(shí)時(shí)刷新進(jìn)度條與小圓點(diǎn)的位置,這里面需要注意的地方是可觸摸區(qū)域的計(jì)算。
靜態(tài)CircleProgressBar繪制
繪制所需要的變量基本都標(biāo)注在上圖中了,圓心坐標(biāo)就是整塊畫(huà)布的中心點(diǎn),我們定義為 (center,center)
,其中 center = size.width * 0.5
。小圓點(diǎn)的半徑定義為 dotRadius
?;疑珜?shí)線(xiàn)部分為底部圓環(huán),progressBar的寬度為紅色虛線(xiàn)部分所示,其大小應(yīng)該比底部圓環(huán)略大,至于大多少,你可以自己定義。在本次的例子中,我將灰色實(shí)線(xiàn)與紅色虛線(xiàn)之間的部定義為 radiusOffset = dotRadius * 0.4
,這個(gè)值盡量不要寫(xiě)死,那么 radiusOffset*2
就是progressBar寬度比底部圓環(huán)大的值。 innerRadius
和 outRadius
分別為底部圓環(huán)的內(nèi)/外半徑,大小如圖上所示(純數(shù)學(xué)知識(shí),不解釋?zhuān)?。然后我們可以根?jù) innerRadius
和 outRadius
計(jì)算出progressBar寬度 progressWith = outerRadius - innerRadius + radiusOffset
。 drawRadius
是一個(gè)大小為畫(huà)布寬度的一半減去小圓點(diǎn)半徑的變量,這個(gè)變量在繪制progressBar和小圓點(diǎn)的時(shí)候很有用,用來(lái)確定progressBar和小圓點(diǎn)的位置。
Step 1 底部圓環(huán)繪制
底部圓環(huán)的繪制非常簡(jiǎn)單,實(shí)際上就是畫(huà)一個(gè)圓。為什么說(shuō)畫(huà)圓環(huán)和畫(huà)圓會(huì)是一樣的呢? Paint
是畫(huà)筆,回想一下我們?cè)趯?xiě)字的時(shí)候,寫(xiě)出來(lái)的字是不是有粗有細(xì)?同樣地, Paint
在畫(huà)線(xiàn)的時(shí)候也是有寬度的,我們畫(huà)一個(gè)有寬度的圓,不就是畫(huà)一個(gè)圓環(huán)了嗎?
canvas.drawCircle(Offset c, double radius, Paint paint)
這個(gè)方法就是繪制一個(gè)圓,其中c為圓心坐標(biāo)點(diǎn),這個(gè)offset偏移值是以畫(huà)布原點(diǎn)(左上角)為坐標(biāo)軸中心點(diǎn)來(lái)計(jì)算的,很明顯大小為 offsetCenter = Offset(center, center)
;radius為圓環(huán)半徑,大小其實(shí)就是圖上標(biāo)示的 drawRadius
;paint就是我們的畫(huà)筆,這里要注意,繪制圓環(huán)需要設(shè)置 style = PaintingStyle.stroke
,否則畫(huà)筆會(huì)默認(rèn)充滿(mǎn)內(nèi)部,那么你繪制出來(lái)的就是一個(gè)圓了。
Step 2 底部進(jìn)度條
繪制進(jìn)度條實(shí)際上就是繪制圓弧,我們使用 canvas.drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
。 rect參數(shù)就是圓弧所在的整圓的Rect,我們使用 Rect.fromCircle
來(lái)構(gòu)造這個(gè)整圓的Rect: final Rect arcRect = Rect.fromCircle(center: offsetCenter, radius: drawRadius);
; startAngle
為起始弧度, sweepAngle
為需要繪制的圓弧長(zhǎng)度,這里要注意,這兩個(gè)值都是 弧度制 的,canvas里面與角度有關(guān)的變量都是弧度制的,在計(jì)算的時(shí)候一定要注意; useCenter
屬性標(biāo)示是否需要將圓弧與圓心相連; paint
就是我們的畫(huà)筆。
補(bǔ)充:弧度與角度的弧線(xiàn)轉(zhuǎn)換:
假設(shè)當(dāng)前進(jìn)度為 progress
(范圍為0.0~1.0),那么當(dāng)前角度為 angle = 360.0 * progress
,當(dāng)前弧度為 radians = degToRad(angle)
,上述代碼可以繪制出一個(gè)基礎(chǔ)的圓弧。但是我們會(huì)發(fā)現(xiàn),圓弧的兩端是平的,很影響美觀,這時(shí)候就需要用到 paint
的 strokeCap
屬性了。
我們將 paint
設(shè)置為 StrokeCap.round
,就能得到一個(gè)最基本的進(jìn)度條了。
接下來(lái)我們給進(jìn)度條添加顏色,按照設(shè)計(jì)稿,我們需要添加一個(gè)漸變色。漸變色可以通過(guò) paint
的 shader
屬性來(lái)實(shí)現(xiàn):
Flutter提供了三種基礎(chǔ)的用來(lái)繪制漸變效果的類(lèi):SweepGradient(掃描漸變)、LinearGradient(線(xiàn)性漸變)和RadialGradient(徑向漸變)。
很明顯,我們需要用到的是 SweepGradient
:
注意,這里有一個(gè)很大的坑,我們可以從上面的SweepGradient事例圖上看到,默認(rèn)情況下是從90°的地方作為起點(diǎn)的,這跟我們的要求明顯是不符的。SweepGradient有一個(gè)startAngle屬性,那么我們是否可以將其設(shè)置為 degToRad(-90°)
就可以解決問(wèn)題了呢?答案是:不可以。這里懷疑是Flutter的一個(gè)bug,startAngle屬性不生效,我們可以看一下這個(gè)issue: SweepGradient startAngle doesn't work as expected.
那么怎么解決呢?我想了很久之后決定采用一個(gè)曲線(xiàn)救國(guó)的方法,那就是: 旋轉(zhuǎn)畫(huà)布
??!。反正是一個(gè)圓弧嘛,那我把畫(huà)布逆時(shí)針旋轉(zhuǎn)90°不就行了嘛(這里還要注意,畫(huà)布默認(rèn)旋轉(zhuǎn)中心為坐標(biāo)軸原點(diǎn),而且貌似不能更改,至少我沒(méi)找到,所以需要旋轉(zhuǎn)后再平移,對(duì)canvas的位置操作需要倒著寫(xiě),所以實(shí)際代碼是先寫(xiě)translate,再寫(xiě)rotate):
畫(huà)到這里你是不是覺(jué)得已經(jīng)很OK了呢?運(yùn)行一下,啊嘞,怎么會(huì)這樣紙?
這是我們給stroke設(shè)置了StrokeCap.round導(dǎo)致的,因?yàn)镕lutter在給線(xiàn)繪制圓角時(shí),是在線(xiàn)長(zhǎng)的外面加了一段圓角,導(dǎo)致實(shí)際長(zhǎng)度會(huì)超過(guò)我們定義的長(zhǎng)度。那怎么辦呢?還是曲線(xiàn)救國(guó),我們?cè)赿rawArc的時(shí)候,將起始角度往后偏移一段不就可以了嗎?我們將這段偏移弧度定義為 offset
,其大小為 offset = asin(progressWidth * 0.5 / drawRadius)
(怎么算出來(lái)的?數(shù)學(xué)問(wèn)題,自己那張草稿紙畫(huà)畫(huà)就知道啦~)。
所以最終的繪制代碼應(yīng)該為:
那么到此為止,我們的進(jìn)度條部分也繪制完成了。
Step 3 繪制小圓點(diǎn)
繪制小圓點(diǎn)就比較簡(jiǎn)單了,只要計(jì)算出小圓點(diǎn)的圓心位置就可以了,純初中數(shù)學(xué)計(jì)算,自己拿紙畫(huà)畫(huà)就知道啦。繪制函數(shù)依然是 canvas.drawCircle
,因?yàn)槭抢L制圓,所以不需要更改PaintingStyle。
Step 4 細(xì)節(jié)修飾:繪制底部圓環(huán)陰影和小圓點(diǎn)外圈
- 繪制圓環(huán)陰影
繪制陰影有兩種方法,實(shí)現(xiàn)出來(lái)的效果也不太一樣。
1)使用 canvas.drawShadow()
來(lái)繪制 :
drawShadow(Path path, Color color, double elevation, bool transparentOccluder)
,根據(jù)API要求,我們需要先計(jì)算出圓環(huán)的Path,Path的相關(guān)API只支持向path中添加圓、弧線(xiàn)、直線(xiàn)、點(diǎn)等屬性,我們沒(méi)法直接構(gòu)建一個(gè)圓環(huán)對(duì)應(yīng)的對(duì)象Path。換個(gè)角度思考一下,圓環(huán)的Path其實(shí)是外層圓與內(nèi)層圓組合的結(jié)果,所以我們使用 Path.combine()
方法來(lái)獲得圓環(huán)的路徑,通過(guò)設(shè)置組合模式為 PathOperation.difference
可以獲取內(nèi)外兩個(gè)圓的公共部分的Path,也就是圓環(huán)的Path:
2)使用paint的 MaskFilter.blur()
來(lái)繪制 :
這個(gè)方法其實(shí)是用來(lái)繪制毛玻璃效果的,用來(lái)繪制陰影,聽(tīng)起來(lái)也有些曲線(xiàn)救國(guó)的意味,但是官方注釋中有一句話(huà):
所以這個(gè)真的也是可以用來(lái)繪制陰影的,而且Flutter在繪制一些Button控件的時(shí)候也是使用來(lái)blur的效果來(lái)實(shí)現(xiàn)的。 MaskFilter.blur()
其實(shí)就是將你繪制的東西變模糊,所以我們可以繪制一個(gè)圓環(huán),然后將其進(jìn)行高斯模糊,造成一種加了“陰影”的假象。
兩者繪制結(jié)果的區(qū)別很明顯, canvas.drawShadow()
是將整個(gè)圓環(huán)作為一個(gè)整體,為其添加陰影;而 MaskFilter.blur()
其實(shí)就是繪制兩個(gè)模糊的圓環(huán),作為一種陰影的替代品。使用哪種方式繪制,還是取決于你需要什么樣的效果。
- 小圓點(diǎn)外圈繪制
這個(gè)沒(méi)什么難度的,就是在小圓點(diǎn)外面再繪制一個(gè)圓環(huán)而已:
到此為止,一個(gè)靜態(tài)的CircleProgressBar就繪制完成了:
添加手勢(shì)控制
手勢(shì)控制我們通過(guò)最簡(jiǎn)單的方式來(lái)實(shí)現(xiàn),那就是在CircleProgressBar外面包裹一層 GestureDetector
,然后在 onPanUpdate
回調(diào)中刷新進(jìn)度:
進(jìn)度的記錄我們依然是使用 AnimationController
,因?yàn)槲覀兛梢允褂?controller.animateTo()
方法,很方便得將進(jìn)度條從當(dāng)前位置平滑地移動(dòng)到目標(biāo)位置:
接下來(lái)就是判斷用戶(hù)的觸摸點(diǎn)是否在有效范圍內(nèi),因?yàn)橛脩?hù)只有在觸摸圓環(huán)的時(shí)候才應(yīng)該觸發(fā)手勢(shì),判斷方法也很簡(jiǎn)單,那就是看系統(tǒng)反饋給我們的pointer位置收否位于圓環(huán)上。但是實(shí)際操作會(huì)有一個(gè)問(wèn)題,那就是系統(tǒng)反饋的觸摸點(diǎn)位置是一個(gè)全局的坐標(biāo)點(diǎn),坐標(biāo)軸原點(diǎn)在屏幕的左上角,然后圓環(huán)在屏幕中的全局坐標(biāo)我們無(wú)法知曉。好在Flutter為我們提供了一個(gè)全局坐標(biāo)與局部坐標(biāo)的轉(zhuǎn)換方法:
拿到局部坐標(biāo)后,通過(guò)計(jì)算觸摸點(diǎn)與圓心的距離,是否在內(nèi)、外半徑范圍內(nèi),就可以判斷是否為有效觸摸了(一般情況下觸摸范圍會(huì)比圓環(huán)更大一線(xiàn),方便用戶(hù)操作,所以我將validInnerRadius的值,設(shè)置地比widget.radius - widget.dotRadius更小一點(diǎn)):
接下來(lái)就是計(jì)算觸摸點(diǎn)所在的角度了,要注意根據(jù)邊來(lái)計(jì)算角度時(shí),位于不同的象限,要做不同的處理:
將觸摸點(diǎn)所在的角度轉(zhuǎn)化為進(jìn)度,改變 progressController.value
的值,通過(guò) setState()
的方式,通知界面刷新,一個(gè)跟隨著用戶(hù)手勢(shì)而更改進(jìn)度的CircleProgressBar就完成了。
這是因?yàn)槲覀冊(cè)诶L制進(jìn)度條的時(shí)候進(jìn)行了偏移導(dǎo)致的,如果你想通過(guò)調(diào)整進(jìn)度條的方式來(lái)修改,會(huì)比較麻煩,不妨換個(gè)角度,當(dāng)角度很小的時(shí)候(radians < offset),進(jìn)度條其實(shí)是被小圓點(diǎn)擋住了,看不到的,那么直接不繪制就可以了。