Building a Multi-Level Dropdown Menu Widget in Flutter
Creating interactive and intuitive user interfaces is crucial for any application. In Flutter, while single-level dropdowns are straightforward, implementing a multi-level dropdown menu—where selecting an item reveals a sub-menu—requires a deeper understanding of Flutter's widget tree, state management, and overlay system. This article will guide you through building a professional multi-level dropdown menu widget in Flutter, leveraging OverlayEntry for flexible positioning and recursive widget design for nested menus.
Introduction
Multi-level dropdown menus are invaluable for applications with complex navigation hierarchies or extensive lists of options that need to be organized. They save screen space while providing a structured way for users to explore deeper categories. The challenge in Flutter lies in managing the dynamic appearance, disappearance, and precise positioning of these menus, especially when they need to stack or appear alongside each other.
Core Concepts for Implementation
Before diving into the code, let's briefly review the key Flutter concepts we'll be utilizing:
OverlayEntryandOverlay: TheOverlaywidget allows us to insert widgets on top of other widgets in the stack, independent of the normal widget tree flow. AnOverlayEntryis essentially a "portal" through which we can render any widget onto theOverlay. This is perfect for dropdowns that need to float above the existing UI.Positioned: Used in conjunction withStackorOverlayEntry,Positionedallows precise placement of widgets using coordinates (left, top, right, bottom) relative to its parent. We'll use this to position our dropdown menus accurately.StatefulWidget: To manage the open/closed state of the dropdown and its sub-menus, we'll needStatefulWidgets.GlobalKeyandRenderBox: To determine the screen coordinates and size of our trigger widget (e.g., a button), we'll use aGlobalKeyto obtain itsRenderBox. This information is critical for positioning the dropdown correctly.- Recursive Widget Structure: To handle arbitrary levels of nesting, our menu item rendering logic will be recursive.
1. Defining the Menu Item Data Model
First, let's define a simple data structure to represent our menu items. Each item will have a text, an optional onTap callback, and a list of children for sub-menus.
import 'package:flutter/material.dart';
class MenuItem {
final String text;
final VoidCallback? onTap;
final List? children;
MenuItem({
required this.text,
this.onTap,
this.children,
});
bool get hasChildren => children != null && children!.isNotEmpty;
}
2. The Main MultiLevelDropdown Widget
This widget will be the entry point. It takes a child widget (e.g., a button) that acts as the trigger, and a list of root MenuItems. It will be responsible for managing the root OverlayEntry.
class MultiLevelDropdown extends StatefulWidget {
final Widget child;
final List items;
final Color backgroundColor;
final Color textColor;
final double itemHeight;
final double dropdownWidth;
const MultiLevelDropdown({
Key? key,
required this.child,
required this.items,
this.backgroundColor = Colors.white,
this.textColor = Colors.black,
this.itemHeight = 48.0,
this.dropdownWidth = 200.0,
}) : super(key: key);
@override
_MultiLevelDropdownState createState() => _MultiLevelDropdownState();
}
class _MultiLevelDropdownState extends State {
OverlayEntry? _overlayEntry;
final GlobalKey _dropdownKey = GlobalKey();
void _showDropdown() {
if (_overlayEntry != null) {
_hideDropdown();
return;
}
final RenderBox renderBox = _dropdownKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
_overlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// A transparent GestureDetector to dismiss the dropdown when tapping outside
Positioned.fill(
child: GestureDetector(
onTap: _hideDropdown,
child: Container(color: Colors.transparent),
),
),
Positioned(
left: offset.dx,
top: offset.dy + size.height, // Position below the trigger
width: widget.dropdownWidth,
child: _DropdownMenuContent(
items: widget.items,
backgroundColor: widget.backgroundColor,
textColor: widget.textColor,
itemHeight: widget.itemHeight,
dropdownWidth: widget.dropdownWidth,
onCloseThisLevel: _hideDropdown, // Closes this root level
onCloseRoot: _hideDropdown, // Also the root closer
),
),
],
),
);
Overlay.of(context)!.insert(_overlayEntry!);
}
void _hideDropdown() {
_overlayEntry?.remove();
_overlayEntry = null;
}
@override
void dispose() {
_hideDropdown(); // Ensure the dropdown is closed when the widget is disposed
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
key: _dropdownKey,
onTap: _showDropdown,
child: widget.child,
);
}
}
3. Building the Nested Menu Content (`_DropdownMenuContent` and `_DropdownItem`)
The core of our multi-level dropdown lies in these two recursive components:
_DropdownMenuContent: Renders a list ofMenuItems for a specific level. It acts as the container for a single menu panel._DropdownItem: Represents an individual menu item. If it has children, it will be responsible for showing its own sub-menuOverlayEntrywhen tapped. It also propagates the root closing mechanism.
class _DropdownMenuContent extends StatefulWidget {
final List items;
final Color backgroundColor;
final Color textColor;
final double itemHeight;
final double dropdownWidth;
final VoidCallback onCloseThisLevel; // To close this specific overlay
final VoidCallback onCloseRoot; // To close the very first (root) overlay
const _DropdownMenuContent({
Key? key,
required this.items,
required this.backgroundColor,
required this.textColor,
required this.itemHeight,
required this.dropdownWidth,
required this.onCloseThisLevel,
required this.onCloseRoot,
}) : super(key: key);
@override
__DropdownMenuContentState createState() => __DropdownMenuContentState();
}
class __DropdownMenuContentState extends State<_DropdownMenuContent> {
@override
Widget build(BuildContext context) {
return Material(
color: widget.backgroundColor,
elevation: 4.0,
borderRadius: BorderRadius.circular(4.0),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.7), // Limit height
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.items.map((item) {
return _DropdownItem(
item: item,
backgroundColor: widget.backgroundColor,
textColor: widget.textColor,
itemHeight: widget.itemHeight,
dropdownWidth: widget.dropdownWidth,
onCloseThisLevel: widget.onCloseThisLevel,
onCloseRoot: widget.onCloseRoot,
);
}).toList(),
),
),
),
);
}
}
class _DropdownItem extends StatefulWidget {
final MenuItem item;
final Color backgroundColor;
final Color textColor;
final double itemHeight;
final double dropdownWidth;
final VoidCallback onCloseThisLevel; // To close the current level's overlay
final VoidCallback onCloseRoot; // To close the very first (root) overlay
const _DropdownItem({
Key? key,
required this.item,
required this.backgroundColor,
required this.textColor,
required this.itemHeight,
required this.dropdownWidth,
required this.onCloseThisLevel,
required this.onCloseRoot,
}) : super(key: key);
@override
__DropdownItemState createState() => __DropdownItemState();
}
class __DropdownItemState extends State<_DropdownItem> {
OverlayEntry? _subOverlayEntry;
final GlobalKey _itemKey = GlobalKey(); // For positioning sub-menu
void _showSubMenu() {
if (_subOverlayEntry != null) {
return; // Sub-menu already open
}
final RenderBox renderBox = _itemKey.currentContext!.findRenderObject() as RenderBox;
final Offset offset = renderBox.localToGlobal(Offset.zero);
final Size size = renderBox.size;
_subOverlayEntry = OverlayEntry(
builder: (context) => Stack(
children: [
// Transparent GestureDetector for tapping outside this sub-menu
Positioned.fill(
child: GestureDetector(
onTap: _hideSubMenu,
child: Container(color: Colors.transparent),
),
),
Positioned(
left: offset.dx + size.width, // Position to the right of the parent item
top: offset.dy,
width: widget.dropdownWidth,
child: _DropdownMenuContent(
items: widget.item.children!,
backgroundColor: widget.backgroundColor,
textColor: widget.textColor,
itemHeight: widget.itemHeight,
dropdownWidth: widget.dropdownWidth,
onCloseThisLevel: _hideSubMenu, // This sub-menu closes itself
onCloseRoot: widget.onCloseRoot, // Propagate root closer
),
),
],
),
);
Overlay.of(context)!.insert(_subOverlayEntry!);
}
void _hideSubMenu() {
_subOverlayEntry?.remove();
_subOverlayEntry = null;
}
@override
void dispose() {
_hideSubMenu(); // Ensure sub-menu is closed on dispose
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
key: _itemKey,
onTap: () {
if (widget.item.hasChildren) {
_showSubMenu();
} else {
widget.item.onTap?.call();
widget.onCloseRoot(); // A non-child item closes the entire dropdown system.
}
},
child: Container(
height: widget.itemHeight,
padding: const EdgeInsets.symmetric(horizontal: 16.0),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: widget.textColor.withOpacity(0.1), width: 0.5),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.item.text,
style: TextStyle(color: widget.textColor),
),
if (widget.item.hasChildren)
Icon(Icons.arrow_right, color: widget.textColor.withOpacity(0.7)),
],
),
),
);
}
}
4. Example Usage
Now, let's put it all together in a simple Flutter application:
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Multi-Level Dropdown Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatelessWidget {
final List menuItems = [
MenuItem(
text: 'File',
children: [
MenuItem(text: 'New', onTap: () => print('New File')),
MenuItem(
text: 'Open',
children: [
MenuItem(text: 'Open Recent', onTap: () => print('Open Recent')),
MenuItem(text: 'Open Project...', onTap: () => print('Open Project')),
],
),
MenuItem(text: 'Save', onTap: () => print('Save File')),
MenuItem(text: 'Save As...', onTap: () => print('Save As')),
],
),
MenuItem(
text: 'Edit',
children: [
MenuItem(text: 'Undo', onTap: () => print('Undo')),
MenuItem(text: 'Redo', onTap: () => print('Redo')),
MenuItem(
text: 'Find',
children: [
MenuItem(text: 'Find...', onTap: () => print('Find')),
MenuItem(text: 'Replace...', onTap: () => print('Replace')),
],
),
],
),
MenuItem(
text: 'Help',
onTap: () => print('Help Clicked'),
),
MenuItem(
text: 'About',
onTap: () => print('About Clicked'),
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Multi-Level Dropdown Demo'),
),
body: Center(
child: MultiLevelDropdown(
items: menuItems,
backgroundColor: Colors.blueGrey[800]!,
textColor: Colors.white,
child: ElevatedButton(
onPressed: () {}, // Handled by MultiLevelDropdown
child: Text('Open Menu'),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 30, vertical: 15),
textStyle: TextStyle(fontSize: 18),
),
),
),
),
);
}
}
Conclusion
You have successfully built a multi-level dropdown menu widget in Flutter! This solution effectively uses OverlayEntry for flexible positioning and a recursive widget structure to handle any depth of nested menus. Key aspects include calculating widget positions using GlobalKey and RenderBox, managing overlay lifecycles, and passing callbacks to ensure proper menu dismissal. This foundation can be further extended with animations, custom styling, and more sophisticated state management for complex applications.