Flutter Web:需要带有子菜单的菜单

Flutter Web : Need menu with sub menu

如何使用 Flutter web 构建带有子菜单的菜单,如下图所示

截至目前,flutter 没有 NestedMenu 小部件。然而,现有的小部件可以帮助构建一个可以有不同子菜单的自定义菜单。在此 dartPad 中,我使用两种不同的想法创建了子菜单。

  1. 使用现有的 PopupMenuButon 小部件嵌套在另一个中,并使用 offset 属性定位子菜单。
  2. 使用全局 showMenu 功能可以将菜单定位在屏幕的任何位置。

您可以检查下面显示的两个实现。请注意,这两种方法都有其自身的注意事项。就像关闭弹出窗口和处理选择和取消一样。然而,这只是为了表明它可能会发生颤动,处理这些情况超出了这个答案的范围。

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),                
              ],
            ),
              ),)

      ),
    );
  }
}

在标准的Flutter库中(material.dart),有一个抽象class PopupMenuEntry,所有children PopupMenuButton 是继承的。目前,有三个具体的子classes:PopupMenuItem(你经常看到的常规项目),'CheckedPopupMenuItem'(常规项目+复选框)和PopupMenuDivider(水平线) .没有什么能阻止我们实现自己的 subclass.

使用@AbhilashChandran 的第一个答案并稍微修改一下,我们可以创建以下泛型 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();
      },
    );
  }
}

这个class的用法简单直观:

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
        },
      ),
    ];
  },
)

结果是这样的:

这种方法有几个缺点:

  • 很明显,子菜单未显示在您希望显示的位置 - 可能需要进行一些复杂的计算;
  • 尽管可以将多个子菜单放在另一个子菜单中,但我不确定关闭底部菜单(或选择值)时顶部菜单是否会正确关闭 - 可能由 Navigator 调用和检查;
  • parent 菜单和子菜单应具有相同类型的值 - 可以使用 subclasses;
  • 处理
  • 需要指定 onSelected 方法两次(对于 parent 菜单和 child 菜单)- 可以使用方法或闭包来处理;
  • 其他一些我可能没有想到的事情 - 可以通过在下面写评论来处理。

PopupSubMenuItem class 可以扩展为包含类似 final String Function(T) formatter; 的内容,以有意义的方式表示您的价值观,但为了简洁起见,省略了此功能。