Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(四)
Flutter: 3.35.6
前面我们实现了单个元素的,现在实现多个元素的。因为有前面功能的落地实现,我们也可以对于部分属性的提前抽取,部分数据模型的提前封装。
还是按照简单到复杂的实现思路,我们先对容器部分进行简单分析。前面也提到最后的手势操作提升到容器,因为对比给每个子元素设置手势,这样的内存开销会减小很多;目前容器的基础属性有宽和高,后期如果需要新的属性直接再添加即可:
dart
import 'package:flutter/material.dart';
class MultipleTransformContainer extends StatefulWidget {
const MultipleTransformContainer({
super.key,
this.containerWidth,
this.containerHeight,
});
/// 容器的宽,不传默认为父容器的最大宽度
final double? containerWidth;
/// 容器的高,不传默认为父容器的最大高度
final double? containerHeight;
@override
State<MultipleTransformContainer> createState() => _MultipleTransformContainerState();
}
class _MultipleTransformContainerState extends State<MultipleTransformContainer> {
/// 按下事件
void _onPanDown(DragDownDetails details) {}
/// 按下移动事件
void _onPanUpdate(DragUpdateDetails details) {}
/// 结束事件
void _onPanEnd() {}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: (details) => _onPanEnd(),
onPanCancel: _onPanEnd,
child: Container(
width: widget.containerWidth ?? double.infinity,
height: widget.containerHeight ?? double.infinity,
color: Colors.transparent,
),
);
}
}
接下来对子元素进行简单分析。子元素主要分为三个部分,一个是自身的属性(随着变换操作而变化),一个是中间临时的变量值(响应单次事件过程中需要初始化和中间临时改变的值),一个是操作的区域(响应变换的事件)。
结合前面的单个案例,我们可以提取子元素的部分属性:
- 元素宽度:一般来说元素的宽属性为必传,如果有默认值可能会导致后期元素拉伸,所以限制为必传
- 元素高度:和宽一样
- 元素的x坐标:坐标就可以设置初始的默认值了,因为不会对元素自身形成拉伸压缩效果
- 元素的y坐标:和x一样
- 旋转角度:和x一样
- id:用于确定当前操作的元素
dart
import '../configs/constants_config.dart';
class ElementModel {
const ElementModel({
required this.id,
required this.elementWidth,
required this.elementHeight,
this.x = ConstantsConfig.initX,
this.y = ConstantsConfig.initY,
this.rotationAngle = ConstantsConfig.initRotationAngle,
});
/// 当前元素的唯一id
final int id;
/// 元素的宽
final double elementWidth;
/// 元素的高
final double elementHeight;
/// 元素的x坐标
final double x;
/// 元素的y坐标
final double y;
/// 元素的旋转角度
final double rotationAngle;
ElementModel copyWith({
double? elementWidth,
double? elementHeight,
double? x,
double? y,
double? rotationAngle,
}) {
return ElementModel(
id: id,
elementWidth: elementWidth ?? this.elementWidth,
elementHeight: elementHeight ?? this.elementHeight,
x: x ?? this.x,
y: y ?? this.y,
rotationAngle: rotationAngle ?? this.rotationAngle,
);
}
}
dart
/// 用于设置一些初始化值
class ConstantsConfig {
/// 元素的初始化x坐标
static const double initX = 10;
/// 元素的初始化y坐标
static const double initY = 10;
/// 元素的初始化旋转角度
static const double initRotationAngle = 0;
}
结合前面的案例,我们抽取临时中间变量如下:
- x坐标:单次操作开始时的x坐标,同上次操作结束时的x坐标
- y坐标:逻辑和x一样
- 旋转角度:逻辑和x一样
- 操作状态值
dart
/// 元素当前操作状态
enum ElementStatus {
move,
rotate,
scale,
}
/// 元素的临时中间变量
class TemporaryModel {
const TemporaryModel({
required this.x,
required this.y,
required this.rotationAngle,
this.status,
});
/// 单次操作完成时的初始x坐标
final double x;
/// 单次操作完成时的初始y坐标
final double y;
/// 单次操作完成时的初始旋转角度
final double rotationAngle;
/// 对应的元素的操作状态
final ElementStatus? status;
TemporaryModel copyWith({
double? x,
double? y,
double? rotationAngle,
ElementStatus? status,
}) {
return TemporaryModel(
x: x ?? this.x,
y: y ?? this.y,
rotationAngle: rotationAngle ?? this.rotationAngle,
status: status ?? this.status,
);
}
}
接下来就是控制操作区域,其实在使用 javascript 实现该功能的时候也分析过,所以这里直接基于这个来做一个简单的说明(难免会站在上帝视角)。
因为常规来说控制的区域位于元素容器的四个顶点处,如果我们也想要自定义去他区域,就要给出相应的计算区域的方式;这里给出一种确定响应区域的计算方式,基于元素本身创建一个坐标系,坐标原点为元素的左上角,使用元素的总体宽高和响应区域中心点来计算出一个比例,通过这个比例就能让我们使用区域内包括区域外的任意区域来做响应的区域,例如,元素整体宽高为20*20,我需要响应区域的中心点在右上角(20, 0),所以这个比例就是 (x: 20/20,y: 0/20)。计算方式有了,下面就该确定响应区域的样式,常规来说一般就是一张图片,我们前期就以图片为主,后面就当作扩展功能允许自定义。最后一点就是该响应区域的触发方式是什么,例如有些操作是响应点击操作(删除,镜像等等),有些操作是响应按下移动操作(移动,缩放,旋转等等),所以我们还需要一个触发方式。基于此我们开始抽取响应区域:
dart
import 'element_model.dart';
enum TriggerMethod {
move,
down,;
}
class ResponseAreaModel {
const ResponseAreaModel({
required this.areaWidth,
required this.areaHeight,
required this.xRatio,
required this.yRatio,
required this.status,
required this.icon,
required this.trigger,
});
/// 响应区域的宽
final double areaWidth;
/// 响应区域的高
final double areaHeight;
/// 响应区域的比例横向
final double xRatio;
/// 响应区域的比例竖向
final double yRatio;
/// 响应区域应该响应什么操作
final ElementStatus status;
/// 响应区域的icon
final String icon;
/// 当前响应操作的触发方式
final TriggerMethod trigger;
}
前期的准备工作差不多就完成了,下面我们简单来实现一个元素的移动。
现在是多个元素的,当前正在操作的肯定只有一个元素,所以按下的时候得选中元素,后续的操作就是作用于选中的元素,因为还只是移动操作,所以也先不考虑旋转。因为我们将容器的宽高设置成了可不传,但是我们操作过程中可能对于边界值需要用到容器的宽高做计算,所以备份一份,如果没有传递则通过GlobalKey去获取容器的宽高:
dart
import 'package:flutter/material.dart';
import 'models/element_model.dart';
import 'transform_item.dart';
class MultipleTransformContainer extends StatefulWidget {
const MultipleTransformContainer({
super.key,
this.containerWidth,
this.containerHeight,
});
/// 容器的宽,不传默认为父容器的最大宽度
final double? containerWidth;
/// 容器的高,不传默认为父容器的最大高度
final double? containerHeight;
@override
State<MultipleTransformContainer> createState() => _MultipleTransformContainerState();
}
class _MultipleTransformContainerState extends State<MultipleTransformContainer> {
/// 用于获取容器的宽高
final GlobalKey _multipleTransformContainerGlobalKey = GlobalKey();
final List<ElementModel> _elementList = [
ElementModel(
id: DateTime.now().microsecondsSinceEpoch,
elementWidth: 100,
elementHeight: 100,
),
];
/// 记录一份容器的宽高,用于没传递的时候有个真实的容器宽高
double _containerWidth = 0;
double _containerHeight = 0;
/// 当前选中的元素
ElementModel? _currentElement;
/// 临时的中间变量,用于计算
TemporaryModel? _temporary;
/// 开始点击的位置
Offset _startPosition = Offset(0, 0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_getContainerSize();
});
}
@override
void dispose() {
_multipleTransformContainerGlobalKey.currentState?.dispose();
super.dispose();
}
/// 获取容器的宽高属性,用于没传递容器宽高的时候有个真实的容器宽高
void _getContainerSize() {
double tempWidth = 0;
double tempHeight = 0;
if (widget.containerHeight != null && widget.containerWidth != null) {
tempHeight = widget.containerHeight!;
tempWidth = widget.containerWidth!;
} else {
tempWidth = _multipleTransformContainerGlobalKey.currentContext?.size?.width ?? 0;
tempHeight = _multipleTransformContainerGlobalKey.currentContext?.size?.height ?? 0;
}
setState(() {
_containerHeight = tempHeight;
_containerWidth = tempWidth;
});
}
/// 按下事件
void _onPanDown(DragDownDetails details) {
final dx = details.localPosition.dx;
final dy = details.localPosition.dy;
ElementModel? currentElement;
TemporaryModel temp = TemporaryModel(x: 0, y: 0, rotationAngle: 0);
// 遍历判断当前点击的位置是否落在了某个元素的响应区域
for (var item in _elementList) {
final status = _onDownZone(x: dx, y: dy, item: item);
if (status != null) {
currentElement = item;
temp = temp.copyWith(status: status);
break;
}
}
if (currentElement != null) {
// 如果点击的区域存在元素,并且点击区域存在的元素和当前选中的元素不是一个
// 则选中该元素,并设置其部分初始化属性
if (_currentElement?.id != currentElement.id) {
_currentElement = currentElement;
}
_temporary = temp.copyWith(
x: currentElement.x,
y: currentElement.y,
);
_startPosition = Offset(dx, dy);
setState(() {});
} else {
// 如果点击的区域不存在元素,并且当前选中的元素不为null,则置空选中
if (_currentElement != null) {
_currentElement = null;
_temporary = null;
setState(() {});
}
}
}
/// 按下移动事件
void _onPanUpdate(DragUpdateDetails details) {
if (_currentElement == null || _temporary == null) return;
if (_temporary?.status == ElementStatus.move) {
_onMove(x: details.localPosition.dx, y: details.localPosition.dy);
}
}
/// 结束事件
void _onPanEnd() {}
/// 处理元素移动
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 (tempX < 0) {
tempX = 0;
}
// 限制右边界
if (tempX > _containerWidth - _currentElement!.elementWidth) {
tempX = _containerWidth - _currentElement!.elementWidth;
}
// 限制上边界
if (tempY < 0) {
tempY = 0;
}
// 限制下边界
if (tempY > _containerHeight - _currentElement!.elementHeight) {
tempY = _containerHeight - _currentElement!.elementHeight;
}
_currentElement = _currentElement!.copyWith(
x: tempX,
y: tempY,
);
_onChange();
}
/// 当前元素属性变化的时候更新列表中对应元素的属性
void _onChange() {
if (_currentElement == null || _temporary == null) return;
for (var i = 0; i < _elementList.length; i++) {
final item = _elementList[i];
if (item.id == _currentElement?.id) {
_elementList[i] = item.copyWith(
x: _currentElement?.x,
y: _currentElement?.y,
);
setState(() {});
break;
}
}
}
/// 判断点击的区域
///
/// 以传入的[item]元素为参考,
/// 判断当前点击的坐标[x]和[y]落在[item]元素的哪个响应区域
ElementStatus? _onDownZone({
required double x,
required double y,
required ElementModel item,
}) {
if (
x >= item.x &&
x <= item.elementWidth + item.x &&
y >= item.y &&
y <= item.elementHeight + item.y
) {
// 判断移动区域,目前没有考虑元素的旋转
return ElementStatus.move;
}
return null;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanDown: _onPanDown,
onPanUpdate: _onPanUpdate,
onPanEnd: (details) => _onPanEnd(),
onPanCancel: _onPanEnd,
child: Container(
key: _multipleTransformContainerGlobalKey,
width: widget.containerWidth ?? double.infinity,
height: widget.containerHeight ?? double.infinity,
color: Colors.transparent,
child: _containerWidth == 0 || _containerHeight == 0 ? null : Stack(
children: [
..._elementList.map((item) => TransformItem(
elementItem: item,
selected: item.id == _currentElement?.id,
)),
],
),
),
);
}
}
dart
import 'package:flutter/material.dart';
import 'models/element_model.dart';
/// 抽取渲染的元素
class TransformItem extends StatelessWidget {
const TransformItem({
super.key,
required this.elementItem,
required this.selected
});
final ElementModel elementItem;
final bool selected;
@override
Widget build(BuildContext context) {
return Positioned(
left: elementItem.x,
top: elementItem.y,
child: Container(
width: elementItem.elementWidth,
height: elementItem.elementHeight,
decoration: BoxDecoration(
color: selected ? Colors.amberAccent : Colors.blueAccent,
),
),
);
}
}
运行效果:

这样就简单实现了元素的移动效果,代码还要很大的优化空间,不着急,我们一步一步来。
感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~
今天的分享就到此结束了,感谢阅读~拜拜~