Flutter-自定義量角器

效果圖

image.png

今天周末,收拾東西的時候,發現了一個尺子,我們叫它量角器,我記得上學的時候,借給一個女同學這樣的量角器,到現在還沒有還我,啥意思嗎?還還我不?

image.png

看來是沒戲了,同學嗎?那么小氣干嘛?不如自己畫一個?畢竟我是最會編程的電工嗎!

image.png

在自定義量角器之前,我先說下,小樣就是小樣,本著實現效果為目的,當然可能會有更好或者更優的方式,就像做數學題目一樣,答案只有一個,但是解答思路有很多種,當然有好的建議和方式下面留言哦!

廢話不多說,走起!

觀察量角器

拿起桌子上的量角器,看了看,想了想,欸?我還是不記得借我尺子的那個同學叫啥來著?


image.png

Sorry!

這個量角器嗎?有這幾個特征。

  • 半圓形(里面有4個半圓)
  • 刻度線(長的、中等的、短的)
  • 有刻度值(正向,反向,注:這里0和180度省去,別問為啥,因為不好看)
  • 測量輔助線(10的倍數)

像這種純繪制的自定義基本上就是考驗對Canvas API使用和數學知識,下面使用Flutter實現。

具體實現

創建Widget

1、StatelessWidget Or StatefulWidget

其實這個區分跟簡單,當一個靜態的,沒有狀態改變的自定義就使用StatelessWidget,否則使用StatefulWidget。因為尺子是一個靜態的,一旦繪制完畢就不需要去改變了,所以說我們直接創建一個 StatelessWidget 就可以,

//量角器Widget
class SemiCircleRulerWidget extends StatelessWidget {
  const SemiCircleRulerWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ...
  }
}
2、尺寸如何定義

我們剛開始一定會考慮這個寬高是如何定義,到底是直接外面傳進來?還是怎么辦?我這里直接采取使用LayoutBuilder,通過LayoutBuilder,我們可以在布局過程中拿到父組件傳遞的約束信息,然后我們可以根據約束信息動態的構建不同的布局。

LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        double radius;
        //當寬的一半大于等于高的時候,采取高來作為半徑。
        if (constraints.maxWidth / 2 >= constraints.maxHeight) {
          radius = constraints.maxHeight;
        }
        //當寬的一半小于高的時候,采取寬的一半作為半徑。
        else {
          radius = constraints.maxWidth / 2;
        }
        //寬 = 2*半徑, 高 = 半徑
        var size = Size(radius * 2, radius);
        return CustomPaint(
          size: size,
          painter: SemiCircleRulerCustomPainter(radius),
        );
      },
    );

這里簡單做了一下判斷,為了在已有空間里繪制最大

  • 當寬的一半大于等于高的時候,采取高來作為半徑。
  • 當寬的一半小于高的時候,采取寬的一半作為半徑。

獲取半徑后,我們就可以為我們的CustomPaint設置大小了。

  • 寬 = 2*半徑, 高 = 半徑
3、創建CustomPainter
class SemiCircleRulerCustomPainter2 extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
     ...
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate)  => false;

}

shouldRepaint返回ture表示需要重繪,返回false表示不需要重繪。所以這里我們直接返回false就可以。

4、paint方法
4.1 坐標系

有的時候,我們要是經常不寫這種自定義的話,很容易忘記這個坐標系和畫布在調用一些api之后的狀態是啥樣子的?所以我一般是這么做的,先寫個繪制坐標系為了查看當時的坐標系情況。

 /*
   * 繪制坐標系( X軸  Y軸) 為了查看坐標系位置
   */
  void drawXY(Canvas canvas) {
    //X軸
    canvas.drawLine(
      const Offset(0, 0),
      const Offset(300, 0),
      Paint()
        ..color = Colors.green
        ..strokeWidth = 3,
    );

    //Y軸
    canvas.drawLine(
      const Offset(0, 0),
      const Offset(0, 300),
      Paint()
        ..color = Colors.red
        ..strokeWidth = 3,
    );
  }
image.png

簡單繪制一個坐標系,為了更方便了解當前的畫布情況。自定義完畢,刪了即可。

4.2 繪制量角器雛形

量角器是一個半圓,所以我們需要調用的API是

drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
  • rect:定義承載圓弧形狀的矩形。通過設置該矩形可以指定圓弧的位置和大小。

  • startAngle: 設置圓弧是從哪個角度順時針繪畫的。順時針為正,逆時針為負(注意:這里是弧度值)

  • sweepAngle: 設置圓弧順時針掃過的角度。(注意:這里是弧度值)

  • useCenter: 繪制的時候是否使用圓心,我們繪制圓弧的時候設置為false,如果設置為true, 并且當前畫筆的描邊屬性設置為Paint.Style.FILL的時候,畫出的就是扇形。

  • paint: 指定繪制的畫筆。

為了更好了解這個API,我們來看下案例

  • 創建一個正方形
  Rect rect = const Rect.fromLTWH(100, 100, 300, 300);
  canvas.drawRect(
      rect,
      Paint()
        ..color = Colors.black
        ..style = PaintingStyle.stroke
        ..strokeWidth = 3,
   );
image.png
  • 繪制一個圓弧

起始角度為0:

  //繪制一個圓弧,從0度到90度
    var paint = Paint()
      ..color = Colors.red
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3;
    canvas.drawArc(rect, degToRad(0), degToRad(90), false, paint);
    
      //角度轉換為弧度
    double degToRad(num deg) => deg * (pi / 180.0);

上面代碼中degToRad方法是一個角度到弧度的轉換。繪制結果是這樣的。


image.png

通過效果我們可以知道起始0度是從水平開始的,掃描是順時針掃描的。如果我們定義的起始不是0度呢?

起始角度為正數:

canvas.drawArc(rect, degToRad(30), degToRad(150), false, paint);
image.png

起始角度為負數:

 canvas.drawArc(rect, degToRad(-90), degToRad(180), false, paint);
image.png

通過上面的了解,大概了解drawArc繪制情況了。

下面直接繪制量角器
  /*
   * 繪制表框(半圓)
   */
  void drawBorder(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(
      center: Offset(radius, radius),
      radius: radius - borderStrokeWidth / 2,
    );
    //第四個參數設置為true,因為尺子是閉合的
    canvas.drawArc(rect, -pi, pi, true, borderPaint);
  }
image.png
4.3 繪制刻度線

繪制線使用的API是:

 drawLine(Offset p1, Offset p2, Paint paint)

2點確定一條直線,所以p1 p2就是2點的坐標,只要我們按照我們需要傳入2個坐標即可,這里就不單獨案例說明了,直接走起。

  • 定位:我想從半圓的左下角開始繪制刻度線。
  • 繪制刻度線:180個刻度,繪制每個刻度,其中10的倍數為大刻度,5結尾的刻度為中刻度,其他為小刻度,這里0和180度我們省略,因為繪制的話和圓弧底線重疊。
定位

這里說的定位,意思是操作畫布來達到我想要繪制的起點,方便我繪制,因為我想在左下角開始繪制刻度線,所以,我執行下面的操作。

    //畫布移動到(radius, radius)點
    canvas.translate(radius, radius);
    //畫布旋轉-90度
    canvas.rotate(degToRad(-90));

這2個操作后畫布變成啥樣子了,看我們的坐標系就明白了。

執行一行代碼:

canvas.translate(radius, radius);
drawXY(canvas);
image.png

看到坐標系圓點移動到了(radius, radius)上了。

執行2行代碼:

    canvas.translate(radius, radius);
    canvas.rotate(degToRad(-90));
    drawXY(canvas);
image.png

上圖就是執行完移動和旋轉后坐標系的位置。這個時候我們可以繪制第一個刻度,也就是0度刻度線,如果我們需要繪制刻度線為20長的刻度線怎么做呢?

通過坐標系我們可以知道,0刻度的起始位置 X軸是0,Y軸是-radius(為了好理解,這里面沒有考慮畫筆的寬度啊,后面繪制會考慮),如果我們要繪制20長度刻度的話,兩點坐標可以為:

  • Offset(0.0, -(radius - borderStrokeWidth / 2))
  • Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
    這里borderStrokeWidth是畫筆的寬度,因為要考慮畫筆所以我們需要減去。
  canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
      scalePaint,
    );
image.png

看到左下角那個紅色橫線了嗎,這個就是0刻度線,那么1刻度線如何繪制呢?我們可以想下,如果我們想保持剛才繪制0刻度和1刻度的代碼不變,是不是只要把這個半圓逆時針旋轉1度就可以了,你想想是不是呢?


image.png

但是我們不好旋轉半圓啊?怎么搞,反過來想,我們可以順時針旋轉畫布1刻度可以達到一樣的效果。來試試

    //繪制0刻度
    canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
      scalePaint,
    );

    //順時針旋轉 1度
    canvas.rotate(degToRad(1));
    //查看坐標系
    drawXY(canvas);

    //繪制1刻度
    canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
      scalePaint,
    );
image.png
image.png

如我們所希望的樣子,呦西,趁勢追擊,直接一步到位。

  /*
   * 繪制刻度
   */
  void drawScale(Canvas canvas, Size size) {
    canvas.save();
    canvas.translate(radius, radius);
    canvas.rotate(degToRad(-90));
    for (int index = 1; index < 180; index++) {
      //旋轉角度
      canvas.rotate(degToRad(1));
      //大刻度
      if (index % 10 == 0) {
        //繪制最長刻度
        drawLongLine(canvas, size);
      }
      //中刻度
      else if (index % 5 == 0) {
        //繪制中刻度
        drawMiddleLine(canvas, size);
      }
      //小刻度
      else {
        //繪制小刻度
        drawShortLine(canvas, size);
      }
    }
    canvas.restore();
  }
  
   /*
   * 繪制長線
   */
  void drawLongLine(Canvas canvas, Size size) {
    canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + longScaleSize),
      scalePaint,
    );
  }

  /*
   * 繪制中線
   */
  void drawMiddleLine(Canvas canvas, Size size) {
    canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + middleScaleSize),
      scalePaint,
    );
  }

  /*
   * 繪制短線
   */
  void drawShortLine(Canvas canvas, Size size) {
    canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + shortScaleSize),
      scalePaint,
    );
  }
image.png

注意:上面代碼中出現的2行代碼

  • canvas.save();
  • canvas.restore();

這2位是成對出現,不能單獨使用,他們的目的就是在你操作畫布(平移,旋轉等)之前,先調用save()方法對當前畫布狀態的保存,當你操作畫布繪制好圖形后,在調用restore()還原之前畫布的狀態,不影響后面繪制操作。

4.3 繪制刻度值

刻度值是在10的倍數才繪制,所以我們直接可以在繪制刻度線代碼中,在繪制長刻度線的地方,多繪制刻度值即可,還有就是這里有2種刻度值,一種順時針,一種逆時針,我們只要順時針直接采用當前角度顯示即可,逆時針使用(180-當前角度)即可。
這里因為坐標系都是在對應位置,所以直接繪制就行。

  /*
   * 繪制數字
   */
  void drawScaleNum(Canvas canvas, int i) {
    //繪制最外圈刻度值
    textPainter.text = TextSpan(
        text: "$i",
        style: TextStyle(
          color: Colors.black,
          fontSize: numTextSize,
        ));
    textPainter.layout();
    double textStarPositionX = -textPainter.size.width / 2;
    double textStarPositionY = -radius + outNumSize;
    textPainter.paint(canvas, Offset(textStarPositionX, textStarPositionY));

    //繪制內圈刻度值
    textPainter.text = TextSpan(
        text: "${180 - i}",
        style: TextStyle(
          color: Colors.black,
          fontSize: numTextSize,
        ));
    textPainter.layout();
    double textStarPositionX2 = -textPainter.size.width / 2;
    double textStarPositionY2 = -radius + inNumSize;
    textPainter.paint(canvas, Offset(textStarPositionX2, textStarPositionY2));
  }
image.png

這里主要是對textPainter API的使用,這里主要1個注意點,就是2個刻度值的間距,通過控制y坐標來控制下就行,我這里 inNumSize=60,outNumSize=30,具體多少自己根據自己的審美修改就行,不做過多的解釋。

4.3 繪制內部半圓

因為我們上面已經了解了繪制半圓的API,所以這里主要注意的點就是控制好半圓和刻度值的位置,避免重疊,其實也就是UI審美問題。

  /*
   * 繪制外半圓
   */
  void drawOuterSemicircle(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(
      center: Offset(radius, radius),
      radius: radius - borderStrokeWidth / 2 - interSemicircleSize,
    );
    canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
  }

  /*
   * 繪制內半圓
   */
  void drawInnerSemicircle(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(
      center: Offset(radius, radius),
      radius: radius - borderStrokeWidth / 2 - outerSemicircleSize,
    );
    canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
  }
image.png
4.4 繪制角度測量輔助線

角度測量輔助線也是10的倍數的刻度才繪制,所以也是在繪制刻度尺代碼中繪制10刻度的if里加上繪制角度測量輔助線代碼即可。輔助線起點:Offset(radius, radius),終點是:內半圓為結束,我們可以把每個半圓和刻度值距離邊緣的距離定義成變量,方便后續其他地方使用。

  void drawScale(Canvas canvas, Size size) {
    ...
    for (int index = 1; index < 180; index++) {
      ...
      //大刻度
      if (index % 10 == 0) {
        ...
        // 繪制刻度線
        drawScaleLine(canvas, size);
      }
      ...
    }
    canvas.restore();
  }
  
  /*
   * 繪制刻度線(10倍數)
   */
  void drawScaleLine(Canvas canvas, Size size) {
    canvas.drawLine(
      const Offset(0, 0),
      Offset(0, -radius + scalePaintWidth + interSemicircleSize),
      scalePaint,
    );
  }
image.png
image.png

什么鬼?不好看,下面那個輔助線起點太多時,導致比較的密集,所以我想優化下,在搞個小半圓給他蓋住,不讓別人知道你的丑。

4.5 繪制小半圓遮住你的美

我們直接畫個半圓,并且使用PaintingStyle.fill類型蓋住他,為了好看我在畫個半圓作為邊框,不愧是我啊。

  /*
   * 繪制最小的半圓
   */
  void drawSmallSemicircle(Canvas canvas, Size size) {
    //繪制半圓區域
    Rect rect = Rect.fromCircle(
      center: Offset(radius, radius - borderStrokeWidth / 2),
      radius: radius / 10,
    );
    //這里先繪制半圓邊框
    canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
    
    //繪制白色半圓
    semicirclePaint.color = Colors.white;
    semicirclePaint.style = PaintingStyle.fill;
    canvas.drawArc(rect, -pi, pi, true, semicirclePaint);
  }
image.png

搞定!文章書寫不易,多多關注!


image.png
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容