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

  注意:這實際上是一篇CustomPaint的使用教程!!git

源碼地址:github.com/yumi0629/Fl…github

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

什麼是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中: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屬性,它包含了基本的畫布大小信息。
  真正地繪製則是經過canvasPaint來實現的,咱們將定義好了的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
複製代碼

繪製步驟分析

  首先是靜態進度條的繪製,咱們先拆解這個CircleProgressBar爲三部分:底部圓環、進度條和顯示當前進度的小圓點。由於 Canvas的繪製順序是按代碼順序一層一層往上疊加的,因此咱們的繪製步驟應該是:繪製底部圓環——>繪製進度條——>繪製小圓點。
  而後是手勢拖動的實現,咱們選用 GestureDetector來實現就能夠了,在 onPanUpdate回調中實時刷新進度條與小圓點的位置,這裏面須要注意的地方是可觸摸區域的計算。

靜態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,不然畫筆會默認充滿內部,那麼你繪製出來的就是一個圓了。 佈局

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就是咱們的畫筆。
補充:弧度與角度的弧線轉換: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),上述代碼能夠繪製出一個基礎的圓弧。可是咱們會發現,圓弧的兩端是平的,很影響美觀,這時候就須要用到paintstrokeCap屬性了。

  咱們將 paint設置爲 StrokeCap.round,就能獲得一個最基本的進度條了。

  接下來咱們給進度條添加顏色,按照設計稿,咱們須要添加一個漸變色。漸變色能夠經過 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(徑向漸變)。

  很明顯,咱們須要用到的是 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),進度條實際上是被小圓點擋住了,看不到的,那麼直接不繪製就能夠了。

  進度的監聽能夠經過暴露的回調progressChanged(double value)獲得,範圍是(0.0~1.0)

相關文章
相關標籤/搜索