0.本文目的
之前已经实现了像素编辑器的基本功能,但是目前绘制的区域是固定大小。这样在行列数非常大时,就会导致绘制格非常小,不便于绘制。所以希望布局区域可以向 Photoshop 一样,能够缩放和平移,让用户更自由地绘制。
其中有几个个关键的难点:
为了便于处理编辑器内容的变换,这里引入 视口相机 (ViewCamera) 的概念。如下所示:
视口尺寸 (viewSize)
;展示尺寸 (playSize)
;可以休息一下 playSize 内的是现实世界的真实物体。现在将 viewSize 区域看做一个照相机。我们可以调节相机的位置、远近等控制真实物体在相机上的成像。这种图形的控制称为变换 ,一般通过 Matrix4
对象进行操作。
这里视口相机 ViewCamera 设计为 mixin
,方便通过混入实现功能的独立。便于复用以及单一职责。此时,可以定义如下三个重要成员:
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;
}
视口尺寸可以依赖外界设置。展示尺寸在 开始时
希望以适合大大小填充视口;网格长边留下 fixPadding 的边距;这样依赖视口尺寸,就可以算出网格适应边的大小;再根据网格尺寸,就可以算出每个网格的尺寸 pixSide
比如网格宽度大于长度时,左右两侧留下 fixPadding ,使其填充相机视口:
尺寸的计算逻辑如下所示,相机设置视口尺寸时,先检验和旧尺寸是否一致。如果未改变,直接返回不做处理。否则通过 _updatePlaySize
方法计算 playSize;然后通过 centerContent
方法通过变换操作将内容居中展示; onViewBoxChanged 是一个回调,来通知外界尺寸变化的时机:
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 :
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);
}
首先看一下平移操作。默认情况下,绘制会从画布的左上角开始。想要让其居中,可以通过平移变换。我们已经知道了 viewSize
和 playSize
两个尺寸,就可以很容易地计算出偏移量。
这里希望当视口尺寸变化时,可以将网格区域适配呈现在中间,这就是 centerContent
的作用。它将变换矩阵重置为单位矩阵,并设置偏移量使视图居中。
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
乘以一个移动矩阵,并通知更新:
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 点;计算完后再移回去。代码如下:
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();
}
视图层处理最重要的一点是,在绘制时使用相机中的 transformer
矩阵来对编辑区域的内容进行矩阵变换。我让 PixPaintLogic
混入了 ViewCamera,所以它就有视口相机的一切能力:
class PixPaintLogic with ChangeNotifier, ViewCamera {
String activeLayerId = '';
final List<PaintLayer> _layers = [];
最后就是在拖拽移动和鼠标滚轮的事件监听和变换:
Listener#onPointerSignal
可以监听到鼠标的滚轮事件,其中触发缩放逻辑。GestureDetector#onPanUpdate
可以监听到鼠标的移动事件,其中触发平移逻辑。在事件回调中,通过相机触发缩放和移动的方法即可:
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);
}
由于点击事件回调的触点时相对于视口左上角的偏移量。当视口进行缩放或者平移时,就需要进行相应的转换。将触点映射到变换后的坐标系中。下面画个移动时的示意图:
右图在移动之后,触点在点击第第二排第二个点时,触点的坐标还是以视口左上角为起点,我们需要将其原点视为 网格区域
的左上角才能计算出正确的网格点位校验。实现很简单,就是将触点坐标减去偏移量即可,缩放同理:
我在相机中添加了 transformOffset 方法,将一个基于 视口左上角 的坐标,转换为基于 网格左上角 的坐标:
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);
}
到这里,就是实现了自由地变换,不用受制于点击区域过小,可以更好地进行编辑。这也是像素编辑器最重要的一步。后续还会带来更多像素编辑器开发的文章,一起来见证这个小破项目的发展,敬请期待 ~