Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

吉原拉麵發表於2018-11-03

  注意:這其實是一篇CustomPaint的使用教程!!

原始碼地址:github.com/yumi0629/Fl…

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

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

什麼是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就是我們的主繪製工具,它是一個CustomPainterforegroundPainter是用來繪製前景的工具;size為畫布大小,這個size會傳遞給PainterisComplexwillChange 是告訴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屬性,它包含了基本的畫布大小資訊。
  真正地繪製則是通過canvasPaint來實現的,我們將定義好了的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
複製程式碼

繪製步驟分析

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

靜態CircleProgressBar繪製

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

  繪製所需要的變數基本都標註在上圖中了,圓心座標就是整塊畫布的中心點,我們定義為(center,center),其中center = size.width * 0.5。小圓點的半徑定義為dotRadius。灰色實線部分為底部圓環,progressBar的寬度為紅色虛線部分所示,其大小應該比底部圓環略大,至於大多少,你可以自己定義。在本次的例子中,我將灰色實線與紅色虛線之間的部定義為radiusOffset = dotRadius * 0.4,這個值儘量不要寫死,那麼radiusOffset*2就是progressBar寬度比底部圓環大的值。innerRadiusoutRadius分別為底部圓環的內/外半徑,大小如圖上所示(純數學知識,不解釋)。然後我們可以根據innerRadiusoutRadius計算出progressBar寬度progressWith = outerRadius - innerRadius + radiusOffsetdrawRadius是一個大小為畫布寬度的一半減去小圓點半徑的變數,這個變數在繪製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,否則畫筆會預設充滿內部,那麼你繪製出來的就是一個圓了。

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

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);
複製程式碼

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

 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),上述程式碼可以繪製出一個基礎的圓弧。但是我們會發現,圓弧的兩端是平的,很影響美觀,這時候就需要用到paintstrokeCap屬性了。

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
  我們將paint設定為StrokeCap.round,就能得到一個最基本的進度條了。

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
  接下來我們給進度條新增顏色,按照設計稿,我們需要新增一個漸變色。漸變色可以通過paintshader屬性來實現:

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(徑向漸變)。

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
  很明顯,我們需要用到的是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.

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
  那麼怎麼解決呢?我想了很久之後決定採用一個曲線救國的方法,那就是:旋轉畫布!!。反正是一個圓弧嘛,那我把畫布逆時針旋轉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了呢?執行一下,啊嘞,怎麼會這樣紙?

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

  這是我們給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);
複製程式碼

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

  兩者繪製結果的區別很明顯,canvas.drawShadow()是將整個圓環作為一個整體,為其新增陰影;而MaskFilter.blur()其實就是繪製兩個模糊的圓環,作為一種陰影的替代品。使用哪種方式繪製,還是取決於你需要什麼樣的效果。

  • 小圓點外圈繪製

  這個沒什麼難度的,就是在小圓點外面再繪製一個圓環而已:

 dotPaint
      ..color = dotEdgeColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = dotRadius * 0.3;
canvas.drawCircle(new Offset(dx, dy), dotRadius, dotPaint);
複製程式碼

  到此為止,一個靜態的CircleProgressBar就繪製完成了:

Flutter:教你用CustomPaint畫一個自定義的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;
  }
複製程式碼

  接下來就是計算觸控點所在的角度了,要注意根據邊來計算角度時,位於不同的象限,要做不同的處理:

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar

  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就完成了。

一些其他的細節

   在實際執行時,如果角度過小時,會出現下面的情況:

Flutter:教你用CustomPaint畫一個自定義的CircleProgressBar
   這是因為我們在繪製進度條的時候進行了偏移導致的,如果你想通過調整進度條的方式來修改,會比較麻煩,不妨換個角度,當角度很小的時候(radians < offset),進度條其實是被小圓點擋住了,看不到的,那麼直接不繪製就可以了。

  進度的監聽可以通過暴露的回撥progressChanged(double value)得到,範圍是(0.0~1.0)

相關文章