持续创作,加速成长!这是我参与「日新计划 6 月更文挑战」的第21天,点击查看活动详情
- 「手绘板的制作——手绘(1)」
- 「手绘板的制作——重置与橡皮擦(2)」
- 「手绘板的制作——命令模式与撤销、重制(3)」
- 「手绘板的制作——画布缩放(4)」
- 「手绘板的制作——画布移动(5)」
- 「手绘板的制作——画布保存(6)」
前言
在这一篇中,我们讲解下画布的缩放,也就是做一个根据手势缩放进行画布缩放的功能。
我们先来梳理下逻辑:
- 监听手势,当为一根手指的时候,就延续之前的操作,执行手绘操作,当操作为两根手指的时候,则执行缩放功能。
- 对画布进行缩放
好了,正文开始!
手势缩放
看了下,GestureDetector 里面有 onScaleStart、onScaleUpdate、onScaleEnd 参数,这…这不是缩放开始、缩放过程中、缩放结束的回调吗?Flutter 真方便,都给封装好了。赶紧试下:
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (details) {
print("onPanStart:准备开始移动");
_paintedBoardProvider.onStart(details);
},
onPanUpdate: (details) {
print("onPanUpdate:正在移动");
_paintedBoardProvider.onUpdate(details);
},
onPanEnd: (details) {
print("onPanDown:移动结束");
widget._invoker.execute(PaintedCommand(
_paintedBoardProvider, _paintedBoardProvider.strokes.last));
},
onScaleStart: (details) { // <- 新增
print("onScaleStart:缩放开始");
},
onScaleUpdate: (details) { // <- 新增
print("onScaleStart:缩放进行中");
},
onScaleEnd: (details) { // <- 新增
print("onScaleStart:缩放结束");
},
child: CustomPaint(
painter: MyPainter(_paintedBoardProvider),
size: Size.infinite,
),
);
}
运行…
======== Exception caught by widgets library =======================================================
The following assertion was thrown building HandPaintedBoard(dirty, state: _HandPaintedBoardState#7df2e):
Incorrect GestureDetector arguments.
Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.
Just use the scale gesture recognizer.
em…咋报错了…
大意就是,缩放手势包含了平移手势,所以,同时赋值缩放手势和平移手势是多余,直接使用缩放手势即可。
具体报错源码就是:
final bool havePan = onPanStart != null || onPanUpdate != null || onPanEnd != null;
final bool haveScale = onScaleStart != null || onScaleUpdate != null || onScaleEnd != null;
if (havePan || haveScale) {
if (havePan && haveScale) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('Incorrect GestureDetector arguments.'),
ErrorDescription(
'Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.',
),
ErrorHint('Just use the scale gesture recognizer.'),
]);
}
这能怎么办?解决!
所以,我们需要把 onPanStart、onPanUpdate、onPanEnd 去掉,只保留 onScaleStart、onScaleUpdate、onScaleEnd,然后在缩放的方法里面进行缩放和平移的区分,所以,我们先定义一个枚举:
enum GestureType {
translate, // 平移
scale, // 缩放
}
具体区分代码:
class _HandPaintedBoardState extends State<HandPaintedBoard> {
PaintedBoardProvider get _paintedBoardProvider =>
widget._paintedBoardProvider;
// 标识手势
GestureType _gestureType = GestureType.translate; // <- 新增
// 记录缩放开始的缩放
double _startScale = 1; // <- 新增
class PaintedBoardProvider extends ChangeNotifier {
// 缩放比例
double scale = 1; // <- 新增
onScaleStart: (details) {
if (details.pointerCount > 1) { // 双指
_gestureType = GestureType.scale;
_startScale = _paintedBoardProvider.scale;
} else { // 单指
_gestureType = GestureType.translate;
_paintedBoardProvider.onStart(details.localFocalPoint);
}
},
onScaleUpdate: (details) {
switch (_gestureType) {
case GestureType.translate:
_paintedBoardProvider.onUpdate(details.localFocalPoint);
break;
case GestureType.scale:
setState(() {
_paintedBoardProvider.scale = _startScale + details.scale - 1;
});
break;
}
},
onScaleEnd: (details) {
switch (_gestureType) {
case GestureType.translate:
widget._invoker.execute(PaintedCommand(
_paintedBoardProvider, _paintedBoardProvider.strokes.last));
break;
case GestureType.scale:
print("onScaleEnd:缩放结束");
break;
}
},
主要的思路其实就是:
- 在 onScaleStart 的时候,判断是单指还是双指,并且进行记录该状态,后续的 onScaleUpdate、onScaleEnd 都是基于这个单指或者双指进行操作的。
- 在 onScaleStart 中进行数据记录:
- 单指:创建 stroke 存储当前绘画信息,便于后续手绘。
- 双指:记录当前的缩放系数。
- 在 onScaleUpdate 中进行状态更改:
- 单指:更新 path 数据,进行手绘刷新。
- 双指:手势过程的缩放系数
details.scale
是基于 1 进行不断增大的,直至缩放过程结束,所以通过_startScale + details.scale - 1
就能拿到当前 Widget 正确的缩放系数,_startScale 为在 onScaleStart 中存储的当前缩放系数。
- 在 onScaleEnd 进行事件的收尾处理:
- 单指:提交命令。
- 双指:无需操作。
视图缩放
经过以上步骤,我们可以获取得到手势缩放的系数,但是这个系数如何用于放大视图?
目前一般有两种步骤:
- 对于 canvas 进行缩放,并且对于 canvas 的绘制内容进行全部缩放,例如画笔原有起点为 (1,1),放大后,需要将画笔原有起点进行更改,可能就要变为 (2,2) 了,这种方式需要更改的比较多,所以我就不在这里实践了,有兴趣的同学可以自己试下。
- 使用 Transform 进行缩放,也就是把整个 Widget 进行放大,所以 canvas 的坐标系是没有改动的,之前绘制的内容不需要重新绘制,目前我采取的是这种方案。这里有个重点,canvas 的坐标系是没有改动的。
所以,使用以下代码即可完成缩放功能:
child: Transform.scale(
scale: _paintedBoardProvider.scale,
child: CustomPaint(
painter: MyPainter(_paintedBoardProvider),
size: Size.infinite,
),
),
所以,这缩放功能就结束了吗?
当然没有这么简单,这后面才是难点。
我们在进行手绘板制作的时候,使用的坐标点是 details.localFocalPoint
,它是基于当前视图的坐标点,但是它的 (0,0) 坐标并不是固定为视图的左上角,当视图大于屏幕的时候,它的 (0,0) 是视图与屏幕的交接处,所以,无论使用 Transform 进行如何缩放,对于同一个点击点,其 details.localFocalPoint
的值都是一样的。(这话可能不够严谨,但是对于我当前的 demo 而言,它原有视图就是铺满整个屏幕,无论它使用 Transform 进行缩放多少倍,同个点击点的 details.localFocalPoint
值都是一样的。)
但是,我们特别强调了,在进行缩放后,canvas 的坐标系是没有改动的,只是视图效果放大而已,所以,即使点击的是同一个位置,在 canvas 的坐标系上的位置也是不相同的,所以,我们要对于后续绘画的点进行处理,将 details.localFocalPoint
其转换为基于视图 (0,0) 点的坐标。
- 蓝框为原图,x、y 为原图的坐标系。
- Transform 默认是基于中心仅放大的,所以,黄框是实际上放大的效果。
- 若放大前的
details.localFocalPoint
为 (10,10),那么放大后同个点击处的details.localFocalPoint
仍然为 (10,10) - 由于手绘绘制是基于画布 (0,0) 位置的,也就是黄框的左上角,所以,我们需要把
details.localFocalPoint
加上两条绿边距离,才是真正的手绘坐标点。 - 那两条绿边怎么计算?我们先算 x 坐标的,假设原图大小为 w1,放大后的大小为 w2,那绿边 x = (w2-w1) / 2,而 w2 其实就是 w1 乘以 scale,所以 x = (scale-1) * w1 /2
实际的代码实操:
首先,我们需要存储原有的画布大小:
class PaintedBoardProvider extends ChangeNotifier {
// 画布原有尺寸
Size realCanvasSize = Size.zero;
具体的赋值在 MyPainter:
class MyPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
paintedBoardProvider.realCanvasSize = size;
剩下的就是换算了:
/// 移动开始时
void onStart(Offset localPosition) {
double startX = localPosition.dx;
double startY = localPosition.dy;
final newStroke = Stroke(
color: isClear ? Colors.transparent : color,
width: paintWidth,
isClear: isClear,
);
newStroke.path.moveTo(
(startX + (scale - 1) * realCanvasSize.width / 2 ) /
scale,
(startY + (scale - 1) * realCanvasSize.height / 2 ) /
scale);
_strokes.add(newStroke);
}
/// 移动
void onUpdate(Offset localPosition) {
_strokes.last.path.lineTo(
(localPosition.dx +
(scale - 1) * realCanvasSize.width / 2 ) /
scale,
(localPosition.dy +
(scale - 1) * realCanvasSize.height / 2 ) /
scale);
notifyListeners();
}
可能会有人有疑问,为什么换算后的值还要除以 scale,em…这还是因为 canvas 的坐标系没有更改过,我们的换算都是基于真正进行放大后的换算,但是实际上坐标系没有放大,所以还要除以 scale 转换回来。
清除误差点
在具体的实操上,其实人点击屏幕的时候,由于手指接触屏幕面积较大,所以,经常会出现缩放结束后,还会触发绘制的效果,所以,我们在手指抬起之后,对于绘制数据进行初步清理,也就是单点的误差的全部清除,当然,我这种方式还不够严谨,剩下的大家可以根据具体需求进行调整:
onScaleEnd: (details) {
switch (_gestureType) {
case GestureType.translate:
// 移除由于误操作导致的小点出现
final lastBounds = _paintedBoardProvider.strokes.last.path.getBounds();
if (lastBounds.width < 0.5 && lastBounds.height < 0.5) {
_paintedBoardProvider.strokes.removeLast();
_paintedBoardProvider.refreshPaintedBoard();
} else {
widget._invoker.execute(PaintedCommand(
_paintedBoardProvider, _paintedBoardProvider.strokes.last));
}
break;
case GestureType.scale:
print("onScaleEnd:缩放结束");
break;
}
},