7

How to build Menu with Submenu like shown below in image using Flutter web

enter image description here

Varma460
  • 497
  • 1
  • 4
  • 13

2 Answers2

7

As of now flutter doesn't have a NestedMenu widget. However existing widgets can help build a custom menu which can have different submenu. Here in this dartPad I have created subMenu's using two different idea.

  1. Using the Existing PopupMenuButon Widget nested one inside another and using the offset attribute to position the subMenu.
  2. Using the global showMenufunction which can position the menu anywhere in the screen.

You can check the two implementations shown below. Note both methods has its own caveats. Like dismissing the popups and handling selection and cancelling. However this is only to show its possible in flutter and handling those cases is out of scope for this answer.

Nested PopupMenuButton

enum WhyFarther { harder, smarter, selfStarter, tradingCharter }

class MainMenu extends StatefulWidget {
  MainMenu({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MainMenuState createState() => _MainMenuState();
}

class _MainMenuState extends State<MainMenu> {
  WhyFarther _selection = WhyFarther.smarter;

  @override
  Widget build(BuildContext context) {
// This menu button widget updates a _selection field (of type WhyFarther,
// not shown here).
    return Padding(
      padding: const EdgeInsets.all(2.0),
      child: PopupMenuButton<WhyFarther>(
        child: Material(
          textStyle: Theme.of(context).textTheme.subtitle1,
          elevation: 2.0,
          child: Container(
            padding: EdgeInsets.all(8),
            child: Text(widget.title),
          ),
        ),
        onSelected: (WhyFarther result) {
          setState(() {
            _selection = result;
          });
        },
        itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
          const PopupMenuItem<WhyFarther>(
            value: WhyFarther.harder,
            child: Text('Working a lot harder'),
          ),
          const PopupMenuItem<WhyFarther>(
            value: WhyFarther.smarter,
            child: Text('Being a lot smarter'),
          ),
          const PopupMenuItem<WhyFarther>(
            value: WhyFarther.selfStarter,
            child: SubMenu('Sub Menu is too long'),
          ),
          const PopupMenuItem<WhyFarther>(
            value: WhyFarther.tradingCharter,
            child: Text('Placed in charge of trading charter'),
          ),
        ],
      ),
    );
  }
}

class SubMenu extends StatefulWidget {
  final String title;
  const SubMenu(this.title);

  @override
  _SubMenuState createState() => _SubMenuState();
}

class _SubMenuState extends State<SubMenu> {
  WhyFarther _selection = WhyFarther.smarter;

  @override
  Widget build(BuildContext context) {
//     print(rendBox.size.bottomRight);

    return PopupMenuButton<WhyFarther>(
      child: Row(
        children: <Widget>[
          Text(widget.title),
          Spacer(),
          Icon(Icons.arrow_right, size: 30.0),
        ],
      ),
      onCanceled: () {
        if (Navigator.canPop(context)) {
          Navigator.pop(context);
        }
      },
      onSelected: (WhyFarther result) {
        setState(() {
          _selection = result;
        });
      },
      // how much the submenu should offset from parent. This seems to have an upper limit.
      offset: Offset(300, 0),
      itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
        const PopupMenuItem<WhyFarther>(
          value: WhyFarther.harder,
          child: Text('Working a lot harder'),
        ),
        const PopupMenuItem<WhyFarther>(
          value: WhyFarther.smarter,
          child: Text('Being a lot smarter'),
        ),
        const PopupMenuItem<WhyFarther>(
          value: WhyFarther.selfStarter,
          child: Text('Being a lot smarter'),
        ),
        const PopupMenuItem<WhyFarther>(
          value: WhyFarther.tradingCharter,
          child: Text('Placed in charge of trading charter'),
        ),
      ],
    );
  }
}

Using showMenu approach

class CustomMenu extends StatefulWidget {
  const CustomMenu({Key key, this.title, this.rootMenu=false}) : super(key: key);

  final String title;
  final bool rootMenu;

  @override
  _CustomMenuState createState() => _CustomMenuState();
}

class _CustomMenuState extends State<CustomMenu> {
  WhyFarther _selection = WhyFarther.smarter;

  @override
  Widget build(BuildContext context) {
// This menu button widget updates a _selection field (of type WhyFarther,
// not shown here).

    return Padding(
      padding: const EdgeInsets.all(2.0),
      child: GestureDetector(
        onTap: () {

          // This offset should depend on the largest text and this is tricky when
          // the menu items are changed
          Offset offset = widget.rootMenu?Offset.zero:Offset(-300,0);

          final RenderBox button = context.findRenderObject();
          final RenderBox overlay =
              Overlay.of(context).context.findRenderObject();
          final RelativeRect position = RelativeRect.fromRect(
            Rect.fromPoints(
              button.localToGlobal(Offset.zero, ancestor: overlay),
              button.localToGlobal(button.size.bottomRight(Offset.zero),
                  ancestor: overlay),
            ),
            offset & overlay.size,
          );
          showMenu(            
              context: context,
              position: position,
              items: <PopupMenuEntry<WhyFarther>>[
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.harder,
                  child: Text('Working a lot harder'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.smarter,
                  child: Text('Being a lot smarter'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.selfStarter,
                  child: CustomMenu(title: 'Sub Menu long'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.tradingCharter,
                  child: Text('Placed in charge of trading charter'),
                ),
              ]).then((selectedValue){
            // do something with the value
            if(Navigator.canPop(context)) Navigator.pop(context);
          });
        },
        child: Material(
              textStyle: Theme.of(context).textTheme.subtitle1,
              elevation: widget.rootMenu?2.0:0.0,              
              child: Padding(
                padding: widget.rootMenu? EdgeInsets.all(8.0):EdgeInsets.all(0.0),
                child: Row(
              children: <Widget>[
                Text(widget.title),
                if(!widget.rootMenu)
                  Spacer(),
                if(!widget.rootMenu)
                  Icon(Icons.arrow_right),                
              ],
            ),
              ),)

      ),
    );
  }
}

Abhilash Chandran
  • 6,803
  • 3
  • 32
  • 50
2

In standard Flutter library (material.dart), there is an abstract class PopupMenuEntry from which all children of PopupMenuButton are inherited. Currently, there are three concrete subclasses: PopupMenuItem (regular item you see all the time), 'CheckedPopupMenuItem' (regular item + checkbox) and PopupMenuDivider (horizontal line). There is nothing preventing us from implementing our own subclass.

Using the first answer of @AbhilashChandran and modifying it a bit, we can create the following generic class:

import 'package:flutter/material.dart';

/// An item with sub menu for using in popup menus
/// 
/// [title] is the text which will be displayed in the pop up
/// [items] is the list of items to populate the sub menu
/// [onSelected] is the callback to be fired if specific item is pressed
/// 
/// Selecting items from the submenu will automatically close the parent menu
/// Closing the sub menu by clicking outside of it, will automatically close the parent menu
class PopupSubMenuItem<T> extends PopupMenuEntry<T> {
  const PopupSubMenuItem({
    @required this.title,
    @required this.items,
    this.onSelected,
  });

  final String title;
  final List<T> items;
  final Function(T) onSelected;

  @override
  double get height => kMinInteractiveDimension; //Does not actually affect anything

  @override
  bool represents(T value) => false; //Our submenu does not represent any specific value for the parent menu

  @override
  State createState() => _PopupSubMenuState<T>();
}

/// The [State] for [PopupSubMenuItem] subclasses.
class _PopupSubMenuState<T> extends State<PopupSubMenuItem<T>> {
  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<T>(
      tooltip: widget.title,
      child: Padding(
        padding: const EdgeInsets.only(left: 16.0, right: 8.0, top: 12.0, bottom: 12.0),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.max,
          children: <Widget>[
            Expanded(
              child: Text(widget.title),
            ),
            Icon(
              Icons.arrow_right,
              size: 24.0,
              color: Theme.of(context).iconTheme.color,
            ),
          ],
        ),
      ),
      onCanceled: () {
        if (Navigator.canPop(context)) {
          Navigator.pop(context);
        }
      },
      onSelected: (T value) {
        if (Navigator.canPop(context)) {
          Navigator.pop(context);
        }
        widget.onSelected?.call(value);
      },
      offset: Offset.zero, //TODO This is the most complex part - to calculate the correct position of the submenu being populated. For my purposes is does not matter where exactly to display it (Offset.zero will open submenu at the poistion where you tapped the item in the parent menu). Others might think of some value more appropriate to their needs.
      itemBuilder: (BuildContext context) {
        return widget.items
            .map(
              (item) => PopupMenuItem<T>(
            value: item,
            child: Text(item.toString()), //MEthod toString() of class T should be overridden to repesent something meaningful
          ),
        )
            .toList();
      },
    );
  }
}

Usage of this class is simple and intuitive:

PopupMenuButton<int>(
  icon: Icon(Icons.arrow_downward),
  tooltip: 'Parent menu',
  onSelected: (value) {
    //Do something with selected parent value
  },
  itemBuilder: (BuildContext context) {
    return <PopupMenuEntry<int>>[
      PopupMenuItem<int>(
        value: 10,
        child: Text('Item 10'),
      ),
      PopupMenuItem<int>(
        value: 20,
        child: Text('Item 20'),
      ),
      PopupMenuItem<int>(
        value: 50,
        child: Text('Item 50'),
      ),
      PopupSubMenuItem<int>(
        title: 'Other items',
        items: [
          100,
          200,
          300,
          400,
          500,
        ],
        onSelected: (value) {
          //Do something with selected child value
        },
      ),
    ];
  },
)

The result is something like this:

Screenshot

There are a couple of drawbacks for this approach:

  • Obviously, the submenu is displayed not at the location you wanted it to be displayed - may be dealt with by some complex calculations;
  • Even though several submenus can be placed one inside another, I am not sure that the top ones will be closed correctly when bottom ones are closed (or the value is selected) - may be dealt with by Navigator calls and checks;
  • Both parent menu and submenues should have values of the same type - mayn be dealt with by using subclasses;
  • The need to specify onSelected method twice (for parent menu and for child menu) - may be dealt with by using methods or closures;
  • Some other things I may have not thought of - may be dealt with by writing comments below.

The PopupSubMenuItem class can be expanded to include something like final String Function(T) formatter; to represent your values in a meaningful way, but for the sake of brevity this functionality was omitted.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
Alex Semeniuk
  • 1,865
  • 19
  • 28
  • Thanks a lot! I modified your code and used in my project and it displays very well! Just a little suggestion: You can use Navigator.pop(context, value); in your onSelected handler to pass the selected value to the parent Menu, so you would not need to write the method twice as you said. – hythloday Jan 20 '22 at 04:14