image

08 Feb 2026

9K

35K

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:

  • OverlayEntry and Overlay: The Overlay widget allows us to insert widgets on top of other widgets in the stack, independent of the normal widget tree flow. An OverlayEntry is essentially a "portal" through which we can render any widget onto the Overlay. This is perfect for dropdowns that need to float above the existing UI.
  • Positioned: Used in conjunction with Stack or OverlayEntry, Positioned allows 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 need StatefulWidgets.
  • GlobalKey and RenderBox: To determine the screen coordinates and size of our trigger widget (e.g., a button), we'll use a GlobalKey to obtain its RenderBox. 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 of MenuItems 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-menu OverlayEntry when 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.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is