Skip to content

Commit

Permalink
Context Menus (flutter#107193)
Browse files Browse the repository at this point in the history
* Can show context menus anywhere in the app, not just on text.
* Unifies all desktop/mobile context menus to go through one class (ContextMenuController).
* All context menus are now just plain widgets that can be fully customized.
* Existing default context menus can be customized and reused.
  • Loading branch information
justinmc committed Oct 28, 2022
1 parent ef1236e commit 0b451b6
Show file tree
Hide file tree
Showing 58 changed files with 5,063 additions and 915 deletions.
159 changes: 159 additions & 0 deletions examples/api/lib/material/context_menu/context_menu_controller.0.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// This sample demonstrates allowing a context menu to be shown in a widget
// subtree in response to user gestures.

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

/// A builder that includes an Offset to draw the context menu at.
typedef ContextMenuBuilder = Widget Function(BuildContext context, Offset offset);

class MyApp extends StatelessWidget {
const MyApp({super.key});

void _showDialog (BuildContext context) {
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) =>
const AlertDialog(title: Text('You clicked print!')),
),
);
}

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Context menu outside of text'),
),
body: _ContextMenuRegion(
contextMenuBuilder: (BuildContext context, Offset offset) {
// The custom context menu will look like the default context menu
// on the current platform with a single 'Print' button.
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
_showDialog(context);
},
label: 'Print',
),
],
);
},
// In this case this wraps a big open space in a GestureDetector in
// order to show the context menu, but it could also wrap a single
// wiget like an Image to give it a context menu.
child: ListView(
children: <Widget>[
Container(height: 20.0),
const Text('Right click or long press anywhere (not just on this text!) to show the custom menu.'),
],
),
),
),
);
}
}

/// 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({
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: (BuildContext 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,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// This example demonstrates showing the default buttons, but customizing their
// appearance.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
MyApp({super.key});

final TextEditingController _controller = TextEditingController(
text: 'Right click or long press to see the menu with custom buttons.',
);

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom button appearance'),
),
body: Center(
child: Column(
children: <Widget>[
const SizedBox(height: 20.0),
TextField(
controller: _controller,
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar(
anchors: editableTextState.contextMenuAnchors,
// Build the default buttons, but make them look custom.
// In a real project you may want to build different
// buttons depending on the platform.
children: editableTextState.contextMenuButtonItems.map((ContextMenuButtonItem 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(),
);
},
),
],
),
),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// This example demonstrates showing a custom context menu only when some
// narrowly defined text is selected.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

const String emailAddress = 'me@example.com';
const String text = 'Select the email address and open the menu: $emailAddress';

class MyApp extends StatelessWidget {
MyApp({super.key});

final TextEditingController _controller = TextEditingController(
text: text,
);

void _showDialog (BuildContext context) {
Navigator.of(context).push(
DialogRoute<void>(
context: context,
builder: (BuildContext context) =>
const AlertDialog(title: Text('You clicked send email!')),
),
);
}

@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Custom button for emails'),
),
body: Center(
child: Column(
children: <Widget>[
Container(height: 20.0),
TextField(
controller: _controller,
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
final List<ContextMenuButtonItem> buttonItems =
editableTextState.contextMenuButtonItems;
// Here we add an "Email" button to the default TextField
// context menu for the current platform, but only if an email
// address is currently selected.
final TextEditingValue value = _controller.value;
if (_isValidEmail(value.selection.textInside(value.text))) {
buttonItems.insert(0, ContextMenuButtonItem(
label: 'Send email',
onPressed: () {
ContextMenuController.removeAny();
_showDialog(context);
},
));
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
},
),
],
),
),
),
);
}
}

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

0 comments on commit 0b451b6

Please sign in to comment.