注意:這實際上是一篇CustomPaint的使用教程!!git
源碼地址:github.com/yumi0629/Fl…github
在Flutter中,CustomPaint
就像是Android中的Paint同樣,能夠用它繪製出各類各樣的自定義圖形。確實,Paint的使用比較複雜,我以爲直接講API的話也太無聊了,要記住Paint的用法,仍是本身動手畫一個比較實在。
那爲何是畫一個CircleProgressBar呢?其實這個控件原本是爲了交做業的,以前在講Hero的時候留了一個小練習,裏面有一個頁面,有一個很炫酷的圓形ProgressBar選擇器,當時爲了偷懶我就沒寫(不要打我),因此如今來補交了。在寫這個CircleProgressBar的時候發現,CustomPaint
中基本的API都使用到了,畫圓、畫弧線、畫布旋轉、Paint的各類屬性的意義等等知識點都有涉及到。因此說,看完這篇文章,你絕對能夠本身動手嘗試畫一些炫酷的UI控件來!
國際慣例,先上效果圖:canvas
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
中:bash
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:app
// 繪製弧線
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
屬性:ide
color:畫筆顏色
style:繪製模式,畫線 or 充滿
maskFilter:繪製完成,尚未被混合到佈局上時,添加的遮罩效果,好比blur效果
strokeWidth:線條寬度
strokeCap:線條結束時的繪製樣式
shader:着色器,通常用來繪製漸變效果或ImageShader
複製代碼
GestureDetector
來實現就能夠了,在
onPanUpdate
回調中實時刷新進度條與小圓點的位置,這裏面須要注意的地方是可觸摸區域的計算。
繪製所須要的變量基本都標註在上圖中了,圓心座標就是整塊畫布的中心點,咱們定義爲(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和小圓點的位置。函數
底部圓環的繪製很是簡單,實際上就是畫一個圓。爲何說畫圓環和畫圓會是同樣的呢?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
,不然畫筆會默認充滿內部,那麼你繪製出來的就是一個圓了。 佈局
繪製進度條實際上就是繪製圓弧,咱們使用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
就是咱們的畫筆。
補充:弧度與角度的弧線轉換:ui
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);
複製代碼
那麼到此爲止,咱們的進度條部分也繪製完成了。
繪製小圓點就比較簡單了,只要計算出小圓點的圓心位置就能夠了,純初中數學計算,本身拿紙畫畫就知道啦。繪製函數依然是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);
複製代碼
繪製陰影有兩種方法,實現出來的效果也不太同樣。
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就完成了。
在實際運行時,若是角度太小時,會出現下面的狀況:
進度的監聽能夠經過暴露的回調progressChanged(double value)
獲得,範圍是(0.0~1.0)
。