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:
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:
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)