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

2021-03-09 09:21 更新

對(duì)于一些復(fù)雜或不規(guī)則的 UI,我們可能無(wú)法通過(guò)組合其它組件的方式來(lái)實(shí)現(xiàn),比如我們需要一個(gè)正六邊形、一個(gè)漸變的圓形進(jìn)度條、一個(gè)棋盤(pán)等。當(dāng)然,有時(shí)候我們可以使用圖片來(lái)實(shí)現(xiàn),但在一些需要?jiǎng)討B(tài)交互的場(chǎng)景靜態(tài)圖片也是實(shí)現(xiàn)不了的,比如要實(shí)現(xiàn)一個(gè)手寫(xiě)輸入面板,這時(shí),我們就需要來(lái)自己繪制 UI 外觀。

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

#CustomPaint

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

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

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

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

#注意

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

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

#CustomPainter

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

void paint(Canvas canvas, Size size);

paint有兩個(gè)參數(shù):

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

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

#畫(huà)筆Paint

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

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

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

#示例:五子棋/盤(pán)

下面我們通過(guò)一個(gè)五子棋游戲中棋盤(pán)和棋子的繪制來(lái)演示自繪UI的過(guò)程,首先我們看一下我們的目標(biāo)效果,如圖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), //指定畫(huà)布大小
        painter: MyPainter(),
      ),
    );
  }
}


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

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


    //畫(huà)棋盤(pán)網(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);
    }


    //畫(huà)一個(gè)黑子
    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,
    );

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


  //在實(shí)際場(chǎng)景中正確利用此回調(diào)可以避免重繪開(kāi)銷(xiāo),本示例我們簡(jiǎn)單的返回true
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

#性能

繪制是比較昂貴的操作,所以我們?cè)趯?shí)現(xiàn)自繪控件時(shí)應(yīng)該考慮到性能開(kāi)銷(xiāo),下面是兩條關(guān)于性能優(yōu)化的建議:

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

#總結(jié)

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

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)