Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)
Flutter: 3.35.6
因为实现了单个的,给出github链接:github.com/yhtqw/Front...
前面我们简单实现了元素的平移和缩放,接下来我们继续实现旋转功能。
元素的旋转会改变角度,角度一变,那么响应事件的热区也会跟着改变,所以我们得提前考虑这些会因为角度改变而改变的地方。
先来简单实现一下旋转,先不考虑上述的热区问题。
要实现旋转,我们就得知道元素的旋转角度,主要得出旋转的角度,那么实现起来就比较简单,所以简单使用数学的知识分析一下吧
从我们这个需求中可以提取到的数据为按下点的坐标,拖动时变换的坐标;所以我们能否根据一个点的坐标,计算出该点与某点形成的夹角,好像刚好有个满足部分,就是arctan2,arctan2的主要作用是根据一个点的坐标,计算出该点与坐标原点所形成的夹角(主要作用);如果我们要知道给出点与任意(x', y')形成的夹角呢?前面的arctan2中将坐标原点换成任意某点不就行了?
使用 arctan2 计算两点连线的角度,核心是计算两点之间的坐标差 (Δx, Δy),然后将其作为 arctan2 的参数。所以实现起来就比较简单了。其实这个实现的原理在很多地方都一样,例如web端的元素拖动旋转也可以使用这个原理。实现的方式应该不止一种吧,只要能计算出这个角度就行了。
值得注意的是,现在我们研究的是单个元素,所以坐标系就是以元素自身形成的(响应的事件也是在这个元素上),等后期要实现多个,坐标系就得以外层容器作为参考了。
dart
// 其他省略...
/// 新增旋转状态热区字符串
const String statusRotate = 'rotate';
/// 抽取响应旋转操作区域的大小
final double rotateWidth = 20;
final double rotateHeight = 20;
/// 旋转角度
double rotateNumber = 0;
double initRotateNumber = 0;
void _onPanUpdate(DragUpdateDetails details) {
print('更新: $details');
if (status == statusMove) {
_onMove(details.localPosition.dx, details.localPosition.dy);
} else if (status == statusScale) {
_onScale(details.delta.dx, details.delta.dy);
} else if (status == statusRotate) {
// 新增旋转热区的响应事件
_onRotate(details.localPosition.dx, details.localPosition.dy);
}
}
void _onPanEnd() {
print('抬起或者因为某些原因并没有触发onPanDown事件');
setState(() {
// 当次结束后重新记录,也可以在按下时记录
initX = x;
initY = y;
// 新增旋转角度的记录
initRotateNumber = rotateNumber;
});
}
/// 处理旋转
void _onRotate(double dx, double dy) {
/// 要计算点 (x, y) 与任意点 (x', y') 连线所成的角度,可以使用 arctan2 函数。
/// 关键在于将两点之间的相对坐标差作为 arctan2 的输入参数。
/// 这里我们以元素的中心为旋转中心
/// 利用上述方法计算起始点(按下时)与中心的连线组成的夹角为初始夹角,
/// 拖动的点与中心点连线组层的夹角为结束时的夹角,
/// 通过初始夹角与结束夹角计算旋转的角度
// 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
double centerX = elementWidth / 2;
double centerY = elementHeight / 2;
double diffStartX = startPosition.dx - centerX;
double diffStartY = startPosition.dy - centerY;
double diffEndX = dx - centerX;
double diffEndY = dy - centerY;
double angleStart = atan2(diffStartY, diffStartX);
double angleEnd = atan2(diffEndY, diffEndX);
setState(() {
rotateNumber = initRotateNumber + angleEnd - angleStart;
});
}
/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
if (
x >= elementWidth - scaleWidth &&
x <= elementWidth &&
y >= elementHeight - scaleHeight &&
y <= elementHeight
) {
return statusScale;
} else if (
x >= elementWidth - rotateHeight &&
x <= elementWidth &&
y >= 0 &&
y <= rotateHeight
) {
// 固定右上角为旋转热区
return statusRotate;
} else if (
x >= 0 &&
x <= elementWidth &&
y >= 0 &&
y <= elementHeight
) {
return statusMove;
}
return null;
}
// 新增响应旋转操作
Positioned(
left: x,
top: y,
child: Transform.rotate(
angle: rotateNumber,
child: GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: (details) => _onPanEnd(),
onPanCancel: _onPanEnd,
child: Container(
width: elementWidth,
height: elementHeight,
color: Colors.transparent,
child: Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Container(
width: elementWidth,
height: elementHeight,
color: Colors.amber,
),
// 响应旋转操作
Positioned(
top: 0,
right: 0,
child: Container(
width: scaleWidth,
height: scaleHeight,
color: Colors.white,
),
),
// 响应缩放操作
],
),
),
),
),
),
// 其他省略...
运行效果:

这样就简单实现了旋转。然后我们继续考虑热区的问题,当旋转一定角度的时候,再次点击对应的热区,就无法响应事件了,因为旋转后热区坐标已经发生改变,所以我们得对点击判断中加入角度的影响。
已知某点坐标和旋转角度,求旋转后的坐标值?
要计算旋转后的坐标,可以使用旋转矩阵。给定一个点 (x, y) 绕原点逆时针旋转角度 θ 后的新坐标 (x', y') 计算公式如下:
x' = x * cosθ - y * sinθ; y' = x * sinθ + y * cosθ;
如果我们是绕任意点而不是原点,需要先平移坐标系
- 平移: 将 (x, y) 平移到原点,新坐标为 (x - a, y - b);
- 旋转: 按照上述公式计算 (x', y');
- 平移回原坐标系: 新坐标为(x' + a, y' + b)。
基于上面的公式,我们更改热区点击判断方法:
dart
/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
final offsetScale = rotatePoint(elementWidth, elementHeight);
// 设置都是最大的顶点坐标,方便下面判断区域的方式结构一致
// 后续就好抽取方法
final offsetRotate = rotatePoint(elementWidth, rotateHeight);
if (
x >= offsetScale.dx - scaleWidth &&
x <= offsetScale.dx &&
y >= offsetScale.dy - scaleHeight &&
y <= offsetScale.dy
) {
return statusScale;
} else if (
x >= offsetRotate.dx - rotateHeight &&
x <= offsetRotate.dx &&
y >= offsetRotate.dy - rotateHeight &&
y <= offsetRotate.dy
) {
return statusRotate;
} else if (
x >= 0 &&
x <= elementWidth &&
y >= 0 &&
y <= elementHeight
) {
return statusMove;
}
return null;
}
/// 计算旋转后的点坐标
Offset rotatePoint(double x, double y) {
final deg = rotateNumber * pi / 180;
// 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
final centerX = elementWidth / 2;
final centerY = elementHeight / 2;
final diffX = x - centerX;
final diffY = y - centerY;
final dx = diffX * cos(deg) - diffY * sin(deg) + centerX;
final dy = diffX * sin(deg) + diffY * cos(deg) + centerY;
return Offset(dx, dy);
}

可以看到的是旋转和缩放热区即使在旋转后依然能够正常响应,还有最后一点,就是移动的时候也要应用旋转角度计算,因为我们使用的是元素自身为坐标系,坐标系旋转了,自然移动时的计算方式也得跟着变,其实对于后期将事件应用到容器上了过后就不需要考虑这些了,因为外层容器并不会变换,所以后期不使用逆运算,所以我们这里直接使用globalPosition来计算值即可(变换计算坐标感兴趣的可以自行研究一下):
dart
void _onPanDown(DragDownDetails details) {
print('按下: $details');
String? tempStatus = _onDownZone(details.localPosition.dx, details.localPosition.dy);
print(tempStatus);
setState(() {
if (tempStatus == statusMove) {
// 如果是移动,则使用globalPosition
startPosition = details.globalPosition;
} else {
startPosition = details.localPosition;
}
status = tempStatus;
});
}
void _onPanUpdate(DragUpdateDetails details) {
print('更新: $details');
if (status == statusMove) {
_onMove(details.globalPosition.dx, details.globalPosition.dy);
} else if (status == statusScale) {
_onScale(details.delta.dx, details.delta.dy);
} else if (status == statusRotate) {
_onRotate(details.localPosition.dx, details.localPosition.dy);
}
}

这样就对单个元素实现了变换的效果,前置就算时铺垫完成了,后续就开始实现多个的。
感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~
今天的分享到此结束,感谢阅读~拜拜~