Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十一)

0 阅读9分钟

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(十一)

Flutter: 3.35.7

前面我们实现了网格辅助线等功能,拥有这些功能,我们就能很好的定位元素在容器内的位置。今天我们就主要实现元素层级的相关操作。

在我们之前的功能中,元素个数比较少,当元素个数达到一定数量后,肯定会存在覆盖的情况,我们无法保证需要操作的元素一定是在顶层,所以就需要对层级进行辅助控制。而且层级操作主要是针对当前选中的元素,所以在未选中元素的时候就不允许点击。

元素层级的控制主要分为如下几类:

  1. 操作当前选中元素的层级
  2. 选中点击坐标内的某个层级的元素,不至于一定要保证它在顶层也可选中进行操作

因为元素层级的操作涉及到几个,以开启按钮位置为基准定位展示,所以要使用到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,
    );
  }
}

展示效果:

image01.gif

这样就简单实现了层级工具栏的展示,接下来我们实现功能。层级操作的功能无非就是那几种,与相邻的交换(上一层或者下一层),置顶或者置底。

简单分析一下:

  • 上一层:当元素并不是处于最顶层的时候,将当前元素与后一个元素交换位置
  • 下一层:当元素并不是处于最底层的时候,将当前元素与前一个元素交换位置
  • 置顶:当元素并不是处于顶层的时候,将当前元素提到最顶层
  • 置底:当元素并不是处于底层的时候,将当前元素提到最底层

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(() {});
    }
  }
}

效果:

image02.gif

这样就简单实现了层级的变化,接下里我们就来实现选中。当我们点击某个区域想选中其中的某个元素并进行操作,但是并不想调整元素的层级,恰好这个元素露出来的东西很少,或者干脆就是一个透明的相框,我想要操作相框下面的图片,那么此时就需要额外的功能来辅助我们实现这块功能。目前我们响应手势的区域虽然是整个变换区域,但是实际上只有手指在元素内部的时候才能执行操作,当元素堆叠在一起的时候,变换操作将会变得很不流畅,因为响应手势的元素始终是最顶层元素,元素之外就自动执行了清空操作。所以我们先对这块进行改变。

改变手势逻辑:

  • 点击
    • 如果没有选中元素,则不做任何事情;
    • 如果存在选中元素,则记录点击点和初始化数据,判断当前点击点落在哪个响应区域,如果没有在任何的响应区域,则默认为移动
  • 滑动
    • 只判断是否存在当前元素,如果存在,则直接执行滑动,不论是在元素内部或者元素外部(为了方便在元素很小或者堆叠在一起的时候需要微调,这样可以更清楚的看到样式);
    • 只要发生了大于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;
}

效果:

image03.gif

这样我们就简单更改了手势的逻辑,可以看到,即使元素堆叠在一起了,我么也可以在另外的区域响应移动手势,而不是需要在元素区域内。

接下来就来实现堆叠元素的选中了,大致是为了好操作下面这种情况:

image04.png

可以看到,下面那张图片在实际中用手去点击很难点击到,如果我们就像操作下面那张图片,就必须将上面图片移开然后再选中操作,这里布局较为简单,如果我们辛辛苦苦的完成了一个很好看的海报,就因为这点小小的瑕疵,就要移开上面的元素去操作下面的元素然后重新排版,这样的操作很让人头疼,所以我们将在这个点击点内的所有元素都罗列出来,默认选中顶层的元素,其他元素排列到一旁,想选中谁就选中谁,这样就能极大的方便我们操作:

/// 结束事件
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,
                    ),
                  ),
                ],
              ),
            )),
          ],
        ),
      ),
    );
  }
}

效果:

image05.gif

粗糙的实现了功能,大致的效果就是如此,这样就可以让我们选中非顶层元素了,而且也可以很好的操作。

今天的分享就到此结束了,感谢阅读~拜拜~