Flutter實戰(zhàn) 自繪組件(CustomPaint與Canvas)

2021-03-09 09:21 更新

對于一些復雜或不規(guī)則的 UI,我們可能無法通過組合其它組件的方式來實現(xiàn),比如我們需要一個正六邊形、一個漸變的圓形進度條、一個棋盤等。當然,有時候我們可以使用圖片來實現(xiàn),但在一些需要動態(tài)交互的場景靜態(tài)圖片也是實現(xiàn)不了的,比如要實現(xiàn)一個手寫輸入面板,這時,我們就需要來自己繪制 UI 外觀。

幾乎所有的 UI 系統(tǒng)都會提供一個自繪 UI 的接口,這個接口通常會提供一塊 2D 畫布Canvas,Canvas內部封裝了一些基本繪制的 API,開發(fā)者可以通過Canvas繪制各種自定義圖形。在 Flutter 中,提供了一個CustomPaint 組件,它可以結合畫筆CustomPainter來實現(xiàn)自定義圖形繪制。

#CustomPaint

我們看看CustomPaint構造函數(shù):

CustomPaint({
  Key key,
  this.painter, 
  this.foregroundPainter,
  this.size = Size.zero, 
  this.isComplex = false, 
  this.willChange = false, 
  Widget child, //子節(jié)點,可以為空
})

  • painter: 背景畫筆,會顯示在子節(jié)點后面;
  • foregroundPainter: 前景畫筆,會顯示在子節(jié)點前面
  • size:當 child 為 null 時,代表默認繪制區(qū)域大小,如果有 child 則忽略此參數(shù),畫布尺寸則為 child 尺寸。如果有 child 但是想指定畫布為特定大小,可以使用 SizeBox 包裹 CustomPaint 實現(xiàn)。
  • isComplex:是否復雜的繪制,如果是,F(xiàn)lutter 會應用一些緩存策略來減少重復渲染的開銷。
  • willChange:和isComplex配合使用,當啟用緩存時,該屬性代表在下一幀中繪制是否會改變。

可以看到,繪制時我們需要提供前景或背景畫筆,兩者也可以同時提供。我們的畫筆需要繼承CustomPainter類,我們在畫筆類中實現(xiàn)真正的繪制邏輯。

#注意

如果CustomPaint有子節(jié)點,為了避免子節(jié)點不必要的重繪并提高性能,通常情況下都會將子節(jié)點包裹在RepaintBoundary組件中,這樣會在繪制時就會創(chuàng)建一個新的繪制層(Layer),其子組件將在新的 Layer 上繪制,而父組件將在原來 Layer 上繪制,也就是說RepaintBoundary 子組件的繪制將獨立于父組件的繪制,RepaintBoundary會隔離其子節(jié)點和CustomPaint本身的繪制邊界。示例如下:

CustomPaint(
  size: Size(300, 300), //指定畫布大小
  painter: MyPainter(),
  child: RepaintBoundary(child:...)), 
)

#CustomPainter

CustomPainter中提定義了一個虛函數(shù)paint

void paint(Canvas canvas, Size size);

paint有兩個參數(shù):

  • Canvas:一個畫布,包括各種繪制方法,我們列出一下常用的方法:
API名稱 功能
drawLine 畫線
drawPoint 畫點
drawPath 畫路徑
drawImage 畫圖像
drawRect 畫矩形
drawCircle 畫圓
drawOval 畫橢圓
drawArc 畫圓弧

  • Size:當前繪制區(qū)域大小。

#畫筆Paint

現(xiàn)在畫布有了,我們最后還缺一個畫筆,F(xiàn)lutter 提供了Paint類來實現(xiàn)畫筆。在Paint中,我們可以配置畫筆的各種屬性如粗細、顏色、樣式等。如:

var paint = Paint() //創(chuàng)建一個畫筆并配置其屬性
  ..isAntiAlias = true //是否抗鋸齒
  ..style = PaintingStyle.fill //畫筆樣式:填充
  ..color=Color(0x77cdb175);//畫筆顏色

更多的配置屬性讀者可以參考Paint類定義。

#示例:五子棋/盤

下面我們通過一個五子棋游戲中棋盤和棋子的繪制來演示自繪UI的過程,首先我們看一下我們的目標效果,如圖10-3所示:

圖10-3

代碼:

import 'package:flutter/material.dart';
import 'dart:math';


class CustomPaintRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        size: Size(300, 300), //指定畫布大小
        painter: MyPainter(),
      ),
    );
  }
}


class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    double eWidth = size.width / 15;
    double eHeight = size.height / 15;

      
    //畫棋盤背景
    var paint = Paint()
      ..isAntiAlias = true
      ..style = PaintingStyle.fill //填充
      ..color = Color(0x77cdb175); //背景為紙黃色
    canvas.drawRect(Offset.zero & size, paint);


    //畫棋盤網(wǎng)格
    paint
      ..style = PaintingStyle.stroke //線
      ..color = Colors.black87
      ..strokeWidth = 1.0;


    for (int i = 0; i <= 15; ++i) {
      double dy = eHeight * i;
      canvas.drawLine(Offset(0, dy), Offset(size.width, dy), paint);
    }


    for (int i = 0; i <= 15; ++i) {
      double dx = eWidth * i;
      canvas.drawLine(Offset(dx, 0), Offset(dx, size.height), paint);
    }


    //畫一個黑子
    paint
      ..style = PaintingStyle.fill
      ..color = Colors.black;
    canvas.drawCircle(
      Offset(size.width / 2 - eWidth / 2, size.height / 2 - eHeight / 2),
      min(eWidth / 2, eHeight / 2) - 2,
      paint,
    );

      
    //畫一個白子
    paint.color = Colors.white;
    canvas.drawCircle(
      Offset(size.width / 2 + eWidth / 2, size.height / 2 - eHeight / 2),
      min(eWidth / 2, eHeight / 2) - 2,
      paint,
    );
  }


  //在實際場景中正確利用此回調可以避免重繪開銷,本示例我們簡單的返回true
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

#性能

繪制是比較昂貴的操作,所以我們在實現(xiàn)自繪控件時應該考慮到性能開銷,下面是兩條關于性能優(yōu)化的建議:

  • 盡可能的利用好shouldRepaint返回值;在 UI 樹重新 build 時,控件在繪制前都會先調用該方法以確定是否有必要重繪;假如我們繪制的 UI 不依賴外部狀態(tài),那么就應該始終返回false,因為外部狀態(tài)改變導致重新 build 時不會影響我們的 UI 外觀;如果繪制依賴外部狀態(tài),那么我們就應該在shouldRepaint中判斷依賴的狀態(tài)是否改變,如果已改變則應返回true來重繪,反之則應返回false不需要重繪。
  • 繪制盡可能多的分層;在上面五子棋的示例中,我們將棋盤和棋子的繪制放在了一起,這樣會有一個問題:由于棋盤始終是不變的,用戶每次落子時變的只是棋子,但是如果按照上面的代碼來實現(xiàn),每次繪制棋子時都要重新繪制一次棋盤,這是沒必要的。優(yōu)化的方法就是將棋盤單獨抽為一個組件,并設置其shouldRepaint回調值為false,然后將棋盤組件作為背景。然后將棋子的繪制放到另一個組件中,這樣每次落子時只需要繪制棋子。

#總結

自繪控件非常強大,理論上可以實現(xiàn)任何2D圖形外觀,實際上 Flutter 提供的所有組件最終都是通過調用 Canvas 繪制出來的,只不過繪制的邏輯被封裝起來了,讀者有興趣可以查看具有外觀樣式的組件源碼,找到其對應的RenderObject對象,如Text對應的RenderParagraph對象最終會通過Canvas實現(xiàn)文本繪制邏輯。下一節(jié)我們會再通過一個自繪的圓形背景漸變進度條的實例來幫助讀者加深印象。

以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號