A new way to customize context menus

Summary

Context menus, or text selection toolbars, are the menus that show up when long pressing or right clicking on text in Flutter, and they show options like Cut, Copy, Paste, and Select all. Previously, it was only possible to narrowly customize them using ToolbarOptions and TextSelectionControls. Now, they have been made composable using widgets, just like everything else in Flutter, and the specific configuration parameters have been deprecated.

Context

Previously, it was possible to disable buttons from the context menus using TextSelectionControls, but any customization beyond that required copying and editing hundreds of lines of custom classes in the framework. Now, all of this has been replaced by a simple builder function, contextMenuBuilder, which allows any Flutter widget to be used as a context menu.

Description of change

Context menus are now built from the contextMenuBuilder parameter, which has been added to all text-editing and text-selection widgets. If one is not provided, then Flutter just sets it to a default that builds the correct context menu for the given platform. All of these default widgets are exposed to users for re-use. Customizing context menus now consists of using contextMenuBuilder to return whatever widget you want, possibly including reusing the built-in context menu widgets.

Here’s an example that shows how to add a Send email button to the default context menus whenever an email address is selected. The full code can be found in the samples repository in email_button_page.dart on GitHub.

TextField(
  contextMenuBuilder: (context, editableTextState) {
    final TextEditingValue value = editableTextState.textEditingValue;
    final List<ContextMenuButtonItem> buttonItems =
        editableTextState.contextMenuButtonItems;
    if (isValidEmail(value.selection.textInside(value.text))) {
      buttonItems.insert(
          0,
          ContextMenuButtonItem(
            label: 'Send email',
            onPressed: () {
              ContextMenuController.removeAny();
              Navigator.of(context).push(_showDialog(context));
            },
          ));
    }
    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: editableTextState.contextMenuAnchors,
      buttonItems: buttonItems,
    );
  },
)

A large number of examples of different custom context menus are available in the samples repo on GitHub.

All related deprecated features were flagged with the deprecation warning “Use contextMenuBuilder instead.”

Migration guide

In general, any previous changes to context menus that have been deprecated now require the use of the contextMenuBuilder parameter on the relevant text-editing or text-selection widget ( on TextField, for example). Return a built-in context menu widget like AdaptiveTextSelectionToolbar to use Flutter’s built-in context menus, or return your own widget for something totally custom.

To transition to contextMenuBuilder, the following parameters and classes have been deprecated.

ToolbarOptions

This class was previously used to explicitly enable or disable certain buttons in a context menu. Before this change, you might have passed it into TextField or other widgets like this:

// Deprecated.
TextField(
  toolbarOptions: ToolbarOptions(
    copy: true,
  ),
)

Now, you can achieve the same effect by adjusting the buttonItems passed into AdaptiveTextSelectionToolbar. For example, you could ensure that the Cut button never appears, but the other buttons do appear as usual:

TextField(
  contextMenuBuilder: (context, editableTextState) {
    final List<ContextMenuButtonItem> buttonItems =
        editableTextState.contextMenuButtonItems;
    buttonItems.removeWhere((ContextMenuButtonItem buttonItem) {
      return buttonItem.type == ContextMenuButtonType.cut;
    });
    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: editableTextState.contextMenuAnchors,
      buttonItems: buttonItems,
    );
  },
)

Or, you could ensure that the Cut button appears exclusively and always:

TextField(
  contextMenuBuilder: (context, editableTextState) {
    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: editableTextState.contextMenuAnchors,
      buttonItems: <ContextMenuButtonItem>[
        ContextMenuButtonItem(
          onPressed: () {
            editableTextState.cutSelection(SelectionChangedCause.toolbar);
          },
          type: ContextMenuButtonType.cut,
        ),
      ],
    );
  },
)

TextSelectionControls.canCut and other button booleans

These booleans previously had the same effect of enabling and disabling certain buttons as ToolbarOptions.cut, and so on had. Before this change, you might have been hiding and showing buttons by overriding TextSelectionControls and setting these booleans like this:

// Deprecated.
class _MyMaterialTextSelectionControls extends MaterialTextSelectionControls {
  @override
  bool canCut() => false,
}

See the previous section on ToolbarOptions for how to achieve a similar effect with contextMenuBuilder.

TextSelectionControls.handleCut and other button callbacks

These functions allowed the modification of the callback called when the buttons were pressed. Before this change, you might have been modifying context menu button callbacks by overriding these handler methods like this:

// Deprecated.
class _MyMaterialTextSelectionControls extends MaterialTextSelectionControls {
  @override
  bool handleCut() {
    // My custom cut implementation here.
  },
}

This is still possible using contextMenuBuilder, including calling out to the original buttons’ actions in the custom handler, using toolbar widgets like AdaptiveTextSelectionToolbar.buttonItems.

This example shows modifying the Copy button to show a dialog in addition to doing its usual copy logic.

TextField(
  contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
    final List<ContextMenuButtonItem> buttonItems =
        editableTextState.contextMenuButtonItems;
    final int copyButtonIndex = buttonItems.indexWhere(
      (ContextMenuButtonItem buttonItem) {
        return buttonItem.type == ContextMenuButtonType.copy;
      },
    );
    if (copyButtonIndex >= 0) {
      final ContextMenuButtonItem copyButtonItem =
          buttonItems[copyButtonIndex];
      buttonItems[copyButtonIndex] = copyButtonItem.copyWith(
        onPressed: () {
          copyButtonItem.onPressed();
          Navigator.of(context).push(
            DialogRoute<void>(
              context: context,
              builder: (BuildContext context) =>
                const AlertDialog(
                  title: Text('Copied, but also showed this dialog.'),
                ),
            );
          )
        },
      );
    }
    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: editableTextState.contextMenuAnchors,
      buttonItems: buttonItems,
    );
  },
)

A full example of modifying a built-in context menu action can be found in the samples repository in modified_action_page.dart on GitHub.

buildToolbar

This function generated the context menu widget similarly to contextMenuBuilder, but required more setup to use. Before this change, you might have been overriding buildToolbar as a part of TextSelectionControls, like this:

// Deprecated.
class _MyMaterialTextSelectionControls extends MaterialTextSelectionControls {
  @override
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
    double textLineHeight,
    Offset selectionMidpoint,
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
    ClipboardStatusNotifier clipboardStatus,
    Offset lastSecondaryTapDownPosition,
  ) {
    return _MyCustomToolbar();
  },
}

Now you can simply use contextMenuBuilder directly as a parameter to TextField (and others). The information provided in the parameters to buildToolbar can be obtained from the EditableTextState that is passed to contextMenuBuilder.

The following example shows how to build a fully-custom toolbar from scratch while still using the default buttons.

class _MyContextMenu extends StatelessWidget {
  const _MyContextMenu({
    required this.anchor,
    required this.children,
  });

  final Offset anchor;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          top: anchor.dy,
          left: anchor.dx,
          child: Container(
            width: 200,
            height: 200,
            color: Colors.amberAccent,
            child: Column(
              children: children,
            ),
          ),
        ),
      ],
    );
  }
}

class _MyTextField extends StatelessWidget {
  const _MyTextField();

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      maxLines: 4,
      minLines: 2,
      contextMenuBuilder: (context, editableTextState) {
        return _MyContextMenu(
          anchor: editableTextState.contextMenuAnchors.primaryAnchor,
          children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
            context,
            editableTextState.contextMenuButtonItems,
          ).toList(),
        );
      },
    );
  }
}

A full example of building a custom context menu can be found in the samples repository in custom_menu_page.dart on GitHub.

Timeline

Landed in version: 3.6.0-0.0.pre
In stable release: 3.7.0

References

API documentation:

Relevant issues:

Relevant PRs: