flutter CustomPaint解析

最近研究了下flutter的自定义绘制,觉得有必要将绘制的使用和应该规避的坑等分享一下。下面就从简到难,循序渐进的给大家介绍一下

先上最终demo演示:

flutter CustomPaint解析_第1张图片

下面从3个方面逐一详细的讲起:

  • 基础介绍和绘制准备
  • 各类图形的绘制
  • 绘制的简单应用(动画、手势、点击)

基础介绍和绘制准备

要知道,flutter的所有绘制都要通过CustomPaint来实现,我们先简单的写一个

return Scaffold(
      appBar: AppBar(
        title: Text('CustomPaint'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            height: 30,
            color: Colors.red,
          ),
          CustomPaint(),
        ],
      ),
    );

要想明白绘制的原理,首先要知道flutter中的一些基础的概念,这点很重要

/// 原点(0, 0)为控件左上角,
/// 宽x:右为正方向,高y:下为正方向
/// 顺时针是旋转正方向(即从x轴的正方向到y轴正方向),和数学相反。
/// pi:圆周率,表示180度。
/// 界面尺寸单位是像素
/// CustomPainter展示的是静态时点的静态页面,如果想要实现动态效果(如动画,手势)需要通过setState更新
/// 所有canvas操作都调用的native方法

如下:
flutter CustomPaint解析_第2张图片
这点明白了,我们就来看看绘制的关键类CustomPaint

CustomPaint(
	  //size确定事件区域,如GestureDetector事件只能作用在size范围内
	  size: Size(30, 60),
	  // painter:绘制的 backgroud 层
	  painter: BasicPainter(context, _image),
	  // child:在 painter 层上绘制
	  child: Text('你好'),
	  // foregroundPainter:在 child 层上绘制
	  // foregroundPainter: BusinessPainter(_percentage, _points, _center),
),

其中painter和foregroundPainter是绘制的画笔,他们继承于CustomPainter,所有的绘制操作都在CustomPainter中实现。
如在第一张demo的gif图中,文本“你好”是在中间层,动画和手势是在最顶层,其他都是在背景层。为了展示效果,这里先把foregroundPainter隐掉。
我们看看CustomPainter下都有哪些东西。这里我顺便把画笔也定义了出来。

class BasicPainter extends CustomPainter {
  Paint _paint = Paint()
    ..color = Colors.red //画笔颜色
    ..strokeWidth = 3 //画笔宽度
  // ..isAntiAlias = true //是否抗锯齿
  // ..style = PaintingStyle.fill //绘画风格,默认为填充
  // ..invertColors = true//图像颜色反转
  // ..colorFilter = ColorFilter.mode(Colors.blueAccent, BlendMode.exclusion) //颜色渲染模式,是混合模式
  // ..maskFilter = MaskFilter.blur(BlurStyle.inner, 3.0) //模糊遮罩效果
  // ..filterQuality = FilterQuality.high //颜色渲染模式的质量
  // ..strokeCap = StrokeCap.round //末端处理 round:圆滑处理(延伸一小截圆形) square:延伸一小截矩形 butt:不延伸
  // ..strokeJoin=StrokeJoin.round  //拐角类型,仅影响多边形连线(Canvas.drawPath) round:圆角 miter:锐角 bevel:斜角
      ;

  // 该方法绘制自定义的效果
  // 所有canvas操作都调用到native方法
  // canvas:画布
  // size:CustomPaint构造方法传入,决定绘制区域宽高
  @override
  void paint(Canvas canvas, Size size) {
  	//这里写绘制逻辑
  }

  //是否需要重绘。通常在当前实例和旧实例属性不一致时返回true
  @override
  bool shouldRepaint(BasicPainter oldDelegate) {
    return this != oldDelegate;
  }
}

好了,现在前期工作完成了,现在我们可以开始绘制了。

各类图形的绘制

绘制的逻辑需要在paint方法中实现,这里会用到我们定义的_paint

  @override
  void paint(Canvas canvas, Size size) {
  }

防止混淆,先声明引入的包

import 'dart:math';
import 'dart:ui' as ui;

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

接下来我们从绘制点,绘制形状,绘制路径三个方面绘制:

绘制点

flutter CustomPaint解析_第3张图片

///绘制点
    var points1 = [
      Offset(size.width / 2, size.height / 2),
      Offset(size.width, size.height),
      Offset(size.width * 2, size.height * 2),
    ];
    canvas.drawPoints(ui.PointMode.points, points1, _paint);
    var points2 = [
      // 原点(0, 0)为控件左上角
      Offset(0, 0),
      Offset(100, 100),
      Offset(100, 300), //被忽略
    ];
    _paint.color = Colors.black;
    //PointMode.lines只2个点有效,后面参数被忽略
    canvas.drawPoints(ui.PointMode.lines, points2, _paint);
    var points3 = [
      Offset(100, 100),
      Offset(200, 100),
      Offset(100, 50),
    ];
    _paint.color = Colors.blueAccent;
    //PointMode.polygon N个点连成线
    canvas.drawPoints(ui.PointMode.polygon, points3, _paint);

drawPoints有3个模式参数,从上到下分别对应红、黑、蓝3种绘制图形

然后我们绘制一条分割线,这里context从构造方法传进来

    //分割线
    var separate1 = [
      Offset(0, 150),
      Offset(MediaQuery.of(context).size.width, 150),
    ];
    _paint.color = Colors.black;
    canvas.drawPoints(ui.PointMode.lines, separate1, _paint);

绘制形状

先绘制圆和椭圆
flutter CustomPaint解析_第4张图片

    ///绘制形状
    _paint
      ..style = PaintingStyle.fill
      ..color = Colors.green;
    //绘制圆形。第二个参数半径
    canvas.drawCircle(Offset(150, 200), 20, _paint);
    //绘制椭圆。两个坐标点,左上右下。Rect是正方形则绘制圆形
    canvas.drawOval(Rect.fromLTRB(200, 170, 300, 220), _paint);
    // canvas.drawOval(Rect.fromLTRB(200, 200, 300, 300), _paint);

接着我们绘制圆弧,重点!!!
flutter CustomPaint解析_第5张图片

    // 矩形确定的几种方式:下列等效
    // fromCenter:确定矩形。第一值是中心点坐标,参数2宽,参数3高
    // Rect rect = Rect.fromCenter(center: Offset(60, 250), width: 80, height: 100);
    // fromCircle:确定正方形。center:中心点,radius:边长一半
    // Rect rect= Rect.fromCircle(center: Offset(60, 250),radius: 40);
    // fromLTRB:确定矩形。前两个值对应左上角的xy坐标,后两个值对应右小角xy坐标
    // Rect rect= Rect.fromLTRB(20, 200, 100, 300);
    // fromPoints:确定矩形。两参数分别为左上角和右下角的点坐标
    // Rect rect = Rect.fromPoints(Offset(20, 200), Offset(100, 300),);
    // fromLTWH:确定矩形。前两个值是左上角坐标,参数3宽,参数4高
    // Rect rect = Rect.fromLTWH(20, 200, 80, 100);
    // Offset&Size:确定矩形。Offset是左上角坐标,Size是宽高
    // Rect rect = Offset(20, 200) & Size(80, 100);

    // 绘制圆弧。重要:pi:圆周率,180度。顺时针是正方向,和数学相反。
    // drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
    // rect:Rect.fromLTRB确定绘制范围。矩形中心点是绘制圆心。
    // startAngle:从哪开始绘制。sweepAngle:绘制多少角度。useCenter:是否和中心相连
    // 解释:以rect中心为原点,startAngle确定绘制起始方向,sweepAngle确定绘制的弧度
    Rect rect = Offset(20, 200) & Size(80, 100);
    canvas.drawArc(rect, pi / 2, pi / 2, false, _paint);
    canvas.drawArc(rect, pi / 2, -pi / 2, false, _paint);
    canvas.drawArc(rect, -pi / 2, -pi / 2, false, _paint);
    canvas.drawArc(rect, -pi / 2, pi / 2, true, _paint);
    //中心点
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(60, 250),
        ],
        _paint);
    //左上角
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(20, 200),
        ],
        _paint);
    //右下角
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(100, 300),
        ],
        _paint);

然后添加一个圆角矩形
flutter CustomPaint解析_第6张图片

    // 圆角矩形几种方式
    // fromLTRBR/fromRectAndRadius:最后一个参数是圆角半径
    // RRect rRect = RRect.fromLTRBR(120, 230, 160, 300, Radius.circular(10));
    // RRect rRect = RRect.fromRectAndRadius(Rect.fromLTRB(120, 230, 160, 300), Radius.circular(10));
    // fromLTRBAndCorners:可以分别设置四个角的半径
    // RRect rRect = RRect.fromLTRBAndCorners(120, 230, 160, 300, topLeft: Radius.circular(10),);
    // fromLTRBXY:最后两个参数XY确定的是椭圆弧度,不是半径相同的圆弧
    // RRect rRect = RRect.fromLTRBXY(120, 230, 160, 300, 20, 10);

    // 绘制圆角矩形
    RRect rRect = RRect.fromLTRBAndCorners(
      120,
      230,
      160,
      300,
      topLeft: Radius.circular(10),
    );
    canvas.drawRRect(rRect, _paint);

canvas可以直接绘制颜色,flutter种的颜色都是混合叠加的,这里我们全局置灰看下
flutter CustomPaint解析_第7张图片
为了显示效果,这里我们先把其注掉。

    // 绘制颜色,画布上的所有颜色均会被混合叠加
    // 全局置灰
    // canvas.drawColor(Colors.grey, BlendMode.color);

接下来稍微叠加点难度,我们绘制一个图像
flutter CustomPaint解析_第8张图片

    // 绘制图像
    // drawImage(Image image, Offset offset, Paint paint)
    // 注意这里的image是ui包里的,而非material包。留意image生成方式
    // offset:图像左上角点
    // canvas.drawImage(image, Offset(200, 250), _paint);
    // drawImageRect(Image image, Rect src, Rect dst, Paint paint)
    // src:截取图像显示部分,dst:确定图像显示区域
    // src确定的部分会在dst确定的范围内平铺
    canvas.drawImageRect(
        image,
        Offset(0, 0) &
            Size(image.width.toDouble() / 2, image.height.toDouble()),
        Offset(180, 250) & Size(150, 80),
        _paint);

注意这里的图像image是dart:ui包下的,而非material.dart。这里我们通过构造函数传进来

class BasicPainter extends CustomPainter {
  final BuildContext context;
  final ui.Image image;

  BasicPainter(this.context, this.image);
}

看看image的生成方式

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  ui.Image _image;

  initState() {
    load("assets/header.png").then((i) {
      setState(() {
        _image = i;
      });
    });
  }

  /// 通过assets路径,获取资源图片
  Future load(String asset) async {
    ByteData data = await rootBundle.load(asset);
    ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
    ui.FrameInfo fi = await codec.getNextFrame();
    return fi.image;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPaint'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            height: 30,
            color: Colors.red,
          ),
          CustomPaint(
            //size确定事件区域,如GestureDetector事件只能在范围内
            size: Size(30, 60),
            // painter:绘制的 backgroud 层
            painter: BasicPainter(context, _image),
            // child:在 painter 层上绘制
            child: Text('你好'),
            // foregroundPainter:在 child 层上绘制
            // foregroundPainter:
            //     BusinessPainter(_percentage, _points, _center),
          ),
        ],
      ),
    );
  }
}

好了,现在我们基本的形状类型都绘制完了,下面我们进入绘制的第3种类型,路径path

绘制路径

从简到难,先来个最简单的
flutter CustomPaint解析_第9张图片

    ///绘制路径
    // moveTo起始点(没有默认起始点是0,0),lineTo途径点(只有1个是线段)
    // close闭合,不调用则开口
    _paint.style = PaintingStyle.stroke;
    var path = Path()
      ..moveTo(30, 350)
      ..lineTo(80, 400)
      ..lineTo(30, 400)
      ..close();
    canvas.drawPath(path, _paint);

然后开始绘制常用的贝塞尔曲线,在这之前,我们先需要reset路径。每一次reset表示我要重新开始绘制一种类型,并且取消之前对path的设置。

    //reset:重新适用参数,取消之前对path的设置
    //如果不reset,界面上所有已绘制过的path都共用_paint新设置的参数。
    path.reset();
    _paint.color = Colors.blueAccent;

reset以后,我们开始绘制二阶的贝塞尔曲线
flutter CustomPaint解析_第10张图片

    //二阶贝塞尔曲线arcTo
    path.moveTo(100, 380);
    rect = Rect.fromCircle(center: Offset(80, 450), radius: 60);
    //arcTo(Rect rect, double startAngle, double sweepAngle, bool forceMoveTo)
    //以rect中心为原点,矩形边长/2为半径,从起点startAngle绘制sweepAngle弧度的弧线。
    //forceMoveTo:true,启动新路径,忽略设置的moveTo。false,原moveTo点和绘制起点相连接
    path.arcTo(rect, 0, pi, false);
    //path绘制圆
    rect = Rect.fromCircle(center: Offset(80, 450), radius: 30);
    //绘制圆。无效,可能是把0和pi*2看成1个点了。这里画重点!!!
    //path.arcTo(rect, 0, pi * 2, true);
    //绘制圆。有效
    path.arcTo(rect, 0, 3.14 * 2, true);
    _paint.style = PaintingStyle.stroke;
    canvas.drawPath(path, _paint);

注意绘制二阶时参数的含义。明白了二阶,三阶贝塞尔就好理解了。
这里我们画一个红心。
flutter CustomPaint解析_第11张图片
对于三阶贝塞尔,我们主要是要理解方法的参数,理解后其实不是很难。
先看看三阶贝塞尔的方法:

    //三阶贝塞尔曲线cubicTo
    //cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
    //定点(x3,y3),控制点(x1,y1)和(x2, y2)。
    //调用cubicTo前一般先moveTo(x0,y0)确认起始点。
    //起点(x0,y0),终点(x3,y3),
    //(x1,y1)指示起点切线方向,(x2,y2)指示终点切线方向。(x1,y1)和(x2,y2)要在绘制路径范围内。

在绘制之前,我们先结合图例分析一下:
flutter CustomPaint解析_第12张图片
这是左半边的,然后我们对照着再把右半边画出就可以了。绘制红心时注意对称性。

    path.reset();
    //确定桃心顶部中间点
    path.moveTo(200, 350);
    //画桃心左半边
    path.cubicTo(150, 320, 150, 380, 200, 400);
    //回到桃心顶部中间点
    path.moveTo(200, 350);
    //画桃心右半边
    path.cubicTo(250, 320, 250, 380, 200, 400);
    //上色填充
    _paint.style = PaintingStyle.fill;
    _paint.color = Colors.red;
    canvas.drawPath(path, _paint);

到现在为止,我们就把第一阶段的绘制全都讲完了,基本的图形应该是都可以绘制了。

绘制的简单应用(动画、手势、点击)

如果是单纯的图形是没有什么灵魂的,接下来我们加入点元素试试。

结合动画

我们先加入动画,当然这里只能简单演示一下,后面的手势也是。我们先实现最简单的效果。
还记得最开始的foregroundPainter吗,我把动画写在了这里,我们先把他打开。

CustomPaint(
	//size确定事件区域,如GestureDetector事件只能在范围内
	size: Size(30, 60),
	// painter:绘制的 backgroud 层
	painter: BasicPainter(context, _image),
	// child:在 painter 层上绘制
	child: Text('你好'),
	// foregroundPainter:在 child 层上绘制
	foregroundPainter:BusinessPainter(_percentage, _points, _center),
),

_points, _center参数是后面演示手势用的,先不要管。打开以后我们看到在图层上方有一个循环的动画。这里实现了偶数次画圈,奇数次画圆的逻辑。看下效果:
flutter CustomPaint解析_第13张图片
代码很简单,但是尤其注意绘制原理。

动画绘制原理:CustomPainter是静态显示的,想要实现动起来的效果,就得逐帧绘制,每变换一帧绘制一部分。监听帧的变化要通过AnimationController动画类。

绘制的实现方式:

class BusinessPainter extends CustomPainter {
  //结合动画
  final double percentage;

  //结合手势
  final List points;
  final Offset center;

  BusinessPainter(
      this.percentage,
      this.points,
      this.center,
      );

  Paint _paint = Paint()
    ..color = Colors.red
    ..strokeWidth = 3
    ..style = PaintingStyle.stroke;

  @override
  void paint(Canvas canvas, Size size) {
    /// painter跟动画结合
    // 动画绘制原理:逐帧绘制,每变换一帧绘制一部分
    // 下一帧绘制时其实把上一帧的覆盖了
    // 这里实现圆圈绘制。奇数化圈,偶数画圆
    bool useCenter = false;
    if ((percentage ~/ 360).isOdd) {
      _paint.style = PaintingStyle.fill;
      useCenter = true;
    } else {
      _paint.style = PaintingStyle.stroke;
      useCenter = false;
    }
    Rect rect = Rect.fromCircle(center: center ?? Offset(100, 250), radius: 40);
    canvas.drawArc(
        rect, 0, 2 * pi * (percentage % 360 / 360), useCenter, _paint);
  }

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

想要实现动态效果,我们就得通过动画帧来更新绘制弧度,即percentage的值
动画的实现方式:

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State {
  ///结合动画
  AnimationController _animationController;
  double _percentage = 0.0;

  initState() {
    _animationController =
    new AnimationController(vsync: this, duration: new Duration(seconds: 1))
    // 每一帧回调
      ..addListener(() {
        setState(() {
          _percentage = _percentage + 1;
        });
      });
    _animationController.repeat();
    super.initState();
  }
}

基本的动画就是这样,如果想实现复杂动画,那就是AnimationController的范畴了。可以通过对AnimationController进行包装改变变化速率,显示复杂炫酷的动画效果。这里就不往下深入了。

结合手势

案例1:拖动图形

接刚才的动画,我们现在实现拖动圆圈使其跟随手势移动的效果。
flutter CustomPaint解析_第14张图片
想要实现这个效果,我们先得把手势结合进去。需要用到GestureDetector。
手势的分析又是一个单独的课题,以后专门摘出来讲,这里先知道使用。

    return Scaffold(
      appBar: AppBar(
        title: Text('CustomPaint'),
      ),
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            height: 30,
            color: Colors.red,
          ),
          Expanded(
            child: Stack(
              children: [
                GestureDetector(
                  behavior: HitTestBehavior.translucent,
                  onPanDown: (DragDownDetails details) {
                  },
                  onPanUpdate: (DragUpdateDetails details) {
                  },
                  onPanEnd: (DragEndDetails details) {
                  },
                ),
                CustomPaint(
                  //size确定事件区域,如GestureDetector事件只能在范围内
                  size: Size(30, 60),
                  // painter:绘制的 backgroud 层
                  painter: BasicPainter(context, _image),
                  // child:在 painter 层上绘制
                  child: Text('你好'),
                  // foregroundPainter:在 child 层上绘制
                  foregroundPainter:
                  BusinessPainter(_percentage, _points, _center),
                ),
              ],
            ),
          ),
        ],
      ),
    );

这里用Stack是因为最开始提到的那个问题,CustomPaint的事件作用域只限于Size框选的区域,所以不能把GestureDetector直接包在CustomPaint上。如果直接包,手势的作用域仅限于size: Size(30, 60)范围内。
我是为了演示方便,绘制的时候并未用到paint方法中的size,几乎所有的尺寸都是写死的。所以想要实现滑动事件作用于整个屏幕,这里用Stack。
大家以后如果自定义view的时候,可以把Size的宽高作为参数传进来。

拖动实现原理:在触摸屏幕时,获取触摸点的位置。如果按下时触摸点在圆内,就把这个位置更新到绘制的圆心上。

  ///结合手势
  Offset _center;
  bool _inner;
  
  GestureDetector(
	  behavior: HitTestBehavior.translucent,
	  onPanDown: (DragDownDetails details) {
		  // 判断拖动点是否在圆内,如果是,拖动时更新圆心坐标
		  Offset localPosition = Offset(
		  details.localPosition.dx, details.localPosition.dy);
		  Offset center = _center ?? Offset(100, 250);
		  double radius = 40;
		  double interval = sqrt(
		  pow((localPosition.dx - center.dx), 2) +
		  pow((localPosition.dy - center.dy), 2));
		  if (interval <= radius) {
			  _inner = true;
			  print('触发点击事件');
		  }
	  },
	  onPanUpdate: (DragUpdateDetails details) {
		  // globalPosition:相对全局坐标系的位置(手机屏幕左上角为原点)
		  // localPosition:相对组件坐标系的位置(当前包裹组件左上角为原点)
		  Offset localPosition = Offset(
		  details.localPosition.dx, details.localPosition.dy);
		  
		  if (_inner ?? false) {
		  // 拖动点在圆内,更新圆心坐标
		  _center = localPosition;
		  } 
		  
		  //刷新圆圈中心点_center
		  setState(() {});
	  },
	  onPanEnd: (DragEndDetails details) {
		  _inner = false;
	  },
),

圆圈的绘制代码刚才已经贴过了,注意下圆心的更新

Rect rect = Rect.fromCircle(center: center ?? Offset(100, 250), radius: 40);
canvas.drawArc(
        rect, 0, 2 * pi * (percentage % 360 / 360), useCenter, _paint);

通过这个例子,我们实现了圆圈的拖动。这里涉及到一个难点,那就是判断点在图形内的问题。因为我写的是圆,判断逻辑简单,只需要判断触摸点和圆心距离小于半径就可以了。但如果是不规则的图形,如果想要实现完美的拖动边界,再加上惯性等因素,就是一个有难度的处理逻辑了。
此外,我们还看到:

if (interval <= radius) {
	_inner = true;
	print('触发点击事件');
}

这就是我们自定义view的点击事件的实现逻辑。如果是不规则的,当然完美实现也是有难度的。

案例2:绘制面板

先看看绘制面板的效果
flutter CustomPaint解析_第15张图片
面板实现原理:在触摸屏幕时,获取到触摸点的坐标。然后将坐标点集通过List存起来,在绘制时两两连接线就可以了。

还是同样先看手势代码:

///结合手势
List _points = [];

GestureDetector(
	behavior: HitTestBehavior.translucent,
	onPanDown: (DragDownDetails details) {
		// 判断拖动点是否在圆内,如果是,拖动时更新圆心坐标
		Offset localPosition = Offset(
		details.localPosition.dx, details.localPosition.dy);
		Offset center = _center ?? Offset(100, 250);
		double radius = 40;
		double interval = sqrt(
		pow((localPosition.dx - center.dx), 2) +
		pow((localPosition.dy - center.dy), 2));
		if (interval <= radius) {
			_inner = true;
			print('触发点击事件');
		}
	},
	onPanUpdate: (DragUpdateDetails details) {
		// globalPosition:相对全局坐标系的位置(手机屏幕左上角为原点)
		// localPosition:相对组件坐标系的位置(当前包裹组件左上角为原点)
		Offset localPosition = Offset(
		details.localPosition.dx, details.localPosition.dy);
		
		if (_inner ?? false) {
		// 拖动点在圆内,更新圆心坐标
		_center = localPosition;
		} else {
		// 拖动点在圆外,更新面板点集
		_points = List.from(_points)..add(localPosition);
		}
		
		//刷新面板点集_points和圆圈中心点_center
		setState(() {});
	},
	onPanEnd: (DragEndDetails details) {
		// add(null),使绘制时松手手指到再次点击的两点不连接
		_points.add(null);
		_inner = false;
	},	
),

CustomPainter中的代码很简单:

/// painter跟手势结合
// 这里实现画板功能。原理是通过手势监听将滑动时的坐标点存起来,然后通过划线绘制出来
for (int i = 0; i < points.length - 1; i++) {
	if (points[i] != null && points[i + 1] != null)
	canvas.drawLine(points[i], points[i + 1], _paint);
}
案例3:点击实现

最后这个小小的点击我也单独摘出来,因为它也是实现自定义view必不可少的一环,刚才在案例1中其实已经体现了。

点击实现原理:在触摸屏幕时,获取到触摸点的坐标。然后判断点是否在区域内。

/// 实现点击事件
// 也需要跟手势结合实现。原理是通过判断按下点是不是在绘制区域内,是的话进行操作

这样所有的功能就完成了,怎么样,是不是感觉也不是很难呢。下面我把完整代码贴出来:

完整代码

import 'dart:math';
import 'dart:ui' as ui;

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State with TickerProviderStateMixin {
  ui.Image _image;

  ///结合动画
  AnimationController _animationController;
  double _percentage = 0.0;

  ///结合手势
  List _points = [];
  Offset _center;
  bool _inner;

  initState() {
    load("assets/header.png").then((i) {
      setState(() {
        _image = i;
      });
    });

    _animationController =
        new AnimationController(vsync: this, duration: new Duration(seconds: 1))
          // 每一帧回调
          ..addListener(() {
            setState(() {
              _percentage = _percentage + 1;
            });
          });
    _animationController.repeat();
    super.initState();
  }

  @override
  void reassemble() {
    _percentage = 0;
    _animationController.repeat();
    _points.clear();
    _center = null;
    super.reassemble();
  }

  /// 通过assets路径,获取资源图片
  Future load(String asset) async {
    ByteData data = await rootBundle.load(asset);
    ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
    ui.FrameInfo fi = await codec.getNextFrame();
    return fi.image;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text('CustomPaint'),
        ),
        body: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              height: 30,
              color: Colors.red,
            ),
            Expanded(
              child: Stack(
                children: [
                  GestureDetector(
                    behavior: HitTestBehavior.translucent,
                    onPanDown: (DragDownDetails details) {
                      // 判断拖动点是否在圆内,如果是,拖动时更新圆心坐标
                      Offset localPosition = Offset(
                          details.localPosition.dx, details.localPosition.dy);
                      Offset center = _center ?? Offset(100, 250);
                      double radius = 40;
                      double interval = sqrt(
                          pow((localPosition.dx - center.dx), 2) +
                              pow((localPosition.dy - center.dy), 2));
                      if (interval <= radius) {
                        _inner = true;
                        print('触发点击事件');
                      }
                    },
                    onPanUpdate: (DragUpdateDetails details) {
                      // globalPosition:相对全局坐标系的位置(手机屏幕左上角为原点)
                      // localPosition:相对组件坐标系的位置(当前包裹组件左上角为原点)
                      Offset localPosition = Offset(
                          details.localPosition.dx, details.localPosition.dy);

                      if (_inner ?? false) {
                        // 拖动点在圆内,更新圆心坐标
                        _center = localPosition;
                      } else {
                        // 拖动点在圆外,更新面板点集
                        _points = List.from(_points)..add(localPosition);
                      }

                      //刷新面板点集_points和圆圈中心点_center
                      setState(() {});
                    },
                    onPanEnd: (DragEndDetails details) {
                      // add(null),使绘制时松手手指到再次点击的两点不连接
                      _points.add(null);
                      _inner = false;
                    },
                  ),
                  CustomPaint(
                    //size确定事件区域,如GestureDetector事件只能在范围内
                    size: Size(30, 60),
                    // painter:绘制的 backgroud 层
                    painter: BasicPainter(context, _image),
                    // child:在 painter 层上绘制
                    child: Text('你好'),
                    // foregroundPainter:在 child 层上绘制
                    foregroundPainter:
                        BusinessPainter(_percentage, _points, _center),
                  ),
                ],
              ),
            ),
          ],
        ));
  }
}

/// 写在前面
/// 所有canvas操作都调用的native方法
/// 原点(0, 0)为控件左上角,
/// 宽x:右为正方向,高y:下为正方向
/// 顺时针是旋转正方向(即从x轴的正方向到y轴正方向),和数学相反。
/// pi:圆周率,表示180度。
/// 界面尺寸单位是像素
/// CustomPainter展示的是静态时点的静态页面,如果想要实现动态效果(如动画,手势)需要通过setState更新
class BasicPainter extends CustomPainter {
  final BuildContext context;
  final ui.Image image;

  BasicPainter(this.context, this.image);

  Paint _paint = Paint()
        ..color = Colors.red //画笔颜色
        ..strokeWidth = 3 //画笔宽度
      // ..isAntiAlias = true //是否抗锯齿
      // ..style = PaintingStyle.fill //绘画风格,默认为填充
      // ..invertColors = true//图像颜色反转
      // ..colorFilter = ColorFilter.mode(Colors.blueAccent, BlendMode.exclusion) //颜色渲染模式,是混合模式
      // ..maskFilter = MaskFilter.blur(BlurStyle.inner, 3.0) //模糊遮罩效果
      // ..filterQuality = FilterQuality.high //颜色渲染模式的质量
      // ..strokeCap = StrokeCap.round //末端处理 round:圆滑处理(延伸一小截圆形) square:延伸一小截矩形 butt:不延伸
      // ..strokeJoin=StrokeJoin.round  //拐角类型,仅影响多边形连线(Canvas.drawPath) round:圆角 miter:锐角 bevel:斜角
      ;

  // 该方法绘制自定义的效果
  // 所有canvas操作都调用到native方法
  // canvas:画布
  // size:CustomPaint构造方法传入,决定绘制区域宽高
  @override
  void paint(Canvas canvas, Size size) {
    ///绘制点
    var points1 = [
      Offset(size.width / 2, size.height / 2),
      Offset(size.width, size.height),
      Offset(size.width * 2, size.height * 2),
    ];
    canvas.drawPoints(ui.PointMode.points, points1, _paint);
    var points2 = [
      // 原点(0, 0)为控件左上角
      Offset(0, 0),
      Offset(100, 100),
      Offset(100, 300), //被忽略
    ];
    _paint.color = Colors.black;
    //PointMode.lines只2个点有效,后面参数被忽略
    canvas.drawPoints(ui.PointMode.lines, points2, _paint);
    var points3 = [
      Offset(100, 100),
      Offset(200, 100),
      Offset(100, 50),
    ];
    _paint.color = Colors.blueAccent;
    //PointMode.polygon N个点连成线
    canvas.drawPoints(ui.PointMode.polygon, points3, _paint);

    //分割线
    var separate1 = [
      Offset(0, 150),
      Offset(MediaQuery.of(context).size.width, 150),
    ];
    _paint.color = Colors.black;
    canvas.drawPoints(ui.PointMode.lines, separate1, _paint);

    ///绘制形状
    _paint
      ..style = PaintingStyle.fill
      ..color = Colors.green;
    //绘制圆形。第二个参数半径
    canvas.drawCircle(Offset(150, 200), 20, _paint);
    //绘制椭圆。两个坐标点,左上右下。Rect是正方形则绘制圆形
    canvas.drawOval(Rect.fromLTRB(200, 170, 300, 220), _paint);
    // canvas.drawOval(Rect.fromLTRB(200, 200, 300, 300), _paint);

    // 矩形确定的几种方式:下列等效
    // fromCenter:确定矩形。第一值是中心点坐标,参数2宽,参数3高
    // Rect rect = Rect.fromCenter(center: Offset(60, 250), width: 80, height: 100);
    // fromCircle:确定正方形。center:中心点,radius:边长一半
    // Rect rect= Rect.fromCircle(center: Offset(60, 250),radius: 40);
    // fromLTRB:确定矩形。前两个值对应左上角的xy坐标,后两个值对应右小角xy坐标
    // Rect rect= Rect.fromLTRB(20, 200, 100, 300);
    // fromPoints:确定矩形。两参数分别为左上角和右下角的点坐标
    // Rect rect = Rect.fromPoints(Offset(20, 200), Offset(100, 300),);
    // fromLTWH:确定矩形。前两个值是左上角坐标,参数3宽,参数4高
    // Rect rect = Rect.fromLTWH(20, 200, 80, 100);
    // Offset&Size:确定矩形。Offset是左上角坐标,Size是宽高
    // Rect rect = Offset(20, 200) & Size(80, 100);

    // 绘制圆弧。重要:pi:圆周率,180度。顺时针是正方向,和数学相反。
    // drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)
    // rect:Rect.fromLTRB确定绘制范围。矩形中心点是绘制圆心。
    // startAngle:从哪开始绘制。sweepAngle:绘制多少角度。useCenter:是否和中心相连
    // 解释:以rect中心为原点,startAngle确定绘制起始方向,sweepAngle确定绘制的弧度
    Rect rect = Offset(20, 200) & Size(80, 100);
    canvas.drawArc(rect, pi / 2, pi / 2, false, _paint);
    canvas.drawArc(rect, pi / 2, -pi / 2, false, _paint);
    canvas.drawArc(rect, -pi / 2, -pi / 2, false, _paint);
    canvas.drawArc(rect, -pi / 2, pi / 2, true, _paint);
    //中心点
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(60, 250),
        ],
        _paint);
    //左上角
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(20, 200),
        ],
        _paint);
    //右下角
    canvas.drawPoints(
        ui.PointMode.points,
        [
          Offset(100, 300),
        ],
        _paint);

    // 圆角矩形几种方式
    // fromLTRBR/fromRectAndRadius:最后一个参数是圆角半径
    // RRect rRect = RRect.fromLTRBR(120, 230, 160, 300, Radius.circular(10));
    // RRect rRect = RRect.fromRectAndRadius(Rect.fromLTRB(120, 230, 160, 300), Radius.circular(10));
    // fromLTRBAndCorners:可以分别设置四个角的半径
    // RRect rRect = RRect.fromLTRBAndCorners(120, 230, 160, 300, topLeft: Radius.circular(10),);
    // fromLTRBXY:最后两个参数XY确定的是椭圆弧度,不是半径相同的圆弧
    // RRect rRect = RRect.fromLTRBXY(120, 230, 160, 300, 20, 10);

    // 绘制圆角矩形
    RRect rRect = RRect.fromLTRBAndCorners(
      120,
      230,
      160,
      300,
      topLeft: Radius.circular(10),
    );
    canvas.drawRRect(rRect, _paint);

    // 绘制颜色,画布上的所有颜色均会被混合叠加
    // 全局置灰
    // canvas.drawColor(Colors.grey, BlendMode.color);

    //绘制图像
    // drawImage(Image image, Offset offset, Paint paint)
    // 注意这里的image是ui包里的,而非material包。留意image生成方式
    // offset:图像左上角点
    // canvas.drawImage(image, Offset(200, 250), _paint);
    // drawImageRect(Image image, Rect src, Rect dst, Paint paint)
    // src:截取图像显示部分,dst:确定图像显示区域
    // src确定的部分会在dst确定的范围内平铺
    canvas.drawImageRect(
        image,
        Offset(0, 0) &
            Size(image.width.toDouble() / 2, image.height.toDouble()),
        Offset(180, 250) & Size(150, 80),
        _paint);

    var separate2 = [
      Offset(0, 310),
      Offset(MediaQuery.of(context).size.width, 310),
    ];
    _paint.color = Colors.black;
    canvas.drawPoints(ui.PointMode.lines, separate2, _paint);

    ///绘制路径
    // moveTo起始点(没有默认起始点是0,0),lineTo途径点(只有1个是线段)
    // close闭合,不调用则开口
    _paint.style = PaintingStyle.stroke;
    var path = Path()
      ..moveTo(30, 350)
      ..lineTo(80, 400)
      ..lineTo(30, 400)
      ..close();
    canvas.drawPath(path, _paint);

    //reset:重新适用参数,取消之前对path的设置
    //如果不reset,界面上所有已绘制过的path都共用_paint新设置的参数。
    path.reset();
    _paint.color = Colors.blueAccent;

    //二阶贝塞尔曲线arcTo
    path.moveTo(100, 380);
    rect = Rect.fromCircle(center: Offset(80, 450), radius: 60);
    //arcTo(Rect rect, double startAngle, double sweepAngle, bool forceMoveTo)
    //以rect中心为原点,矩形边长/2为半径,从起点startAngle绘制sweepAngle弧度的弧线。
    //forceMoveTo:true,启动新路径,忽略设置的moveTo。false,原moveTo点和绘制起点相连接
    path.arcTo(rect, 0, pi, false);
    //path绘制圆
    rect = Rect.fromCircle(center: Offset(80, 450), radius: 30);
    //绘制圆。无效,可能是把0和pi*2看成1个点了!!!
    // path.arcTo(rect, 0, pi * 2, true);
    //绘制圆。有效
    path.arcTo(rect, 0, 3.14 * 2, true);
    _paint.style = PaintingStyle.stroke;
    canvas.drawPath(path, _paint);

    //三阶贝塞尔曲线cubicTo
    //cubicTo(double x1, double y1, double x2, double y2, double x3, double y3)
    //定点(x3,y3),控制点(x1,y1)和(x2, y2)。
    //调用cubicTo前一般先moveTo(x0,y0)确认起始点。
    //起点(x0,y0),终点(x3,y3),
    //(x1,y1)指示起点切线方向,(x2,y2)指示终点切线方向。(x1,y1)和(x2,y2)要在绘制路径范围内。

    path.reset();
    //确定桃心顶部中间点
    path.moveTo(200, 350);
    //画桃心左半边
    path.cubicTo(150, 320, 150, 380, 200, 400);
    //回到桃心顶部中间点
    path.moveTo(200, 350);
    //画桃心右半边
    path.cubicTo(250, 320, 250, 380, 200, 400);
    //上色填充
    _paint.style = PaintingStyle.fill;
    _paint.color = Colors.red;
    canvas.drawPath(path, _paint);
  }

  //是否需要重绘。通常在当前实例和旧实例属性不一致时返回true
  @override
  bool shouldRepaint(BasicPainter oldDelegate) {
    return this != oldDelegate;
  }
}

class BusinessPainter extends CustomPainter {
  //结合动画
  final double percentage;

  //结合手势
  final List points;
  final Offset center;

  BusinessPainter(
    this.percentage,
    this.points,
    this.center,
  );

  Paint _paint = Paint()
    ..color = Colors.red
    ..strokeWidth = 3
    ..style = PaintingStyle.stroke;

  @override
  void paint(Canvas canvas, Size size) {
    /// painter跟动画结合
    // 动画绘制原理:逐帧绘制,每变换一帧绘制一部分
    // 下一帧绘制时其实把上一帧的覆盖了
    // 这里实现圆圈绘制。奇数化圈,偶数画圆
    bool useCenter = false;
    if ((percentage ~/ 360).isOdd) {
      _paint.style = PaintingStyle.fill;
      useCenter = true;
    } else {
      _paint.style = PaintingStyle.stroke;
      useCenter = false;
    }
    Rect rect = Rect.fromCircle(center: center ?? Offset(100, 250), radius: 40);
    canvas.drawArc(
        rect, 0, 2 * pi * (percentage % 360 / 360), useCenter, _paint);

    /// painter跟手势结合
    // 这里实现画板功能。原理是通过手势监听将滑动时的坐标点存起来,然后通过划线绘制出来
    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null)
        canvas.drawLine(points[i], points[i + 1], _paint);
    }

    /// 实现点击事件
    // 也需要跟手势结合实现。原理是通过判断按下点是不是在绘制区域内,是的话进行操作
  }

  @override
  bool shouldRepaint(BusinessPainter oldDelegate) {
    return oldDelegate.points != points;
  }
}

你可能感兴趣的:(Android,flutter)