Place widget outside of bounding box

Issue

My app shows various Container() Widget()s in several columns in a certain view.

I tried to place some icons inside the Container()s to provide operations like delete, minimize etc. Unfortunately, that doesn’t look good on native targets.

Therefore I’d like to keep the visual appearance as is and show an actions menu above the actual Container() once the mouse pointer moves over the Container().

This menu would be above all other widgets, be non-modal and disappear, once the pointer leaves the bound box of the Container(). Containers() shouldn’t change size and location.

Using MouseRegion(), I’d make the menu appear and disappear.

May I place some Widget() outside the bounding rectangle of a Container() [or other widgets)? Ideally, I’d like to place it relative to the other bounding box.

UPDATE 2022-03-24

Created an OverlayMenu() class which renders something like this:

enter image description here

Usage:

OverlayMenu(
              actionWidget: 
                  const Icon(Icons.settings, color: Colors.blue, size: 20), 
              callbacks: [
                () {
                  // Click on icons 1 action
                },
                () {
                  // Click on icon 2 action
                }
                ],
              icons: [
                 Icons.delete, Icons.tv
                ],
              leftOffset: StoreProvider.of<EModel>(context)
                  .state
                  .columnWidth
                  .toDouble())

And the implementation of OverlayMenu():

import ‘package:flutter/material.dart’;

class OverlayMenu {

  List<IconData> icons;
  List<Function> callbacks;
  Widget actionWidget;

  double leftOffset = 0.0;
  bool mayShowMenu = true;

  OverlayMenu({ required this.actionWidget, required this.icons, required this.callbacks, required this.leftOffset  });

  Widget insert( BuildContext context ) {

    return MouseRegion(
      onEnter: (_) {
        if (mayShowMenu) {
          _showOverlay( context );
        }
      },
      onExit: (_) {
        mayShowMenu = true;
      },
      child: const Icon(Icons.settings, color: Colors.blue, size: 20),
    );

  }


  ///
  ///
  ///
  void _showOverlay(BuildContext outerContext ) async {
    final renderBox = outerContext.findRenderObject() as RenderBox;
    var size = renderBox.size;
    var offset = renderBox.localToGlobal(Offset.zero);

    assert( icons.length == callbacks.length, 'Need to provide as many icons as callbacks' );

    List<Widget> actionElements = List.empty( growable: true );

    for( var n=0; n<icons.length; n++ ) {

      actionElements.add( GestureDetector(
          onTap: () {
            callbacks[ n ]();
          },
          child: Icon( icons[ n ], size: 22 ))
      );

    }

    // Declaring and Initializing OverlayState
    // and OverlayEntry objects
    OverlayState? overlayState = Overlay.of(outerContext);
    OverlayEntry? overlayEntry;
    overlayEntry = OverlayEntry(builder: (context) {
      // You can return any widget you like here
      // to be displayed on the Overlay
      return Stack(children: [
        Positioned(
          top: offset.dy,
          left: offset.dx +
              leftOffset -
              40,
          child: MouseRegion(
              onExit: (_) {
                overlayEntry?.remove();
                print(DateTime.now().toString() + ' ovl: removed');
              },
              cursor: SystemMouseCursors.contextMenu,
              child: Material(
                  child:Container(
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(4),
                          border: Border.all(
                            color: Colors.grey.shade300,
                            width: 1,
                          ),
                          color: Colors.white,
                          boxShadow: [
                            BoxShadow(
                                color: Colors.grey.withOpacity(0.1),
                                spreadRadius: 4,
                                blurRadius: 4,
                                offset: const Offset( 4,4 ),
                                blurStyle: BlurStyle.normal),
                          ]
                      ),
                      child: SizedBox(
                          width: 60,
                          height: 60,
                          child: Wrap(spacing: 4, children: actionElements
                          ))))),
        ),
      ]);
    });

    // Inserting the OverlayEntry into the Overlay
    overlayState?.insert(overlayEntry);
    mayShowMenu = true;

    print(DateTime.now().toString() + ' ovl: inserted');

  }

}

Solution

One approach you can use to achieve what you want is Overlay widget since it’s non-modal and also does’t require layout/size changes to have hit testable items.

Based on your question I assume this flow is what want:

animated image showing sample

Insert an overlay entry once the pointer has entered the widget and remove it once it leaves

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Stack(
          children: [
            MouseRegion(
              onEnter: (_) {
                Overlay.of(context)!.insert(this._overlayEntry);
              },
              onExit: (_) => clear(),
              child: FloatingActionButton(
                key: buttonKey,
                onPressed: () {},
              ),
            ),
          ],
        ),
      ),
    );
  } 

This is how we remove the entry, a check whether we can remove or not and a delay (for smoothing) :

  Future<void> clear() async {
    if (!keepMenuVisible) {
      await Future.delayed(Duration(milliseconds: 200));
      if (!keepMenuVisible) {
        _overlayEntry.remove();
      }
    }
  }

The additional delays are used to ensure that the menu doesn’t despair reactively but instead we make it smoother.

keepMenuVisible is used to lock the menu and keep it visible once the menu it self has been hovered.

Finally, we create the entry and place the items relative to the main widget (FAB in this case):

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
      _overlayEntry = _createOverlayEntry();
    });
  }

  OverlayEntry _createOverlayEntry() {
    final renderBox = buttonKey.currentContext!.findRenderObject() as RenderBox;
    var size = renderBox.size;
    var offset = renderBox.localToGlobal(Offset.zero);

    return OverlayEntry(
      builder: (_) => Positioned(
        left: offset.dx,
        top: offset.dy + size.height + 5.0,
        width: 200,
        child: MouseRegion(
          onEnter: (_) {
            keepMenuVisible = true;
          },
          onHover: (_) {
            keepMenuVisible = true;
          },
          onExit: (_) async {
            keepMenuVisible = false;
            clear();
          },
          child: Material(
            elevation: 4.0,
            child: ListView(
              padding: EdgeInsets.zero,
              shrinkWrap: true,
              children: <Widget>[
                ListTile(
                  onTap: () => print('tap action 1'),
                  title: Text('Action 1'),
                ),
                ListTile(
                  onTap: () => print('tap action 2'),
                  title: Text('Action 2'),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }

check the full sample here

Answered By – Raouf Rahiche

Answer Checked By – Terry (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *