Gravid Banner

Reusable overflow menu in Flutter

One of the problems of learning new languages is that the majority of example code shows only the simplest use cases. With Flutter particularly I have found that the samples in the documentation and answers available on Stack Overflow are built to solve specific single widget needs rather than being coded for reusability.

When you are building a larger app, however, you need to be thinking not just about how to get the job done, but how maintainable it will be in the long haul. As we talked about with flexible themes – if you hard code every style into each property it becomes as massive problem if you need to re-theme your app at a later date.

This week I needed to add an overflow menu to two of my widgets, and whilst there was some similarity between their menus, there were also some differences. The cop-out solution would have been separate overflow menus in each widgets Scaffold. This has two problems. Firstly deep Scaffold nesting decreases code readability, and secondly if functionality needs to be be changed in future (to add features or cope with deprecation of some property) it needs to be done in multiple locations,

The Menu Item

To get started let’s create editmenu.dart with a brand new class called menuItem to include all of the things a menu item might need: some text to display, an icon to show, a callback function to be called when the item is selected. There are also two booleans, one to enabled or disable the menu entry and another to show or hide it completely.

class menuItem {
  String text;
  IconData icon;
  VoidCallback callback;
  bool enabled;
  bool show;

  menuItem({
  required String this.text,
  required IconData this.icon,
  required VoidCallback this.callback,
  bool this.show=true,
  bool this.enabled=true});
}
PHP

The Menu Widget

To create our reusable menu, as we explored with the NumberSlider we need to use a stateful widget. This takes a list of the menuItems we’ve just defined as a parameter and creates the menu state

import 'package:flutter/material.dart';
import 'package:tigertext/main.dart';

class EditMenu extends StatefulWidget {
  final List<menuItem> items;

  EditMenu(List<menuItem> this.items);

  @override
  _EditMenuState createState() => _EditMenuState();
}
PHP

The Menu State

Our menu state object has two methods. The first is there just to call the callback function for the selected menu item.

The build method is where the menu is created. It uses PopupMenuButton to create the link button for the menu, and PopupMenuItem for each individual menuItem, The itemBuilder allows the build function to iterate through the list of items that was passed into the widget above, rather than coding each item individually as shown in most examples. All of this is then converted back into a list for the popup menu to display.

The where clause after the widget.items filters the list by the show property we created in the menuItem class above, so that only those items where show is true are built.

class _EditMenuState extends State<EditMenu> {

  void _chooseMenu(menuItem item) {
    item.callback();
  }
  
  @override
  Widget build(BuildContext context) {
    return PopupMenuButton<menuItem>(
      onSelected: _chooseMenu,
      itemBuilder: (BuildContext context) {
        return widget.items.where((element) => element.show).map((menuItem item) {
          return  PopupMenuItem<menuItem> (
            value: item,
            child: Row (
              children: [
                Icon(item.icon),
                Padding (
                  child: Text(item.text, style: Theme.of(context).textTheme.bodyMedium),
                  padding: EdgeInsets.only(left: 10),
                )
              ],
            )
          );
          }
        ).toList();}
    );
  }
}
PHP

Using ThemeData to visually indicate disabled Icons and text

The only thing that this code does not currently do is deal with whether the menu item is enabled or not. This is easy enough to implement by adding the enabled property of the PopupMenuItem

          return  PopupMenuItem<menuItem> (
            value: item,
            enabled: item.enabled,
PHP

However, we also need to inform the user that these menu items are disabled. Using the approach I outlined in my post on Flexible Themes this is easily accomplished in our apps for Icons by adding the following to ThemeData.

theme: ThemeData(
  iconTheme: IconThemeData(
    color: (_isDark) ? Colors.white : Color(0xff999999),
    size: _iconSize),
  ),
),
PHP

For the text it is a little more complicated. There are two possible approaches. The first is to style the text in situ by adding a test for enabled and applying a different colour to the disabled text.

child: Row (
              children: [
                Icon(item.icon),
                Padding (
                  child: (item.enabled) 
                  ? Text(item.text,
                      style: Theme.of(context).textTheme.bodyMedium)
                  : Text(item.text,
                      style: Theme.of(context).textTheme.bodyMedium!.copyWith(
                        color: Color(0xff999999))),
                  padding: EdgeInsets.only(left: 10),
                )
              ],
            )
PHP

This works but it introduces the same problem that we started out trying to solve by hardcoding a colour for a particular element in the code rather than setting it directly from the theme.

In order to apply this change at a Theme level we need a different approach. It took quite a lot of digging to find an elegant way of achieving this – until I stumbled on this article in Medium.

Extending BuildContext to create new text themes

The first thing we need to do is to create an extension in the same file as our app theme. This extends BuildContext, allowing us to access the current theme’s text styles and create a new one.

extension CustomStyles on BuildContext {
  TextStyle get disabledTextTheme => 
    Theme.of(this).textTheme.bodyMedium!.copyWith(color: Color(0xff999999));
}
PHP

Then all we need to do is modify the code we create above to use our text theme

child: Row (
              children: [
                Icon(item.icon),
                Padding (
                  child: (item.enabled) 
                  ? Text(item.text,
                      style: Theme.of(context).textTheme.bodyMedium)
                  : Text(item.text,
                      style: context.disabledTextTheme),
                  padding: EdgeInsets.only(left: 10),
                )
              ],
            )
PHP

Using our overflow menu in a widget

Having created a reusable menu, we need to put it in to practice. In order to do this we have to include the editmenu.dart file, populate a list of menuItems and add EditMenu to our app scaffold. The sample below is not complete – but hopefully it gives the idea.

I’ve created _buildMenu as a separate function for readability which is called by the build routine to rebuild the menu based on the current state.

import 'editmenu.dart';

// Widget code etc here

class _SomeWidgetState extends State<SomeWidget> {
  List<menuItem> _menuItems = [];
  TextEditingController _tec = TextEditingController();
  
  void _buildMenu() {
    _menuItems.clear();
    _menuItems.add(menuItem(
      text: "Undo",
      icon: Icons.undo,
      callback: _undo));
    _menuItems.add(menuItem(
      text: "Copy",
      icon: Icons.copy,
      callback: _copy,
      enabled: !_tec.text.isEmpty));
  }
  
  @override
  Widget build(BuildContext context) {
    _buildMenu();
    
    return Scaffold(
      appBar: AppBar(
        title: Text("Editor");
        actions: <Widget> [EditMenu(_menuItems),]
      ),
      body: TextField (
        controller: _tec,
        // etc
    );
  }
  
  void _copy() {
    Clipboard.setData(ClipboardData(text: _tec.text));
  }
  
  void _undo() {
    // code here to undo
  }
}
PHP

You can see this in action in our recently released Text Editor app, Tiger Text


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *