Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)
Flutter: 3.35.6
前面有人提到在元素内部的那块判断怎么那么写的,看来对知识渴望的小伙伴还是有,这样挺好的。不至于说牢记部分知识,只需要大致了解一下有个印象后面如果哪里再用到了就可以根据这个印象去查阅资料。
接下来我们说一下判断原理。当我们知晓矩形的四个顶点坐标(包括任意旋转后),可以使用向量叉乘法来判断是否在矩形内部。
向量叉乘法的核心思想就是:如果一个点在凸多边形内部,那么它应该始终位于该多边形每条边的同一侧。
注:凸多边形定义为所有内角均小于180度,并且任意两点之间的连线都完全位于多边形内部或边界。
所以我们利用向量叉乘法来判断点位于线的哪一侧。假设矩形顶点按顺时针 或逆时针顺序为 A,B,C,D:
- 对于边 AB,计算向量 AB 和 AP 的叉积。
- 对于边 BC,计算向量 BC 和 BP 的叉积。
- 对于边 CD,计算向量 CD 和 CP 的叉积。
- 对于边 DA,计算向量 DA 和 DP 的叉积。
如果点 P 在所有边的同一侧(即所有叉积结果的符号相同),那么点 P 就在矩形内部。我们假设矩形某条边的顶点为(x1, y1), (x2, y2), 判断的点坐标为(x, y),那么就有:
(x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0
这样就可以判断在某侧,如果其他三条边也满足,那就是内侧了,要转换为下面代码中的形式,那就做一下加减乘除就行了:
- (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0: 初始
- (x2 - x1) * (y - y1) / (y2 - y1) - (x - x1) > 0: 两边同时除以(y2 - y1)
- (x2 - x1) * (y - y1) / (y2 - y1) - x + x1 > 0: 展开括号
- (x2 - x1) * (y - y1) / (y2 - y1) + x1 > x: 将x移项
这样就得到了代码中的判断依据,至于循环遍历顶点的写法,就是为了获取相邻两个顶点,这个就可以带入square坐标和循环去算一下就行了,保证每次循环都是相邻的两个顶点。
我们使用的顶点坐标顺序是顺时针,第一次循环 i = 0,j = 3,那么i就是左上顶点,j就是左下顶点,两个顶点刚好构成矩形左边;第二次循环 j = i++,此时 j = 0,i = 1后续喜欢以此类推即可。
这样判断在内侧差不多就解释完了。接下来开始我们今天正文。前面我们就简单完成了多个元素的相应操作,剩下的就是一些优化和一些简单的扩展功能。
既然是多个元素,那么肯定就涉及到新增和删除,之前的新增都是在列表里面直接添加,现在我们单独提取一个方法用于新增。至于删除功能我们就定义在元素左上角为删除区域,触发方式为点击。之前我们对热区的数据模型中添加了 trigger 字段,用于表示当前区域触发操作的方式是什么,所以我们得对点击方法进行优化,并且在临时中间变量上面存储 trigger 字段,用于判断:
dart
class ResponseAreaModel {
// 其他省略...
/// 当前响应操作的触发方式
final TriggerMethod trigger;
}
/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _onDownZone({
required double x,
required double y,
required ElementModel item,
}) {
// 先判断是否在响应对应操作的区域
final (ElementStatus, TriggerMethod)? areaStatus = _getElementZone(x: x, y: y, item: item);
if (areaStatus != null) {
return areaStatus;
} else if (_insideElement(x: x, y: y, item: item)) {
// 因为加入旋转,所以单独抽取落点是否在元素内部的方法
return (ElementStatus.move, TriggerMethod.move);
}
return null;
}
/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _getElementZone({
required double x,
required double y,
required ElementModel item,
}) {
// 新增Records记录返回的状态和触发方式
(ElementStatus, TriggerMethod)? tempStatus;
for (var i = 0; i < ConstantsConfig.baseAreaList.length; i++) {
// 其他省略...
if (
x >= dx - areaCW &&
x <= dx + areaCW &&
y >= dy - areaCH &&
y <= dy + areaCH
) {
tempStatus = (currentArea.status, currentArea.trigger);
break;
}
}
return tempStatus;
}
这样触发方式和状态都记录了,我们就开始实现删除功能,依然按照之前的步骤快速实现:
dart
// 新增删除
ResponseAreaModel(
areaWidth: 20,
areaHeight: 20,
xRatio: 0,
yRatio: 0,
status: ElementStatus.deleteStatus,
icon: 'assets/images/icon_delete.png',
trigger: TriggerMethod.down,
),
dart
/// 处理删除元素
void _onDelete() {
if (_currentElement == null) return;
_elementList.removeWhere((item) => item.id == _currentElement?.id);
}
dart
/// 按下事件
void _onPanDown(DragDownDetails details) {
// 其他省略...
// 遍历判断当前点击的位置是否落在了某个元素的响应区域
for (var item in _elementList) {
// 新增Records数据,存储元素状态和触发方式
final (ElementStatus, TriggerMethod)? status = _onDownZone(x: dx, y: dy, item: item);
if (status != null) {
currentElement = item;
temp = temp.copyWith(status: status.$1, trigger: status.$2);
break;
}
}
// 新增判断
// 如果当前有选中的元素且和点击区域的currentElement是一个元素
// 并且 temp 的 status对应的触发方式为点击,那么就响应对应的点击事件
if (currentElement?.id == _currentElement?.id && temp.trigger == TriggerMethod.down) {
if (temp.status == ElementStatus.deleteStatus) {
_onDelete();
// 因为是删除,就置空选中,让下面代码执行最后的清除
currentElement = null;
}
}
// 其他省略...
}
运行效果:

这样就简单实现了元素的删除功能。到此操作区域常用的功能差不多就完成,接下来我们考虑一些区域的自定义;例如我希望旋转的区域在右下角(现在在右上角),并且不使用缩放功能,还想自定义一个区域,这时候该如何实现呢?
允许传递配置,通过这份配置来决定元素应该有什么响应区域并且是否使用这些响应区域,然而操作这些内置的我们可以使用之前定义的 final ElementStatus status; 字段来确定要修改哪个区域,毕竟一个操作应该是对应一个区域;对于自定义区域,我们的ElementStatus是个枚举类型且为必传,这就限制了自定义区域,所以我们的改造一下,用户传递的自定义区域,status为自行设置的字符串,我们内部也同时更改为字符串(涉及更改的地方有一些,这里不做过多的说明,后续可以查阅源码):
dart
/// 元素当前操作状态
/// 更改新增字符串的value属性
enum ElementStatus {
move(value: 'move'),
rotate(value: 'rotate'),
scale(value: 'scale'),
deleteStatus(value: 'deleteStatus'),;
final String value;
const ElementStatus({required this.value});
}
/// 大致说一些需要更改的地方
/// TemporaryModel 的 status 更改为字符串类型
/// ResponseAreaModel 的 status 更改为字符串类型
/// _onDownZone 方法中 Records 第一项也返回 String
/// _getElementZone 方法中 Records 第一项也返回 String
接下来我们确定自定义区域配置中需要的字段:
- status:用于映射内置的区域,方便做更改(String 必须)
- use:用于确定该 status 对应的内置区域是否使用(bool 非必须)
- xRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
- yRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
- trigger:用于确定区域的触发方式(TriggerMethod 非必须,默认TriggerMethod.down)
- icon:用于确定操作区域的展示icon(String 如果是内置的 status 就是非必须,如果不是内置的就是必须)
- fn:自定义区域需要执行的方法(Function({required double x, required double y}) 如果是内置的就是非必须,如果不是内置的就必须)
基于上述开始进行编码:
dart
/// 新增自定义区域配置
class CustomAreaConfig {
const CustomAreaConfig({
required this.status,
this.use,
this.xRatio,
this.yRatio,
this.trigger = TriggerMethod.down,
this.icon,
this.fn,
});
/// 区域的操作状态字符串,可以是内置的,如果是内置的就覆盖内置的属性
final String status;
/// 是否启用
final bool? use;
/// 自定义位置
final double? xRatio;
final double? yRatio;
/// 区域响应操作的触发方式
final TriggerMethod trigger;
/// 自定义区域就是必传
final String? icon;
/// 自定义区域就是必传,点击对应的响应区域就执行自定义的方法
final Function({required double x, required double y})? fn;
}
dart
/// 新增自定义区域配置
final List<CustomAreaConfig> _customAreaList = [
// 不使用缩放区域
CustomAreaConfig(
status: ElementStatus.scale.value,
use: false,
),
// 将旋转移到右下角
CustomAreaConfig(
status: ElementStatus.rotate.value,
xRatio: 1,
yRatio: 1,
),
];
/// 容器响应操作区域,之前是直接使用的常量里面的配置
List<ResponseAreaModel> _areaList = [];
/// 初始化响应区域
void _initArea() {
List<ResponseAreaModel> areaList = [];
for (var area in ConstantsConfig.baseAreaList) {
final int index = _customAreaList.indexWhere((item) => item.status == area.status);
if (index > -1) {
final CustomAreaConfig customArea = _customAreaList[index];
// 如果是不使用,则跳出本次循环
if (customArea.use == false) {
continue;
}
areaList.add(area.copyWith(
xRatio: customArea.xRatio,
yRatio: customArea.yRatio,
icon: customArea.icon,
fn: customArea.fn,
));
} else {
areaList.add(area);
}
}
setState(() {
_areaList = areaList;
});
}
dart
// 其他省略...
/// 抽取渲染的元素
class TransformItem extends StatelessWidget {
const TransformItem({
// 其他省略...
required this.areaList,
});
// 其他省略...
final List<ResponseAreaModel> areaList;
@override
Widget build(BuildContext context) {
return Positioned(
left: elementItem.x,
top: elementItem.y,
// 新增旋转功能
child: Transform.rotate(
angle: elementItem.rotationAngle,
child: Container(
// 其他省略...
// 新增区域的渲染
child: selected ? Stack(
clipBehavior: Clip.none,
children: [
// 修改从外界传递区域列表
...areaList.map((item) => Positioned(
// 其他省略...
)),
],
) : null,
),
)
);
}
}
其他编码不算核心,就不再展示了,反正就一个,之前从 ConstantsConfig.baseAreaList 拿的数据现在都直接使用 _areaList。
运行效果:

可以看到,我们将旋转区域移到右下角了,并且不使用缩放区域,这样就简单完成了区域自定义的配置。
感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~
今天的分享就到此结束了,感谢阅读~拜拜~