Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十)
首先我们来简单的限制移动的边界,通过上面我们知道,移动的点都是通过左上角的坐标定位的,在元素旋转后,判断边界就应该以最接近边界的顶点去计算,我们首先获取四个顶点的坐标,再通过通过顶点坐标的最值判断边界。之前在未旋转的时候,我么通过左上和右下值来判断的吸附,现在我们旋转了元素,再用之前那个吸附方法,判断的永远也只是初始的蓝色矩形,这样很不和逻辑,当旋转后,理应是旋转后的某个顶点在吸附阈值内就进行吸附
Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十)
Flutter: 3.35.7
前面将基础的功能完成了,现在开始完善辅助功能,常规的辅助功能有网格辅助线,元素层级调整,撤销还原等等,下面我们就简单来实现这些功能。
对于部分辅助功能,我们抽取单独的结构来实现功能,底部工具栏专门用于新增元素,抽取顶部功能区域来实现其他功能。
首先我们来实现网格辅助线,应用网格辅助线可以快速让用户定位到辅助线的位置。要实现这个功能,首先我们得绘制网格线(因为不是绘制相关的知识,所以直接上代码,后续空了会单独写绘制相关的):
import 'package:flutter/material.dart';
import 'configs/constants_config.dart';
/// 绘制网格线
class GridPainter extends CustomPainter {
void paint(Canvas canvas, Size size) {
const double gridSize = ConstantsConfig.gridSize;
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..strokeWidth = 1.0;
// 绘制垂直线
for (double x = 0; x < size.width; x += gridSize) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
// 绘制水平线
for (double y = 0; y < size.height; y += gridSize) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
效果如下:

现在网格线绘制完毕,抽取了网格线的部分属性,后续直接更改属性调整网格间距。现在我们就开始实现功能,当元素拖动到网格线附近的时候,就出现吸附效果,上下左右,谁离得近就吸附谁。当实现吸附的时候,我们可以只需要判断左上和右下的点即可,因为左上和右下分别对应了最小和最大的x和y,通过矩形的特性,这两个点就能够包括所有吸附的情况:
/// 获取开启网格辅助线时低于阈值的x和y
///
/// 通过当前的[x]坐标和[y]坐标计算吸附坐标,如果低于阈值,则不吸附
(double, double) _getUseGridXY({required double x, required double y}) {
double tempX = x;
double tempY = y;
final double gridSize = ConstantsConfig.gridSize;
// 吸附的阈值
final double snapThreshold = ConstantsConfig.snapThreshold;
// 计算最近(左上)的网格点
double snappedLeftX = (x / gridSize).roundToDouble() * gridSize;
double snappedLeftY = (y / gridSize).roundToDouble() * gridSize;
// 计算最近(右下)的网格点
double snappedRightX = ((x + _currentElement!.elementWidth) / gridSize).roundToDouble() * gridSize;
double snappedRightY = ((y + _currentElement!.elementHeight) / gridSize).roundToDouble() * gridSize;
// 检查是否在吸附范围内
double dxLeftDistance = (x - snappedLeftX).abs();
double dyLeftDistance = (y - snappedLeftY).abs();
double dxRightDistance = ((x + _currentElement!.elementWidth) - snappedRightX).abs();
double dyRightDistance = ((y + _currentElement!.elementHeight) - snappedRightY).abs();
// 在X轴方向上应用吸附
if (dxLeftDistance < dxRightDistance && dxLeftDistance < snapThreshold) {
// 如果靠近左边且小于阈值,则吸附到左边
tempX = snappedLeftX;
} else if (dxRightDistance < dxLeftDistance && dxRightDistance < snapThreshold) {
// 如果靠近右边且小于阈值,则吸附到右边
tempX = snappedRightX - _currentElement!.elementWidth;
}
// 在Y轴方向上应用吸附
if (dyLeftDistance < dyRightDistance && dyLeftDistance < snapThreshold) {
// 如果靠近上面且小于阈值,则吸附到上面
tempY = snappedLeftY;
} else if (dyRightDistance < dyLeftDistance && dyRightDistance < snapThreshold) {
// 如果靠近下面且小于阈值,则吸附到下面
tempY = snappedRightY - _currentElement!.elementHeight;
}
return (tempX, tempY);
}
/// 处理元素移动
void _onMove({required double x, required double y}) {
if (_currentElement == null || _temporary == null) return;
double tempX = _temporary!.x + x - _startPosition.dx;
double tempY = _temporary!.y + y - _startPosition.dy;
// 新增网格线辅助
if (_useGrid) {
(tempX, tempY) = _getUseGridXY(x: tempX, y: tempY);
}
// 其他省略...
}
运行效果(为了方便看效果,所以网格画得很大):

这样我们就简单实现了网格辅助线,在没有旋转的时候,只需要以左上和右下点为基准即可,那如果我们旋转了元素,此时的辅助线又该怎么吸附呢?之前限制边界值也是如此,并没有考虑旋转的情况,此时一并解决。
我们现在的元素定位是基于初始状态矩形来实现的,当元素发生旋转,其实只是内部发生了旋转,基础的矩形并没有,如下图所示:

可以看到,旋转后原始的矩形并没有发生旋转,所以此时定位的x和y还是原始的x和y。
首先我们来简单的限制移动的边界,通过上面我们知道,移动的点都是通过左上角的坐标定位的,在元素旋转后,判断边界就应该以最接近边界的顶点去计算,我们首先获取四个顶点的坐标,再通过通过顶点坐标的最值判断边界。这样虽然能判断边界,假如有个元素的宽度就是容器的宽度,或者高度就是容器的高度,在旋转过程中,始终会存在超过边界的情况,不至于说边旋转边改变元素的大小。所以为了综合两部分,我们用中心点坐标为基准,只要中心点在容器内就行了,这样即便是超出容器一半,也可以接受。为了部分的灵活性,方便后期自定义,所以抽取比例,中心点比例是长宽的一半,这样后期如果我们想用另外的点来计算边界值也行:
/// 获取移动时的边界
///
/// 通过当前移动的[x]坐标和[y]坐标来计算中心点是否达到边界,
/// 如果达到边界,则中心点坐标应用边界值
(double, double) _getMoveBoundary({required double x, required double y}) {
final double tempWidth = _currentElement!.elementWidth / ConstantsConfig.boundaryRatio;
final double tempHeight = _currentElement!.elementHeight / ConstantsConfig.boundaryRatio;
double centerX = x + tempWidth;
double centerY = y + tempHeight;
// 限制左边界
if (centerX < 0) {
centerX = 0;
}
// 限制右边界
if (centerX > _transformWidth) {
centerX = _transformWidth;
}
// 限制上边界
if (centerY < 0) {
centerY = 0;
}
// 限制下边界
if (centerY > _transformHeight) {
centerY = _transformHeight;
}
return (centerX - tempWidth, centerY - tempHeight);
}
运行效果:

这样就简单重新限制了移动边界,当然,边界的限制可以根据自己的需求来定制,这里主要是为了说明一个情况,不论我们旋转还是移动,虽然元素的顶点坐标发生了变化,但是它的中心点始终没变,下面我们就可以使用这个中心点来计算其他的东西。
现在来实现旋转后的网格辅助线附近的吸附效果,之前我们在未旋转时应用了左上和右下点来确定,现在加入旋转,就无法正确的判断了,上面我们可以看到因为旋转是元素内部的旋转,和它的坐标点不存在直接的联系。我们在未旋转的时候,吸附效果就是基于蓝色矩形实现的。左上和右下分别可以表示元素的最小坐标和最大坐标,只要某个点在辅助线附近,就执行吸附,满足最大和最小的情况。当应用了旋转,此时的最大和最小坐标就不是原来的坐标了,而是要通过原始的点进行计算。前面我们已经给出了计算旋转后顶点坐标的代码,现在抽取为方法:
/// 计算元素的顶点坐标
///
/// 计算传入元素[item]的顶点坐标,返回顺序为左上,右上,右下,左下
(Offset, Offset, Offset, Offset) _getElementVertex({
required ElementModel item
}) {
// 计算元素的四个顶点坐标
return (
_rotatePoint(
x: item.x,
y: item.y,
item: item,
),
_rotatePoint(
x: item.x + item.elementWidth,
y: item.y,
item: item,
),
_rotatePoint(
x: item.x + item.elementWidth,
y: item.y + item.elementHeight,
item: item,
),
_rotatePoint(
x: item.x,
y: item.y + item.elementHeight,
item: item,
),
);
}
为了更加直观的看到吸附的效果,我们先来实现辅助线,当我们有了顶点坐标计算方法后,就可以很好的实现辅助线,其实就是将元素用个矩形框框起来,不论元素怎么旋转,都在这个矩形框内。所以我们只需要知道最大和最小的顶点坐标即可:
/// 获取顶点坐标中的最大xy和最小xy
///
/// 通过顶点坐标列表[vertexList]对比出最大和最小
(double, double, double, double) _getExtremeVertex({
required List<Offset> vertexList
}) {
double minDx = vertexList[0].dx;
double minDy = vertexList[0].dy;
double maxDx = vertexList[3].dx;
double maxDy = vertexList[3].dy;
for (var item in vertexList) {
if (item.dx < minDx) {
minDx = item.dx;
} else if (item.dx > maxDx) {
maxDx = item.dx;
}
if (item.dy < minDy) {
minDy = item.dy;
} else if (item.dy > maxDy) {
maxDy = item.dy;
}
}
return (minDx, minDy, maxDx, maxDy);
}
为了更加方便的使用,我们添加如下逻辑:
/// 快速的获取元素的最小的顶点坐标值和最大的顶点坐标值
(double, double, double, double) get _elementVertex {
if (_currentElement == null) {
return (0, _transformHeight, 0, _transformWidth);
}
final (leftTop, rightTop, rightBottom, leftBottom) = _getElementVertex(
item: _currentElement!,
);
final List<Offset> vertexList = [
leftTop,
leftBottom,
rightBottom,
rightTop
];
return _getExtremeVertex(vertexList: vertexList);
}
接下来就是添加辅助线了,添加在变换区域内部,简单来说就是画4条线框住我们的元素即可:
// 辅助线
if (_useAuxiliaryLine) Positioned(
top: 0,
left: _elementVertex.$1,
child: Container(
width: 1,
height: _transformHeight,
color: Colors.blueAccent,
),
),
if (_useAuxiliaryLine) Positioned(
top: _elementVertex.$2,
left: 0,
child: Container(
width: _transformWidth,
height: 1,
color: Colors.blueAccent,
),
),
if (_useAuxiliaryLine) Positioned(
top: 0,
left: _elementVertex.$3,
child: Container(
width: 1,
height: _transformHeight,
color: Colors.blueAccent,
),
),
if (_useAuxiliaryLine) Positioned(
top: _elementVertex.$4,
left: 0,
child: Container(
width: _transformWidth,
height: 1,
color: Colors.blueAccent,
),
),
最终效果(为了方便看效果所以隐藏了功能区的渲染):

接下来就是实现吸附效果了,有了辅助线就可以很好的理解吸附的原理。当元素旋转的时候,我们只要辅助线所形成的矩形边框在网格线吸附阈值内即可,所以我们对之前的吸附逻辑进行更改。
之前在未旋转的时候,我么通过左上和右下值来判断的吸附,现在我们旋转了元素,再用之前那个吸附方法,判断的永远也只是初始的蓝色矩形,这样很不和逻辑,当旋转后,理应是旋转后的某个顶点在吸附阈值内就进行吸附,所以我们通过用最值顶点来判断吸附。
吸附的逻辑有了,那么移动的距离呢?旋转后的顶点坐标应用吸附,它所移动的x和y并不代表是原始矩形应该移动的x和y。当然我们可以通过实时计算还原初始的x和y,只需要将最终的x和y通过旋转负数该角度即可得到原始的角度,这样就出现了一个新的问题,我们之前计算旋转角度是通过 atan2 来实现了,它的范围为 -π 到 π(不包括 -π),所以我们要让它覆盖四个象限,则需要转换成 0 到 2π 范围,不然旋转后的还原坐标会出现错误:
double angle = _temporary!.rotationAngle + angleEnd - angleStart;
if (angle < 0) {
angle += 2 * pi;
}
这种方法实现也不算很复杂,但是我们用更简单的方法,就是之前我们看到了原始的矩形和旋转后的矩形的中心点其实是一个,我们通过这个中心点来计算原始的x和y就非常的方便,旋转后的顶点移动x和y,那么对应的中心点也是移动这个x和y,中心点是一个,再通过中心点加上一半的宽高就能得到原始的x和y了:
/// 获取开启网格辅助线时低于阈值的x和y
///
/// 通过当前的[x]坐标和[y]坐标计算吸附坐标,如果低于阈值,则不吸附
(double, double) _getUseGridXY({required double x, required double y}) {
double tempX = x;
double tempY = y;
final double gridSize = ConstantsConfig.gridSize;
// 吸附的阈值
final double snapThreshold = ConstantsConfig.snapThreshold;
// 当旋转的移动过程中,计算出来的x和y其实就是原始矩形的x和y
// 所以此时我们将item的x和y改成计算出来的,通过这个来计算真实的顶点
final (leftTop, leftBottom, rightBottom, rightTop) = _getElementVertex(
item: _currentElement!.copyWith(x: x, y: y),
);
final List<Offset> vertexList = [
leftTop,
leftBottom,
rightBottom,
rightTop
];
final (minDx, minDy, maxDx, maxDy) = _getExtremeVertex(
vertexList: vertexList,
);
// 计算最近(最小顶点坐标点)的网格点
double snappedLeftX = (minDx / gridSize).roundToDouble() * gridSize;
double snappedLeftY = (minDy / gridSize).roundToDouble() * gridSize;
// 计算最近(最大顶点坐标点)的网格点
double snappedRightX = (maxDx / gridSize).roundToDouble() * gridSize;
double snappedRightY = (maxDy / gridSize).roundToDouble() * gridSize;
// 检查是否在吸附范围内
double dxLeftDistance = minDx - snappedLeftX;
double dyLeftDistance = minDy - snappedLeftY;
double dxRightDistance = maxDx - snappedRightX;
double dyRightDistance = maxDy - snappedRightY;
// 计算旋转中心
double cx = (maxDx - minDx) / 2 + minDx;
double cy = (maxDy - minDy) / 2 + minDy;
// 元素的一半宽高
double halfWidth = _currentElement!.elementWidth / 2;
double halfHeight = _currentElement!.elementHeight / 2;
if (!(minDx == snappedLeftX || maxDx == snappedRightX)) {
// 在X轴方向上应用吸附
if (dxLeftDistance.abs() < dxRightDistance.abs() && dxLeftDistance.abs() < snapThreshold) {
// 如果靠近左边且小于阈值,则吸附到左边
tempX = cx - dxLeftDistance - halfWidth;
} else if (dxRightDistance.abs() < dxLeftDistance.abs() && dxRightDistance.abs() < snapThreshold) {
// 如果靠近右边且小于阈值,则吸附到右边
tempX = cx - dxRightDistance - halfWidth;
}
}
if (!(minDy == snappedLeftY || maxDy == snappedRightY)) {
// 在Y轴方向上应用吸附
if (dyLeftDistance.abs() < dyRightDistance.abs() && dyLeftDistance.abs() < snapThreshold) {
// 如果靠近上面且小于阈值,则吸附到上面
tempY = cy - dyLeftDistance - halfHeight;
} else if (dyRightDistance.abs() < dyLeftDistance.abs() && dyRightDistance.abs() < snapThreshold) {
// 如果靠近下面且小于阈值,则吸附到下面
tempY = cy - dyRightDistance - halfHeight;
}
}
return (tempX, tempY);
}
效果如下:

这样我们就简单实现了元素辅助线和网格线的辅助功能。再添加一个小的功能,就是旋转的90度这些特殊的值的小范围内,执行回正效果:
// 在特殊角度处
if ((angle - pi / 2).abs() <= angleThreshold) {
angle = pi / 2;
} else if ((angle - pi).abs() <= angleThreshold) {
angle = pi;
} else if ((angle - pi * 3 / 2).abs() <= angleThreshold) {
angle = pi * 3 / 2;
} else if ((angle - pi * 2).abs() <= angleThreshold || angle.abs() <= angleThreshold) {
angle = 0;
}
效果如下:

一些移动过程中简单的辅助功能就算完成了。下面来看一下完整的效果:

好了,今天的分享就到此结束了,这个系列也快迎来尾声了。
感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~
感谢阅读~拜拜~
更多推荐



所有评论(0)