使用Canvas自绘控件

Canvas就是一块2D画布,内部封装了一些基本绘制的API,开发者可以通过Canvas绘制各种自定义图形。在Flutter中,提供了一个CustomPaint控件,它需要结合一个画笔CustomPainter来实现绘制自定义图形。

Canvas 内的坐标系,其原点在左上角,水平向右为 x 轴正方向,垂直向下为 y 轴正方向

具体步骤

  1. 写一个类继承自CustomPainter,并实现paintshouldRepaint方法

     class MyPainter extends CustomPainter {
    
       @override
       void paint(ui.Canvas canvas, ui.Size size) {
         /// 该方法中实现绘制逻辑
         Paint paint = Paint()
           ..isAntiAlias = true
           ..color = Colors.blue
           ..strokeWidth = 10
           ..style = PaintingStyle.fill;
    
         canvas.drawLine(Offset(10, 10), Offset(250, 250), paint);
       }
    
       @override
       bool shouldRepaint(CustomPainter oldDelegate) {
         ///  返回 true 会进行重绘,否则就只会绘制一次
         return false;
       }
     }
    
  2. 使用CustomPaint包装我们自定义的画笔类,如同普通Widget一样插入控件树中

     Widget build(BuildContext context) {
        return Center(
          child: CustomPaint(
            size: Size(300, 300), //指定画布大小
            painter: MyPainter(),
          ),
        );
      }
    

需要注意,绘制是比较耗费性能的操作,所以在实现自绘控件时应该考虑到性能开销,做一些优化。

  • 利用好shouldRepaint方法的返回值。在控件树重新build时,如果我们绘制的UI不依赖外部状态,那么就应该始终返回false,因为外部状态改变导致重新build时不会影响我们的自绘控件;如果依赖外部状态,则应该在shouldRepaint中判断所依赖的状态是否改变,如果已改变则返回true来重绘,反之则应返回false不重绘。

  • 如果CustomPaint有子控件,为了避免子控件不必要的重绘而提高性能,可以将子控件包裹在RepaintBoundary 控件中,这样在绘制时会创建一个新的图层(Layer),其子控件将在新的图层上绘制,而父Widget将在原来图层上绘制。

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

CustomPaint有两个属性用于设置painter

  • painter: 绘制效果会显示在child的下面
  • foregroundPainter: 绘制效果会显示在child的上面

关于Canvas 的大小

CustomPaint对象创建一块画布,其绘制区域大小与子控件的大小相同。如果未提供child参数(这是可选的),则绘制区域大小由实例化CustomPaint时其设置的size属性来确定。特别注意,默认Canvas是全屏的, 此处大小指的是绘制区域大小,而非Canvas 大小,千万不可混淆!

  1. 如果child == null,则绘制区域为size大小
  2. 如果child != null,则绘制区域为child大小
  3. 如果child != null 并且想指定指定绘制区域大小,可使用SizeBox包装CustomPaint

Canvas 的方法

Canvas 的操作主要有两类:

  • 针对 Canvas 的变换操作,如平移、旋转、缩放、图层等
  • 绘制基础图形的操作,如线段、路径、图片、几何图形等

绘制相关操作

  • drawArc(Rect rect,double startAngle, double sweepAngle, bool useCenter, Paint paint)

    在给定的矩形中画一个弧线。它从椭圆周围的startAngle弧度开始,直至椭圆周围的startAngle+sweepAngle弧度,零弧度是椭圆右侧与矩形中心相交的水平线相交的点,正角绕椭圆顺时针走。如果useCenter为真,弧线就会闭合回到中心,形成一个圆扇形。否则,弧线不闭合,形成一个圆段。

参数 类型 说明
rect Rect 圆弧所在椭圆的外接矩形
startAngle double 起始位置的弧度。弧度制
sweepAngle double 设置圆弧扫过多少弧度。弧度制
useCenter bool 表示是否链接到圆弧所在椭圆的中心
paint Paint 画笔

变换相关操作

  • scale(double sx, [double sy])

    在当前变换中添加一个轴对齐的标尺,在水平方向上按第一个参数缩放,在垂直方向上按第二个参数缩放。

  • skew(double sx, double sy)

    在当前变换中增加一个轴对齐的倾斜度,第一个参数是以原点为单位顺时针上升的水平倾斜度,第二个参数是以原点为单位顺时针上升的垂直倾斜度。

  • transform(Float64List matrix4)

    将当前变换乘以指定的4×4变换矩阵,该矩阵指定为以列为主的值列表。

  • translate(double dx, double dy)

    在当前的变换中添加一个平移,通过第一个参数水平移动坐标空间,通过第二个参数垂直移动坐标空间。

  • rotate(double radians)

    在当前的变换中加入旋转。参数的单位是顺时针的弧度。

  • getSaveCount()

    返回保存栈中的条目数,包括初始状态。这意味着如果画布是干净的,则返回1,每次调用savesaveLayer都会使其递增,每次匹配调用restore都会使其递减。

  • restore()

    弹出当前的保存堆栈(如果有要弹出的内容)。 否则,什么都不做。

  • save()

    在保存堆栈上保存当前变换和剪辑的副本。

  • saveLayer(Rect bounds, Paint paint)

    在保存堆栈上保存当前变换和剪辑的副本,然后创建一个新的组,后续调用将成为该组的一部分。当随后打开保存堆栈时,该组将被扁平化为一个图层,并应用给定的paintPaint.colorFilterPaint.blendMode

裁剪相关操作

  • clipPath(Path path, {bool doAntiAlias: true})

    将剪辑区域缩小至目前剪辑与给定路径的交点。如果doAntiAlias为true,则剪辑将被消除锯齿。

  • clipRect(Rect rect, {ClipOp clipOp: ClipOp.intersect, bool doAntiAlias: true})

    缩小剪辑区域至目前剪辑与指定矩形的交点

  • clipRRect(RRect rrect, {bool doAntiAlias: true})

    将剪辑区域缩小到当前剪辑和给定圆角矩形的交点。

Paint

在Canvas上绘图时要使用的样式的描述。大多数Canvas上的API都会取一个Paint对象来描述该操作要使用的样式。简单说,该类主要是用来设置真正画笔的一些属性。

属性 类型 简述
isAntiAlias bool 是否开启抗锯齿,开启抗锯齿能够使边缘平滑
color Color 描边或填充形状时使用的颜色。
colorFilter ColorFilter 绘制形状或合成图层时应用的颜色滤镜。
filterQuality FilterQuality 控制应用滤镜(如 maskFilter)或绘制图像(如 Canvas.drawImageRectCanvas.drawImageNine)时的性能与质量权衡。
invertColors bool 绘制时图像的颜色是否反转
maskFilter MaskFilter 遮罩滤镜(例如,模糊),在形状被绘制后,但在它被合成到图像之前,应用于它。
imageFilter ImageFilter 绘制光栅图像时要使用的ImageFilter。例如,如果要使用Canvas.drawImage模糊图像,应用ImageFilter.blur
shader Shader 描边或填充形状时要使用的着色器。
strokeCap StrokeCap 当样式设置为PaintingStyle.stroke时,要放置在线条末端的边缘风格。如圆角、方形等
strokeJoin StrokeJoin 设置两个绘制形状衔接处的风格。如圆角、方形等
strokeWidth double 当样式设置为PaintingStyle.stroke时,画笔的宽度。宽度以逻辑像素为单位
style PaintingStyle 填充方式。PaintingStyle.fill充满;PaintingStyle.stroke空心
blendMode BlendMode 像素混合模式。当画一个shape或者合成图层的时候会生效。

关于BlendMode类型,官方做了详细解释,这里给一篇 中文翻译链接

常用绘制方法示例

// 绘制直线
canvas.drawLine(Offset(10, 10), Offset(250, 250), paint);

// 绘制一系列的点,也可连成线段
canvas.drawPoints(
    PointMode.points,
    [Offset(200, 200), Offset(250, 250), Offset(50, 200), Offset(100, 250)],
    paint);

// 绘制路径
var path = Path()
      ..moveTo(30.0, 100.0)
      ..lineTo(120.0, 100.0)
      ..lineTo(90.0, 130.0)
      ..lineTo(180.0, 130.0)
      ..close();
canvas.drawPath(path, paint);

// 绘制矩形
Rect rect = Rect.fromCircle(
        center: Offset(size.width / 2, size.height / 2), radius: 100);
canvas.drawRect(rect, paint);

// 绘制圆角矩形
Rect rect1 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 150);
RRect rRect = RRect.fromRectAndRadius(rect1, Radius.circular(20));
canvas.drawRRect(rRect, paint);

// 绘制嵌套圆角矩形
Rect r1 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 140);
Rect r2 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 160);
RRect rRect1 = RRect.fromRectAndRadius(r1, Radius.circular(20));
RRect rRect2 = RRect.fromRectAndRadius(r2, Radius.circular(20));
// 第一个参数为外部矩形,第二个参数为内部矩形
canvas.drawDRRect(rRect2, rRect1, paint);

// 绘制圆形
canvas.drawCircle(Offset(size.width / 2, size.height / 2), 100, paint);

// 绘制椭圆
Rect rect2 = Rect.fromLTRB(size.width / 2 - 100, size.height / 2 - 50,
                           size.width / 2 + 100, size.height / 2 + 50);
canvas.drawOval(rect2, paint);

// 绘制圆弧
Rect rect3 = Rect.fromCircle(
    center: Offset(size.width / 2, size.height / 2), radius: 140);
canvas.drawArc(rect3, 0, math.pi / 2, true, paint);

// 绘制阴影
Path path2 = Path()..addRect(rect.translate(20, 0));
canvas.drawShadow(path2, Colors.amberAccent, 20, true);

// 绘制背景色
canvas.drawColor(Colors.pinkAccent, BlendMode.srcIn);

PointMode类型的取值

enum PointMode {
  /// 分别绘制每个点。
  /// 如果Paint.strokeCap是StrokeCap.round,那么每个点被画成直径为Paint.strokeWidth的圆
  /// 否则,每个点被画成一个边长为Paint.strokeWidth的轴对齐的正方形,按照Paint.style的描述进行填充
  points,

  /// 将每两个点绘制为线段。如果点数为奇数,则忽略最后一个点。
  lines,

  /// 将整个点的序列画成一条线。线条按Paint.style的描述进行描边
  polygon,
}

矩形Rect的几种构造方式

  • Rect.fromPoints 根据两个点(左上角点/右下角点)来绘制

  • Rect.fromLTRB 以屏幕左上角为坐标系圆点,分别设置上下左右四个方向距离

  • Rect.fromLTWH 根据矩形左上角的点坐标与矩形宽高来绘制

  • Rect.fromCircle 根据给定圆形获得一个正方形

圆角矩形RRect的几种构造方式

  • RRect.fromLTRBXY 前四个参数用来绘制矩形位置,剩余两个参数绘制固定 x/y 弧度

  • RRect.fromLTRBR 前四个参数用来绘制矩形位置,最后一个参数绘制 Radius 弧度

  • RRect.fromLTRBAndCorners 前四个参数用来绘制矩形位置,剩余四个可选参数,根据需求设置四个角 Radius 弧度

  • RRect.fromRectXY 第一个参数绘制矩形,剩余两个参数绘制固定 x/y 弧度

  • RRect.fromRectAndRadius 第一个参数绘制矩形,最后一个参数绘制 Radius 弧度

  • RRect.fromRectAndCorners 第一个参数绘制矩形,剩余四个可选参数,根据需求设置四个角 Radius 弧度

需要注意,当使用drawColor绘制背景色时,其第二个参数BlendMode的类型较为复杂,不一定会达到预期的效果,详细参见BlendMode 文档

除了绘制几何图形,Canvas还能绘制图片,但需要注意,在flutter中有两个名为 Image 的类,它们位于不同的包中。一个是作为Widget的Image ;另一个是ui包中的Image,它是对原始解码图像数据(像素)的不透明句柄。

class MyImage extends CustomPainter{
  final ui.Image image;

  MyPainter(this.image);

  @override
  void paint(Canvas canvas, Size size) {
    if (_image != null) {
       var paint = Paint();
      canvas.drawImage(_image, Offset(0, 0), paint);
    }
  }

  @override
  bool shouldRepaint(MyPainter oldDelegate){
    return oldDelegate.image != this.image;
  }
}

加载图片

class _TestDrawImageState extends State<TestDrawImage> {
  ui.Image _img;

  @override
  void initState() {
    super.initState();
    _loadImage('assets/test.jpg').then((res) {
      setState(() {
        _img = res;
      });
    });
  }

  /// 加载图片
  Future<ui.Image> _loadImage(String path) async {
    var data = await rootBundle.load(path);
    var codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
    var info = await codec.getNextFrame();
    return info.image;
  }

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: MyPainter(_img),
    );
  }
}

当我们需要绘制文本时,有两种方式

  • 使用 drawParagraph方法。第一个参数是Paragraph,它来自dart.ui库,是引擎创建的类,不能被继承,需要通过ParagraphBuilder来构造;第二个参数是绘制的位置。需要注意,这里的TextStyle是来自ui

      import 'dart:ui' as ui;
    
      void paint(Canvas canvas, Size size) {
        /// 决定文本的大小和样式
        final textStyle = ui.TextStyle(
          color: Colors.black,
          fontSize: 30,
        );
    
        /// 决定了ParagraphBuilder用来定位文本段落中的行的配置
        final paragraphStyle = ui.ParagraphStyle(
          textAlign: TextAlign.center,
          textDirection: TextDirection.ltr,
        );
    
        final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle)
          ..pushStyle(textStyle)
          ..addText('Hello, world.'); // 添加文本和样式
    
        final paragraph = paragraphBuilder.build();
    
        /// 在绘制文本之前必须先进行布局。该任务被传递给Skia引擎
        paragraph.layout(ui.ParagraphConstraints(width: 300));
    
        canvas.drawParagraph(paragraph, Offset(50, 100));
      }
    
  • 使用TextPainter绘制。它是一个将TextSpan树绘制到Canvas中的对象,这是Flutter提供的一种简化的封装,无需再导入dart:ui,使用我们熟悉的TextStyleTextSpan即可。

    void paint(Canvas canvas, Size size) {
      final textSpan = TextSpan(
        text: 'Hello, world.',
        style: TextStyle(
          color: Colors.black,
          fontSize: 30,
        ),
      );
    
      /// 创建TextPainter对象
      final textPainter = TextPainter(
        text: textSpan,
        textDirection: TextDirection.ltr,
      );
    
      /// 进行布局
      textPainter.layout(
        minWidth: 0,
        maxWidth: size.width,
      );
    
      /// 绘制文本
      textPainter.paint(canvas, Offset(50, 100));
    }
    

在对Canvas进行变换相关操作时,需要先将画布save(或saveLayer),调用该函数之后的绘制操作和变换操作,会重新记录。变换完成后,再调用restore进行恢复。注意, save() 或者 saveLayer() 必须与 restore() 成对使用,即Canvas 的变换操作需要放到 save()saveLayer()restore() 之间进行

canvas.save();
// 平移画布
canvas.translate(100, 100);
canvas.drawImage(background, Offset.zero, paint);
canvas.restore();

saveLayersave类似,不同的是 saveLayer() 会创建一个新的图层。因此 saveLayer()restore() 之间的操作是在新图层上进行的,最终它们合成到一起。

自绘实例

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

class CustomArcWidget extends StatelessWidget {
  final Color color;
  final Color bgColor;
  final double radius;
  final AlignmentGeometry alignment;
  final Widget child;

  CustomArcWidget(
      {this.color = Colors.white,
      this.bgColor = Colors.blue,
      @required this.radius,
      this.alignment,
      this.child});

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      painter: ArcPainter(color, bgColor),
      child: Container(
          alignment: alignment, width: radius, height: radius, child: child),
    );
  }
}

class ArcPainter extends CustomPainter {
  final Color color;
  final Color bgColor;

  ArcPainter(this.color, this.bgColor);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 5
      ..strokeCap = StrokeCap.round
      ..color = color;

    /// 创建一个新的指定大小的图层
    canvas.saveLayer(Rect.fromLTWH(0, 0, size.width, size.height), paint);

    /// 绘制背景色
    canvas.drawColor(bgColor, BlendMode.src);

    /// 回到旧图层
    canvas.restore();

    final maxRadius = size.width / 2 - 5;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2), radius: maxRadius),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
    paint.strokeWidth = 2;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2),
            radius: maxRadius - 5),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
    paint.strokeWidth = 1;
    canvas.drawArc(
        Rect.fromCircle(
            center: Offset(size.width / 2, size.height / 2),
            radius: maxRadius - 10),
        -240 * (math.pi / 180),
        300 * (math.pi / 180),
        false,
        paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}

使用

CustomArcWidget(
  radius: 100,
  alignment: Alignment.center,
  child: Text(
    "99",
    style: TextStyle(color: Colors.white, fontSize: 20),
  ),
),

实现手绘板

import 'dart:ui';

import 'package:flutter/material.dart';

///
///  手绘板
///
class HandPaintedBoard extends StatefulWidget {
  final Color color;
  final double width;
  final double height;
  final BoardController controller;

  HandPaintedBoard(
      {this.color = Colors.blue, @required this.width, @required this.height,this.controller});

  @override
  _HandPaintedBoardState createState() => _HandPaintedBoardState();
}

class _HandPaintedBoardState extends State<HandPaintedBoard> {

  GlobalKey _gk = GlobalKey();

  final List<DrawStroke> strokes = List<DrawStroke>();

  // 总笔画数
  int _numOfStrokes = 0;

  ///
  /// 边界检查
  ///
  bool checkBoundary(Offset curPosition) {
    Size curSize = _gk.currentContext.size;
    RenderBox rBox = _gk.currentContext.findRenderObject();
    // 将控件内部的相对位置转换为屏幕上的绝对坐标值
    Offset topLeft = rBox.localToGlobal(Offset(0, 0));
    Offset bottomRight =
        rBox.localToGlobal(Offset(curSize.width, curSize.height));

    if (curPosition.dx < topLeft.dx || curPosition.dx > bottomRight.dx)
      return false;
    if (curPosition.dy < topLeft.dy || curPosition.dy > bottomRight.dy)
      return false;

    return true;
  }

  @override
  void initState() {
    super.initState();
    if(widget.controller != null){
      widget.controller.reset = reset;
    }
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        key: _gk,
        width: widget.width,
        height: widget.height,
        child: GestureDetector(
          onPanDown: (details) {
            if (checkBoundary(details.globalPosition)) {
              RenderBox rBox = context.findRenderObject();
              // 将屏幕上的绝对坐标值转换为控件内部的相对位置
              Offset curPoint = rBox.globalToLocal(details.globalPosition);
              var stroke = DrawStroke(widget.color);
              stroke.points.add(curPoint);
              strokes.add(stroke);
              setState(() {});
            }
          },
          onPanUpdate: (details) {
            RenderBox rBox = context.findRenderObject();
            Offset curPoint = rBox.globalToLocal(details.globalPosition);

            if (checkBoundary(details.globalPosition)) {
              setState(() {
                strokes[_numOfStrokes].points.add(curPoint);
              });
            }
          },
          onPanEnd: (details) {
            _numOfStrokes++;
          },
          child: CustomPaint(
            painter: HandPainter(strokes),
          ),
        ));
  }

  void reset(){
    _numOfStrokes = 0;
    strokes.clear();
    setState(() {});
  }
}

typedef ControllerReset = void Function();
class BoardController {

  ControllerReset reset;
}

class HandPainter extends CustomPainter {
  final List<DrawStroke> strokes;

  HandPainter(this.strokes):assert(strokes != null);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()
      ..strokeWidth = 5.0
      ..isAntiAlias = true
      ..strokeCap = StrokeCap.round;

    for (var stroke in strokes) {
      paint.color = stroke.color;

      // 该模式将点连成线
      var _pointMode = PointMode.polygon;
      if(stroke.points.length == 1){
        // 只有一个点时,pointMode类型为PointMode.points,画点模式
        _pointMode = PointMode.points;
      }
      canvas.drawPoints(_pointMode, stroke.points, paint);
    }
  }

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

///
/// 画的每一笔用一个[DrawStroke]对象表示
///
class DrawStroke {
  // 每一笔都由N个点序列组成
  final List<Offset> points = List<Offset>();

  // 这一笔的颜色
  Color color;

  DrawStroke(this.color);
}

使用

Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      DecoratedBox(
          decoration: BoxDecoration(
              border: Border.all(color: Colors.green)
          ),
          child: HandPaintedBoard(
            width: 400,height: 400,color: _color,controller: _controller,)),
      SizedBox(height: 16,),

      Wrap(
        children: List.of(Colors.primaries.map<Widget>((color){
          return InkWell(
            onTap: (){
              setState(() {
                _color = color;
              });
            },
            child: Container(
              margin: EdgeInsets.symmetric(horizontal: 4,vertical: 2),
              width: 30,
              height: 30,
              color: color,
            ),
          );
        })),
      ),
      SizedBox(height: 16,),
      IconButton(
        icon: Icon(Icons.delete_forever),
        onPressed: (){
          _controller.reset();
        },
      )
    ],
  ),
),

公众号“编程之路从0到1”

20190301102949549

Copyright © Arcticfox 2020 all right reserved,powered by Gitbook文档修订于: 2022-05-01 12:00:54

results matching ""

    No results matching ""