Flutter 知识梳理 (自定义组件) - CustomPaint

一、背景知识

Android中,当我们需要自定义View的时候,需要重写View.draw(Canvas canvas)方法,并通过Painter结合CanvasAPI实现绘制的逻辑,Flutter的核心实现跟这个很类似。

Flutter自定义组件中,有两个重要的概念:

  • CustomPaint:它是SingleChildRenderObjectWidget的子类,我们将它放在Widget树的节点中,而CustomPainter是它的一个属性,负责实现具体的绘制逻辑。
class SimplePainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        painter: SimplePainter(),
      ),
    );
  }
}
  • CustomPainter:通过继承该类,并重void paint(Canvas canvas, Size size)方法,使用PainterCanvas提供的API,完成绘制的过程。

下面,我们分别介绍一下这两个概念。

1.1 CustomPaint

首先,CustomPaint的定义如下:

const CustomPaint({
  Key key,
  this.painter, 
  this.foregroundPainter,
  this.size = Size.zero, 
  this.isComplex = false, 
  this.willChange = false, 
  Widget child, 
})
  • painter:画笔,显示在子节点后面。
  • foregroundPainter:前景画笔,显示在子节点前面。
  • size:当childnull,代表默认大小,如果有child则为child大小。如果希望在有child的时候限制大小,那么可以用SizeBox包裹CustomPaint
  • isComplex:是否复杂绘制,如果是,会应用一些缓存策略来减少重复渲染的次数。
  • willChange:和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。

在有子节点时,为了避免子节点不必要的重绘并提高性能,通常会将子节点包裹在RepaintBoundary Widget中。

这样会在绘制时创建一个新的绘制层,其子Widget将在新的Layer绘制,父Widget将会在原来的Layer上绘制。

CustomPaint(
  size: Size(300, 300),
  painter: MyPainter(),
  child: RepaintBoundary(child:...))
)

1.2 CustomPainter

通过实现CustomPaintervoid paint(Canvas canvas, Size size)方法,并结合Paint的属性,完成绘制操作,如果之前有过Android中自定义View的经验,那么上手很快。

CustomPainter涉及到的知识点有以下四个方面:

  • Paint提供的属性:圆角、颜色、线宽等等。
  • Canvas.drawXXX方法:点、弧形、圆形、正方形、长方形、路径等等。
  • Canvas的变换:scaletranslaterotateskew
  • Canvassaverestore方法运用:save方法作用是保存画布当前状态,restore则是取出,例如要对画布进行多个动作处理,第一个动作进行了缩放,如果没有在缩放动作处理前保存一下,那么在执行第二个动作时也会有缩放动作的影响。

1.3 一些绘制 API 的参考资料

我做练习的时候分为了以下两步,大家可以参考:

  • 先过一遍API,对于支持哪些方法有一个基本的认识。
  • Github上,找一个Android中自定义View的案例,通过FlutterAPI去实现一下,由于AndroidFlutter的实现很相似,因此有想不明白的地方也可以去参考。

下面是一些参考的资料:

二、示例

以下是三个例子:

  • 简单示例:CustomPaintCustomPainter的基本结构。
  • 棋盘示例:掌握基本的API
  • 仪表盘:Canvas变换、save/restore、弧形、绘制文字,结合动画。

2.1 简单示例

一个最简单的CustomPainter如下所示:

import 'package:flutter/material.dart';

void main() => runApp(CustomPainterWidget());

class CustomPainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text("SimplePainter")),
        body: SimplePainterWidget(),
      ),
    );
  }
}

class SimplePainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        painter: SimplePainter(),
      ),
    );
  }
}

class SimplePainter extends CustomPainter {

  Paint painter = Paint()..color = Colors.blue
    ..style = PaintingStyle.fill
    ..strokeCap = StrokeCap.butt
    ..isAntiAlias = true;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawCircle(Offset(0, 0), 40, painter);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

效果图:

image.png

2.2 棋盘示例

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

class GoCustomPainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        painter: GoCustomPainter(),
        size: Size(300, 300),
      ),
    );
  }
}

class GoCustomPainter extends CustomPainter {

  static const GRID_NUM = 12;

  @override
  void paint(Canvas canvas, Size size) {
    var goWidth = min(size.width, size.height);
    //1.绘制背景。
    Paint paint = new Paint()
      ..color = Colors.yellow
      ..style = PaintingStyle.fill;
    canvas.drawRect(Rect.fromLTWH(0, 0, goWidth, goWidth), paint);
    paint.color = Colors.black;
    for (int i = 0; i <= GRID_NUM; i++) {
      var index = goWidth / GRID_NUM * i;
      //绘制横线。
      canvas.drawLine(Offset(0, index), Offset(goWidth, index), paint);
      //绘制竖线。
      canvas.drawLine(Offset(index, 0), Offset(index, goWidth), paint);
    }
    for (int i = 0; i < 8; i++) {
      _drawDots(canvas, paint..color = Colors.white, goWidth);
      _drawDots(canvas, paint..color = Colors.black, goWidth);
    }
  }

  _drawDots(Canvas canvas, Paint paint, var goWidth) {
    Random random = new Random();
    var unit = goWidth / GRID_NUM;
    var whiteX = unit * (1 + random.nextInt(GRID_NUM - 2));
    var whiteY = unit * (1 + random.nextInt(GRID_NUM - 2));
    canvas.drawCircle(Offset(whiteX, whiteY), 5, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

在主工程中引用:

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

void main() => runApp(CustomPainterWidget());

class CustomPainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text("SimplePainter")),
        body: GoCustomPainterWidget(),
      ),
    );
  }
}

效果图:

image.png

2.3 仪表盘

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

class CanvasAnimateWidget extends StatefulWidget {

  @override
  State<StatefulWidget> createState() {
    return _CanvasAnimateWidgetState();
  }
}

class _CanvasAnimateWidgetState extends State<CanvasAnimateWidget> with SingleTickerProviderStateMixin {

  static const MAX_VALUE = 750.0;
  static const VALUE = 500.0;

  AnimationController controller;
  Animation<double> animation;
  var value = VALUE;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(duration : Duration(seconds: 1), vsync: this);
    animation = Tween(begin: 0.0, end : VALUE).animate(controller)
      ..addListener(() { setState(() {});});
    controller.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: CustomPaint(
        painter: DashBoardPainter(value: animation.value, maxValue: MAX_VALUE),
        size: Size(300, 300),
      ),
    );
  }

}

class DashBoardPainter extends CustomPainter {

  static const int GRID_NUM = 24;

  var maxValue;
  var value;

  DashBoardPainter({this.maxValue, this.value});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = new Paint();
    //1.绘制背景。
    _drawBg(canvas, paint, size);
    //2.绘制圆弧。
    _drawArc(canvas, paint, size);
  }

  _drawBg(Canvas canvas, Paint paint, Size size) {
    paint..color = Colors.blue
      ..style = PaintingStyle.fill;
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
  }

  _drawArc(Canvas canvas, Paint paint, Size size) {
    var padding = 10.0;
    var width = size.width - 2*padding;
    var height = size.height - padding;
    canvas.save();
    canvas.translate(padding, padding);

    //1.绘制灰色的外环。
    paint..color = Colors.white10
      ..strokeWidth = 2.0
      ..style = PaintingStyle.stroke;
    canvas.drawArc(Rect.fromCircle(center: Offset(width/2, height/2), radius: min(height, width)/2), pi, pi, false, paint);

    //2.根据比例绘制白色的外环。
    paint..color = Colors.white;
    var faction = value / maxValue;
    canvas.drawArc(Rect.fromCircle(center: Offset(width/2, height/2), radius: min(height, width)/2), pi, pi * faction, false, paint);

    //3.绘制刻度的环。
    var arcX = 10.0;
    var arcWidth = 10.0;
    paint..strokeWidth = arcWidth..color = Colors.white10;
    canvas.drawArc(Rect.fromCircle(center: Offset(width/2, height/2), radius: width/2 - arcX - arcWidth/2), pi, pi, false, paint);

    //4.绘制刻度的横线,已经跨过的部分是白色,否则为浅色。
    paint.strokeWidth = 2.0;
    var threadHold = (value / (maxValue / GRID_NUM));
    for (var i = 0; i <= GRID_NUM; i++) {
      canvas.save();
      paint.color = i <= threadHold ? Colors.white : Colors.white24;
      canvas.translate(width/2, height/2);
      canvas.rotate(pi*i/GRID_NUM);
      canvas.translate(-width/2, -height/2);
      canvas.drawLine(Offset(arcX, height/2), Offset(arcX+arcWidth, height/2), paint);
      canvas.restore();
    }

    //5.绘制文字。
    TextSpan textSpan = TextSpan(
      style: TextStyle(
        color: Colors.white,
        fontSize: 50
      ),
      text: '${(value as double).toStringAsFixed(0)}'
    );
    TextPainter textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr
    );
    textPainter.layout();
    textPainter.paint(canvas, Offset(width/2 - textPainter.width/2, height/3));
    canvas.restore();
  }


  @override
  bool shouldRepaint(DashBoardPainter oldDelegate) =>
      (maxValue != oldDelegate.maxValue || value != oldDelegate.value);


}

实现文件。

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

void main() => runApp(CustomPainterWidget());

class CustomPainterWidget extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text("SimplePainter")),
        body: CanvasAnimateWidget(),
      ),
    );
  }
}

效果图:

image.png

参考文章

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342