Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
注意:這其實是一篇CustomPaint的使用教程!!
原始碼地址: FlutterUI%2Ftree%2Fmaster%2Flib%2Fcircleprogressbar" rel="nofollow,noindex">github.com/yumi0629/Fl…
在Flutter中, CustomPaint
就像是Android中的Paint一樣,可以用它繪製出各種各樣的自定義圖形。確實,Paint的使用比較複雜,我覺得直接講API的話也太無聊了,要記住Paint的用法,還是自己動手畫一個比較實在。
那為什麼是畫一個CircleProgressBar呢?其實這個控制元件本來是為了交作業的,之前在講Hero的時候留了一個小練習,裡面有一個頁面,有一個很炫酷的圓形ProgressBar選擇器,當時為了偷懶我就沒寫(不要打我),所以現在來補交來。在寫這個CircleProgressBar的時候發現, CustomPaint
中基本的API都使用到了,畫圓、畫弧線、畫布旋轉、Paint的各種屬性的意義等等知識點都有涉及到。所以說,看完這篇文章,你絕對可以自己動手嘗試畫一些炫酷的UI控制元件來!
國際慣例,先上效果圖:

什麼是CustomPaint
const CustomPaint({ Key key, this.painter, this.foregroundPainter, this.size = Size.zero, this.isComplex = false, this.willChange = false, Widget child, }) 複製程式碼
CustomPaint
是一個繼承自 SingleChildRenderObjectWidget
的控制元件,所以注意,不能用setState的方式來重新整理它!! painter
就是我們的主繪製工具,它是一個 CustomPainter
; foregroundPainter
是用來繪製前景的工具; size
為畫布大小,這個size會傳遞給 Painter
; isComplex
和 willChange
是告訴Flutter你的 CustomPaint
是否複雜到需要使用cache相關的功能; child
屬性我們一般不填,即使你是想要在你的 CustomPaint
上新增一些其他的佈局,也不建議放在child屬中性,因為你會發現你並不會得到你想要的結果。
所有的繪製都是發生在Painter裡面的,繪製的程式碼寫在我們的自定義 CustomPainter
中:
class ProgressPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { // 繪製程式碼 } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } } 複製程式碼
我們需要重寫 paint()
和 shouldRepaint()
這兩個方法,一個是繪製流程,一個是在重新整理佈局的時候告訴Flutter是否需要重繪。注意下 paint
方法中的size引數,就是我們在 CustomPaint
中定義的size屬性,它包含了基本的畫布大小資訊。
真正地繪製則是通過 canvas
和 Paint
來實現的,我們將定義好了的Paint畫筆傳遞給 canvas.drawXXX()
方法,這個方法會告訴Flutter我們需要繪製一個什麼東西,是一個圓呢、還是一條線呢?
一些常用的 canvas
繪製API:
// 繪製弧線 drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint) // 繪製圖片 drawImage(Image image, Offset p, Paint paint) // 繪製圓 drawCircle(Offset c, double radius, Paint paint) // 繪製線條 drawLine(Offset p1, Offset p2, Paint paint) // 繪製橢圓 drawOval(Rect rect, Paint paint) // 繪製文字 drawParagraph(Paragraph paragraph, Offset offset) // 繪製路徑 drawPath(Path path, Paint paint) // 繪製點 drawPoints(PointMode pointMode, List<Offset> points, Paint paint) // 繪製Rect drawRect(Rect rect, Paint paint) // 繪製陰影 drawShadow(Path path, Color color, double elevation, bool transparentOccluder) 複製程式碼
一些常用的 Paint
屬性:
color:畫筆顏色 style:繪製模式,畫線 or 充滿 maskFilter:繪製完成,還沒有被混合到佈局上時,新增的遮罩效果,比如blur效果 strokeWidth:線條寬度 strokeCap:線條結束時的繪製樣式 shader:著色器,一般用來繪製漸變效果或ImageShader 複製程式碼
繪製步驟分析

首先是靜態進度條的繪製,我們先拆解這個CircleProgressBar為三部分:底部圓環、進度條和顯示當前進度的小圓點。因為 Canvas的繪製順序是按程式碼順序一層一層往上疊加的 ,所以我們的繪製步驟應該是:繪製底部圓環——>繪製進度條——>繪製小圓點。
然後是手勢拖動的實現,我們選用GestureDetector
來實現就可以了,在
onPanUpdate
回撥中實時重新整理進度條與小圓點的位置,這裡面需要注意的地方是可觸控區域的計算。
靜態CircleProgressBar繪製

繪製所需要的變數基本都標註在上圖中了,圓心座標就是整塊畫布的中心點,我們定義為 (center,center)
,其中 center = size.width * 0.5
。小圓點的半徑定義為 dotRadius
。灰色實線部分為底部圓環,progressBar的寬度為紅色虛線部分所示,其大小應該比底部圓環略大,至於大多少,你可以自己定義。在本次的例子中,我將灰色實線與紅色虛線之間的部定義為 radiusOffset = dotRadius * 0.4
,這個值儘量不要寫死,那麼 radiusOffset*2
就是progressBar寬度比底部圓環大的值。 innerRadius
和 outRadius
分別為底部圓環的內/外半徑,大小如圖上所示(純數學知識,不解釋)。然後我們可以根據 innerRadius
和 outRadius
計算出progressBar寬度 progressWith = outerRadius - innerRadius + radiusOffset
。 drawRadius
是一個大小為畫布寬度的一半減去小圓點半徑的變數,這個變數在繪製progressBar和小圓點的時候很有用,用來確定progressBar和小圓點的位置。
Step 1 底部圓環繪製
底部圓環的繪製非常簡單,實際上就是畫一個圓。為什麼說畫圓環和畫圓會是一樣的呢? Paint
是畫筆,回想一下我們在寫字的時候,寫出來的字是不是有粗有細?同樣地, Paint
在畫線的時候也是有寬度的,我們畫一個有寬度的圓,不就是畫一個圓環了嗎?
final Offset offsetCenter = Offset(center, center); final ringPaint = Paint() ..style = PaintingStyle.stroke ..color = ringColor ..strokeWidth = (outerRadius - innerRadius); canvas.drawCircle(offsetCenter, drawRadius, ringPaint); 複製程式碼
canvas.drawCircle(Offset c, double radius, Paint paint)
這個方法就是繪製一個圓,其中c為圓心座標點,這個offset偏移值是以畫布原點(左上角)為座標軸中心點來計算的,很明顯大小為 offsetCenter = Offset(center, center)
;radius為圓環半徑,大小其實就是圖上標示的 drawRadius
;paint就是我們的畫筆,這裡要注意,繪製圓環需要設定 style = PaintingStyle.stroke
,否則畫筆會預設充滿內部,那麼你繪製出來的就是一個圓了。

Step 2 底部進度條
繪製進度條實際上就是繪製圓弧,我們使用 canvas.drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
。 rect引數就是圓弧所在的整圓的Rect,我們使用 Rect.fromCircle
來構造這個整圓的Rect: final Rect arcRect = Rect.fromCircle(center: offsetCenter, radius: drawRadius);
; startAngle
為起始弧度, sweepAngle
為需要繪製的圓弧長度,這裡要注意,這兩個值都是 弧度制 的,canvas裡面與角度有關的變數都是弧度制的,在計算的時候一定要注意; useCenter
屬性標示是否需要將圓弧與圓心相連; paint
就是我們的畫筆。
補充:弧度與角度的弧線轉換:
num degToRad(num deg) => deg * (pi / 180.0); num radToDeg(num rad) => rad * (180.0 / pi); 複製程式碼

final angle = 360.0 * progress; final double radians = degToRad(angle); final Rect arcRect = Rect.fromCircle(center: offsetCenter, radius: drawRadius); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = progressWidth; canvas.drawArc(arcRect, 0.0, degToRad(angle), false, progressPaint); 複製程式碼
假設當前進度為 progress
(範圍為0.0~1.0),那麼當前角度為 angle = 360.0 * progress
,當前弧度為 radians = degToRad(angle)
,上述程式碼可以繪製出一個基礎的圓弧。但是我們會發現,圓弧的兩端是平的,很影響美觀,這時候就需要用到 paint
的 strokeCap
屬性了。

paint
設定為
StrokeCap.round
,就能得到一個最基本的進度條了。

paint
的
shader
屬性來實現:
final Gradient gradient = new SweepGradient( endAngle: radians, colors: [ Colors.white, currentDotColor, ], ); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeWidth = progressWidth ..shader = gradient.createShader(arcRect); 複製程式碼
Flutter提供了三種基礎的用來繪製漸變效果的類:SweepGradient(掃描漸變)、LinearGradient(線性漸變)和RadialGradient(徑向漸變)。

SweepGradient
:
final Gradient gradient = new SweepGradient( endAngle: radians, colors: [ Colors.white, currentDotColor, ], ); 複製程式碼
注意,這裡有一個很大的坑,我們可以從上面的SweepGradient事例圖上看到,預設情況下是從90°的地方作為起點的,這跟我們的要求明顯是不符的。SweepGradient有一個startAngle屬性,那麼我們是否可以將其設定為 degToRad(-90°)
就可以解決問題了呢?答案是:不可以。這裡懷疑是Flutter的一個bug,startAngle屬性不生效,我們可以看一下這個issue: SweepGradient startAngle doesn't work as expected.

!!。反正是一個圓弧嘛,那我把畫布逆時針旋轉90°不就行了嘛(這裡還要注意,畫布預設旋轉中心為座標軸原點,而且貌似不能更改,至少我沒找到,所以需要旋轉後再平移,對canvas的位置操作需要倒著寫,所以實際程式碼是先寫translate,再寫rotate):
canvas.save(); canvas.translate(0.0, size.width); canvas.rotate(degToRad(-90.0)); ······ canvas.drawArc(arcRect, 0.0, degToRad(angle), false, paint); canvas.restore(); 複製程式碼
畫到這裡你是不是覺得已經很OK了呢?執行一下,啊嘞,怎麼會這樣紙?

這是我們給stroke設定了StrokeCap.round導致的,因為Flutter在給線繪製圓角時,是線上長的外面加了一段圓角,導致實際長度會超過我們定義的長度。那怎麼辦呢?還是曲線救國,我們在drawArc的時候,將起始角度往後偏移一段不就可以了嗎?我們將這段偏移弧度定義為 offset
,其大小為 offset = asin(progressWidth * 0.5 / drawRadius)
(怎麼算出來的?數學問題,自己那張草稿紙畫畫就知道啦~)。
所以最終的繪製程式碼應該為:
canvas.drawArc(arcRect, offset, degToRad(angle) - offset, false, progressPaint); 複製程式碼
那麼到此為止,我們的進度條部分也繪製完成了。
Step 3 繪製小圓點
繪製小圓點就比較簡單了,只要計算出小圓點的圓心位置就可以了,純初中數學計算,自己拿紙畫畫就知道啦。繪製函式依然是 canvas.drawCircle
,因為是繪製圓,所以不需要更改PaintingStyle。
final double dx = center + drawRadius * sin(radians); final double dy = center - drawRadius * cos(radians); final dotPaint = Paint()..color = currentDotColor; canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint); dotPaint ..color = dotEdgeColor ..style = PaintingStyle.stroke ..strokeWidth = dotRadius * 0.3; canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint); 複製程式碼
Step 4 細節修飾:繪製底部圓環陰影和小圓點外圈
- 繪製圓環陰影
繪製陰影有兩種方法,實現出來的效果也不太一樣。
1)使用 canvas.drawShadow()
來繪製 :
drawShadow(Path path, Color color, double elevation, bool transparentOccluder)
,根據API要求,我們需要先計算出圓環的Path,Path的相關API只支援向path中新增圓、弧線、直線、點等屬性,我們沒法直接構建一個圓環對應的物件Path。換個角度思考一下,圓環的Path其實是外層圓與內層圓組合的結果,所以我們使用 Path.combine()
方法來獲得圓環的路徑,通過設定組合模式為 PathOperation.difference
可以獲取內外兩個圓的公共部分的Path,也就是圓環的Path:
Path path = Path.combine(PathOperation.difference, Path()..addOval(Rect.fromCircle(center: offsetCenter, radius: outerRadius)), Path()..addOval(Rect.fromCircle(center: offsetCenter, radius: innerRadius))); canvas.drawShadow(path, shadowColor, 4.0, true); 複製程式碼
2)使用paint的 MaskFilter.blur()
來繪製 :
這個方法其實是用來繪製毛玻璃效果的,用來繪製陰影,聽起來也有些曲線救國的意味,但是官方註釋中有一句話:
Creates a mask filter that takes the shape being drawn and blurs it.This is commonly used to approximate shadows.
所以這個真的也是可以用來繪製陰影的,而且Flutter在繪製一些Button控制元件的時候也是使用來blur的效果來實現的。 MaskFilter.blur()
其實就是將你繪製的東西變模糊,所以我們可以繪製一個圓環,然後將其進行高斯模糊,造成一種加了“陰影”的假象。
final shadowPaint = Paint() ..style = PaintingStyle.stroke ..color = shadowColor ..strokeWidth = shadowWidth ..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowWidth); canvas.drawCircle(offsetCenter, outerRadius, shadowPaint); canvas.drawCircle(offsetCenter, innerRadius, shadowPaint); 複製程式碼

兩者繪製結果的區別很明顯, canvas.drawShadow()
是將整個圓環作為一個整體,為其新增陰影;而 MaskFilter.blur()
其實就是繪製兩個模糊的圓環,作為一種陰影的替代品。使用哪種方式繪製,還是取決於你需要什麼樣的效果。
- 小圓點外圈繪製
這個沒什麼難度的,就是在小圓點外面再繪製一個圓環而已:
dotPaint ..color = dotEdgeColor ..style = PaintingStyle.stroke ..strokeWidth = dotRadius * 0.3; canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint); 複製程式碼
到此為止,一個靜態的CircleProgressBar就繪製完成了:

新增手勢控制
手勢控制我們通過最簡單的方式來實現,那就是在CircleProgressBar外面包裹一層 GestureDetector
,然後在 onPanUpdate
回撥中重新整理進度:
GestureDetector( onPanStart: _onPanStart, onPanUpdate: _onPanUpdate, onPanEnd: _onPanEnd, child: Container( alignment: FractionalOffset.center, child: CustomPaint( key: paintKey, size: size, painter: ProgressPainter(), ), ), ) 複製程式碼
進度的記錄我們依然是使用 AnimationController
,因為我們可以使用 controller.animateTo()
方法,很方便得將進度條從當前位置平滑地移動到目標位置:
AnimationController progressController; @override void initState() { super.initState(); progressController = AnimationController(duration: Duration(milliseconds: 300), vsync: this); if (widget.progress != null) progressController.value = widget.progress; progressController.addListener(() { if (widget.progressChanged != null) widget.progressChanged(progressController.value); setState(() {}); }); } 複製程式碼
接下來就是判斷使用者的觸控點是否在有效範圍內,因為使用者只有在觸控圓環的時候才應該觸發手勢,判斷方法也很簡單,那就是看系統反饋給我們的pointer位置收否位於圓環上。但是實際操作會有一個問題,那就是系統反饋的觸控點位置是一個全域性的座標點,座標軸原點在螢幕的左上角,然後圓環在螢幕中的全域性座標我們無法知曉。好在Flutter為我們提供了一個全域性座標與區域性座標的轉換方法:
void _onPanUpdate(DragUpdateDetails details) { RenderBox getBox = key.currentContext.findRenderObject(); Offset local = getBox.globalToLocal(details.globalPosition); } 複製程式碼
拿到區域性座標後,通過計算觸控點與圓心的距離,是否在內、外半徑範圍內,就可以判斷是否為有效觸摸了(一般情況下觸控範圍會比圓環更大一線,方便使用者操作,所以我將validInnerRadius的值,設定地比widget.radius - widget.dotRadius更小一點):
bool _checkValidTouch(Offset pointer) { final double validInnerRadius = widget.radius - widget.dotRadius * 3; final double dx = pointer.dx; final double dy = pointer.dy; final double distanceToCenter = sqrt(pow(dx - widget.radius, 2) + pow(dy - widget.radius, 2)); if (distanceToCenter < validInnerRadius || distanceToCenter > widget.radius) { return false; } return true; } 複製程式碼
接下來就是計算觸控點所在的角度了,要注意根據邊來計算角度時,位於不同的象限,要做不同的處理:

void _onPanUpdate(DragUpdateDetails details) { if (!isValidTouch) { return; } RenderBox getBox = paintKey.currentContext.findRenderObject(); Offset local = getBox.globalToLocal(details.globalPosition); final double x = local.dx; final double y = local.dy; final double center = widget.radius; double radians = atan((x - center) / (center - y)); if (y > center) { radians = radians + degToRad(180.0); } else if (x < center) { radians = radians + degToRad(360.0); } progressController.value = radians / degToRad(360.0); } 複製程式碼
將觸控點所在的角度轉化為進度,改變 progressController.value
的值,通過 setState()
的方式,通知介面重新整理,一個跟隨著使用者手勢而更改進度的CircleProgressBar就完成了。
這是因為我們在繪製進度條的時候進行了偏移導致的,如果你想通過調整進度條的方式來修改,會比較麻煩,不妨換個角度,當角度很小的時候(radians < offset),進度條其實是被小圓點擋住了,看不到的,那麼直接不繪製就可以了。