天天看點

Flutter 如何實作顯示上下文自定義菜單

Flutter 實作長按顯示上下文自定義菜單

很多場景需要我們點選或長按顯示操作菜單,如點選gridview中的一項顯示上下文菜單,本博就以gridview來作為例子,具體效果如下

Flutter 如何實作顯示上下文自定義菜單

要在指定的位置顯示上下文菜單,需要解決以下幾個問題

  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參數的原型是

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使用的文章