theme: cyanosis
本系列,将通过 Flutter 实现一个全平台的像素编辑器应用。源码见开源项目 【pix_editor】。在前三篇中,我们已经完成了一个简易的图像编辑器,并且简单引入了图层的概念,支持切换图层显示不同的像素画面。
- 《Flutter 像素编辑器#01 | 像素网格》
- 《Flutter 像素编辑器#02 | 配置编辑》
- 《Flutter 像素编辑器#03 | 像素图层》
- 《Flutter 像素编辑器#04 | 导入导出图像》
0.本文目的
之前已经实现了像素编辑器的基本功能,但是目前绘制的区域是固定大小。这样在行列数非常大时,就会导致绘制格非常小,不便于绘制。所以希望布局区域可以向 Photoshop 一样,能够缩放和平移,让用户更自由地绘制。
jvideo
其中有几个个关键的难点:
- 如何通过手势、鼠标操作,触发缩放和平移事件。
- 绘制区域进行缩放平移变换后,落点在单元格内的校验逻辑如何适应。
- 如何支持行列数不同的像素网格。
1. 引入视口相机的概念
为了便于处理编辑器内容的变换,这里引入 视口相机 (ViewCamera) 的概念。如下所示: - 红色区域是编辑器的最大区域,称之为 视口尺寸 (viewSize)
; - 蓝色区域是编辑器的实际的操作区,称之为 展示尺寸 (playSize)
;
可以休息一下 playSize 内的是现实世界的真实物体。现在将 viewSize 区域看做一个照相机。我们可以调节相机的位置、远近等控制真实物体在相机上的成像。这种图形的控制称为变换 ,一般通过 Matrix4
对象进行操作。
这里视口相机 ViewCamera 设计为 mixin
,方便通过混入实现功能的独立。便于复用以及单一职责。此时,可以定义如下三个重要成员:
```dart mixin ViewCamera on ChangeNotifier { Size _viewSize = Size.zero; late Size _playSize; final Matrix4 _transformer = Matrix4.identity();
Size get viewSize => _viewSize; Size get playSize => _playSize; Matrix4 get transformer => _transformer; } ```
2. 两个尺寸的赋值
视口尺寸可以依赖外界设置。展示尺寸在 开始时
希望以适合大大小填充视口;网格长边留下 fixPadding 的边距;这样依赖视口尺寸,就可以算出网格适应边的大小;再根据网格尺寸,就可以算出每个网格的尺寸 pixSide
比如网格宽度大于长度时,左右两侧留下 fixPadding ,使其填充相机视口:
尺寸的计算逻辑如下所示,相机设置视口尺寸时,先检验和旧尺寸是否一致。如果未改变,直接返回不做处理。否则通过 _updatePlaySize
方法计算 playSize;然后通过 centerContent
方法通过变换操作将内容居中展示; onViewBoxChanged 是一个回调,来通知外界尺寸变化的时机:
```dart set viewSize(Size size) { if (size == _viewSize) return; Size oldSize = _viewSize; _viewSize = size; _updatePlaySize(size); centerContent(size, _playSize); scheduleMicrotask(() { onViewBoxChanged(oldSize, size); }); }
@protected void onViewBoxChanged(Size old, Size size) {} ```
playSize 的计算,需要依赖网格行列数,由于 ViewCamera 并不需要持有和维护该数据,可以通过 抽象方法 gridSize
交由混入它的类实现。计算过程也比较简单,根据 viewSize 计算出适合的像素边长 _pixSide
;乘以网格个行列数就可以的到 playSize :
```dart double _pixSide = 0; double get pixSide => _pixSide; (int, int) get gridSize; double fitPadding = 20;
void _updatePlaySize(Size viewSize) { double padding = fitPadding * 2; int row = gridSize.$1; int column = gridSize.$2; if (row > column) { _pixSide = (viewSize.width - padding) / row; } else { _pixSide = (viewSize.height - padding) / column; } _playSize = Size(gridSize.$1 * _pixSide, gridSize.$2 * _pixSide); } ```
3. 相机的变换操作
首先看一下平移操作。默认情况下,绘制会从画布的左上角开始。想要让其居中,可以通过平移变换。我们已经知道了 viewSize
和 playSize
两个尺寸,就可以很容易地计算出偏移量。
这里希望当视口尺寸变化时,可以将网格区域适配呈现在中间,这就是 centerContent
的作用。它将变换矩阵重置为单位矩阵,并设置偏移量使视图居中。
dart void centerContent(Size viewBox, Size pixSize) { _transformer.setIdentity(); double dx = (viewBox.width - pixSize.width) / 2; double dy = (viewBox.height - pixSize.height) / 2; _transformer.translate(dx, dy); }
相机的移动通过 translation
方法处理,将 _transformer
乘以一个移动矩阵,并通知更新:
```dart void translation(double dx, double dy) { Matrix4 moveM = Matrix4.translationValues(dx / scale, dy / scale, 0); _transformer.multiply(moveM); notifyListeners(); }
double get scale => _transformer.getMaxScaleOnAxis(); ```
缩放操作最重要的是计算好缩放中心 center
。缩放变换计算前,先通过移动将变换中心移到 center 点;计算完后再移回去。代码如下:
dart void setScale(double value, {Offset origin = Offset.zero}) { double dx = _transformer.getTranslation().x; double dy = _transformer.getTranslation().y; Offset center = (origin - Offset(dx, dy)) / scale; Matrix4 scaleM = Matrix4.diagonal3Values(value, value, 0); Matrix4 moveM = Matrix4.translationValues(center.dx, center.dy, 0); Matrix4 backM = Matrix4.translationValues(-center.dx, -center.dy, 0); _transformer.multiply(moveM); _transformer.multiply(scaleM); _transformer.multiply(backM); notifyListeners(); }
4. 视图层处理
视图层处理最重要的一点是,在绘制时使用相机中的 transformer
矩阵来对编辑区域的内容进行矩阵变换。我让 PixPaintLogic
混入了 ViewCamera,所以它就有视口相机的一切能力:
dart class PixPaintLogic with ChangeNotifier, ViewCamera { String activeLayerId = ''; final List<PaintLayer> _layers = [];
最后就是在拖拽移动和鼠标滚轮的事件监听和变换:
- 通过
Listener#onPointerSignal
可以监听到鼠标的滚轮事件,其中触发缩放逻辑。 - 通过
GestureDetector#onPanUpdate
可以监听到鼠标的移动事件,其中触发平移逻辑。
在事件回调中,通过相机触发缩放和移动的方法即可:
```dart void onScale(PointerSignalEvent event) { if (event is PointerScrollEvent) { if (event.scrollDelta.dy < 0) { paintLogic.setScale(1.1, origin: event.localPosition); } else { paintLogic.setScale(0.9, origin: event.localPosition); } } }
void onMove(DragUpdateDetails details) { paintLogic.translation(details.delta.dx, details.delta.dy); } ```
5. 点击格点坐标校验
由于点击事件回调的触点时相对于视口左上角的偏移量。当视口进行缩放或者平移时,就需要进行相应的转换。将触点映射到变换后的坐标系中。下面画个移动时的示意图:
右图在移动之后,触点在点击第第二排第二个点时,触点的坐标还是以视口左上角为起点,我们需要将其原点视为 网格区域
的左上角才能计算出正确的网格点位校验。实现很简单,就是将触点坐标减去偏移量即可,缩放同理:
我在相机中添加了 transformOffset 方法,将一个基于 视口左上角 的坐标,转换为基于 网格左上角 的坐标:
```dart Offset transformOffset(Offset src) { double dx = _transformer.getTranslation().x; double dy = _transformer.getTranslation().y; return (src - Offset(dx, dy)) / scale; }
(int x, int y) transformPoint(Offset src) { Offset offset = transformOffset(src); return (offset.dx ~/ pixSide, offset.dy ~/ pixSide); } ```
到这里,就是实现了自由地变换,不用受制于点击区域过小,可以更好地进行编辑。这也是像素编辑器最重要的一步。后续还会带来更多像素编辑器开发的文章,一起来见证这个小破项目的发展,敬请期待 ~