Flutter 如何实现显示上下文自定义菜单

Flutter 实现长按显示上下文自定义菜单


很多场景需要我们点击或长按显示操作菜单,如点击gridview中的一项显示上下文菜单,本博就以gridview来作为例子,具体效果如下
Flutter 如何实现显示上下文自定义菜单_第1张图片
要在指定的位置显示上下文菜单,需要解决以下几个问题

  1. 淡化当前页面,如同我们显示对话框淡化背景一样
  2. 定位上下文菜单显示位置
  3. 点击淡化的背景,关闭上下文菜单,回复之前的状态

让我们来按顺序解决这几个问题,首先我们来解决淡化背景这个问题,Flutter显示对话框时是通过push一个新的Route(路由)来实现,那路由Route又是怎么管理界面的呢,其实路由是由Overlay管理的,Overlay管理一堆OverlayEntry,push一个新页面,会创建Overlay,按照这个思路,我们可以在当前的Overlay插入一个OverlayEntry来覆盖当前页面从而达到淡化页面的效果,首先我们先构建测试用的GridView

class ContextMenuPage extends StatelessWidget {
  ContextMenuPage({this.items});

  OverlayEntry _overlayEntry;

  final List<String> items;

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          childAspectRatio: 1.0,
        ),
        itemCount: items.length,
        itemBuilder: (BuildContext context, int index) {
          return LayoutBuilder(
            builder: (context, _) => GestureDetector(
                  onLongPress: () {
                    _showLayer(context);
                  },
                  child: Container(
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(4.0),
                      border: Border.all(
                          width: 1.0 / MediaQuery.of(context).devicePixelRatio,
                          color: Colors.grey.withOpacity(0.5)),
                    ),
                    alignment: Alignment.center,
                    //color: Colors.red,
                    child: Text('item ${index}'),
                  ),
                ),
          );
        });
  }

代码不多,值得一提的是,我们这里使用LayoutBuilder,LayoutBuilder构造函数中的builder参数的原型是

typedef LayoutWidgetBuilder = Widget Function(BuildContext context, BoxConstraints constraints);

constraints为父控件追加的约束条件,context其实就是布局渲染对象,我们可以通过它获取Grid Item的RenderBox,RenderBox包含位置和大小信息,至于怎么获取该信息后面会讲解,代码中现在只剩下响应长按事件的方法_showLayer没实现,这也是我们本博的关键代码。

那如何定位长按的Grid Item的位置和大小信息呢, 就是通过上面提到的context,还可以通过设置Widget key属性为 GlobalKey,然后通过GlobalKey的currentContext属性获取,其实该属性和context值相同,废话不多说,上代码

Rect _getPosition(BuildContext context) {
    final RenderBox box = context.findRenderObject() as RenderBox;
    final Offset topLeft = box.size.topLeft(box.localToGlobal(Offset.zero));
    final Offset bottomRight =
        box.size.bottomRight(box.localToGlobal(Offset.zero));
    return Rect.fromLTRB(
        topLeft.dx, topLeft.dy, bottomRight.dx, bottomRight.dy);
  }

Flutter 目前主要有两种布局方式

  • Box布局
  • Sliver布局

其渲染对象分别对应RenderBox,RenderSliver,Sliver布局主要针对scrollable widget,比较复杂,超出本博的范畴,这里提到布局方式,主要是如果遇到_getPosition调用崩溃问题,主要原因就是context对应的渲染对象不是RenderBox所致

位置大小信息获取到了,那么我们怎么在指定的位置显示上下文菜单呢,方法有多种,我们通过Stack + Positioned来显示,具体怎么构建该布局,大家可以自己试一试,下面主要是通过另一种方法来构建:CustomSingleChildLayout,

CustomSingleChildLayout提供了一个控制单一child布局的SingleChildLayoutDelegate ,这个delegate可以控制这些因素:

  • 可以控制child的布局constraints
  • 可以控制child的位置;

SingleChildLayoutDelegate是一个抽象类,我们需要通过派生来实现它

class _ContextMenuLayoutDelegate extends SingleChildLayoutDelegate {
  _ContextMenuLayoutDelegate({this.position});

  final Rect position;
   
  // 获取child的size constraint, 参数是传入的父视图constraint 
  @override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints.loose(Size(position.width, position.height));
  }

  // 定位child
  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset(position.left, position.top);
  }

  // 是否需要重新布局
  @override
  bool shouldRelayout(_ContextMenuLayoutDelegate oldDelegate) {
    return position != oldDelegate.position;
  }
}

准备工作做好后,就开始进入创建上下文菜单的流程,我们创建OverlayEntry来显示上下文菜单

 _createContextMenuOverlayEntry({Rect widgetPosition}) {
    return OverlayEntry(
      builder: (BuildContext context) {
        return GestureDetector(
          onTap: _removeOverlay,
          child: Material(
            color: Colors.black54,
            child: CustomSingleChildLayout(
                delegate: _ContextMenuLayoutDelegate(
                  position: widgetPosition,
                ),
                child: Container(
                  alignment: Alignment.bottomCenter,
                  color: Colors.green.withOpacity(0.5),
                  child: Row(
                    children: <Widget>[
                      FlatButton(child: const Icon(Icons.add, color: Colors.white)),
                      FlatButton(child: const Icon(Icons.delete, color: Colors.white))
                    ],
                  ),
                )),
          ),
        );
      },
    );
  }

点击淡化的背景时需要移除OverlayEntry

  void _removeOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

下面我们来实现显示OverlayEntry

void _showLayer(BuildContext context) {
    OverlayState overlayState = Overlay.of(context);
    final Rect overlayPosition = _getPosition(overlayState.context);
    final Rect widgetPosition = _getPosition(context).translate(
      -overlayPosition.left,
      -overlayPosition.top,
    );
    _overlayEntry = _createContextMenuOverlayEntry(widgetPosition: widgetPosition);
    overlayState.insert(
      _overlayEntry,
    );
  }

Overlay.of(context)获取当前的OverlayState, 上下文菜单位置相对Overlay需要通过translate转换坐标系,一切就绪后,直接OverlayState中插入OverlayEntry, 到此我们就完成了整个上下文OverlayEntry的整个实现流程。Overlay可以实现很多普通方式难以实现的效果, 比如我们可以实现以下功能

  • hero动画效果
  • 显示Toast消息
  • 页面上浮一层功能介绍

具体实现方法,我想通过本文的学习大家可能找到实现方法,大家可以自己练手, 有空的话我会抽空再写一篇关于Overlay使用的文章

你可能感兴趣的:(IOS,Flutter,android,UI,移动跨平台)