最近研究了下flutter的自定义绘制,觉得有必要将绘制的使用和应该规避的坑等分享一下。下面就从简到难,循序渐进的给大家介绍一下
先上最终demo演示:
下面从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方法
如下:
这点明白了,我们就来看看绘制的关键类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';
接下来我们从绘制点,绘制形状,绘制路径三个方面绘制:
///绘制点
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);
///绘制形状
_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可以直接绘制颜色,flutter种的颜色都是混合叠加的,这里我们全局置灰看下
为了显示效果,这里我们先把其注掉。
// 绘制颜色,画布上的所有颜色均会被混合叠加
// 全局置灰
// 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);
注意这里的图像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
///绘制路径
// 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;
//二阶贝塞尔曲线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);
到现在为止,我们就把第一阶段的绘制全都讲完了,基本的图形应该是都可以绘制了。
如果是单纯的图形是没有什么灵魂的,接下来我们加入点元素试试。
我们先加入动画,当然这里只能简单演示一下,后面的手势也是。我们先实现最简单的效果。
还记得最开始的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参数是后面演示手势用的,先不要管。打开以后我们看到在图层上方有一个循环的动画。这里实现了偶数次画圈,奇数次画圆的逻辑。看下效果:
代码很简单,但是尤其注意绘制原理。
动画绘制原理: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进行包装改变变化速率,显示复杂炫酷的动画效果。这里就不往下深入了。
接刚才的动画,我们现在实现拖动圆圈使其跟随手势移动的效果。
想要实现这个效果,我们先得把手势结合进去。需要用到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的点击事件的实现逻辑。如果是不规则的,当然完美实现也是有难度的。
先看看绘制面板的效果
面板实现原理:在触摸屏幕时,获取到触摸点的坐标。然后将坐标点集通过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);
}
最后这个小小的点击我也单独摘出来,因为它也是实现自定义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;
}
}