注意:這其實是一篇CustomPaint的使用教程!!
原始碼地址: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.
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),進度條其實是被小圓點擋住了,看不到的,那麼直接不繪製就可以了。 進度的監聽可以通過暴露的回撥progressChanged(double value)
得到,範圍是(0.0~1.0)
。