Flutter自定義View的實現

縱馬天下發表於2021-06-01

上一篇中(Flutter自定義Banner的實現)banner的indicator實現就是採取了自定義view的形式。在這篇文章中我們來重點介紹一下自定義view是如何實現的。在Android中如果要自定義view的話,需要繼承View,在他的onDraw方法中用畫筆(paint)在畫布(canvas)上繪製相應的內容,如果要觸發重繪的話只需呼叫invalidate即可。同樣在這裡我們需要繼承CustomPainter,在它的paint方法中來繪製相應的內容,通過shouldRepaint的返回值來判斷是否需要重繪。接下來我們就詳細介紹一下它的使用,本篇的內容會以下圖的內容作為示例

device-2021-05-31-181914.gif

CustomPainter是作為CustomPaint的子Widget來使用的。那麼就首先來看一下CustomPaint的構造方法

const CustomPaint({
  Key? key,
  this.painter,
  this.foregroundPainter,
  this.size = Size.zero,
  this.isComplex = false,
  this.willChange = false,
  Widget? child,
})
複製程式碼

這裡的painter以及foregroundPainter都是CustomPainter,他們分別表示child的背景和前景,而Size則表示大小。注意如果同時指定了child和Size那麼最終的大小以child的尺寸為準。對於自定義view我們可以分為以下幾步:

  • 建立繼承CustomPainter的類。比如這裡的class ProgressRingPainter extends CustomPainter
  • 在類中的paint方法繪製想要的內容:在這個方法中會得到畫布canvas以及控制元件的尺寸Size,我們只需要利用畫筆(paint)在畫布(canvas)繪製相應的內容即可。
  • 在類中的shouldRepaint方法返回需要重繪的條件:如果返回true則會在相應的值變化時會觸發重繪。
  • 如果需要做動畫那麼還需要在自定義類的建構函式中新增:super(repaint:xxx)就能自動的新增監聽器。這樣當值變化時就能自動的觸發重繪。具體原因如下
    • 類CustomPainter結構如下:可以看到如果我們新增了super方法其實是為這裡的repaint賦值。它是一個listenable物件

image.png

- 我們在CustomPaint中指定painter時,在建立這個CustomPaint時會呼叫painter的set方法。
複製程式碼

image.png

- 這裡主要是_didUpdatePainter方法
複製程式碼

image.png

-  呼叫了addListenter方法,這個addListener方法就是CustomPainter類中定義的,可以看到我們用剛剛傳進去的repaint物件新增了監聽器。所以我們可以在CustomPainter中如此更新頁面。   
複製程式碼

原理我們就先介紹到這兒。接下來我們在介紹一些基礎知識,其實自定義view可以總結為在畫布上用畫筆畫出想要的圖形。所以又三個關鍵字畫筆畫布圖形

  1. 畫筆:Paint,這個是我們必須用到的。常用的屬性有color(設定畫筆顏色),style(描邊還是填充對應於PaintingStyle.stroke及PaintingStyle.fill),strokeWidth(描邊的寬度),isAntiAlias(是否抗鋸齒),shader(繪製漸變色常用的LinearGradient線性漸變,SweepGradient掃描漸變,RadialGradient輻射式漸變)。這裡就不在深入的舉例了,如果感興趣的話可以把這些屬性設定給畫筆就可以看到效果。

  2. 畫布:Canvas,利用畫筆在畫布上繪製內容其實就是利用canvas.drawxxx(xxx,paint)來實現的)這裡的xxx其實就是下圖中的內容

    image.png

    比如這裡的drawPath則是按照指定的路徑Path繪製,drawCircle則是繪製圓形,drawLine則是繪製直線等等。畫布不僅僅可以繪製,他還可以變換和裁剪。比如常用的平移canvas.translate,旋轉canvas.rotate,裁剪canvas.clipRect。為了確保所畫的內容不超過控制元件大小通常在繪製開始時會剪裁畫布的大小,讓畫布的大小為設定的Size,然後將座標系原點移動到view的中間(系統座標系是在左上角,向右及向下分別為x及y軸正向)。

    image.png

  3. 圖形:Path,這裡叫做圖形其實並不準確,通過上面我們可以知道通過canvas我們可以直接畫圓形,方形,點,線等。但如果比較複雜的比如畫貝賽爾曲線,這就需要Path,所以這裡重點介紹一下Path。canvas.drawxxx實現的圖形Path都可以實現。比如canvas.drawLine則可以通過path.lineTo實現,canvas.drawCircle則可以通過path.addOval來實現。對於Path還有很多自己的操作

    • close:將首尾連線形成閉合路徑(path.close())
    • reset:重置路徑,清空內容(path.reset())
    • shift:路徑平移,且返回一條新的路徑,比如畫布上只有一個path是一個三角形,執行path.shift(40,0)的話就是在原來三角形右側40的地方再畫一個一模一樣的三角形,整個畫布上到目前為止有兩個三角形
    • contains:判斷某個點是否在path中,可以用來做觸點判斷和碰撞檢測(path.contains(Offset(20, 20))
    • getBounds:當前path所在的矩形區域,返回的是一個Rect。(Rect bounds = path.getBounds();)
    • transform:路徑變換。對於對稱性圖案,當已經有一部分單體路徑,可以根據一個4*4的矩陣對路徑進行變換。可以使用Matrix4物件進行輔助生成矩陣。能很方便進行旋轉、平移、縮放、斜切等變換效果。
    • combine:路徑聯合,可用於複雜路徑的生成。canvas.drawPath(Path.combine(PathOperation.xor, path1, path2), paint);這裡需要注意的其實就是PathOperation的值。xor則是繪製path1與path2但不繪製二者重合的部分;difference則是僅繪製path1而且與path2重合的內容不繪製;reverseDifference則是僅繪製path2而且與path1重合的部分不繪製;intersect則是繪製path1與path2的交集,union則是繪製path1與path2的並集
    • computeMetrics:通過path.computeMetrics(),可以獲取一個可迭代PathMetrics類物件 它迭代出的是PathMetric物件,也就是每個路徑的測量資訊。也就是說通過path.computeMetrics()你獲得是一組路徑的測量資訊。這些資訊包括路徑長度 length、路徑索引 contourIndex 及 isClosed路徑是否閉合isClosed。
    • 根據測量的路徑獲取位置資訊:比如我想要在路徑一半的地方繪製一個小球,如果通過自己計算的話,非常困難。幸運的是通過路徑測量,實現起來就非常方便。甚至還能得到改點的角度、速度資訊。下面通過pm.length * 0.5表示在路徑長度50%的點的資訊。pm.getTangentForOffset則是獲取在路徑上某處的正切值,通過這個正切值我們可以拿到改點的座標以及角度速度資訊。比如做按軌跡運動的動畫,就可以按動畫進度(progress)更新路徑上點的位置就行,也就是下面的pm.length*progress。
    • 根據動畫的進度來繪製軌跡:這個就用到了PathMetrics中的extractPath方法,Path extractPath(double start, double end, {bool startWithMoveTo = true})

    至此準備工作已經完成。接下來真正開始繪製我們的進度條。首先我們分析一下

  • 最外層有一個灰色的圓環,這個比較好實現其實就是畫筆採用描邊的方式畫一個圓形,圓環的寬度就是描邊的寬度。所以這裡需要一個畫筆paint,而且他填充方式為stroke
  • 外層則是隨著進度的不斷增加逐漸繪製的粉紅色圓環,本質上也是圓環,只是繪製的多少問題。這裡是按照動畫的進度或者說是我們給定的進度來繪製相應的長度。所以這裡就用到了我們上面所說的路徑的測量與擷取,所以這裡需要一個PathMetric來擷取當前的路徑一點一點繪製,這個一點一點就是進度,通過開篇的分析可以知道這裡需要一個Listenable物件,這裡我們採用Animation<double>,當然也可以採用ValueNotifier等。
  • 剩下的就是文字的繪製,這裡我們採用CustomPaint的child實現(為了簡化自定義view的繪製以及理解CustomPaint的child屬性)。

按照上面說的自定義view步驟我們建立ProgressRingPainter類讓其繼承CustomPainter。並定義我們需要的引數(當然這裡的底色,進度條顏色,圓環寬度等都應該抽成變數支援配置,這裡就偷個懶)

class ProgressRingPainter extends CustomPainter {
  //畫筆
  Paint _paint;
  //進度
  final Animation<double> progress;
  //路徑測量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
   ...
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
   ...
  }
}
複製程式碼

可以看到在構造方法中新增了super:(repaint:progress),也就是說在這個值發生變化時會嘗試重繪。在構造方法中我們通過path.addOval方式來繪製圓形並且對該條路徑進行測量得到PathMetric物件。 接下來我們先實現觸發重繪的條件,其實就是上一次的progress和現在的不一致就行

@override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
複製程式碼

接下來才是重要的繪製方法paint,直接上程式碼

@override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);

    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }
複製程式碼

我們按照之前說的首先將畫布裁剪為大小為Size的矩形,防止繪製超出範圍(如果不裁剪繪製超出Size大小也會顯示感興趣的可以試一下),這裡不僅僅可以剪裁為矩形也可以使用clipRRect剪裁為圓角矩形,也可以使用CilpPath按特定形狀剪裁。然後我們將座標系原點移動到view的中心;接著繪製了一個靜態的圓環,最後通過drawPath來繪製可變的進度,當然這裡獲取了整個路徑的長度,通過百分比來繪製。對於可變的圓環我們就繪製完畢,完整程式碼如下

class ProgressRingPainter extends CustomPainter {
  //畫筆
  Paint _paint;
  //進度
  final Animation<double> progress;
  //路徑測量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);
    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
}

複製程式碼

剩下就是文字部分,隨著進度的不斷變化,文字會不斷的重繪,想達到整個效果要麼是整個StatefullWidget呼叫setState重建,要麼就是文字控制元件自身重新整理,我們採用後者。因為更新進度的Animation(abstract class Animation<T> extends Listenable implements ValueListenable<T> )他繼承了Listenable,只要接收他的通知就可以重建view。這裡我們採用ValueListenableBuilder來建立這個Text

class ValueListenableBuilder<T> extends StatefulWidget {
  /// Creates a [ValueListenableBuilder].
  ///
  /// The [valueListenable] and [builder] arguments must not be null.
  /// The [child] is optional but is good practice to use if part of the widget
  /// subtree does not depend on the value of the [valueListenable].
  const ValueListenableBuilder({
    Key? key,
    required this.valueListenable,
    required this.builder,
    this.child,
  }) : assert(valueListenable != null),
       assert(builder != null),
       super(key: key);

  /// The [ValueListenable] whose value you depend on in order to build.
  ///
  /// This widget does not ensure that the [ValueListenable]'s value is not
  /// null, therefore your [builder] may need to handle null values.
  ///
  /// This [ValueListenable] itself must not be null.
  final ValueListenable<T> valueListenable;
      ....
  }
複製程式碼

可以看到他有兩個必要的引數,valueListenable則是傳入可變化的變數這裡正好是動畫的進度,builder則是根據這個變化的值來構建新的widget,builder的實現如下

Widget buildText(BuildContext context, double value, Widget child) {
    return Text("${(value * 100).toInt()}%");
  }
複製程式碼

至此各部分的實現都已完成,所以使用的完整程式碼如下

class ProgressRing extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _StateProgressRing();
}

class _StateProgressRing extends State<ProgressRing>
    with SingleTickerProviderStateMixin {
  AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 10),
    )..repeat(reverse: true);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPaint'),
      ),
      body: Center(
        child: CustomPaint(
          size: Size(100, 100),
          painter: ProgressRingPainter(controller),
          child: Container(
            width: 100,
            height: 100,
            child: Center(
              child: ValueListenableBuilder(
                valueListenable: controller,
                builder: buildText,
              ),
            ),
          ),
        ),
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();

    super.dispose();
  }

  Widget buildText(BuildContext context, double value, Widget child) {
    return Text("${(value * 100).toInt()}%");
  }
}

class ProgressRingPainter extends CustomPainter {
  //畫筆
  Paint _paint;
  //進度
  final Animation<double> progress;
  //路徑測量
  PathMetric pathMetric;

  ProgressRingPainter(this.progress) : super(repaint: progress) {
    Path  _path = Path();
    _path.addOval(Rect.fromCenter(center: Offset(0, 0), width: 90, height: 90));
    pathMetric = _path.computeMetrics().first;

    _paint = Paint()
      ..color = Colors.black38
      ..strokeWidth = 10
      ..style = PaintingStyle.stroke;
  }

  @override
  void paint(Canvas canvas, Size size) {
    canvas.clipRect(Offset.zero & size);
    canvas.translate(size.width / 2, size.height / 2);
    canvas.drawCircle(Offset(0, 0), size.width / 2 - 5, _paint);

    // canvas.rotate(-pi / 2);

    canvas.drawPath(
        pathMetric.extractPath(
          0,
          pathMetric.length * progress.value,
        ),
        Paint()
          ..color = Colors.pinkAccent
          ..strokeWidth = 10
          ..style = PaintingStyle.stroke);
  }

  @override
  bool shouldRepaint(covariant ProgressRingPainter oldDelegate) {
    return progress.value != oldDelegate.progress.value;
  }
}

複製程式碼

自定義view已實現,還請大家批評指正。

相關文章