Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十一)
Flutter: 3.35.7
前面我们实现了网格辅助线等功能,拥有这些功能,我们就能很好的定位元素在容器内的位置。今天我们就主要实现元素层级的相关操作。
在我们之前的功能中,元素个数比较少,当元素个数达到一定数量后,肯定会存在覆盖的情况,我们无法保证需要操作的元素一定是在顶层,所以就需要对层级进行辅助控制。而且层级操作主要是针对当前选中的元素,所以在未选中元素的时候就不允许点击。
元素层级的控制主要分为如下几类:
- 操作当前选中元素的层级
- 选中点击坐标内的某个层级的元素,不至于一定要保证它在顶层也可选中进行操作
因为元素层级的操作涉及到几个,以开启按钮位置为基准定位展示,所以要使用到OverlayEntry,我们单独抽取结构:
import 'package:flutter/material.dart';
import 'base_icon_button.dart';
class LevelBar extends StatefulWidget {
const LevelBar({
super.key,
required this.onChangeUsePosition,
required this.usePosition,
required this.disabled,
});
final Function() onChangeUsePosition;
final bool usePosition;
final bool disabled;
@override
State<LevelBar> createState() => _LevelBarState();
}
class _LevelBarState extends State<LevelBar> {
/// 用于获取定位信息
final GlobalKey _globalKey = GlobalKey();
/// 容器
OverlayEntry? _overlayEntry;
/// 宽高定位信息用于定位容器
double _barWidth = 0;
double _barHeight = 0;
double _barTop = 0;
double _barLeft = 0;
@override
void didUpdateWidget(covariant LevelBar oldWidget) {
super.didUpdateWidget(oldWidget);
// 当使用层级工具,展示,不使用,隐藏
if (oldWidget.usePosition != widget.usePosition) {
if (widget.usePosition == false) {
_hideLevelBar();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
_showLevelBar();
});
}
}
}
@override
void dispose() {
_globalKey.currentState?.dispose();
_hideLevelBar();
super.dispose();
}
/// 隐藏层级工具栏
void _hideLevelBar() {
if (_overlayEntry != null) {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
/// 展示层级工具栏
void _showLevelBar() {
_getDimensions();
_hideLevelBar();
// 创建 OverlayEntry
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
top: _barHeight + _barTop,
left: _barLeft - 100 + _barWidth / 2,
child: Material(
borderRadius: BorderRadius.circular(10),
child: Container(
width: 200,
decoration: BoxDecoration(
color: Color(0xFFF0F0F0),
border: Border.all(
color: Colors.blueAccent,
width: 2,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
BaseIconButton(
iconSrc: 'assets/images/icon_top.png',
onPressed: () {},
),
BaseIconButton(
iconSrc: 'assets/images/icon_upper_level.png',
onPressed: () {},
),
BaseIconButton(
iconSrc: 'assets/images/icon_next_level.png',
onPressed: () {},
),
BaseIconButton(
iconSrc: 'assets/images/icon_bottom.png',
onPressed: () {},
),
],
),
),
),
),
);
// 插入 Overlay
Overlay.of(context).insert(_overlayEntry!);
}
/// 获取尺寸和布局信息
void _getDimensions() {
RenderBox? renderBox = _globalKey.currentContext?.findRenderObject() as RenderBox?;
// 检查是否成功获取
if (renderBox != null) {
_barWidth = renderBox.size.width;
_barHeight = renderBox.size.height;
Offset offset = renderBox.localToGlobal(Offset.zero);
_barTop = offset.dy;
_barLeft = offset.dx;
}
}
void _onTap() {
if (!widget.disabled) {
widget.onChangeUsePosition();
}
}
@override
Widget build(BuildContext context) {
return BaseIconButton(
key: _globalKey,
iconSrc: 'assets/images/icon_level.png',
onPressed: _onTap,
isSelected: widget.usePosition,
disabled: widget.disabled,
);
}
}
展示效果:
这样就简单实现了层级工具栏的展示,接下来我们实现功能。层级操作的功能无非就是那几种,与相邻的交换(上一层或者下一层),置顶或者置底。
简单分析一下:
- 上一层:当元素并不是处于最顶层的时候,将当前元素与后一个元素交换位置
- 下一层:当元素并不是处于最底层的时候,将当前元素与前一个元素交换位置
- 置顶:当元素并不是处于顶层的时候,将当前元素提到最顶层
- 置底:当元素并不是处于底层的时候,将当前元素提到最底层
Stack 中元素的堆叠顺序是写入的子元素的顺序,所以我们只需要操作元素列表即可:
/// 处理层级
void _onLevel(LevelType type) {
final index = _elementList.indexWhere((ele) => ele.id == _currentElement?.id);
if (index > -1) {
final len = _elementList.length;
final tempItem = _elementList[index];
if (type == LevelType.top && index != (len -1)) {
_elementList.removeAt(index);
_elementList.insert(len - 1, tempItem);
setState(() {});
} else if (type == LevelType.bottom && index != 0) {
_elementList.removeAt(index);
_elementList.insert(0, tempItem);
setState(() {});
} else if (type == LevelType.upper && index < (len -1)) {
_elementList[index] = _elementList[index + 1];
_elementList[index + 1] = tempItem;
setState(() {});
} else if (type == LevelType.next && index > 0) {
_elementList[index] = _elementList[index - 1];
_elementList[index - 1] = tempItem;
setState(() {});
}
}
}
效果:
这样就简单实现了层级的变化,接下里我们就来实现选中。当我们点击某个区域想选中其中的某个元素并进行操作,但是并不想调整元素的层级,恰好这个元素露出来的东西很少,或者干脆就是一个透明的相框,我想要操作相框下面的图片,那么此时就需要额外的功能来辅助我们实现这块功能。目前我们响应手势的区域虽然是整个变换区域,但是实际上只有手指在元素内部的时候才能执行操作,当元素堆叠在一起的时候,变换操作将会变得很不流畅,因为响应手势的元素始终是最顶层元素,元素之外就自动执行了清空操作。所以我们先对这块进行改变。
改变手势逻辑:
- 点击
- 如果没有选中元素,则不做任何事情;
- 如果存在选中元素,则记录点击点和初始化数据,判断当前点击点落在哪个响应区域,如果没有在任何的响应区域,则默认为移动
- 滑动
- 只判断是否存在当前元素,如果存在,则直接执行滑动,不论是在元素内部或者元素外部(为了方便在元素很小或者堆叠在一起的时候需要微调,这样可以更清楚的看到样式);
- 只要发生了大于1个单位的移动,则标记当前已经执行了移动;
- 抬起
- 如果不存在选中的元素
- 判断抬起点区域范围内是否存在元素,存在就选中;
- 如果存在选中的元素
- 没有执行移动操作
- 判断响应的区域是否存在元素,如果不存在则置空;
- 判断存在的元素和选中的元素是否是同一个,如果不是同一个,则选中这个元素;
- 如果是在响应元素的down触发方式的区域,则执行这些区域的特定事件(例如删除之类的操作);
- 如果是在响应元素的区域内,且不在down触发的区域,则执行置空(本来存在响应的元素,再次按在响应的区域,则说明是第二次点击,就置空。这种是为了防止在变换区域内不存在没有元素的空白区域,此时就无法取消选中,所以加入点击两次就取消选中的逻辑);
- 执行了移动则不做任何操作,只执行基础逻辑
- 没有执行移动操作
- 如果不存在选中的元素
可以看到,逻辑理清楚了,接下来就是实现了:
/// 按下事件
void _onPanDown(DragDownDetails details) {
// 当存在选中元素的时候,记录点击点和初始化数据
if (_currentElement != null) {
final double dx = details.localPosition.dx;
final double dy = details.localPosition.dy;
final (String, TriggerMethod)? status = _onDownZone(
x: dx,
y: dy,
item: _currentElement!,
);
_temporary = TemporaryModel(
x: _currentElement!.x,
y: _currentElement!.y,
width: _currentElement!.elementWidth,
height: _currentElement!.elementHeight,
rotationAngle: _currentElement!.rotationAngle,
status: status?.$1 ?? ElementStatus.move.value,
trigger: status?.$2 ?? TriggerMethod.move,
);
_startPosition = Offset(dx, dy);
setState(() {});
}
}
/// 按下移动事件
void _onPanUpdate(DragUpdateDetails details) {
final double x = details.localPosition.dx;
final double y = details.localPosition.dy;
if (
_currentElement == null
|| _temporary == null
|| ((x - _startPosition.dx).abs() < 1 && (y - _startPosition.dy).abs() < 1 && !_isMove)
) {
return;
}
// 新增一个判断,如果发生了一个单位的移动且移动状态未false,则标记移动为true
_isMove = true;
final Function? fn = _onElementStatus(x: x, y: y)[_temporary?.status];
if (_temporary?.trigger == TriggerMethod.move) {
if (fn != null) {
fn();
} else {
_onCustomFn(
element: _currentElement!,
tapPoint: _startPosition,
movePoint: Offset(x, y),
status: _temporary?.status,
);
}
}
}
/// 结束事件
void _onPanEnd(DragEndDetails details) {
final double dx = details.localPosition.dx;
final double dy = details.localPosition.dy;
ElementModel? currentElement;
// 判断抬起点的区域是否存在元素
for (var i = (_elementList.length - 1); i >= 0; i--) {
final item = _elementList[i];
final (String, TriggerMethod)? status = _onDownZone(
x: dx,
y: dy,
item: item,
);
if (status != null) {
currentElement = item;
break;
}
}
if (_currentElement == null && currentElement != null) {
// 如果不存在当前元素,但是抬起的区域内存在元素,
// 则选中这个元素
_currentElement = currentElement;
setState(() {});
} else {
if (!_isMove) {
if (currentElement == null) {
// 如果抬起的区域内不存在任何的元素,
// 则说明是空白区域,这执行清空
_clean();
} else {
if (currentElement.id == _currentElement?.id) {
// 如果响应区域的元素和选中的元素是同一个,
// 则判断点击区域,如果点击区域是响应down的区域,
// 则执行对应的down方法
final (String, TriggerMethod)? status = _onDownZone(
x: dx,
y: dy,
item: _currentElement!,
);
if (status != null && status.$2 == TriggerMethod.down) {
final Function? fn = _onElementStatus(x: dx, y: dy)[status.$1];
if (fn != null) {
fn();
} else {
_onCustomFn(
element: _currentElement!,
tapPoint: Offset(dx, dy),
status: status.$1,
);
}
if (status.$1 == ElementStatus.deleteStatus.value) {
// 因为是删除,就置空选中,让下面代码执行最后的清除
_clean();
}
} else {
// 如果不存在down方法,则说明是二次点击,
// 则取消选中
_clean();
}
} else {
// 如果不是同一个元素,则选中另外的那个元素
_currentElement = currentElement;
setState(() {});
}
}
}
}
// 之前的逻辑
if (_currentElement?.type != ElementType.textType.type && _isShowTextOptions) {
setState(() {
_isShowTextOptions = false;
});
} else if (_currentElement?.type == ElementType.textType.type && !_isShowTextOptions) {
setState(() {
_isShowTextOptions = true;
});
}
// 重置移动状态
_isMove = false;
}
效果:
这样我们就简单更改了手势的逻辑,可以看到,即使元素堆叠在一起了,我么也可以在另外的区域响应移动手势,而不是需要在元素区域内。
接下来就来实现堆叠元素的选中了,大致是为了好操作下面这种情况:
可以看到,下面那张图片在实际中用手去点击很难点击到,如果我们就像操作下面那张图片,就必须将上面图片移开然后再选中操作,这里布局较为简单,如果我们辛辛苦苦的完成了一个很好看的海报,就因为这点小小的瑕疵,就要移开上面的元素去操作下面的元素然后重新排版,这样的操作很让人头疼,所以我们将在这个点击点内的所有元素都罗列出来,默认选中顶层的元素,其他元素排列到一旁,想选中谁就选中谁,这样就能极大的方便我们操作:
/// 结束事件
void _onPanEnd(DragEndDetails details) {
final double dx = details.localPosition.dx;
final double dy = details.localPosition.dy;
// 每次结束后置空选中
setState(() {
_allOptionalElement.clear();
});
ElementModel? currentElement;
// 判断抬起点的区域是否存在元素
for (var i = (_elementList.length - 1); i >= 0; i--) {
final item = _elementList[i];
final (String, TriggerMethod)? status = _onDownZone(
x: dx,
y: dy,
item: item,
);
if (status != null) {
// 新增如果是在点击区域内可选中的元素,则加入可选列表
_allOptionalElement.add(item);
currentElement ??= item;
// break;
}
}
// 其他删除...
}
import 'dart:io';
import 'package:flutter/material.dart';
import 'models/element_model.dart';
class AllOptionalElementList extends StatelessWidget {
const AllOptionalElementList({
super.key,
required this.onSelected,
required this.list,
});
final List<ElementModel> list;
final Function(ElementModel) onSelected;
@override
Widget build(BuildContext context) {
return Container(
width: 60,
padding: EdgeInsets.symmetric(vertical: 20),
decoration: BoxDecoration(
color: Color(0xFFF0F0F0),
border: Border.all(
color: Colors.blueAccent,
width: 2,
),
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(
maxHeight: 200,
),
child: SingleChildScrollView(
child: Column(
spacing: 10,
children: [
...list.map((item) => GestureDetector(
onTap: () => onSelected(item),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (item.type == ElementType.imageType.type) Image.file(
File(item.imagePath!),
width: 50,
height: 50,
fit: BoxFit.scaleDown,
),
if (item.type == ElementType.textType.type && item.textOptions != null) SizedBox(
width: 50,
child: Text(
item.textOptions!.text,
style: TextStyle(
fontSize: 10,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
)),
],
),
),
);
}
}
效果:
粗糙的实现了功能,大致的效果就是如此,这样就可以让我们选中非顶层元素了,而且也可以很好的操作。
今天的分享就到此结束了,感谢阅读~拜拜~