Flutter自定義繪製Widget初探

銅板街技術發表於2019-04-17

我們知道,應用開發如果單純只靠系統提供的控制元件,對於那些較為絢爛介面效果來說是遠遠不夠的,這就需要開發者自己去自定義繪製Widget。

當然,自定義Widget講究靈活性,同一種效果可以由多種實現方案,我們要做的就是找到代價最小、最高效的解決方案。

Flutter自定義繪製Widget

從如何使用Canvas draw/paint我們瞭解到,在Flutter中使用自繪方式自定義Widget,大致需要以下步驟:

  • 繼承CustomPainter並重寫paint方法和shouldRepaint方法

  • 在寫paint方法中繪製內容

  • 使用CustomPaint來構建Widget

舉個例子,比如下面程式碼就實現了30*30的網格

class BackGrid extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      child: Center(
        // 使用CustomPaint
        child: CustomPaint(
          size: Size(300, 300),
          painter: MyPainter(),
        ),
      ),
    );
  }
}

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

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

    //畫棋盤網格
    paint
      ..style = PaintingStyle.stroke //線
      ..color = Color(0xFF888888)
      ..strokeWidth = 1.0;

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

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

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}複製程式碼

顯示效果:

Flutter自定義繪製Widget初探
                                                   image.png

1、Flutter繪製相關知識

和Android開發中的自定義View類似,Flutter中的繪製也是依靠Canvas和Paint來實現的

1.1 Canvas

畫布,為開發者提供了點、線、矩形、圓形、巢狀矩形等繪製方法。

1.2 Paint

畫筆,可以設定抗鋸齒,畫筆顏色,粗細,填充模式等屬性,繪製時可以定義多個畫筆以滿足不同的繪製需求。

1.3 Offset

座標,可以用來表示某個點在畫布中的座標位置。

1.4 Rect

矩形,在圖形的繪製中,一般都是分割槽域繪製的,這個區域一般都是一個矩形,在繪製中通常使用Rect來儲存繪製的位置資訊。

1.5 座標系

在Flutter中,座標系原點(0,0)位於左上角,X軸向右變大,Y軸向下變大。

下面我們看一下Paint的常用基本屬性設定

Paint _paint = new Paint()
// 畫筆顏色
..color = Colors.red 
// 畫筆筆觸型別
// round-畫筆筆觸呈半圓形輪廓開始和結束
// butt-筆觸開始和結束邊緣平坦,沒有外延
// square-筆觸開始和結束邊緣平坦,向外延伸長度為畫筆寬度的一半
..strokeCap = StrokeCap.round
//是否啟動抗鋸齒
..isAntiAlias = true
//繪畫風格,預設為填充,有fill和stroke兩種
..style=PaintingStyle.fill
..blendMode=BlendMode.exclusion//顏色混合模式
..colorFilter=ColorFilter.mode(Colors.blueAccent, BlendMode.exclusion)//顏色渲染模式
..maskFilter=MaskFilter.blur(BlurStyle.inner, 3.0)//模糊遮罩效果
..filterQuality=FilterQuality.high//顏色渲染模式的質量
..strokeWidth = 15.0;//畫筆的寬度複製程式碼

2、Flutter繪製方法介紹

接下來我們瞭解一下Flutter中Canvas的各種繪製方法

PS:以下各種繪製方法所使用的_paint定義如下:

var _paint = Paint()
..color = Color(0xFFFf0000)
..strokeWidth = 4
..style = PaintingStyle.stroke
..isAntiAlias = true;複製程式碼

2.1 繪製點

List<Offset> points = [
  Offset(0, 0),
  Offset(30, 50),
  Offset(20, 80),
  Offset(100, 40),
  Offset(150, 90),
  Offset(60, 110),
  Offset(260, 160),
];
canvas.drawPoints(PointMode.points, points, _paint);
複製程式碼

PointMode是一個列舉類:

enum PointMode {
  points,// 繪製點
  lines,// 繪製點,且陣列內隔點相連,如1-2相連,3-4相連。如最後只剩下一個點,則不去繪製該點
  polygon,// 陣列內相鄰點連線
}複製程式碼
Flutter自定義繪製Widget初探
                                                                    image-2.png

2.2 繪製線

var _startPoint = Offset(30, 30);// 起始點
var _endPoint = Offset(100, 170);// 終點
canvas.drawLine(_startPoint, _endPoint, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                         image-3.png

2.3 繪製矩形

首先我們瞭解一下矩形Rect的構建:

// 利用矩形左邊的X座標、矩形頂部的Y座標、矩形右邊的X座標、矩形底部的Y座標確定矩形的大小和位置
Rect.fromLTRB(double left, double top, double right, double bottom)
// 利用矩形的左邊的X軸、頂邊的Y軸位置配合設定矩形的長和寬來確定矩形的大小和位置
Rect.fromLTWH(double left, double top, double width, double height)
// 利用矩形的中心點和矩形所在圓的半徑來確定矩形的大小和位置,此方法確定的是一個正方形
Rect.fromCircle({ Offset center, double radius })
// 利用矩形的左上角和右下角的座標確定矩形的大小和位置,
// 需要注意的是兩點座標中,如果X軸相同或者Y軸相同,確定的是一條線,
// 如果兩點XY座標都是同一個數字,確定的是一個點。
Rect.fromPoints(Offset a, Offset b) 複製程式碼

繪製矩形的方法

void drawRect(Rect rect, Paint paint)複製程式碼
Flutter自定義繪製Widget初探
                                                                      image-4.png

2.4 繪製圓角矩形

圓角矩形的構建方式有以下幾種:

// 利用矩形的左邊的X座標、矩形頂部的Y座標、矩形右邊的X座標、矩形底部的Y座標
// 以及可選的四個頂點圓角來確定圓角矩形
RRect.fromLTRBAndCorners(
    double left,
    double top,
    double right,
    double bottom, {
    Radius topLeft: Radius.zero,
    Radius topRight: Radius.zero,
    Radius bottomRight: Radius.zero,
    Radius bottomLeft: Radius.zero,
})

// 需要先定義一個Rect,使用方式和fromLTRBAndCorners類似
RRect.fromRectAndCorners(
    Rect rect,
    {
      Radius topLeft: Radius.zero,
      Radius topRight: Radius.zero,
      Radius bottomRight: Radius.zero,
      Radius bottomLeft: Radius.zero
    }
  )

// 頂點X軸Y軸的圓角設定相同
const Radius.circular(double radius)
// 頂點X軸Y軸的圓角設定可以不相同
const Radius.elliptical(this.x, this.y)複製程式碼

繪製圓角矩形的方法

void drawRRect(RRect rrect, Paint paint)複製程式碼
Flutter自定義繪製Widget初探
                                                          image-5.png

// 利用矩形的左邊的X座標、矩形頂部的Y座標、矩形右邊的X座標、矩形底部的Y座標
// 以及一個Radius來確定圓角矩形
RRect.fromLTRBR(double left, double top, double right, double bottom, Radius radius)

// 需要先定義一個Rect,使用方式和fromLTRBR類似
RRect.fromRectAndRadius(Rect rect, Radius radius)複製程式碼
Flutter自定義繪製Widget初探
                                                               image-6.png

// 利用矩形的左邊的X座標、矩形頂部的Y座標、矩形右邊的X座標、矩形底部的Y座標
// 以及設定四個頂點的X軸相同圓角,設定四個頂點的Y軸相同圓角來確定圓角矩形
RRect.fromLTRBXY(double left, double top, double right, double bottom, double radiusX, double radiusY)

// 需要先定義一個Rect,使用方式和fromLTRBXY類似
RRect.fromRectXY(Rect rect, double radiusX, double radiusY)複製程式碼
Flutter自定義繪製Widget初探
                                                                image-7.png

圓角矩形繪製有一個需要注意的地方,如果設定矩形的半徑和圓角的半徑相等,則繪製出來的是一個圓

// 設定矩形的半徑和圓角的半徑不相等,效果如下面左圖
Rect rect = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 50.0);
RRect rRect = RRect.fromRectAndRadius(rect, Radius.circular(10.0));
// 設定矩形的半徑和圓角的半徑相等,效果如下面右圖
Rect rect = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 50.0);
RRect rRect = RRect.fromRectAndRadius(rect, Radius.circular(50.0));複製程式碼
Flutter自定義繪製Widget初探
                                                                image-8.png

2.5 繪製雙圓角矩形

Flutter中提供了繪製雙圓角矩形的方法

void drawDRRect(RRect outer, RRect inner, Paint paint)複製程式碼

這裡需要注意的是,內圓角矩形的半徑不能大於外圓角矩形的半徑,否則無法繪製。

// 第一種:外圓角矩形的半徑大於內圓角矩形半徑,如下面左圖
Rect rectOut = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 80.0);
Rect rectInner = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 40.0);
// 第二種:外圓角矩形的半徑等於內圓角矩形半徑,如下面中圖
Rect rectOut = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 80.0);
Rect rectInner = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 80.0);
// 第三種:外圓角矩形的半徑小於內圓角矩形半徑,無法繪製,如下面右圖
Rect rectOut = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 80.0);
Rect rectInner = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 81.0);複製程式碼

接下來繪製上面三種情況下的雙圓角矩形

RRect rRectOut = RRect.fromRectAndRadius(rectOut, Radius.circular(10.0));
RRect rRectInner = RRect.fromRectAndRadius(rectInner, Radius.circular(30.0));

canvas.drawDRRect(rRectOut, rRectInner, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                                   image-9.png

2.6 繪製圓

// 根據圓心座標和圓的半徑確定圓的位置和大小void drawCircle(Offset c, double radius, Paint paint)複製程式碼

畫一個圓

// 繪製圓,Paint預設填充PaintingStyle.fill,如下面左圖canvas.drawCircle(Offset(100.0, 100.0), 50.0, _paint);// 繪製圓,Paint設定為不填充,如下面右圖_paint.style = PaintingStyle.stroke;canvas.drawCircle(Offset(100.0, 100.0), 50.0, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                                 image-10.png

2.7 繪製橢圓

// Rect矩形區域的確定參見前文
void drawOval(Rect rect, Paint paint)
// 橢圓的寬度大於高度,如下面左圖
Rect rect= Rect.fromPoints(Offset(50.0, 50.0), Offset(130.0, 100.0));

// 橢圓的寬度小於高度,如下面中圖
Rect rect= Rect.fromPoints(Offset(40.0, 80.0), Offset(80.0, 170.0));

// 橢圓的寬度等於高度,繪製出的是圓,如下面右圖
Rect rect= Rect.fromPoints(Offset(80.0, 70.0), Offset(180.0, 170.0));複製程式碼

繪製一個橢圓

canvas.drawOval(rect, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                                  image-11.png

2.8 繪製圓弧

// rect:矩形區域// startAngle:開始的弧度// sweepAngle:掃過的弧度,正數順時針,負數逆時針// useCenter:是否使用中心點繪製void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)複製程式碼

弧度

一週的弧度數為2πr/r=2π,360°角=2π弧度。

因此,1弧度約為57.3°,即57°17’44.806’’,1°為π/180弧度,近似值為0.01745弧度。
周角為2π弧度,平角(即180°角)為π弧度,直角為π/2弧度。

下表是一些特殊的角度和弧度之間的換算

角度弧度
0
30°π/6
45°π/4
60°π/3
90°π/2
120°2π/3
180°π
270°3π/2
360°

使用π需要引入:

import 'dart:math';複製程式碼

下面我們嘗試繪製一些圓弧

var rect = Rect.fromCircle(center: Offset(100.0, 100.0), radius: 50.0);
// 開始度數為90度,順時針掃度數90度,如下方左圖
canvas.drawArc(rect, -pi / 2,  pi / 2, false, _paint);

// 開始度數為0度,順時針掃度數60度,如下方中圖
canvas.drawArc(rect, 0,  pi / 3, false, _paint);

// 開始度數為0度,逆時針掃度數180度,使用中心點繪製,如下方右圖
canvas.drawArc(rect, 0,  -pi, true, _paint);複製程式碼
Flutter自定義繪製Widget初探

                                                                  image-12.png

2.9 繪製路徑

void drawPath(Path path, Paint paint)複製程式碼

Path方法介紹

// 將路徑的起始點移動到指定的座標點上
void moveTo(double x, double y)

// 將起始點和終點連成線段,如果沒有顯式的指明起始點座標,預設為(0,0)
void lineTo(double x, double y)
// 起始點(30,30),終點(100,100),如下面左圖
Path path = new Path();
path.moveTo(30, 30);
path.lineTo(100, 100);
canvas.drawPath(path, _paint);

// 起始點預設(0,0),終點(100,100),如下面右圖
Path path = new Path();
path.lineTo(100, 100);
canvas.drawPath(path, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                              image-13.png

// 相對於當前位置座標在X軸上偏移dx,在Y軸上偏移dy
void relativeMoveTo(double dx, double dy)
// 起始點(0,0),和(20,20)之間連線,後移動到(30,30),和(70,70)之間連線
Path path = new Path();
path.lineTo(20, 20);
path.moveTo(30, 30);
path.lineTo(70, 70);
canvas.drawPath(path, _paint);

// 起始點(0,0),和(20,20)之間連線,
// 後以當前(20,20)座標為起始點X軸偏移30,Y軸偏移30,
// 即偏移後的新座標(50,50)和(70,70)之間連線
Path path = new Path();
path.lineTo(20, 20);
path.relativeMoveTo(30, 30);
path.lineTo(70, 70);
canvas.drawPath(path, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                               image-14.png

// 確定相對於當前位置座標,在X軸上偏移dx,在Y軸上偏移dy後的新座標,
// 確定好之後當前位置座標和新座標之間連線
void relativeLineTo(double dx, double dy)
// (0,0)和(10,10)連線,(10,10)和(20,20)連線,如下面左圖
Path path = new Path();
path.lineTo(10, 10);
path.lineTo(20, 20);
canvas.drawPath(path, _paint);

// (0,0)和(10,10)連線,(10,10)和當前位置偏移後的新座標(30,30)連線
Path path = new Path();
path.lineTo(10, 10);
path.relativeLineTo(20, 20);
canvas.drawPath(path, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                           image-15.png

// 繪製二階貝塞爾曲線
// 繪製需要一個起始點、一個終點和一個控制點
// 此方法的前兩個引數是控制點的座標,後兩個引數是終點的座標
void quadraticBezierTo(double x1, double y1, double x2, double y2)

// 繪製二階貝塞爾曲線
// 繪製需要一個起始點、一個終點和一個控制點
// 此方法的前兩個引數是控制點的座標,後兩個引數是終點的座標
// 和quadraticBezierTo不同在於終點座標的位置是當前起點位置座標X軸偏移x2,Y軸偏移y2後的座標
// 舉例(略)
void relativeQuadraticBezierTo(double x1, double y1, double x2, double y2)
// 繪製曲線,如下面左圖
Path path = new Path();
path.moveTo(150, 100);
path.quadraticBezierTo(250, 40, 250, 200);
canvas.drawPath(path, _paint);
// 繪製控制點
_paint.strokeWidth = 8;
_paint.color = Colors.blue;
canvas.drawPoints(PointMode.points, [Offset(250, 40)], _paint);
// 繪製曲線,如下面中圖
Path path = new Path();
path.moveTo(150, 100);
path.quadraticBezierTo(200, 200, 250, 200);
canvas.drawPath(path, _paint);
// 繪製控制點
_paint.strokeWidth = 8;
_paint.color = Colors.blue;
canvas.drawPoints(PointMode.points, [Offset(200, 200)], _paint);
// 繪製曲線,如下面右圖
Path path = new Path();
path.moveTo(150, 100);
path.quadraticBezierTo(60, 130, 150, 200);
canvas.drawPath(path, _paint);
// 繪製控制點
_paint.strokeWidth = 8;
_paint.color = Colors.blue;
canvas.drawPoints(PointMode.points, [Offset(60, 130)], _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                                 image-16.png

// 繪製三階貝塞爾曲線
// 繪製需要一個起始點、一個終點和兩個控制點
// 此方法的前四個引數分別是兩個控制點的XY座標,後兩個引數是終點的座標
void cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)

// 繪製三階貝塞爾曲線
// 繪製需要一個起始點、一個終點和兩個控制點
// 此方法的前四個引數分別是兩個控制點的XY座標,後兩個引數是終點的座標
// 和cubicTo不同在於終點座標的位置是當前起點位置座標X軸偏移x2,Y軸偏移y2後的座標
// 舉例(略)
void relativeCubicTo(double x1, double y1, double x2, double y2, double x3, double y3)複製程式碼

下面使用cubicTo繪製心形效果

var width = 300;
var height = 300;

Path leftPath = new Path();
// 畫筆移動到左邊曲線起點
leftPath.moveTo(width / 2, height / 4 + 20);
leftPath.cubicTo(
    width / 8, height / 10, 
    width / 13, (height * 2) / 5,
    width / 2, height - height / 4);
// 繪製心形左邊曲線    
canvas.drawPath(leftPath, _paint);

Path rightPath = new Path();
// 畫筆移動到右邊曲線起點
rightPath.moveTo(width / 2, height / 4 + 20);
rightPath.cubicTo(
    width - width / 8, height / 10,
    (width * 12) / 13, (height * 2) / 5,
    width / 2, height - height / 4);
// 繪製心形右邊曲線    
canvas.drawPath(rightPath, _paint);
// 繪製兩個控制點
_paint.strokeWidth = 8;
_paint.color = Colors.blue;
canvas.drawPoints(
    PointMode.points,
    [
        Offset(width / 8, height / 10),
        Offset(width / 13, (height * 2) / 5),
        Offset(width - width / 8, height / 10),
        Offset((width * 12) / 13, (height * 2) / 5)
    ],
    _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                         image-17.png

// 畫弧線
// rect:矩形區域
// startAngle:開始的弧度
// sweepAngle:掃過的弧度,正數順時針,負數逆時針
// forceMoveTo:true-畫筆從當前位置抬起來,移動到弧線起點
//              false-畫筆從當前位置不抬起來直接連線到弧線起點
void arcTo(Rect rect, double startAngle, double sweepAngle, bool forceMoveTo)
// 效果如下面左圖,畫筆從(80, 100)抬起來,移動到弧線起點
boo forceMoveTo = true;

// 效果如下面右圖,畫筆從(80, 100)不抬起來直接連線到弧線起點
boo forceMoveTo = false;

Path path = new Path();
path.moveTo(20, 40);
path.lineTo(80, 100);
path.arcTo(new Rect.fromLTWH(60, 60, 100, 100), 0, pi / 2, forceMoveTo);
canvas.drawPath(path, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                            image-18.png

// 封閉當前路徑,以上面程式碼為例,如下圖
void close();複製程式碼

複製程式碼
                                                              image-19.png

其他方法

// 為path新增矩形
void addRect(Rect rect);

// 為path新增圓角矩形
void addRRect(RRect rrect);

// 為path新增橢圓
void addOval(Rect oval)

// 為path新增圓弧
void addArc(Rect oval, double startAngle, double sweepAngle);

// 為path新增多邊形,points-座標陣列,close-是否首位封閉
void addPolygon(List<Offset> points, bool close);

// 為path新增一個path,偏移offset座標
void addPath(Path path, Offset offset, {Float64List matrix4})

// path路徑是否包含point座標點
bool contains(Offset point)

// 重置路徑
void reset();

// 給路徑做matrix4變換
Path transform(Float64List matrix4)複製程式碼

下面舉一個addPath的例子

Path path = new Path();
path.lineTo(20, 20);

Path path1 = new Path();
path1.lineTo(40, 40);

// 如下圖,addPath方法就是在(30,30)座標位置偏移(40,40)並連線
path.addPath(path1, Offset(30, 30));

canvas.drawPath(path, _paint);複製程式碼
Flutter自定義繪製Widget初探
                                                             image-20.png

由於篇幅原因,不可能將Canvas和Path的所有方法一一列舉,想要進一步瞭解可檢視相關原始碼方法。

總結

本文列舉了Flutter開發中,Canvas繪製流程常用的方法並提供了簡單的示例,可以看出,和Android的Canvas還是很相似的,上手也非常的快。要做出酷炫的Widget,最好還是需要配合動畫效果,當然,用canvas做些簡單的icon也是可以的。


作者簡介


風少,銅板街客戶端開發工程師,2013年5月加入團隊,目前主要負責APP端專案開發。



                                               Flutter自定義繪製Widget初探

                                   更多精彩內容,請掃碼關注 “銅板街技術” 微信公眾號。 


相關文章