Flutter-自定義量角器

效果圖

image.png

今天周末,收拾東西的時(shí)候,發(fā)現(xiàn)了一個(gè)尺子,我們叫它量角器,我記得上學(xué)的時(shí)候,借給一個(gè)女同學(xué)這樣的量角器,到現(xiàn)在還沒(méi)有還我,啥意思嗎?還還我不?

image.png

看來(lái)是沒(méi)戲了,同學(xué)嗎?那么小氣干嘛?不如自己畫一個(gè)?畢竟我是最會(huì)編程的電工嗎!

image.png

在自定義量角器之前,我先說(shuō)下,小樣就是小樣,本著實(shí)現(xiàn)效果為目的,當(dāng)然可能會(huì)有更好或者更優(yōu)的方式,就像做數(shù)學(xué)題目一樣,答案只有一個(gè),但是解答思路有很多種,當(dāng)然有好的建議和方式下面留言哦!

廢話不多說(shuō),走起!

觀察量角器

拿起桌子上的量角器,看了看,想了想,欸?我還是不記得借我尺子的那個(gè)同學(xué)叫啥來(lái)著?


image.png

Sorry!

這個(gè)量角器嗎?有這幾個(gè)特征。

  • 半圓形(里面有4個(gè)半圓)
  • 刻度線(長(zhǎng)的、中等的、短的)
  • 有刻度值(正向,反向,注:這里0和180度省去,別問(wèn)為啥,因?yàn)椴缓每矗?/li>
  • 測(cè)量輔助線(10的倍數(shù))

像這種純繪制的自定義基本上就是考驗(yàn)對(duì)Canvas API使用和數(shù)學(xué)知識(shí),下面使用Flutter實(shí)現(xiàn)。

具體實(shí)現(xiàn)

創(chuàng)建Widget

1、StatelessWidget Or StatefulWidget

其實(shí)這個(gè)區(qū)分跟簡(jiǎn)單,當(dāng)一個(gè)靜態(tài)的,沒(méi)有狀態(tài)改變的自定義就使用StatelessWidget,否則使用StatefulWidget。因?yàn)槌咦邮且粋€(gè)靜態(tài)的,一旦繪制完畢就不需要去改變了,所以說(shuō)我們直接創(chuàng)建一個(gè) StatelessWidget 就可以,

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

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

我們剛開始一定會(huì)考慮這個(gè)寬高是如何定義,到底是直接外面?zhèn)鬟M(jìn)來(lái)?還是怎么辦?我這里直接采取使用LayoutBuilder,通過(guò)LayoutBuilder,我們可以在布局過(guò)程中拿到父組件傳遞的約束信息,然后我們可以根據(jù)約束信息動(dòng)態(tài)的構(gòu)建不同的布局。

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

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

  • 當(dāng)寬的一半大于等于高的時(shí)候,采取高來(lái)作為半徑。
  • 當(dāng)寬的一半小于高的時(shí)候,采取寬的一半作為半徑。

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

  • 寬 = 2*半徑, 高 = 半徑
3、創(chuàng)建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 坐標(biāo)系

有的時(shí)候,我們要是經(jīng)常不寫這種自定義的話,很容易忘記這個(gè)坐標(biāo)系和畫布在調(diào)用一些api之后的狀態(tài)是啥樣子的?所以我一般是這么做的,先寫個(gè)繪制坐標(biāo)系為了查看當(dāng)時(shí)的坐標(biāo)系情況。

 /*
   * 繪制坐標(biāo)系( X軸  Y軸) 為了查看坐標(biāo)系位置
   */
  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

簡(jiǎn)單繪制一個(gè)坐標(biāo)系,為了更方便了解當(dāng)前的畫布情況。自定義完畢,刪了即可。

4.2 繪制量角器雛形

量角器是一個(gè)半圓,所以我們需要調(diào)用的API是

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

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

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

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

  • paint: 指定繪制的畫筆。

為了更好了解這個(gè)API,我們來(lái)看下案例

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

起始角度為0:

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

上面代碼中degToRad方法是一個(gè)角度到弧度的轉(zhuǎn)換。繪制結(jié)果是這樣的。


image.png

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

起始角度為正數(shù):

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

起始角度為負(fù)數(shù):

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

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

下面直接繪制量角器
  /*
   * 繪制表框(半圓)
   */
  void drawBorder(Canvas canvas, Size size) {
    Rect rect = Rect.fromCircle(
      center: Offset(radius, radius),
      radius: radius - borderStrokeWidth / 2,
    );
    //第四個(gè)參數(shù)設(shè)置為true,因?yàn)槌咦邮情]合的
    canvas.drawArc(rect, -pi, pi, true, borderPaint);
  }
image.png
4.3 繪制刻度線

繪制線使用的API是:

 drawLine(Offset p1, Offset p2, Paint paint)

2點(diǎn)確定一條直線,所以p1 p2就是2點(diǎn)的坐標(biāo),只要我們按照我們需要傳入2個(gè)坐標(biāo)即可,這里就不單獨(dú)案例說(shuō)明了,直接走起。

  • 定位:我想從半圓的左下角開始繪制刻度線。
  • 繪制刻度線:180個(gè)刻度,繪制每個(gè)刻度,其中10的倍數(shù)為大刻度,5結(jié)尾的刻度為中刻度,其他為小刻度,這里0和180度我們省略,因?yàn)槔L制的話和圓弧底線重疊。
定位

這里說(shuō)的定位,意思是操作畫布來(lái)達(dá)到我想要繪制的起點(diǎn),方便我繪制,因?yàn)槲蚁朐谧笙陆情_始繪制刻度線,所以,我執(zhí)行下面的操作。

    //畫布移動(dòng)到(radius, radius)點(diǎn)
    canvas.translate(radius, radius);
    //畫布旋轉(zhuǎn)-90度
    canvas.rotate(degToRad(-90));

這2個(gè)操作后畫布變成啥樣子了,看我們的坐標(biāo)系就明白了。

執(zhí)行一行代碼:

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

看到坐標(biāo)系圓點(diǎn)移動(dòng)到了(radius, radius)上了。

執(zhí)行2行代碼:

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

上圖就是執(zhí)行完移動(dòng)和旋轉(zhuǎn)后坐標(biāo)系的位置。這個(gè)時(shí)候我們可以繪制第一個(gè)刻度,也就是0度刻度線,如果我們需要繪制刻度線為20長(zhǎng)的刻度線怎么做呢?

通過(guò)坐標(biāo)系我們可以知道,0刻度的起始位置 X軸是0,Y軸是-radius(為了好理解,這里面沒(méi)有考慮畫筆的寬度啊,后面繪制會(huì)考慮),如果我們要繪制20長(zhǎng)度刻度的話,兩點(diǎn)坐標(biāo)可以為:

  • Offset(0.0, -(radius - borderStrokeWidth / 2))
  • Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
    這里borderStrokeWidth是畫筆的寬度,因?yàn)橐紤]畫筆所以我們需要減去。
  canvas.drawLine(
      Offset(0.0, -(radius - borderStrokeWidth / 2)),
      Offset(0.0, -(radius - borderStrokeWidth / 2) + 20),
      scalePaint,
    );
image.png

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


image.png

但是我們不好旋轉(zhuǎn)半圓?。吭趺锤?,反過(guò)來(lái)想,我們可以順時(shí)針旋轉(zhuǎn)畫布1刻度可以達(dá)到一樣的效果。來(lái)試試

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

    //順時(shí)針旋轉(zhuǎn) 1度
    canvas.rotate(degToRad(1));
    //查看坐標(biāo)系
    drawXY(canvas);

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

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

  /*
   * 繪制刻度
   */
  void drawScale(Canvas canvas, Size size) {
    canvas.save();
    canvas.translate(radius, radius);
    canvas.rotate(degToRad(-90));
    for (int index = 1; index < 180; index++) {
      //旋轉(zhuǎn)角度
      canvas.rotate(degToRad(1));
      //大刻度
      if (index % 10 == 0) {
        //繪制最長(zhǎng)刻度
        drawLongLine(canvas, size);
      }
      //中刻度
      else if (index % 5 == 0) {
        //繪制中刻度
        drawMiddleLine(canvas, size);
      }
      //小刻度
      else {
        //繪制小刻度
        drawShortLine(canvas, size);
      }
    }
    canvas.restore();
  }
  
   /*
   * 繪制長(zhǎng)線
   */
  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

注意:上面代碼中出現(xiàn)的2行代碼

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

這2位是成對(duì)出現(xiàn),不能單獨(dú)使用,他們的目的就是在你操作畫布(平移,旋轉(zhuǎn)等)之前,先調(diào)用save()方法對(duì)當(dāng)前畫布狀態(tài)的保存,當(dāng)你操作畫布繪制好圖形后,在調(diào)用restore()還原之前畫布的狀態(tài),不影響后面繪制操作。

4.3 繪制刻度值

刻度值是在10的倍數(shù)才繪制,所以我們直接可以在繪制刻度線代碼中,在繪制長(zhǎng)刻度線的地方,多繪制刻度值即可,還有就是這里有2種刻度值,一種順時(shí)針,一種逆時(shí)針,我們只要順時(shí)針直接采用當(dāng)前角度顯示即可,逆時(shí)針使用(180-當(dāng)前角度)即可。
這里因?yàn)樽鴺?biāo)系都是在對(duì)應(yīng)位置,所以直接繪制就行。

  /*
   * 繪制數(shù)字
   */
  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));

    //繪制內(nèi)圈刻度值
    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

這里主要是對(duì)textPainter API的使用,這里主要1個(gè)注意點(diǎn),就是2個(gè)刻度值的間距,通過(guò)控制y坐標(biāo)來(lái)控制下就行,我這里 inNumSize=60,outNumSize=30,具體多少自己根據(jù)自己的審美修改就行,不做過(guò)多的解釋。

4.3 繪制內(nèi)部半圓

因?yàn)槲覀兩厦嬉呀?jīng)了解了繪制半圓的API,所以這里主要注意的點(diǎn)就是控制好半圓和刻度值的位置,避免重疊,其實(shí)也就是UI審美問(wèn)題。

  /*
   * 繪制外半圓
   */
  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);
  }

  /*
   * 繪制內(nèi)半圓
   */
  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 繪制角度測(cè)量輔助線

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

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

什么鬼?不好看,下面那個(gè)輔助線起點(diǎn)太多時(shí),導(dǎo)致比較的密集,所以我想優(yōu)化下,在搞個(gè)小半圓給他蓋住,不讓別人知道你的丑。

4.5 繪制小半圓遮住你的美

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

  /*
   * 繪制最小的半圓
   */
  void drawSmallSemicircle(Canvas canvas, Size size) {
    //繪制半圓區(qū)域
    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

搞定!文章書寫不易,多多關(guān)注!


image.png
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容