1
0
mirror of https://github.com/flutter/samples.git synced 2025-11-08 13:58:47 +00:00

Move context menus out of experimental (#2134)

Now that the context menu code is on stable, move the sample out of
experimental.

Fixes https://github.com/flutter/samples/issues/2110

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

---------

Co-authored-by: Brett Morgan <brett.morgan@gmail.com>
This commit is contained in:
Justin McCandless
2024-01-05 02:55:29 -08:00
committed by GitHub
parent 9b17f50671
commit 6c8d54a82b
162 changed files with 64 additions and 59 deletions

View File

@@ -0,0 +1,100 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'context_menu_region.dart';
import 'platform_selector.dart';
class AnywherePage extends StatelessWidget {
AnywherePage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'anywhere';
static const String title = 'Context Menu Anywhere Example';
static const String subtitle = 'A context menu outside of a text field';
final PlatformCallback onChangedPlatform;
final TextEditingController _materialController = TextEditingController(
text: 'TextField shows the default menu still.',
);
final TextEditingController _cupertinoController = TextEditingController(
text: 'CupertinoTextField shows the default menu still.',
);
final TextEditingController _editableController = TextEditingController(
text: 'EditableText has no default menu, so it shows the custom one.',
);
static const String url = '$kCodeUrl/anywhere_page.dart';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(AnywherePage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: ContextMenuRegion(
contextMenuBuilder: (context, primaryAnchor, [secondaryAnchor]) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: primaryAnchor,
secondaryAnchor: secondaryAnchor as Offset?,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).pop();
},
label: 'Back',
),
],
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 64.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Container(height: 20.0),
const Text(
'Right click anywhere outside of a field to show a custom menu.',
),
Container(height: 140.0),
CupertinoTextField(controller: _cupertinoController),
Container(height: 40.0),
TextField(controller: _materialController),
Container(height: 40.0),
Container(
color: Colors.white,
child: EditableText(
controller: _editableController,
focusNode: FocusNode(),
style: Typography.material2021().black.displayMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.white,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'context_menu_region.dart';
import 'platform_selector.dart';
// This example is based on the MenuBar docs example:
// https://master-api.flutter.dev/flutter/material/MenuBar-class.html
class CascadingMenuPage extends StatelessWidget {
const CascadingMenuPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'cascading';
static const String title = 'Cascading Menu Example';
static const String subtitle = 'A context menu with nested submenus.';
final PlatformCallback onChangedPlatform;
static const String url = '$kCodeUrl/anywhere_page.dart';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(CascadingMenuPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: const _MyContextMenuRegion(),
);
}
}
class _MyContextMenuRegion extends StatefulWidget {
const _MyContextMenuRegion();
@override
State<_MyContextMenuRegion> createState() => _MyContextMenuRegionState();
}
class _MyContextMenuRegionState extends State<_MyContextMenuRegion> {
String? _lastSelection;
Color get backgroundColor => _backgroundColor;
Color _backgroundColor = Colors.red;
set backgroundColor(Color value) {
if (_backgroundColor != value) {
setState(() {
_backgroundColor = value;
});
}
}
bool get showingMessage => _showMessage;
bool _showMessage = true;
set showingMessage(bool value) {
if (_showMessage != value) {
setState(() {
_showMessage = value;
});
}
}
@override
Widget build(BuildContext context) {
return ContextMenuRegion(
contextMenuBuilder: (context, primaryAnchor, [secondaryAnchor]) {
return _MyCascadingContextMenu(
anchor: primaryAnchor,
showingMessage: _showMessage,
onToggleMessageVisibility: () {
setState(() {
_showMessage = !_showMessage;
});
},
onChangeBackgroundColor: (color) {
setState(() {
_backgroundColor = color;
});
},
onChangeSelection: (selection) {
setState(() {
_lastSelection = selection;
});
},
);
},
child: Container(
alignment: Alignment.center,
color: backgroundColor,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
showingMessage
? 'Right click or long press anywhere to show the cascading menu.'
: '',
style: Theme.of(context).textTheme.headlineSmall,
),
),
Text(
_lastSelection != null ? 'Last Selected: $_lastSelection' : ''),
],
),
),
);
}
}
/// A class for consolidating the definition of menu entries.
///
/// This sort of class is not required, but illustrates one way that defining
/// menus could be done.
class MenuEntry {
const MenuEntry(
{required this.label, this.shortcut, this.onPressed, this.menuChildren})
: assert(menuChildren == null || onPressed == null,
'onPressed is ignored if menuChildren are provided');
final String label;
final MenuSerializableShortcut? shortcut;
final VoidCallback? onPressed;
final List<MenuEntry>? menuChildren;
static List<Widget> build(List<MenuEntry> selections) {
Widget buildSelection(MenuEntry selection) {
if (selection.menuChildren != null) {
return SubmenuButton(
menuChildren: MenuEntry.build(selection.menuChildren!),
child: Text(selection.label),
);
}
return MenuItemButton(
shortcut: selection.shortcut,
onPressed: selection.onPressed,
child: Text(selection.label),
);
}
return selections.map<Widget>(buildSelection).toList();
}
static Map<MenuSerializableShortcut, Intent> shortcuts(
List<MenuEntry> selections) {
final Map<MenuSerializableShortcut, Intent> result =
<MenuSerializableShortcut, Intent>{};
for (final MenuEntry selection in selections) {
if (selection.menuChildren != null) {
result.addAll(MenuEntry.shortcuts(selection.menuChildren!));
} else {
if (selection.shortcut != null && selection.onPressed != null) {
result[selection.shortcut!] =
VoidCallbackIntent(selection.onPressed!);
}
}
}
return result;
}
}
typedef _StringCallback = void Function(String string);
typedef _ColorCallback = void Function(Color color);
class _MyCascadingContextMenu extends StatefulWidget {
const _MyCascadingContextMenu({
required this.anchor,
required this.onToggleMessageVisibility,
required this.onChangeBackgroundColor,
required this.onChangeSelection,
required this.showingMessage,
});
final Offset anchor;
final VoidCallback onToggleMessageVisibility;
final _ColorCallback onChangeBackgroundColor;
final _StringCallback onChangeSelection;
final bool showingMessage;
@override
State<_MyCascadingContextMenu> createState() =>
_MyCascadingContextMenuState();
}
class _MyCascadingContextMenuState extends State<_MyCascadingContextMenu> {
ShortcutRegistryEntry? _shortcutsEntry;
List<MenuEntry> get _menus {
final List<MenuEntry> result = <MenuEntry>[
MenuEntry(
label: 'About',
onPressed: () {
ContextMenuController.removeAny();
showAboutDialog(
context: context,
applicationName: 'MenuBar Sample',
applicationVersion: '1.0.0',
);
widget.onChangeSelection('About');
},
),
MenuEntry(
label: widget.showingMessage ? 'Hide' : 'Show',
onPressed: () {
ContextMenuController.removeAny();
widget.onChangeSelection(
widget.showingMessage ? 'Hide Message' : 'Show Message');
widget.onToggleMessageVisibility();
},
shortcut: const SingleActivator(LogicalKeyboardKey.keyS, control: true),
),
// Hides the message, but is only enabled if the message isn't
// already hidden.
MenuEntry(
label: 'Reset',
onPressed: widget.showingMessage
? () {
ContextMenuController.removeAny();
widget.onChangeSelection('Reset');
widget.onToggleMessageVisibility();
}
: null,
shortcut: const SingleActivator(LogicalKeyboardKey.escape),
),
MenuEntry(
label: 'Color',
menuChildren: <MenuEntry>[
MenuEntry(
label: 'Red',
onPressed: () {
ContextMenuController.removeAny();
widget.onChangeSelection('Red Background');
widget.onChangeBackgroundColor(Colors.red);
},
shortcut:
const SingleActivator(LogicalKeyboardKey.keyR, control: true),
),
MenuEntry(
label: 'Green',
onPressed: () {
ContextMenuController.removeAny();
widget.onChangeSelection('Green Background');
widget.onChangeBackgroundColor(Colors.green);
},
shortcut:
const SingleActivator(LogicalKeyboardKey.keyG, control: true),
),
MenuEntry(
label: 'Blue',
onPressed: () {
ContextMenuController.removeAny();
widget.onChangeSelection('Blue Background');
widget.onChangeBackgroundColor(Colors.blue);
},
shortcut:
const SingleActivator(LogicalKeyboardKey.keyB, control: true),
),
],
),
];
// (Re-)register the shortcuts with the ShortcutRegistry so that they are
// available to the entire application, and update them if they've changed.
_shortcutsEntry?.dispose();
_shortcutsEntry =
ShortcutRegistry.of(context).addAll(MenuEntry.shortcuts(result));
return result;
}
@override
void dispose() {
_shortcutsEntry?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return DesktopTextSelectionToolbar(
anchor: widget.anchor,
children: MenuEntry.build(_menus),
);
}
}

View File

@@ -0,0 +1,2 @@
const String kCodeUrl =
'https://github.com/flutter/samples/blob/experimental/context_menus/lib';

View File

@@ -0,0 +1,97 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
typedef ContextMenuBuilder = Widget Function(
BuildContext context, Offset offset);
/// Shows and hides the context menu based on user gestures.
///
/// By default, shows the menu on right clicks and long presses.
class ContextMenuRegion extends StatefulWidget {
/// Creates an instance of [ContextMenuRegion].
const ContextMenuRegion({
super.key,
required this.child,
required this.contextMenuBuilder,
});
/// Builds the context menu.
final ContextMenuBuilder contextMenuBuilder;
/// The child widget that will be listened to for gestures.
final Widget child;
@override
State<ContextMenuRegion> createState() => _ContextMenuRegionState();
}
class _ContextMenuRegionState extends State<ContextMenuRegion> {
Offset? _longPressOffset;
final ContextMenuController _contextMenuController = ContextMenuController();
static bool get _longPressEnabled {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return true;
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return false;
}
}
void _onSecondaryTapUp(TapUpDetails details) {
_show(details.globalPosition);
}
void _onTap() {
if (!_contextMenuController.isShown) {
return;
}
_hide();
}
void _onLongPressStart(LongPressStartDetails details) {
_longPressOffset = details.globalPosition;
}
void _onLongPress() {
assert(_longPressOffset != null);
_show(_longPressOffset!);
_longPressOffset = null;
}
void _show(Offset position) {
_contextMenuController.show(
context: context,
contextMenuBuilder: (context) {
return widget.contextMenuBuilder(context, position);
},
);
}
void _hide() {
_contextMenuController.remove();
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapUp: _onSecondaryTapUp,
onTap: _onTap,
onLongPress: _longPressEnabled ? _onLongPress : null,
onLongPressStart: _longPressEnabled ? _onLongPressStart : null,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class CustomButtonsPage extends StatelessWidget {
CustomButtonsPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'custom-buttons';
static const String title = 'Custom Buttons';
static const String subtitle =
'The usual buttons, but with a custom appearance.';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text:
'Show the menu to see the usual default buttons, but with a custom appearance.',
);
static const String url = '$kCodeUrl/custom_buttons_page.dart';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(CustomButtonsPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 300.0,
child: TextField(
controller: _controller,
maxLines: 4,
minLines: 2,
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar(
anchors: editableTextState.contextMenuAnchors,
// Build the default buttons, but make them look custom.
// Note that in a real project you may want to build
// different buttons depending on the platform.
children:
editableTextState.contextMenuButtonItems.map((buttonItem) {
return CupertinoButton(
borderRadius: null,
color: const Color(0xffaaaa00),
disabledColor: const Color(0xffaaaaff),
onPressed: buttonItem.onPressed,
padding: const EdgeInsets.all(10.0),
pressedOpacity: 0.7,
child: SizedBox(
width: 200.0,
child: Text(
CupertinoTextSelectionToolbarButton.getButtonLabel(
context, buttonItem),
),
),
);
}).toList(),
);
},
),
),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class CustomMenuPage extends StatelessWidget {
CustomMenuPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'custom-menu';
static const String title = 'Custom Menu';
static const String subtitle =
'A custom menu built from scratch, but using the default buttons.';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text: 'Show the menu to see a custom menu with the default buttons.',
);
static const String url = '$kCodeUrl/custom_menu_page.dart';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(CustomMenuPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 300.0,
child: TextField(
controller: _controller,
maxLines: 4,
minLines: 2,
contextMenuBuilder: (context, editableTextState) {
return _MyContextMenu(
anchor: editableTextState.contextMenuAnchors.primaryAnchor,
children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
context,
editableTextState.contextMenuButtonItems,
).toList(),
);
},
),
),
),
);
}
}
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.0,
height: 200.0,
color: Colors.amberAccent,
child: Column(
children: children,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class DefaultValuesPage extends StatelessWidget {
DefaultValuesPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'default-values';
static const String title = 'Default API Values Example';
static const String subtitle =
'Shows what happens when you pass various things into contextMenuBuilder.';
final PlatformCallback onChangedPlatform;
final TextEditingController _controllerNone = TextEditingController(
text: "When contextMenuBuilder isn't given anything at all.",
);
final TextEditingController _controllerNull = TextEditingController(
text: "When contextMenuBuilder is explicitly given null.",
);
final TextEditingController _controllerCustom = TextEditingController(
text: "When something custom is passed to contextMenuBuilder.",
);
static const String url = '$kCodeUrl/default_values_page.dart';
DialogRoute _showDialog(BuildContext context, String message) {
return DialogRoute<void>(
context: context,
builder: (context) => AlertDialog(title: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(DefaultValuesPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 400.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'This example simply shows what happens when contextMenuBuilder is given null, a custom value, or omitted altogether.',
),
const SizedBox(
height: 40.0,
),
TextField(
maxLines: 2,
minLines: 2,
controller: _controllerNone,
),
TextField(
maxLines: 2,
minLines: 2,
controller: _controllerNull,
contextMenuBuilder: null,
),
TextField(
maxLines: 2,
minLines: 2,
controller: _controllerCustom,
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
label: 'Custom button',
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).push(_showDialog(
context, 'You clicked the custom button.'));
},
),
...editableTextState.contextMenuButtonItems,
],
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'is_valid_email.dart';
import 'platform_selector.dart';
class EmailButtonPage extends StatelessWidget {
EmailButtonPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'email-button';
static const String title = 'Email Button';
static const String subtitle = 'A selection-aware email button';
static const String url = '$kCodeUrl/email_button_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text: 'Select the email address and open the menu: me@example.com',
);
DialogRoute _showDialog(BuildContext context) {
return DialogRoute<void>(
context: context,
builder: (context) =>
const AlertDialog(title: Text('You clicked send email!')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(EmailButtonPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 300.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 20.0),
const Text(
'This example shows how to add a special button to the context menu depending on the current selection.',
),
const SizedBox(height: 40.0),
TextField(
maxLines: 2,
controller: _controller,
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,
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class FieldTypesPage extends StatelessWidget {
FieldTypesPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'field-types';
static const String title = 'The Context Menu in Different Field Types';
static const String subtitle =
'How contextual menus work in TextField, CupertinoTextField, and EditableText';
static const String url = '$kCodeUrl/field_types_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text:
"Material text field shows the menu for any platform by default. You'll see the correct menu for your platform here.",
);
final TextEditingController _cupertinoController = TextEditingController(
text:
"CupertinoTextField can't show Material menus by default. On non-Apple platforms, you'll still see a Cupertino menu here.",
);
final TextEditingController _cupertinoControllerFixed = TextEditingController(
text:
"But CupertinoTextField can be made to adaptively show any menu. You'll see the correct menu for your platform here.",
);
final TextEditingController _cupertinoControllerForced =
TextEditingController(
text: 'Or forced to always show a specific menu (Material desktop menu).',
);
final TextEditingController _editableController = TextEditingController(
text:
"EditableText doesn't show any selection menu by itself, even when contextMenuBuilder is passed.",
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(FieldTypesPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 400.0,
child: ListView(
children: <Widget>[
const SizedBox(height: 20.0),
TextField(
maxLines: 3,
controller: _controller,
),
const SizedBox(height: 60.0),
CupertinoTextField(
maxLines: 3,
controller: _cupertinoController,
),
const SizedBox(height: 20.0),
CupertinoTextField(
maxLines: 3,
controller: _cupertinoControllerFixed,
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
},
),
const SizedBox(height: 20.0),
CupertinoTextField(
maxLines: 3,
controller: _cupertinoControllerForced,
contextMenuBuilder: (context, editableTextState) {
return DesktopTextSelectionToolbar(
anchor: editableTextState.contextMenuAnchors.primaryAnchor,
children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
context,
editableTextState.contextMenuButtonItems,
).toList(),
);
},
),
const SizedBox(height: 60.0),
Container(
color: Colors.white,
child: EditableText(
maxLines: 3,
controller: _editableController,
focusNode: FocusNode(),
style: Typography.material2021().black.displayMedium!,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.white,
// contextMenuBuilder doesn't do anything here!
// EditableText has no built-in gesture detection for
// selection. A wrapper would have to implement
// TextSelectionGestureDetectorBuilderDelegate, etc.
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
},
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'context_menu_region.dart';
import 'is_valid_email.dart';
import 'platform_selector.dart';
class FullPage extends StatelessWidget {
FullPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'full';
static const String title = 'Combined Example';
static const String subtitle =
'Combining several different types of custom menus.';
static const String url = '$kCodeUrl/full_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text: 'Custom menus everywhere. me@example.com',
);
DialogRoute _showDialog(BuildContext context, String message) {
return DialogRoute<void>(
context: context,
builder: (context) => AlertDialog(title: Text(message)),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(FullPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: ContextMenuRegion(
contextMenuBuilder: (context, offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).pop();
},
label: 'Back',
),
],
);
},
child: Center(
child: SizedBox(
width: 400.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'This example simply shows how many of the previous examples can be combined in a single app.',
),
const SizedBox(
height: 60.0,
),
ContextMenuRegion(
contextMenuBuilder: (context, offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).push(_showDialog(
context, 'Image saved! (not really though)'));
},
label: 'Save',
),
],
);
},
child: const SizedBox(
width: 200.0,
height: 200.0,
child: FlutterLogo(),
),
),
Container(height: 20.0),
TextField(
controller: _controller,
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, 'You clicked send email'));
},
));
}
return AdaptiveTextSelectionToolbar(
anchors: editableTextState.contextMenuAnchors,
// Build the default buttons, but make them look crazy.
// Note that in a real project you may want to build
// different buttons depending on the platform.
children: buttonItems.map((buttonItem) {
return CupertinoButton(
borderRadius: null,
color: const Color(0xffaaaa00),
disabledColor: const Color(0xffaaaaff),
onPressed: buttonItem.onPressed,
padding: const EdgeInsets.all(10.0),
pressedOpacity: 0.7,
child: SizedBox(
width: 200.0,
child: Text(
CupertinoTextSelectionToolbarButton
.getButtonLabel(context, buttonItem),
),
),
);
}).toList(),
);
},
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class GlobalSelectionPage extends StatelessWidget {
GlobalSelectionPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'global-selection';
static const String title = 'Global Selection Example';
static const String subtitle = 'Context menus in and out of global selection';
static const String url = '$kCodeUrl/global_selection_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text: 'TextFields still show their specific context menu.',
);
@override
Widget build(BuildContext context) {
return SelectionArea(
contextMenuBuilder: (context, selectableRegionState) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: selectableRegionState.contextMenuAnchors,
buttonItems: <ContextMenuButtonItem>[
...selectableRegionState.contextMenuButtonItems,
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).pop();
},
label: 'Back',
),
],
);
},
child: Scaffold(
appBar: AppBar(
title: const Text(GlobalSelectionPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 400.0,
child: ListView(
children: <Widget>[
const SizedBox(height: 20.0),
const Text(
'This entire page is wrapped in a SelectionArea with a custom context menu. Clicking on any of the plain text, including the AppBar title, will show the custom menu.',
),
const SizedBox(height: 40.0),
TextField(controller: _controller),
const SizedBox(height: 40.0),
const SelectableText(
'SelectableText also shows its own separate context menu.'),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'context_menu_region.dart';
import 'platform_selector.dart';
class ImagePage extends StatelessWidget {
const ImagePage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'image';
static const String title = 'ContextMenu on an Image';
static const String subtitle =
'A ContextMenu the displays on an Image widget';
static const String url = '$kCodeUrl/image_page.dart';
final PlatformCallback onChangedPlatform;
DialogRoute _showDialog(BuildContext context) {
return DialogRoute<void>(
context: context,
builder: (context) =>
const AlertDialog(title: Text('Image saved! (not really though)')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(ImagePage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ContextMenuRegion(
contextMenuBuilder: (context, offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
Navigator.of(context).push(_showDialog(context));
},
label: 'Save',
),
],
);
},
child: const SizedBox(
width: 200.0,
height: 200.0,
child: FlutterLogo(),
),
),
Container(height: 20.0),
const Text(
'Right click or long press on the image to see a special menu.',
),
],
),
);
}
}

View File

@@ -0,0 +1,10 @@
/// Returns true if the given String is a valid email address.
bool isValidEmail(String text) {
return RegExp(
r'(?<name>[a-zA-Z0-9]+)'
r'@'
r'(?<domain>[a-zA-Z0-9]+)'
r'\.'
r'(?<topLevelDomain>[a-zA-Z0-9]+)',
).hasMatch(text);
}

215
context_menus/lib/main.dart Normal file
View File

@@ -0,0 +1,215 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'anywhere_page.dart';
import 'cascading_menu_page.dart';
import 'custom_buttons_page.dart';
import 'custom_menu_page.dart';
import 'default_values_page.dart';
import 'email_button_page.dart';
import 'field_types_page.dart';
import 'full_page.dart';
import 'global_selection_page.dart';
import 'image_page.dart';
import 'modified_action_page.dart';
import 'platform_selector.dart';
import 'reordered_buttons_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({
super.key,
});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
void onChangedPlatform(TargetPlatform platform) {
setState(() {
debugDefaultTargetPlatformOverride = platform;
});
}
@override
void initState() {
super.initState();
// On web, disable the browser's context menu everywhere so that the custom
// Flutter-rendered context menu shows.
if (kIsWeb) {
BrowserContextMenu.disableContextMenu();
}
}
@override
void dispose() {
if (kIsWeb) {
BrowserContextMenu.enableContextMenu();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Context Menu Examples',
theme: ThemeData(
primarySwatch: Colors.blue,
platform: defaultTargetPlatform,
useMaterial3: true,
),
initialRoute: '/',
routes: <String, Widget Function(BuildContext)>{
'/': (context) => MyHomePage(onChangedPlatform: onChangedPlatform),
AnywherePage.route: (context) =>
AnywherePage(onChangedPlatform: onChangedPlatform),
CustomButtonsPage.route: (context) =>
CustomButtonsPage(onChangedPlatform: onChangedPlatform),
CustomMenuPage.route: (context) =>
CustomMenuPage(onChangedPlatform: onChangedPlatform),
ReorderedButtonsPage.route: (context) =>
ReorderedButtonsPage(onChangedPlatform: onChangedPlatform),
EmailButtonPage.route: (context) =>
EmailButtonPage(onChangedPlatform: onChangedPlatform),
ImagePage.route: (context) =>
ImagePage(onChangedPlatform: onChangedPlatform),
FieldTypesPage.route: (context) =>
FieldTypesPage(onChangedPlatform: onChangedPlatform),
FullPage.route: (context) =>
FullPage(onChangedPlatform: onChangedPlatform),
ModifiedActionPage.route: (context) =>
ModifiedActionPage(onChangedPlatform: onChangedPlatform),
GlobalSelectionPage.route: (context) =>
GlobalSelectionPage(onChangedPlatform: onChangedPlatform),
DefaultValuesPage.route: (context) =>
DefaultValuesPage(onChangedPlatform: onChangedPlatform),
CascadingMenuPage.route: (context) =>
CascadingMenuPage(onChangedPlatform: onChangedPlatform),
},
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage({
super.key,
required this.onChangedPlatform,
});
final PlatformCallback onChangedPlatform;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Context Menu Demos'),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
],
),
body: ListView(
children: const <Widget>[
_MyListItem(
route: AnywherePage.route,
title: AnywherePage.title,
subtitle: AnywherePage.subtitle,
),
_MyListItem(
route: GlobalSelectionPage.route,
title: GlobalSelectionPage.title,
subtitle: GlobalSelectionPage.subtitle,
),
_MyListItem(
route: ImagePage.route,
title: ImagePage.title,
subtitle: ImagePage.subtitle,
),
_MyListItem(
route: CustomButtonsPage.route,
title: CustomButtonsPage.title,
subtitle: CustomButtonsPage.subtitle,
),
_MyListItem(
route: CustomMenuPage.route,
title: CustomMenuPage.title,
subtitle: CustomMenuPage.subtitle,
),
_MyListItem(
route: EmailButtonPage.route,
title: EmailButtonPage.title,
subtitle: EmailButtonPage.subtitle,
),
_MyListItem(
route: ReorderedButtonsPage.route,
title: ReorderedButtonsPage.title,
subtitle: ReorderedButtonsPage.subtitle,
),
_MyListItem(
route: ModifiedActionPage.route,
title: ModifiedActionPage.title,
subtitle: ModifiedActionPage.subtitle,
),
_MyListItem(
route: FieldTypesPage.route,
title: FieldTypesPage.title,
subtitle: FieldTypesPage.subtitle,
),
_MyListItem(
route: DefaultValuesPage.route,
title: DefaultValuesPage.title,
subtitle: DefaultValuesPage.subtitle,
),
_MyListItem(
route: CascadingMenuPage.route,
title: CascadingMenuPage.title,
subtitle: CascadingMenuPage.subtitle,
),
_MyListItem(
route: FullPage.route,
title: FullPage.title,
subtitle: FullPage.subtitle,
),
],
),
);
}
}
class _MyListItem extends StatelessWidget {
const _MyListItem({
required this.route,
required this.subtitle,
required this.title,
});
final String route;
final String subtitle;
final String title;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.of(context).pushNamed(route);
},
child: Card(
margin: const EdgeInsets.all(12.0),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: ListTile(
title: Text(title),
subtitle: Text(subtitle),
),
),
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class ModifiedActionPage extends StatelessWidget {
ModifiedActionPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'modified-action';
static const String title = 'Modified Action';
static const String subtitle =
'The copy button copies but also shows a menu.';
static const String url = '$kCodeUrl/modified_action_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controller = TextEditingController(
text: 'Try using the copy button.',
);
DialogRoute _showDialog(BuildContext context) {
return DialogRoute<void>(
context: context,
builder: (context) => const AlertDialog(
title: Text('Copied, but also showed this dialog.')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(ModifiedActionPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 300.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'This example shows adding to the behavior of a default button.',
),
const SizedBox(
height: 30.0,
),
TextField(
controller: _controller,
contextMenuBuilder: (context, editableTextState) {
final List<ContextMenuButtonItem> buttonItems =
editableTextState.contextMenuButtonItems;
// Modify the copy buttonItem to show a dialog after copying.
final int copyButtonIndex = buttonItems.indexWhere(
(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(_showDialog(context));
},
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
},
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
typedef PlatformCallback = void Function(TargetPlatform platform);
class PlatformSelector extends StatefulWidget {
const PlatformSelector({
super.key,
required this.onChangedPlatform,
});
final PlatformCallback onChangedPlatform;
@override
State<PlatformSelector> createState() => _PlatformSelectorState();
}
class _PlatformSelectorState extends State<PlatformSelector> {
static const int targetPlatformStringLength = 15; // 'TargetPlatform.'.length
static String _platformToString(TargetPlatform platform) {
return platform.toString().substring(targetPlatformStringLength);
}
final TargetPlatform originaPlatform = defaultTargetPlatform;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 170.0,
child: DropdownButton<TargetPlatform>(
value: defaultTargetPlatform,
icon: const Icon(Icons.arrow_downward),
elevation: 16,
onChanged: (value) {
if (value == null) {
return;
}
widget.onChangedPlatform(value);
setState(() {});
},
items: TargetPlatform.values.map((platform) {
return DropdownMenuItem<TargetPlatform>(
value: platform,
child: Row(
children: <Widget>[
if (platform == originaPlatform)
const Icon(
Icons.home,
color: Color(0xff616161),
),
Text(_platformToString(platform)),
],
),
);
}).toList(),
),
);
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'constants.dart';
import 'platform_selector.dart';
class ReorderedButtonsPage extends StatelessWidget {
ReorderedButtonsPage({
super.key,
required this.onChangedPlatform,
});
static const String route = 'reordered-buttons';
static const String title = 'Reordered Buttons';
static const String subtitle = 'The usual buttons, but in a different order.';
static const String url = '$kCodeUrl/reordered_buttons_page.dart';
final PlatformCallback onChangedPlatform;
final TextEditingController _controllerNormal = TextEditingController(
text: 'This button has the default buttons for reference.',
);
final TextEditingController _controllerReordered = TextEditingController(
text: 'This field has reordered buttons.',
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(ReorderedButtonsPage.title),
actions: <Widget>[
PlatformSelector(
onChangedPlatform: onChangedPlatform,
),
IconButton(
icon: const Icon(Icons.code),
onPressed: () async {
if (!await launchUrl(Uri.parse(url))) {
throw 'Could not launch $url';
}
},
),
],
),
body: Center(
child: SizedBox(
width: 300.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
TextField(
maxLines: 2,
controller: _controllerNormal,
),
const SizedBox(height: 20.0),
TextField(
controller: _controllerReordered,
maxLines: 2,
contextMenuBuilder: (context, editableTextState) {
// Reorder the button datas by type.
final HashMap<ContextMenuButtonType, ContextMenuButtonItem>
buttonItemsMap =
HashMap<ContextMenuButtonType, ContextMenuButtonItem>();
for (ContextMenuButtonItem buttonItem
in editableTextState.contextMenuButtonItems) {
buttonItemsMap[buttonItem.type] = buttonItem;
}
final List<ContextMenuButtonItem> reorderedButtonItems =
<ContextMenuButtonItem>[
if (buttonItemsMap
.containsKey(ContextMenuButtonType.selectAll))
buttonItemsMap[ContextMenuButtonType.selectAll]!,
if (buttonItemsMap.containsKey(ContextMenuButtonType.paste))
buttonItemsMap[ContextMenuButtonType.paste]!,
if (buttonItemsMap.containsKey(ContextMenuButtonType.copy))
buttonItemsMap[ContextMenuButtonType.copy]!,
if (buttonItemsMap.containsKey(ContextMenuButtonType.cut))
buttonItemsMap[ContextMenuButtonType.cut]!,
];
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: reorderedButtonItems,
);
},
),
],
),
),
),
);
}
}