Context Menus (#107193)
* 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.
diff --git a/examples/api/lib/material/context_menu/context_menu_controller.0.dart b/examples/api/lib/material/context_menu/context_menu_controller.0.dart
new file mode 100644
index 0000000..0bf5ae5
--- /dev/null
+++ b/examples/api/lib/material/context_menu/context_menu_controller.0.dart
@@ -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,
+ );
+ }
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart
new file mode 100644
index 0000000..c932abc
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart
@@ -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(),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart
new file mode 100644
index 0000000..4f599d8
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart
@@ -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);
+}
diff --git a/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart
new file mode 100644
index 0000000..cd8c686
--- /dev/null
+++ b/examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart
@@ -0,0 +1,107 @@
+// 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 how to create a custom toolbar that retains the
+// look of the default buttons for the current platform.
+
+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 a custom toolbar.',
+ );
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ home: Scaffold(
+ appBar: AppBar(
+ title: const Text('Custom toolbar, default-looking buttons'),
+ ),
+ body: Center(
+ child: Column(
+ children: <Widget>[
+ const SizedBox(height: 20.0),
+ TextField(
+ controller: _controller,
+ contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
+ return _MyTextSelectionToolbar(
+ anchor: editableTextState.contextMenuAnchors.primaryAnchor,
+ // getAdaptiveButtons creates the default button widgets for
+ // the current platform.
+ children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
+ context,
+ // These buttons just close the menu when clicked.
+ <ContextMenuButtonItem>[
+ ContextMenuButtonItem(
+ label: 'One',
+ onPressed: () => ContextMenuController.removeAny(),
+ ),
+ ContextMenuButtonItem(
+ label: 'Two',
+ onPressed: () => ContextMenuController.removeAny(),
+ ),
+ ContextMenuButtonItem(
+ label: 'Three',
+ onPressed: () => ContextMenuController.removeAny(),
+ ),
+ ContextMenuButtonItem(
+ label: 'Four',
+ onPressed: () => ContextMenuController.removeAny(),
+ ),
+ ContextMenuButtonItem(
+ label: 'Five',
+ onPressed: () => ContextMenuController.removeAny(),
+ ),
+ ],
+ ).toList(),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// A simple, yet totally custom, text selection toolbar.
+///
+/// Displays its children in a scrollable grid.
+class _MyTextSelectionToolbar extends StatelessWidget {
+ const _MyTextSelectionToolbar({
+ 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.cyanAccent.withOpacity(0.5),
+ child: GridView.count(
+ padding: const EdgeInsets.all(12.0),
+ crossAxisCount: 2,
+ children: children,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart b/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart
new file mode 100644
index 0000000..9f40adc
--- /dev/null
+++ b/examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart
@@ -0,0 +1,68 @@
+// 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 a custom context menu in non-editable text using
+// SelectionArea.
+
+import 'package:flutter/material.dart';
+
+void main() => runApp(const MyApp());
+
+const String text = 'I am some text inside of SelectionArea. Right click or long press me to show the customized context menu.';
+
+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 anywhere'),
+ ),
+ body: Center(
+ child: SizedBox(
+ width: 200.0,
+ child: SelectionArea(
+ contextMenuBuilder: (
+ BuildContext context,
+ SelectableRegionState selectableRegionState,
+ ) {
+ return AdaptiveTextSelectionToolbar.buttonItems(
+ anchors: selectableRegionState.contextMenuAnchors,
+ buttonItems: <ContextMenuButtonItem>[
+ ...selectableRegionState.contextMenuButtonItems,
+ ContextMenuButtonItem(
+ onPressed: () {
+ ContextMenuController.removeAny();
+ _showDialog(context);
+ },
+ label: 'Print',
+ ),
+ ],
+ );
+ },
+ child: ListView(
+ children: const <Widget>[
+ SizedBox(height: 20.0),
+ Text(text),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/examples/api/test/material/context_menu/context_menu_controller.0_test.dart b/examples/api/test/material/context_menu/context_menu_controller.0_test.dart
new file mode 100644
index 0000000..48aee51
--- /dev/null
+++ b/examples/api/test/material/context_menu/context_menu_controller.0_test.dart
@@ -0,0 +1,44 @@
+// 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.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/context_menu_controller.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('showing and hiding the custom context menu in the whole app', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const example.MyApp(),
+ );
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Right clicking the middle of the app shows the custom context menu.
+ final Offset center = tester.getCenter(find.byType(Scaffold));
+ final TestGesture gesture = await tester.startGesture(
+ center,
+ kind: PointerDeviceKind.mouse,
+ buttons: kSecondaryMouseButton,
+ );
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.text('Print'), findsOneWidget);
+
+ // Tap to dismiss.
+ await tester.tapAt(center);
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Long pressing also shows the custom context menu.
+ await tester.longPressAt(center);
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.text('Print'), findsOneWidget);
+ });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart
new file mode 100644
index 0000000..38c28ee
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart
@@ -0,0 +1,36 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('showing and hiding the context menu in TextField with custom buttons', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ example.MyApp(),
+ );
+
+ await tester.tap(find.byType(EditableText));
+ await tester.pump();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Long pressing the field shows the default context menu but with custom
+ // buttons.
+ await tester.longPress(find.byType(EditableText));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.byType(CupertinoButton), findsAtLeastNWidgets(1));
+
+ // Tap to dismiss.
+ await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoButton), findsNothing);
+ });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart
new file mode 100644
index 0000000..bee046b
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart
@@ -0,0 +1,69 @@
+// 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.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.1.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('showing and hiding the custom context menu in TextField with a specific selection', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ example.MyApp(),
+ );
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Right clicking the Text in the TextField shows the custom context menu,
+ // but no email button since no email address is selected.
+ TestGesture gesture = await tester.startGesture(
+ tester.getTopLeft(find.text(example.text)),
+ kind: PointerDeviceKind.mouse,
+ buttons: kSecondaryMouseButton,
+ );
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.text('Send email'), findsNothing);
+
+ // Tap to dismiss.
+ await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Select the email address.
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ state.updateEditingValue(state.textEditingValue.copyWith(
+ selection: TextSelection(
+ baseOffset: example.text.indexOf(example.emailAddress),
+ extentOffset: example.text.length,
+ ),
+ ));
+ await tester.pump();
+
+ // Right clicking the Text in the TextField shows the custom context menu
+ // with the email button.
+ gesture = await tester.startGesture(
+ tester.getCenter(find.text(example.text)),
+ kind: PointerDeviceKind.mouse,
+ buttons: kSecondaryMouseButton,
+ );
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.text('Send email'), findsOneWidget);
+
+ // Tap to dismiss.
+ await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ });
+}
diff --git a/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart b/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart
new file mode 100644
index 0000000..90f2a23
--- /dev/null
+++ b/examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart
@@ -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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/editable_text_toolbar_builder.2.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('showing and hiding the context menu in TextField with a custom toolbar', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ example.MyApp(),
+ );
+
+ await tester.tap(find.byType(EditableText));
+ await tester.pump();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Long pressing the field shows the custom context menu.
+ await tester.longPress(find.byType(EditableText));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // The buttons use the default widgets but with custom labels.
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ expect(find.byType(TextSelectionToolbarTextButton), findsAtLeastNWidgets(1));
+ break;
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+ break;
+ case TargetPlatform.macOS:
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsAtLeastNWidgets(1));
+ break;
+ }
+ expect(find.text('Copy'), findsNothing);
+ expect(find.text('Cut'), findsNothing);
+ expect(find.text('Paste'), findsNothing);
+ expect(find.text('Select all'), findsNothing);
+
+ // Tap to dismiss.
+ await tester.tapAt(tester.getTopLeft(find.byType(EditableText)));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+ expect(find.text('Copy'), findsNothing);
+ expect(find.text('Cut'), findsNothing);
+ expect(find.text('Paste'), findsNothing);
+ expect(find.text('Select all'), findsNothing);
+ });
+}
diff --git a/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart
new file mode 100644
index 0000000..090eba4
--- /dev/null
+++ b/examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart
@@ -0,0 +1,41 @@
+// 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.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_api_samples/material/context_menu/selectable_region_toolbar_builder.0.dart' as example;
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('showing and hiding the custom context menu on SelectionArea', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ const example.MyApp(),
+ );
+
+ // Allow the selection overlay geometry to be created.
+ await tester.pump();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Right clicking the Text in the SelectionArea shows the custom context
+ // menu.
+ final TestGesture gesture = await tester.startGesture(
+ tester.getCenter(find.text(example.text)),
+ kind: PointerDeviceKind.mouse,
+ buttons: kSecondaryMouseButton,
+ );
+ await tester.pump();
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ expect(find.text('Print'), findsOneWidget);
+
+ // Tap to dismiss.
+ await tester.tapAt(tester.getCenter(find.byType(Scaffold)));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ });
+}
diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart
index 72df249..af3de26 100644
--- a/packages/flutter/lib/cupertino.dart
+++ b/packages/flutter/lib/cupertino.dart
@@ -23,6 +23,7 @@
library cupertino;
export 'src/cupertino/activity_indicator.dart';
+export 'src/cupertino/adaptive_text_selection_toolbar.dart';
export 'src/cupertino/app.dart';
export 'src/cupertino/bottom_tab_bar.dart';
export 'src/cupertino/button.dart';
@@ -33,6 +34,8 @@
export 'src/cupertino/date_picker.dart';
export 'src/cupertino/debug.dart';
export 'src/cupertino/desktop_text_selection.dart';
+export 'src/cupertino/desktop_text_selection_toolbar.dart';
+export 'src/cupertino/desktop_text_selection_toolbar_button.dart';
export 'src/cupertino/dialog.dart';
export 'src/cupertino/form_row.dart';
export 'src/cupertino/form_section.dart';
diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart
index 33e77f8..795bb64 100644
--- a/packages/flutter/lib/material.dart
+++ b/packages/flutter/lib/material.dart
@@ -22,6 +22,7 @@
export 'src/material/about.dart';
export 'src/material/action_chip.dart';
+export 'src/material/adaptive_text_selection_toolbar.dart';
export 'src/material/animated_icons.dart';
export 'src/material/app.dart';
export 'src/material/app_bar.dart';
@@ -64,6 +65,8 @@
export 'src/material/date_picker.dart';
export 'src/material/debug.dart';
export 'src/material/desktop_text_selection.dart';
+export 'src/material/desktop_text_selection_toolbar.dart';
+export 'src/material/desktop_text_selection_toolbar_button.dart';
export 'src/material/dialog.dart';
export 'src/material/dialog_theme.dart';
export 'src/material/divider.dart';
diff --git a/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart
new file mode 100644
index 0000000..9850179
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart
@@ -0,0 +1,226 @@
+// 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.
+
+import 'package:flutter/foundation.dart' show defaultTargetPlatform;
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
+import 'text_selection_toolbar.dart';
+import 'text_selection_toolbar_button.dart';
+
+/// The default Cupertino context menu for text selection for the current
+/// platform with the given children.
+///
+/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms}
+/// Builds the mobile Cupertino context menu on all mobile platforms, not just
+/// iOS, and builds the desktop Cupertino context menu on all desktop platforms,
+/// not just MacOS. For a widget that builds the native-looking context menu for
+/// all platforms, see [AdaptiveTextSelectionToolbar].
+/// {@endtemplate}
+///
+/// See also:
+///
+/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget
+/// but for all platforms, not just the Cupertino-styled platforms.
+/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds
+/// the Cupertino button Widgets for the current platform given
+/// [ContextMenuButtonItem]s.
+class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget {
+ /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+ /// given [children].
+ ///
+ /// See also:
+ ///
+ /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+ /// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list
+ /// of [ContextMenuButtonItem]s instead of [children] widgets.
+ /// {@endtemplate}
+ /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+ /// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the
+ /// default Cupertino children for an editable field.
+ /// {@endtemplate}
+ /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+ /// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the
+ /// default Cupertino children for an [EditableText].
+ /// {@endtemplate}
+ /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+ /// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the
+ /// Cupertino children for content that is selectable but not editable.
+ /// {@endtemplate}
+ const CupertinoAdaptiveTextSelectionToolbar({
+ super.key,
+ required this.children,
+ required this.anchors,
+ }) : buttonItems = null;
+
+ /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose
+ /// children will be built from the given [buttonItems].
+ ///
+ /// See also:
+ ///
+ /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+ /// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children
+ /// directly as a list of widgets.
+ /// {@endtemplate}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+ const CupertinoAdaptiveTextSelectionToolbar.buttonItems({
+ super.key,
+ required this.buttonItems,
+ required this.anchors,
+ }) : children = null;
+
+ /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+ /// default children for an editable field.
+ ///
+ /// If a callback is null, then its corresponding button will not be built.
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but
+ /// includes Material and Cupertino toolbars.
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+ CupertinoAdaptiveTextSelectionToolbar.editable({
+ super.key,
+ required ClipboardStatus clipboardStatus,
+ required VoidCallback? onCopy,
+ required VoidCallback? onCut,
+ required VoidCallback? onPaste,
+ required VoidCallback? onSelectAll,
+ required this.anchors,
+ }) : children = null,
+ buttonItems = EditableText.getEditableButtonItems(
+ clipboardStatus: clipboardStatus,
+ onCopy: onCopy,
+ onCut: onCut,
+ onPaste: onPaste,
+ onSelectAll: onSelectAll,
+ );
+
+ /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+ /// default children for an [EditableText].
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this
+ /// but includes Material and Cupertino toolbars.
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable}
+ CupertinoAdaptiveTextSelectionToolbar.editableText({
+ super.key,
+ required EditableTextState editableTextState,
+ }) : children = null,
+ buttonItems = editableTextState.contextMenuButtonItems,
+ anchors = editableTextState.contextMenuAnchors;
+
+ /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the
+ /// default children for selectable, but not editable, content.
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but
+ /// includes Material and Cupertino toolbars.
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText}
+ CupertinoAdaptiveTextSelectionToolbar.selectable({
+ super.key,
+ required VoidCallback onCopy,
+ required VoidCallback onSelectAll,
+ required SelectionGeometry selectionGeometry,
+ required this.anchors,
+ }) : children = null,
+ buttonItems = SelectableRegion.getSelectableButtonItems(
+ selectionGeometry: selectionGeometry,
+ onCopy: onCopy,
+ onSelectAll: onSelectAll,
+ );
+
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors}
+ final TextSelectionToolbarAnchors anchors;
+
+ /// The children of the toolbar, typically buttons.
+ final List<Widget>? children;
+
+ /// The [ContextMenuButtonItem]s that will be turned into the correct button
+ /// widgets for the current platform.
+ final List<ContextMenuButtonItem>? buttonItems;
+
+ /// Returns a List of Widgets generated by turning [buttonItems] into the
+ /// the default context menu buttons for Cupertino on the current platform.
+ ///
+ /// This is useful when building a text selection toolbar with the default
+ /// button appearance for the given platform, but where the toolbar and/or the
+ /// button actions and labels may be custom.
+ ///
+ /// Does not build Material buttons. On non-Apple platforms, Cupertino buttons
+ /// will still be used, because the Cupertino library does not access the
+ /// Material library. To get the native-looking buttons on every platform, use
+ /// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material
+ /// library.
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material
+ /// equivalent of this class and builds only the Material buttons. It
+ /// includes a live example of using `getAdaptiveButtons`.
+ static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ return buttonItems.map((ContextMenuButtonItem buttonItem) {
+ return CupertinoTextSelectionToolbarButton.buttonItem(
+ buttonItem: buttonItem,
+ );
+ });
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ case TargetPlatform.macOS:
+ return buttonItems.map((ContextMenuButtonItem buttonItem) {
+ return CupertinoDesktopTextSelectionToolbarButton.buttonItem(
+ buttonItem: buttonItem,
+ );
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // If there aren't any buttons to build, build an empty toolbar.
+ if ((children?.isEmpty ?? false) || (buttonItems?.isEmpty ?? false)) {
+ return const SizedBox.shrink();
+ }
+
+ final List<Widget> resultChildren = children
+ ?? getAdaptiveButtons(context, buttonItems!).toList();
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.iOS:
+ case TargetPlatform.fuchsia:
+ return CupertinoTextSelectionToolbar(
+ anchorAbove: anchors.primaryAnchor,
+ anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor,
+ children: resultChildren,
+ );
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ case TargetPlatform.macOS:
+ return CupertinoDesktopTextSelectionToolbar(
+ anchor: anchors.primaryAnchor,
+ children: resultChildren,
+ );
+ }
+ }
+}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
index 9711d66..88b9d9b 100644
--- a/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection.dart
@@ -3,33 +3,18 @@
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show clampDouble;
-import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
-import 'button.dart';
-import 'colors.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
import 'localizations.dart';
-import 'theme.dart';
-// Minimal padding from all edges of the selection toolbar to all edges of the
-// screen.
-const double _kToolbarScreenPadding = 8.0;
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
-// a Macbook Pro.
-const double _kToolbarWidth = 222.0;
-const Radius _kToolbarBorderRadius = Radius.circular(4.0);
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.16 on a
-// Macbook Pro.
-const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
- color: Color(0xFFBBBBBB),
- darkColor: Color(0xFF505152),
-);
-const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
- color: Color(0xffECE8E6),
- darkColor: Color(0xff302928),
-);
+/// MacOS Cupertino styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+class _CupertinoDesktopTextSelectionHandleControls extends CupertinoDesktopTextSelectionControls with TextSelectionHandleControls {
+}
/// Desktop Cupertino styled text selection controls.
///
@@ -42,7 +27,11 @@
return Size.zero;
}
- /// Builder for the Mac-style copy/paste text selection toolbar.
+ /// Builder for the MacOS-style copy/paste text selection toolbar.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
Widget buildToolbar(
BuildContext context,
@@ -80,6 +69,10 @@
return Offset.zero;
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void handleSelectAll(TextSelectionDelegate delegate) {
super.handleSelectAll(delegate);
@@ -87,7 +80,15 @@
}
}
-/// Text selection controls that follows Mac design conventions.
+/// Text selection handle controls that follow MacOS design conventions.
+@Deprecated(
+ 'Use `cupertinoDesktopTextSelectionControls` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls cupertinoDesktopTextSelectionHandleControls =
+ _CupertinoDesktopTextSelectionHandleControls();
+
+/// Text selection controls that follows MacOS design conventions.
final TextSelectionControls cupertinoDesktopTextSelectionControls =
CupertinoDesktopTextSelectionControls();
@@ -145,8 +146,8 @@
@override
void dispose() {
- super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ super.dispose();
}
@override
@@ -180,7 +181,7 @@
items.add(onePhysicalPixelVerticalDivider);
}
- items.add(_CupertinoDesktopTextSelectionToolbarButton.text(
+ items.add(CupertinoDesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
@@ -206,182 +207,9 @@
return const SizedBox.shrink();
}
- return _CupertinoDesktopTextSelectionToolbar(
+ return CupertinoDesktopTextSelectionToolbar(
anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
children: items,
);
}
}
-
-/// A Mac-style text selection toolbar.
-///
-/// Typically displays buttons for text manipulation, e.g. copying and pasting
-/// text.
-///
-/// Tries to position itself as closely as possible to [anchor] while remaining
-/// fully on-screen.
-///
-/// See also:
-///
-/// * [TextSelectionControls.buildToolbar], where this is used by default to
-/// build a Mac-style toolbar.
-/// * [TextSelectionToolbar], which is similar, but builds an Android-style
-/// toolbar.
-class _CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
- /// Creates an instance of CupertinoTextSelectionToolbar.
- const _CupertinoDesktopTextSelectionToolbar({
- required this.anchor,
- required this.children,
- }) : assert(children.length > 0);
-
- /// The point at which the toolbar will attempt to position itself as closely
- /// as possible.
- final Offset anchor;
-
- /// {@macro flutter.material.TextSelectionToolbar.children}
- ///
- /// See also:
- /// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
- /// Mac-style text selection toolbar text button.
- final List<Widget> children;
-
- // Builds a toolbar just like the default Mac toolbar, with the right color
- // background, padding, and rounded corners.
- static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
- return Container(
- width: _kToolbarWidth,
- decoration: BoxDecoration(
- color: _kToolbarBackgroundColor.resolveFrom(context),
- border: Border.all(
- color: _kToolbarBorderColor.resolveFrom(context),
- ),
- borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
- ),
- child: Padding(
- padding: const EdgeInsets.symmetric(
- // This value was measured from a screenshot of TextEdit on MacOS
- // 10.15.7 on a Macbook Pro.
- vertical: 3.0,
- ),
- child: child,
- ),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- assert(debugCheckHasMediaQuery(context));
- final MediaQueryData mediaQuery = MediaQuery.of(context);
-
- final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
- final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
-
- return Padding(
- padding: EdgeInsets.fromLTRB(
- _kToolbarScreenPadding,
- paddingAbove,
- _kToolbarScreenPadding,
- _kToolbarScreenPadding,
- ),
- child: CustomSingleChildLayout(
- delegate: DesktopTextSelectionToolbarLayoutDelegate(
- anchor: anchor - localAdjustment,
- ),
- child: _defaultToolbarBuilder(context, Column(
- mainAxisSize: MainAxisSize.min,
- children: children,
- )),
- ),
- );
- }
-}
-
-// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
-// a Macbook Pro.
-const TextStyle _kToolbarButtonFontStyle = TextStyle(
- inherit: false,
- fontSize: 14.0,
- letterSpacing: -0.15,
- fontWeight: FontWeight.w400,
-);
-
-// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
-// Macbook Pro.
-const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
- 20.0,
- 0.0,
- 20.0,
- 3.0,
-);
-
-/// A button in the style of the Mac context menu buttons.
-class _CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
- /// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
- const _CupertinoDesktopTextSelectionToolbarButton({
- required this.onPressed,
- required this.child,
- });
-
- /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
- /// a [Text] widget styled like the default Mac context menu button.
- _CupertinoDesktopTextSelectionToolbarButton.text({
- required BuildContext context,
- required this.onPressed,
- required String text,
- }) : child = Text(
- text,
- overflow: TextOverflow.ellipsis,
- style: _kToolbarButtonFontStyle.copyWith(
- color: const CupertinoDynamicColor.withBrightness(
- color: CupertinoColors.black,
- darkColor: CupertinoColors.white,
- ).resolveFrom(context),
- ),
- );
-
- /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
- final VoidCallback onPressed;
-
- /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
- final Widget child;
-
- @override
- _CupertinoDesktopTextSelectionToolbarButtonState createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
-}
-
-class _CupertinoDesktopTextSelectionToolbarButtonState extends State<_CupertinoDesktopTextSelectionToolbarButton> {
- bool _isHovered = false;
-
- void _onEnter(PointerEnterEvent event) {
- setState(() {
- _isHovered = true;
- });
- }
-
- void _onExit(PointerExitEvent event) {
- setState(() {
- _isHovered = false;
- });
- }
-
- @override
- Widget build(BuildContext context) {
- return SizedBox(
- width: double.infinity,
- child: MouseRegion(
- onEnter: _onEnter,
- onExit: _onExit,
- child: CupertinoButton(
- alignment: Alignment.centerLeft,
- borderRadius: null,
- color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
- minSize: 0.0,
- onPressed: widget.onPressed,
- padding: _kToolbarButtonPadding,
- pressedOpacity: 0.7,
- child: widget.child,
- ),
- ),
- );
- }
-}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart
new file mode 100644
index 0000000..9900765
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart
@@ -0,0 +1,114 @@
+// 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.
+
+import 'package:flutter/widgets.dart';
+
+import 'colors.dart';
+
+// The minimum padding from all edges of the selection toolbar to all edges of
+// the screen.
+const double _kToolbarScreenPadding = 8.0;
+
+// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
+// a Macbook Pro.
+const double _kToolbarWidth = 222.0;
+const Radius _kToolbarBorderRadius = Radius.circular(4.0);
+const EdgeInsets _kToolbarPadding = EdgeInsets.symmetric(
+ vertical: 3.0,
+);
+
+// These values were measured from a screenshot of TextEdit on macOS 10.16 on a
+// Macbook Pro.
+const CupertinoDynamicColor _kToolbarBorderColor = CupertinoDynamicColor.withBrightness(
+ color: Color(0xFFBBBBBB),
+ darkColor: Color(0xFF505152),
+);
+const CupertinoDynamicColor _kToolbarBackgroundColor = CupertinoDynamicColor.withBrightness(
+ color: Color(0xffECE8E6),
+ darkColor: Color(0xff302928),
+);
+
+/// A macOS-style text selection toolbar.
+///
+/// Typically displays buttons for text manipulation, e.g. copying and pasting
+/// text.
+///
+/// Tries to position itself as closely as possible to [anchor] while remaining
+/// fully inside the viewport.
+///
+/// See also:
+///
+/// * [CupertinoAdaptiveTextSelectionToolbar], where this is used to build the
+/// toolbar for desktop platforms.
+/// * [AdaptiveTextSelectionToolbar], where this is used to build the toolbar on
+/// macOS.
+/// * [DesktopTextSelectionToolbar], which is similar but builds a
+/// Material-style desktop toolbar.
+class CupertinoDesktopTextSelectionToolbar extends StatelessWidget {
+ /// Creates a const instance of CupertinoTextSelectionToolbar.
+ const CupertinoDesktopTextSelectionToolbar({
+ super.key,
+ required this.anchor,
+ required this.children,
+ }) : assert(children.length > 0);
+
+ /// {@macro flutter.material.DesktopTextSelectionToolbar.anchor}
+ final Offset anchor;
+
+ /// {@macro flutter.material.TextSelectionToolbar.children}
+ ///
+ /// See also:
+ /// * [CupertinoDesktopTextSelectionToolbarButton], which builds a default
+ /// macOS-style text selection toolbar text button.
+ final List<Widget> children;
+
+ // Builds a toolbar just like the default Mac toolbar, with the right color
+ // background, padding, and rounded corners.
+ static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
+ return Container(
+ width: _kToolbarWidth,
+ decoration: BoxDecoration(
+ color: _kToolbarBackgroundColor.resolveFrom(context),
+ border: Border.all(
+ color: _kToolbarBorderColor.resolveFrom(context),
+ ),
+ borderRadius: const BorderRadius.all(_kToolbarBorderRadius),
+ ),
+ child: Padding(
+ padding: _kToolbarPadding,
+ child: child,
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ assert(debugCheckHasMediaQuery(context));
+ final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+ final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
+ final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+
+ return Padding(
+ padding: EdgeInsets.fromLTRB(
+ _kToolbarScreenPadding,
+ paddingAbove,
+ _kToolbarScreenPadding,
+ _kToolbarScreenPadding,
+ ),
+ child: CustomSingleChildLayout(
+ delegate: DesktopTextSelectionToolbarLayoutDelegate(
+ anchor: anchor - localAdjustment,
+ ),
+ child: _defaultToolbarBuilder(
+ context,
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ children: children,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart
new file mode 100644
index 0000000..b035f62
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart
@@ -0,0 +1,131 @@
+// 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.
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/widgets.dart';
+
+import 'button.dart';
+import 'colors.dart';
+import 'text_selection_toolbar_button.dart';
+import 'theme.dart';
+
+// These values were measured from a screenshot of TextEdit on MacOS 10.15.7 on
+// a Macbook Pro.
+const TextStyle _kToolbarButtonFontStyle = TextStyle(
+ inherit: false,
+ fontSize: 14.0,
+ letterSpacing: -0.15,
+ fontWeight: FontWeight.w400,
+);
+
+// This value was measured from a screenshot of TextEdit on MacOS 10.15.7 on a
+// Macbook Pro.
+const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
+ 20.0,
+ 0.0,
+ 20.0,
+ 3.0,
+);
+
+/// A button in the style of the Mac context menu buttons.
+class CupertinoDesktopTextSelectionToolbarButton extends StatefulWidget {
+ /// Creates an instance of CupertinoDesktopTextSelectionToolbarButton.
+ ///
+ /// [child] cannot be null.
+ const CupertinoDesktopTextSelectionToolbarButton({
+ super.key,
+ required this.onPressed,
+ required Widget this.child,
+ }) : assert(child != null),
+ buttonItem = null;
+
+ /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] whose child is
+ /// a [Text] widget styled like the default Mac context menu button.
+ CupertinoDesktopTextSelectionToolbarButton.text({
+ super.key,
+ required BuildContext context,
+ required this.onPressed,
+ required String text,
+ }) : buttonItem = null,
+ child = Text(
+ text,
+ overflow: TextOverflow.ellipsis,
+ style: _kToolbarButtonFontStyle.copyWith(
+ color: const CupertinoDynamicColor.withBrightness(
+ color: CupertinoColors.black,
+ darkColor: CupertinoColors.white,
+ ).resolveFrom(context),
+ ),
+ );
+
+ /// Create an instance of [CupertinoDesktopTextSelectionToolbarButton] from
+ /// the given [ContextMenuButtonItem].
+ ///
+ /// [buttonItem] cannot be null.
+ CupertinoDesktopTextSelectionToolbarButton.buttonItem({
+ super.key,
+ required ContextMenuButtonItem this.buttonItem,
+ }) : assert(buttonItem != null),
+ onPressed = buttonItem.onPressed,
+ child = null;
+
+ /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+ final VoidCallback onPressed;
+
+ /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
+ final Widget? child;
+
+ /// {@macro flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+ final ContextMenuButtonItem? buttonItem;
+
+ @override
+ State<CupertinoDesktopTextSelectionToolbarButton> createState() => _CupertinoDesktopTextSelectionToolbarButtonState();
+}
+
+class _CupertinoDesktopTextSelectionToolbarButtonState extends State<CupertinoDesktopTextSelectionToolbarButton> {
+ bool _isHovered = false;
+
+ void _onEnter(PointerEnterEvent event) {
+ setState(() {
+ _isHovered = true;
+ });
+ }
+
+ void _onExit(PointerExitEvent event) {
+ setState(() {
+ _isHovered = false;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final Widget child = widget.child ?? Text(
+ CupertinoTextSelectionToolbarButton.getButtonLabel(context, widget.buttonItem!),
+ overflow: TextOverflow.ellipsis,
+ style: _kToolbarButtonFontStyle.copyWith(
+ color: const CupertinoDynamicColor.withBrightness(
+ color: CupertinoColors.black,
+ darkColor: CupertinoColors.white,
+ ).resolveFrom(context),
+ ),
+ );
+ return SizedBox(
+ width: double.infinity,
+ child: MouseRegion(
+ onEnter: _onEnter,
+ onExit: _onExit,
+ child: CupertinoButton(
+ alignment: Alignment.centerLeft,
+ borderRadius: null,
+ color: _isHovered ? CupertinoTheme.of(context).primaryColor : null,
+ minSize: 0.0,
+ onPressed: widget.onPressed,
+ padding: _kToolbarButtonPadding,
+ pressedOpacity: 0.7,
+ child: child,
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart
index 65806d1..a4d0db0 100644
--- a/packages/flutter/lib/src/cupertino/text_field.dart
+++ b/packages/flutter/lib/src/cupertino/text_field.dart
@@ -10,6 +10,7 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'desktop_text_selection.dart';
import 'icons.dart';
@@ -234,7 +235,11 @@
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
- ToolbarOptions? toolbarOptions,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '•',
@@ -273,6 +278,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@@ -313,31 +319,7 @@
),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
- enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
- toolbarOptions = toolbarOptions ??
- (obscureText
- ? (readOnly
- // No point in even offering "Select All" in a read-only obscured
- // field.
- ? const ToolbarOptions()
- // Writable, but obscured.
- : const ToolbarOptions(
- selectAll: true,
- paste: true,
- ))
- : (readOnly
- // Read-only, not obscured.
- ? const ToolbarOptions(
- selectAll: true,
- copy: true,
- )
- // Writable, not obscured.
- : const ToolbarOptions(
- copy: true,
- cut: true,
- selectAll: true,
- paste: true,
- )));
+ enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// Creates a borderless iOS-style text field.
///
@@ -397,7 +379,11 @@
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
- ToolbarOptions? toolbarOptions,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '•',
@@ -436,6 +422,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@@ -477,31 +464,7 @@
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
- enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
- toolbarOptions = toolbarOptions ??
- (obscureText
- ? (readOnly
- // No point in even offering "Select All" in a read-only obscured
- // field.
- ? const ToolbarOptions()
- // Writable, but obscured.
- : const ToolbarOptions(
- selectAll: true,
- paste: true,
- ))
- : (readOnly
- // Read-only, not obscured.
- ? const ToolbarOptions(
- selectAll: true,
- copy: true,
- )
- // Writable, not obscured.
- : const ToolbarOptions(
- copy: true,
- cut: true,
- selectAll: true,
- paste: true,
- )));
+ enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// Controls the text being edited.
///
@@ -605,7 +568,11 @@
/// If not set, select all and paste will default to be enabled. Copy and cut
/// will be disabled if [obscureText] is true. If [readOnly] is true,
/// paste and cut will be disabled regardless.
- final ToolbarOptions toolbarOptions;
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ final ToolbarOptions? toolbarOptions;
/// {@macro flutter.material.InputDecorator.textAlignVertical}
final TextAlignVertical? textAlignVertical;
@@ -787,6 +754,21 @@
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ ///
+ /// If not provided, will build a default menu based on the platform.
+ ///
+ /// See also:
+ ///
+ /// * [CupertinoAdaptiveTextSelectionToolbar], which is built by default.
+ final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+ static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+ return CupertinoAdaptiveTextSelectionToolbar.editableText(
+ editableTextState: editableTextState,
+ );
+ }
+
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
@@ -1226,12 +1208,12 @@
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
- textSelectionControls ??= cupertinoTextSelectionControls;
+ textSelectionControls ??= cupertinoTextSelectionHandleControls;
break;
case TargetPlatform.macOS:
case TargetPlatform.windows:
- textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+ textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
handleDidGainAccessibilityFocus = () {
// Automatically activate the TextField when it receives accessibility focus.
if (!_effectiveFocusNode.hasFocus && _effectiveFocusNode.canRequestFocus) {
@@ -1380,6 +1362,7 @@
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+ contextMenuBuilder: widget.contextMenuBuilder,
spellCheckConfiguration: spellCheckConfiguration,
),
),
diff --git a/packages/flutter/lib/src/cupertino/text_form_field_row.dart b/packages/flutter/lib/src/cupertino/text_form_field_row.dart
index e46a62d..a77669d 100644
--- a/packages/flutter/lib/src/cupertino/text_form_field_row.dart
+++ b/packages/flutter/lib/src/cupertino/text_form_field_row.dart
@@ -5,6 +5,7 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'form_row.dart';
import 'text_field.dart';
@@ -116,6 +117,10 @@
TextAlignVertical? textAlignVertical,
bool autofocus = false,
bool readOnly = false,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
ToolbarOptions? toolbarOptions,
bool? showCursor,
String obscuringCharacter = '•',
@@ -151,6 +156,7 @@
fontWeight: FontWeight.w400,
color: CupertinoColors.placeholderText,
),
+ EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
}) : assert(initialValue == null || controller == null),
assert(textAlign != null),
assert(autofocus != null),
@@ -234,6 +240,7 @@
autofillHints: autofillHints,
placeholder: placeholder,
placeholderStyle: placeholderStyle,
+ contextMenuBuilder: contextMenuBuilder,
),
);
},
@@ -262,6 +269,12 @@
/// initialize its [TextEditingController.text] with [initialValue].
final TextEditingController? controller;
+ static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+ return CupertinoAdaptiveTextSelectionToolbar.editableText(
+ editableTextState: editableTextState,
+ );
+ }
+
@override
FormFieldState<String> createState() => _CupertinoTextFormFieldRowState();
}
diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart
index 67d004a..512ec71 100644
--- a/packages/flutter/lib/src/cupertino/text_selection.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection.dart
@@ -22,142 +22,6 @@
// screen. Eyeballed value.
const double _kArrowScreenPadding = 26.0;
-// Generates the child that's passed into CupertinoTextSelectionToolbar.
-class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
- const _CupertinoTextSelectionControlsToolbar({
- required this.clipboardStatus,
- required this.endpoints,
- required this.globalEditableRegion,
- required this.handleCopy,
- required this.handleCut,
- required this.handlePaste,
- required this.handleSelectAll,
- required this.selectionMidpoint,
- required this.textLineHeight,
- });
-
- final ClipboardStatusNotifier? clipboardStatus;
- final List<TextSelectionPoint> endpoints;
- final Rect globalEditableRegion;
- final VoidCallback? handleCopy;
- final VoidCallback? handleCut;
- final VoidCallback? handlePaste;
- final VoidCallback? handleSelectAll;
- final Offset selectionMidpoint;
- final double textLineHeight;
-
- @override
- _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
-}
-
-class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
- void _onChangedClipboardStatus() {
- setState(() {
- // Inform the widget that the value of clipboardStatus has changed.
- });
- }
-
- @override
- void initState() {
- super.initState();
- widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
- }
-
- @override
- void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.clipboardStatus != widget.clipboardStatus) {
- oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
- widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
- }
- }
-
- @override
- void dispose() {
- super.dispose();
- widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
- }
-
- @override
- Widget build(BuildContext context) {
- // Don't render the menu until the state of the clipboard is known.
- if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
- return const SizedBox.shrink();
- }
-
- assert(debugCheckHasMediaQuery(context));
- final MediaQueryData mediaQuery = MediaQuery.of(context);
-
- // The toolbar should appear below the TextField when there is not enough
- // space above the TextField to show it, assuming there's always enough
- // space at the bottom in this case.
- final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
- _kArrowScreenPadding + mediaQuery.padding.left,
- mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
- );
-
- final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
- final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
-
- // The y-coordinate has to be calculated instead of directly quoting
- // selectionMidpoint.dy, since the caller
- // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
- // going to be facing up or down.
- final Offset anchorAbove = Offset(
- anchorX,
- anchorTop,
- );
- final Offset anchorBelow = Offset(
- anchorX,
- widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
- );
-
- final List<Widget> items = <Widget>[];
- final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
- final Widget onePhysicalPixelVerticalDivider =
- SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
-
- void addToolbarButton(
- String text,
- VoidCallback onPressed,
- ) {
- if (items.isNotEmpty) {
- items.add(onePhysicalPixelVerticalDivider);
- }
-
- items.add(CupertinoTextSelectionToolbarButton.text(
- onPressed: onPressed,
- text: text,
- ));
- }
-
- if (widget.handleCut != null) {
- addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
- }
- if (widget.handleCopy != null) {
- addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
- }
- if (widget.handlePaste != null
- && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
- addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
- }
- if (widget.handleSelectAll != null) {
- addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
- }
-
- // If there is no option available, build an empty widget.
- if (items.isEmpty) {
- return const SizedBox.shrink();
- }
-
- return CupertinoTextSelectionToolbar(
- anchorAbove: anchorAbove,
- anchorBelow: anchorBelow,
- children: items,
- );
- }
-}
-
/// Draws a single text selection handle with a bar and a ball.
class _TextSelectionHandlePainter extends CustomPainter {
const _TextSelectionHandlePainter(this.color);
@@ -190,6 +54,17 @@
bool shouldRepaint(_TextSelectionHandlePainter oldPainter) => color != oldPainter.color;
}
+/// iOS Cupertino styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+@Deprecated(
+ 'Use `CupertinoTextSelectionControls`. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+class CupertinoTextSelectionHandleControls extends CupertinoTextSelectionControls with TextSelectionHandleControls {
+}
+
/// iOS Cupertino styled text selection controls.
///
/// The [cupertinoTextSelectionControls] global variable has a
@@ -205,6 +80,10 @@
}
/// Builder for iOS-style copy/paste text selection toolbar.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
Widget buildToolbar(
BuildContext context,
@@ -213,7 +92,7 @@
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
- ClipboardStatusNotifier? clipboardStatus,
+ ValueNotifier<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return _CupertinoTextSelectionControlsToolbar(
@@ -305,5 +184,150 @@
}
}
-/// Text selection controls that follows iOS design conventions.
-final TextSelectionControls cupertinoTextSelectionControls = CupertinoTextSelectionControls();
+/// Text selection handle controls that follow iOS design conventions.
+@Deprecated(
+ 'Use `cupertinoTextSelectionControls` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls cupertinoTextSelectionHandleControls =
+ CupertinoTextSelectionHandleControls();
+
+/// Text selection controls that follow iOS design conventions.
+final TextSelectionControls cupertinoTextSelectionControls =
+ CupertinoTextSelectionControls();
+
+// Generates the child that's passed into CupertinoTextSelectionToolbar.
+class _CupertinoTextSelectionControlsToolbar extends StatefulWidget {
+ const _CupertinoTextSelectionControlsToolbar({
+ required this.clipboardStatus,
+ required this.endpoints,
+ required this.globalEditableRegion,
+ required this.handleCopy,
+ required this.handleCut,
+ required this.handlePaste,
+ required this.handleSelectAll,
+ required this.selectionMidpoint,
+ required this.textLineHeight,
+ });
+
+ final ValueNotifier<ClipboardStatus>? clipboardStatus;
+ final List<TextSelectionPoint> endpoints;
+ final Rect globalEditableRegion;
+ final VoidCallback? handleCopy;
+ final VoidCallback? handleCut;
+ final VoidCallback? handlePaste;
+ final VoidCallback? handleSelectAll;
+ final Offset selectionMidpoint;
+ final double textLineHeight;
+
+ @override
+ _CupertinoTextSelectionControlsToolbarState createState() => _CupertinoTextSelectionControlsToolbarState();
+}
+
+class _CupertinoTextSelectionControlsToolbarState extends State<_CupertinoTextSelectionControlsToolbar> {
+ void _onChangedClipboardStatus() {
+ setState(() {
+ // Inform the widget that the value of clipboardStatus has changed.
+ });
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
+ }
+
+ @override
+ void didUpdateWidget(_CupertinoTextSelectionControlsToolbar oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.clipboardStatus != widget.clipboardStatus) {
+ oldWidget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ widget.clipboardStatus?.addListener(_onChangedClipboardStatus);
+ }
+ }
+
+ @override
+ void dispose() {
+ widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // Don't render the menu until the state of the clipboard is known.
+ if (widget.handlePaste != null && widget.clipboardStatus?.value == ClipboardStatus.unknown) {
+ return const SizedBox.shrink();
+ }
+
+ assert(debugCheckHasMediaQuery(context));
+ final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+ // The toolbar should appear below the TextField when there is not enough
+ // space above the TextField to show it, assuming there's always enough
+ // space at the bottom in this case.
+ final double anchorX = clampDouble(widget.selectionMidpoint.dx + widget.globalEditableRegion.left,
+ _kArrowScreenPadding + mediaQuery.padding.left,
+ mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding,
+ );
+
+ final double topAmountInEditableRegion = widget.endpoints.first.point.dy - widget.textLineHeight;
+ final double anchorTop = math.max(topAmountInEditableRegion, 0) + widget.globalEditableRegion.top;
+
+ // The y-coordinate has to be calculated instead of directly quoting
+ // selectionMidpoint.dy, since the caller
+ // (TextSelectionOverlay._buildToolbar) does not know whether the toolbar is
+ // going to be facing up or down.
+ final Offset anchorAbove = Offset(
+ anchorX,
+ anchorTop,
+ );
+ final Offset anchorBelow = Offset(
+ anchorX,
+ widget.endpoints.last.point.dy + widget.globalEditableRegion.top,
+ );
+
+ final List<Widget> items = <Widget>[];
+ final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
+ final Widget onePhysicalPixelVerticalDivider =
+ SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
+
+ void addToolbarButton(
+ String text,
+ VoidCallback onPressed,
+ ) {
+ if (items.isNotEmpty) {
+ items.add(onePhysicalPixelVerticalDivider);
+ }
+
+ items.add(CupertinoTextSelectionToolbarButton.text(
+ onPressed: onPressed,
+ text: text,
+ ));
+ }
+
+ if (widget.handleCut != null) {
+ addToolbarButton(localizations.cutButtonLabel, widget.handleCut!);
+ }
+ if (widget.handleCopy != null) {
+ addToolbarButton(localizations.copyButtonLabel, widget.handleCopy!);
+ }
+ if (widget.handlePaste != null
+ && widget.clipboardStatus?.value == ClipboardStatus.pasteable) {
+ addToolbarButton(localizations.pasteButtonLabel, widget.handlePaste!);
+ }
+ if (widget.handleSelectAll != null) {
+ addToolbarButton(localizations.selectAllButtonLabel, widget.handleSelectAll!);
+ }
+
+ // If there is no option available, build an empty widget.
+ if (items.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ return CupertinoTextSelectionToolbar(
+ anchorAbove: anchorAbove,
+ anchorBelow: anchorBelow,
+ children: items,
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
index 82cece4..a630b3d 100644
--- a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart
@@ -5,6 +5,7 @@
import 'dart:collection';
import 'dart:ui' as ui;
+import 'package:flutter/foundation.dart' show clampDouble;
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@@ -21,6 +22,10 @@
const double _kToolbarScreenPadding = 8.0;
const Size _kToolbarArrowSize = Size(14.0, 7.0);
+// Minimal padding from tip of the selection toolbar arrow to horizontal edges of the
+// screen. Eyeballed value.
+const double _kArrowScreenPadding = 26.0;
+
// Values extracted from https://developer.apple.com/design/resources/.
const Radius _kToolbarBorderRadius = Radius.circular(8);
@@ -45,6 +50,13 @@
Widget child,
);
+class _CupertinoToolbarButtonDivider extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(width: 1.0 / MediaQuery.of(context).devicePixelRatio);
+ }
+}
+
/// An iOS-style text selection toolbar.
///
/// Typically displays buttons for text manipulation, e.g. copying and pasting
@@ -58,8 +70,8 @@
///
/// See also:
///
-/// * [TextSelectionControls.buildToolbar], where this is used by default to
-/// build an iOS-style toolbar.
+/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+/// platform.
/// * [TextSelectionToolbar], which is similar, but builds an Android-style
/// toolbar.
class CupertinoTextSelectionToolbar extends StatelessWidget {
@@ -91,6 +103,19 @@
/// default Cupertino toolbar.
final CupertinoToolbarBuilder toolbarBuilder;
+ // Add the visial vertical line spacer between children buttons.
+ static List<Widget> _addChildrenSpacers(List<Widget> children) {
+ final List<Widget> nextChildren = <Widget>[];
+ for (int i = 0; i < children.length; i++) {
+ final Widget child = children[i];
+ if (i != 0) {
+ nextChildren.add(_CupertinoToolbarButtonDivider());
+ }
+ nextChildren.add(child);
+ }
+ return nextChildren;
+ }
+
// Builds a toolbar just like the default iOS toolbar, with the right color
// background and a rounded cutout with an arrow.
static Widget _defaultToolbarBuilder(BuildContext context, Offset anchor, bool isAbove, Widget child) {
@@ -115,8 +140,19 @@
+ _kToolbarHeight;
final bool fitsAbove = anchorAbove.dy >= toolbarHeightNeeded;
- const Offset contentPaddingAdjustment = Offset(0.0, _kToolbarContentDistance);
- final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+ // The arrow, which points to the anchor, has some margin so it can't get
+ // too close to the horizontal edges of the screen.
+ final double leftMargin = _kArrowScreenPadding + mediaQuery.padding.left;
+ final double rightMargin = mediaQuery.size.width - mediaQuery.padding.right - _kArrowScreenPadding;
+
+ final Offset anchorAboveAdjusted = Offset(
+ clampDouble(anchorAbove.dx, leftMargin, rightMargin),
+ anchorAbove.dy - _kToolbarContentDistance - paddingAbove,
+ );
+ final Offset anchorBelowAdjusted = Offset(
+ clampDouble(anchorBelow.dx, leftMargin, rightMargin),
+ anchorBelow.dy - _kToolbarContentDistance + paddingAbove,
+ );
return Padding(
padding: EdgeInsets.fromLTRB(
@@ -127,15 +163,15 @@
),
child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
- anchorAbove: anchorAbove - localAdjustment - contentPaddingAdjustment,
- anchorBelow: anchorBelow - localAdjustment + contentPaddingAdjustment,
+ anchorAbove: anchorAboveAdjusted,
+ anchorBelow: anchorBelowAdjusted,
fitsAbove: fitsAbove,
),
child: _CupertinoTextSelectionToolbarContent(
- anchor: fitsAbove ? anchorAbove : anchorBelow,
+ anchor: fitsAbove ? anchorAboveAdjusted : anchorBelowAdjusted,
isAbove: fitsAbove,
toolbarBuilder: toolbarBuilder,
- children: children,
+ children: _addChildrenSpacers(children),
),
),
);
diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
index b934b6e..6cc1cc4 100644
--- a/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
+++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar_button.dart
@@ -6,6 +6,8 @@
import 'button.dart';
import 'colors.dart';
+import 'debug.dart';
+import 'localizations.dart';
const TextStyle _kToolbarButtonFontStyle = TextStyle(
inherit: false,
@@ -24,11 +26,14 @@
/// A button in the style of the iOS text selection toolbar buttons.
class CupertinoTextSelectionToolbarButton extends StatelessWidget {
/// Create an instance of [CupertinoTextSelectionToolbarButton].
+ ///
+ /// [child] cannot be null.
const CupertinoTextSelectionToolbarButton({
super.key,
this.onPressed,
- required this.child,
- });
+ required Widget this.child,
+ }) : assert(child != null),
+ buttonItem = null;
/// Create an instance of [CupertinoTextSelectionToolbarButton] whose child is
/// a [Text] widget styled like the default iOS text selection toolbar button.
@@ -36,7 +41,8 @@
super.key,
this.onPressed,
required String text,
- }) : child = Text(
+ }) : buttonItem = null,
+ child = Text(
text,
overflow: TextOverflow.ellipsis,
style: _kToolbarButtonFontStyle.copyWith(
@@ -44,20 +50,67 @@
),
);
+ /// Create an instance of [CupertinoTextSelectionToolbarButton] from the given
+ /// [ContextMenuButtonItem].
+ ///
+ /// [buttonItem] cannot be null.
+ CupertinoTextSelectionToolbarButton.buttonItem({
+ super.key,
+ required ContextMenuButtonItem this.buttonItem,
+ }) : assert(buttonItem != null),
+ child = null,
+ onPressed = buttonItem.onPressed;
+
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.child}
/// The child of this button.
///
/// Usually a [Text] or an [Icon].
/// {@endtemplate}
- final Widget child;
+ final Widget? child;
/// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
/// Called when this button is pressed.
/// {@endtemplate}
final VoidCallback? onPressed;
+ /// {@template flutter.cupertino.CupertinoTextSelectionToolbarButton.onPressed}
+ /// The buttonItem used to generate the button when using
+ /// [CupertinoTextSelectionToolbarButton.buttonItem].
+ /// {@endtemplate}
+ final ContextMenuButtonItem? buttonItem;
+
+ /// Returns the default button label String for the button of the given
+ /// [ContextMenuButtonItem]'s [ContextMenuButtonType].
+ static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
+ if (buttonItem.label != null) {
+ return buttonItem.label!;
+ }
+
+ assert(debugCheckHasCupertinoLocalizations(context));
+ final CupertinoLocalizations localizations = CupertinoLocalizations.of(context);
+ switch (buttonItem.type) {
+ case ContextMenuButtonType.cut:
+ return localizations.cutButtonLabel;
+ case ContextMenuButtonType.copy:
+ return localizations.copyButtonLabel;
+ case ContextMenuButtonType.paste:
+ return localizations.pasteButtonLabel;
+ case ContextMenuButtonType.selectAll:
+ return localizations.selectAllButtonLabel;
+ case ContextMenuButtonType.custom:
+ return '';
+ }
+ }
+
@override
Widget build(BuildContext context) {
+ final Widget child = this.child ?? Text(
+ getButtonLabel(context, buttonItem!),
+ overflow: TextOverflow.ellipsis,
+ style: _kToolbarButtonFontStyle.copyWith(
+ color: onPressed != null ? CupertinoColors.white : CupertinoColors.inactiveGray,
+ ),
+ );
return CupertinoButton(
borderRadius: null,
color: _kToolbarBackgroundColor,
diff --git a/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart
new file mode 100644
index 0000000..d0ec3eb
--- /dev/null
+++ b/packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart
@@ -0,0 +1,319 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/rendering.dart';
+
+import 'debug.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
+import 'material_localizations.dart';
+import 'text_selection_toolbar.dart';
+import 'text_selection_toolbar_text_button.dart';
+import 'theme.dart';
+
+/// The default context menu for text selection for the current platform.
+///
+/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders}
+/// Typically, this widget would be passed to `contextMenuBuilder` in a
+/// supported parent widget, such as:
+///
+/// * [EditableText.contextMenuBuilder]
+/// * [TextField.contextMenuBuilder]
+/// * [CupertinoTextField.contextMenuBuilder]
+/// * [SelectionArea.contextMenuBuilder]
+/// * [SelectableText.contextMenuBuilder]
+/// {@endtemplate}
+///
+/// See also:
+///
+/// * [EditableText.getEditableButtonItems], which returns the default
+/// [ContextMenuButtonItem]s for [EditableText] on the platform.
+/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+/// Widgets for the current platform given [ContextMenuButtonItem]s.
+/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this
+/// widget but only for Cupertino context menus.
+/// * [TextSelectionToolbar], the default toolbar for Android.
+/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms
+/// other than MacOS.
+/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS.
+/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS.
+class AdaptiveTextSelectionToolbar extends StatelessWidget {
+ /// Create an instance of [AdaptiveTextSelectionToolbar] with the
+ /// given [children].
+ ///
+ /// See also:
+ ///
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of
+ /// [ContextMenuButtonItem]s instead of [children] widgets.
+ /// {@endtemplate}
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.editable}
+ /// * [AdaptiveTextSelectionToolbar.editable], which builds the default
+ /// children for an editable field.
+ /// {@endtemplate}
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText}
+ /// * [AdaptiveTextSelectionToolbar.editableText], which builds the default
+ /// children for an [EditableText].
+ /// {@endtemplate}
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable}
+ /// * [AdaptiveTextSelectionToolbar.selectable], which builds the default
+ /// children for content that is selectable but not editable.
+ /// {@endtemplate}
+ const AdaptiveTextSelectionToolbar({
+ super.key,
+ required this.children,
+ required this.anchors,
+ }) : buttonItems = null;
+
+ /// Create an instance of [AdaptiveTextSelectionToolbar] whose children will
+ /// be built from the given [buttonItems].
+ ///
+ /// See also:
+ ///
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.new}
+ /// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as
+ /// a list of widgets.
+ /// {@endtemplate}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+ const AdaptiveTextSelectionToolbar.buttonItems({
+ super.key,
+ required this.buttonItems,
+ required this.anchors,
+ }) : children = null;
+
+ /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+ /// children for an editable field.
+ ///
+ /// If a callback is null, then its corresponding button will not be built.
+ ///
+ /// See also:
+ ///
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+ AdaptiveTextSelectionToolbar.editable({
+ super.key,
+ required ClipboardStatus clipboardStatus,
+ required VoidCallback? onCopy,
+ required VoidCallback? onCut,
+ required VoidCallback? onPaste,
+ required VoidCallback? onSelectAll,
+ required this.anchors,
+ }) : children = null,
+ buttonItems = EditableText.getEditableButtonItems(
+ clipboardStatus: clipboardStatus,
+ onCopy: onCopy,
+ onCut: onCut,
+ onPaste: onPaste,
+ onSelectAll: onSelectAll,
+ );
+
+ /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+ /// children for an [EditableText].
+ ///
+ /// See also:
+ ///
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+ AdaptiveTextSelectionToolbar.editableText({
+ super.key,
+ required EditableTextState editableTextState,
+ }) : children = null,
+ buttonItems = editableTextState.contextMenuButtonItems,
+ anchors = editableTextState.contextMenuAnchors;
+
+ /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+ /// children for selectable, but not editable, content.
+ ///
+ /// See also:
+ ///
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+ AdaptiveTextSelectionToolbar.selectable({
+ super.key,
+ required VoidCallback onCopy,
+ required VoidCallback onSelectAll,
+ required SelectionGeometry selectionGeometry,
+ required this.anchors,
+ }) : children = null,
+ buttonItems = SelectableRegion.getSelectableButtonItems(
+ selectionGeometry: selectionGeometry,
+ onCopy: onCopy,
+ onSelectAll: onSelectAll,
+ );
+
+ /// Create an instance of [AdaptiveTextSelectionToolbar] with the default
+ /// children for a [SelectableRegion].
+ ///
+ /// See also:
+ ///
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText}
+ /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable}
+ AdaptiveTextSelectionToolbar.selectableRegion({
+ super.key,
+ required SelectableRegionState selectableRegionState,
+ }) : children = null,
+ buttonItems = selectableRegionState.contextMenuButtonItems,
+ anchors = selectableRegionState.contextMenuAnchors;
+
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems}
+ /// The [ContextMenuButtonItem]s that will be turned into the correct button
+ /// widgets for the current platform.
+ /// {@endtemplate}
+ final List<ContextMenuButtonItem>? buttonItems;
+
+ /// The children of the toolbar, typically buttons.
+ final List<Widget>? children;
+
+ /// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors}
+ /// The location on which to anchor the menu.
+ /// {@endtemplate}
+ final TextSelectionToolbarAnchors anchors;
+
+ /// Returns the default button label String for the button of the given
+ /// [ContextMenuButtonType] on any platform.
+ static String getButtonLabel(BuildContext context, ContextMenuButtonItem buttonItem) {
+ if (buttonItem.label != null) {
+ return buttonItem.label!;
+ }
+
+ switch (Theme.of(context).platform) {
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ return CupertinoTextSelectionToolbarButton.getButtonLabel(
+ context,
+ buttonItem,
+ );
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ assert(debugCheckHasMaterialLocalizations(context));
+ final MaterialLocalizations localizations = MaterialLocalizations.of(context);
+ switch (buttonItem.type) {
+ case ContextMenuButtonType.cut:
+ return localizations.cutButtonLabel;
+ case ContextMenuButtonType.copy:
+ return localizations.copyButtonLabel;
+ case ContextMenuButtonType.paste:
+ return localizations.pasteButtonLabel;
+ case ContextMenuButtonType.selectAll:
+ return localizations.selectAllButtonLabel;
+ case ContextMenuButtonType.custom:
+ return '';
+ }
+ }
+ }
+
+ /// Returns a List of Widgets generated by turning [buttonItems] into the
+ /// the default context menu buttons for the current platform.
+ ///
+ /// This is useful when building a text selection toolbar with the default
+ /// button appearance for the given platform, but where the toolbar and/or the
+ /// button actions and labels may be custom.
+ ///
+ /// {@tool dartpad}
+ /// This sample demonstrates how to use `getAdaptiveButtons` to generate
+ /// default button widgets in a custom toolbar.
+ ///
+ /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart **
+ /// {@end-tool}
+ ///
+ /// See also:
+ ///
+ /// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the
+ /// Cupertino equivalent of this class and builds only the Cupertino
+ /// buttons.
+ static Iterable<Widget> getAdaptiveButtons(BuildContext context, List<ContextMenuButtonItem> buttonItems) {
+ switch (Theme.of(context).platform) {
+ case TargetPlatform.iOS:
+ return buttonItems.map((ContextMenuButtonItem buttonItem) {
+ return CupertinoTextSelectionToolbarButton.text(
+ onPressed: buttonItem.onPressed,
+ text: getButtonLabel(context, buttonItem),
+ );
+ });
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.android:
+ final List<Widget> buttons = <Widget>[];
+ for (int i = 0; i < buttonItems.length; i++) {
+ final ContextMenuButtonItem buttonItem = buttonItems[i];
+ buttons.add(TextSelectionToolbarTextButton(
+ padding: TextSelectionToolbarTextButton.getPadding(i, buttonItems.length),
+ onPressed: buttonItem.onPressed,
+ child: Text(getButtonLabel(context, buttonItem)),
+ ));
+ }
+ return buttons;
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ return buttonItems.map((ContextMenuButtonItem buttonItem) {
+ return DesktopTextSelectionToolbarButton.text(
+ context: context,
+ onPressed: buttonItem.onPressed,
+ text: getButtonLabel(context, buttonItem),
+ );
+ });
+ case TargetPlatform.macOS:
+ return buttonItems.map((ContextMenuButtonItem buttonItem) {
+ return CupertinoDesktopTextSelectionToolbarButton.text(
+ context: context,
+ onPressed: buttonItem.onPressed,
+ text: getButtonLabel(context, buttonItem),
+ );
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ // If there aren't any buttons to build, build an empty toolbar.
+ if ((children != null && children!.isEmpty)
+ || (buttonItems != null && buttonItems!.isEmpty)) {
+ return const SizedBox.shrink();
+ }
+
+ final List<Widget> resultChildren = children != null
+ ? children!
+ : getAdaptiveButtons(context, buttonItems!).toList();
+
+ switch (Theme.of(context).platform) {
+ case TargetPlatform.iOS:
+ return CupertinoTextSelectionToolbar(
+ anchorAbove: anchors.primaryAnchor,
+ anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
+ children: resultChildren,
+ );
+ case TargetPlatform.android:
+ return TextSelectionToolbar(
+ anchorAbove: anchors.primaryAnchor,
+ anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
+ children: resultChildren,
+ );
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ return DesktopTextSelectionToolbar(
+ anchor: anchors.primaryAnchor,
+ children: resultChildren,
+ );
+ case TargetPlatform.macOS:
+ return CupertinoDesktopTextSelectionToolbar(
+ anchor: anchors.primaryAnchor,
+ children: resultChildren,
+ );
+ }
+ }
+}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection.dart b/packages/flutter/lib/src/material/desktop_text_selection.dart
index fbe6848..b67c371 100644
--- a/packages/flutter/lib/src/material/desktop_text_selection.dart
+++ b/packages/flutter/lib/src/material/desktop_text_selection.dart
@@ -3,20 +3,19 @@
// found in the LICENSE file.
import 'package:flutter/foundation.dart' show clampDouble;
-import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
-import 'colors.dart';
-import 'constants.dart';
import 'debug.dart';
-import 'material.dart';
+import 'desktop_text_selection_toolbar.dart';
+import 'desktop_text_selection_toolbar_button.dart';
import 'material_localizations.dart';
-import 'text_button.dart';
-import 'text_selection_toolbar.dart';
-import 'theme.dart';
-const double _kToolbarScreenPadding = 8.0;
-const double _kToolbarWidth = 222.0;
+/// Desktop Material styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+class _DesktopTextSelectionHandleControls extends DesktopTextSelectionControls with TextSelectionHandleControls {
+}
/// Desktop Material styled text selection controls.
///
@@ -30,6 +29,10 @@
}
/// Builder for the Material-style desktop copy/paste text selection toolbar.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
Widget buildToolbar(
BuildContext context,
@@ -67,6 +70,10 @@
return Offset.zero;
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
bool canSelectAll(TextSelectionDelegate delegate) {
// Allow SelectAll when selection is not collapsed, unless everything has
@@ -77,6 +84,10 @@
!(value.selection.start == 0 && value.selection.end == value.text.length);
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void handleSelectAll(TextSelectionDelegate delegate) {
super.handleSelectAll(delegate);
@@ -84,7 +95,17 @@
}
}
-/// Text selection controls that loosely follows Material design conventions.
+/// Desktop text selection handle controls that loosely follow Material design
+/// conventions.
+@Deprecated(
+ 'Use `desktopTextSelectionControls` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls desktopTextSelectionHandleControls =
+ _DesktopTextSelectionHandleControls();
+
+/// Desktop text selection controls that loosely follow Material design
+/// conventions.
final TextSelectionControls desktopTextSelectionControls =
DesktopTextSelectionControls();
@@ -142,8 +163,8 @@
@override
void dispose() {
- super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ super.dispose();
}
@override
@@ -173,7 +194,7 @@
String text,
VoidCallback onPressed,
) {
- items.add(_DesktopTextSelectionToolbarButton.text(
+ items.add(DesktopTextSelectionToolbarButton.text(
context: context,
onPressed: onPressed,
text: text,
@@ -199,153 +220,9 @@
return const SizedBox.shrink();
}
- return _DesktopTextSelectionToolbar(
+ return DesktopTextSelectionToolbar(
anchor: widget.lastSecondaryTapDownPosition ?? midpointAnchor,
children: items,
);
}
}
-
-/// A Material-style desktop text selection toolbar.
-///
-/// Typically displays buttons for text manipulation, e.g. copying and pasting
-/// text.
-///
-/// Tries to position itself as closely as possible to [anchor] while remaining
-/// fully on-screen.
-///
-/// See also:
-///
-/// * [_DesktopTextSelectionControls.buildToolbar], where this is used by
-/// default to build a Material-style desktop toolbar.
-/// * [TextSelectionToolbar], which is similar, but builds an Android-style
-/// toolbar.
-class _DesktopTextSelectionToolbar extends StatelessWidget {
- /// Creates an instance of _DesktopTextSelectionToolbar.
- const _DesktopTextSelectionToolbar({
- required this.anchor,
- required this.children,
- }) : assert(children.length > 0);
-
- /// The point at which the toolbar will attempt to position itself as closely
- /// as possible.
- final Offset anchor;
-
- /// {@macro flutter.material.TextSelectionToolbar.children}
- ///
- /// See also:
- /// * [DesktopTextSelectionToolbarButton], which builds a default
- /// Material-style desktop text selection toolbar text button.
- final List<Widget> children;
-
- // Builds a desktop toolbar in the Material style.
- static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
- return SizedBox(
- width: _kToolbarWidth,
- child: Material(
- borderRadius: const BorderRadius.all(Radius.circular(7.0)),
- clipBehavior: Clip.antiAlias,
- elevation: 1.0,
- type: MaterialType.card,
- child: child,
- ),
- );
- }
-
- @override
- Widget build(BuildContext context) {
- assert(debugCheckHasMediaQuery(context));
- final MediaQueryData mediaQuery = MediaQuery.of(context);
-
- final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
- final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
-
- return Padding(
- padding: EdgeInsets.fromLTRB(
- _kToolbarScreenPadding,
- paddingAbove,
- _kToolbarScreenPadding,
- _kToolbarScreenPadding,
- ),
- child: CustomSingleChildLayout(
- delegate: DesktopTextSelectionToolbarLayoutDelegate(
- anchor: anchor - localAdjustment,
- ),
- child: _defaultToolbarBuilder(context, Column(
- mainAxisSize: MainAxisSize.min,
- children: children,
- )),
- ),
- );
- }
-}
-
-const TextStyle _kToolbarButtonFontStyle = TextStyle(
- inherit: false,
- fontSize: 14.0,
- letterSpacing: -0.15,
- fontWeight: FontWeight.w400,
-);
-
-const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
- 20.0,
- 0.0,
- 20.0,
- 3.0,
-);
-
-/// A [TextButton] for the Material desktop text selection toolbar.
-class _DesktopTextSelectionToolbarButton extends StatelessWidget {
- /// Creates an instance of DesktopTextSelectionToolbarButton.
- const _DesktopTextSelectionToolbarButton({
- required this.onPressed,
- required this.child,
- });
-
- /// Create an instance of [_DesktopTextSelectionToolbarButton] whose child is
- /// a [Text] widget in the style of the Material text selection toolbar.
- _DesktopTextSelectionToolbarButton.text({
- required BuildContext context,
- required this.onPressed,
- required String text,
- }) : child = Text(
- text,
- overflow: TextOverflow.ellipsis,
- style: _kToolbarButtonFontStyle.copyWith(
- color: Theme.of(context).colorScheme.brightness == Brightness.dark
- ? Colors.white
- : Colors.black87,
- ),
- );
-
- /// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
- final VoidCallback onPressed;
-
- /// {@macro flutter.material.TextSelectionToolbarTextButton.child}
- final Widget child;
-
- @override
- Widget build(BuildContext context) {
- // TODO(hansmuller): Should be colorScheme.onSurface
- final ThemeData theme = Theme.of(context);
- final bool isDark = theme.colorScheme.brightness == Brightness.dark;
- final Color foregroundColor = isDark ? Colors.white : Colors.black87;
-
- return SizedBox(
- width: double.infinity,
- child: TextButton(
- style: TextButton.styleFrom(
- alignment: Alignment.centerLeft,
- enabledMouseCursor: SystemMouseCursors.basic,
- disabledMouseCursor: SystemMouseCursors.basic,
- foregroundColor: foregroundColor,
- shape: const RoundedRectangleBorder(),
- minimumSize: const Size(kMinInteractiveDimension, 36.0),
- padding: _kToolbarButtonPadding,
- ),
- onPressed: onPressed,
- child: child,
- ),
- );
- }
-}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart b/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart
new file mode 100644
index 0000000..e1dbfe6
--- /dev/null
+++ b/packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart
@@ -0,0 +1,93 @@
+// 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.
+
+import 'package:flutter/widgets.dart';
+
+import 'material.dart';
+import 'text_selection_toolbar.dart';
+
+// These values were measured from a screenshot of TextEdit on macOS 10.15.7 on
+// a Macbook Pro.
+const double _kToolbarScreenPadding = 8.0;
+const double _kToolbarWidth = 222.0;
+
+/// A Material-style desktop text selection toolbar.
+///
+/// Typically displays buttons for text manipulation, e.g. copying and pasting
+/// text.
+///
+/// Tries to position its top left corner as closely as possible to [anchor]
+/// while remaining fully inside the viewport.
+///
+/// See also:
+///
+/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+/// platform.
+/// * [TextSelectionToolbar], which is similar, but builds an Android-style
+/// toolbar.
+class DesktopTextSelectionToolbar extends StatelessWidget {
+ /// Creates a const instance of DesktopTextSelectionToolbar.
+ const DesktopTextSelectionToolbar({
+ super.key,
+ required this.anchor,
+ required this.children,
+ }) : assert(children.length > 0);
+
+ /// {@template flutter.material.DesktopTextSelectionToolbar.anchor}
+ /// The point where the toolbar will attempt to position itself as closely as
+ /// possible.
+ /// {@endtemplate}
+ final Offset anchor;
+
+ /// {@macro flutter.material.TextSelectionToolbar.children}
+ ///
+ /// See also:
+ /// * [DesktopTextSelectionToolbarButton], which builds a default
+ /// Material-style desktop text selection toolbar text button.
+ final List<Widget> children;
+
+ // Builds a desktop toolbar in the Material style.
+ static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
+ return SizedBox(
+ width: _kToolbarWidth,
+ child: Material(
+ borderRadius: const BorderRadius.all(Radius.circular(7.0)),
+ clipBehavior: Clip.antiAlias,
+ elevation: 1.0,
+ type: MaterialType.card,
+ child: child,
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ assert(debugCheckHasMediaQuery(context));
+ final MediaQueryData mediaQuery = MediaQuery.of(context);
+
+ final double paddingAbove = mediaQuery.padding.top + _kToolbarScreenPadding;
+ final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
+
+ return Padding(
+ padding: EdgeInsets.fromLTRB(
+ _kToolbarScreenPadding,
+ paddingAbove,
+ _kToolbarScreenPadding,
+ _kToolbarScreenPadding,
+ ),
+ child: CustomSingleChildLayout(
+ delegate: DesktopTextSelectionToolbarLayoutDelegate(
+ anchor: anchor - localAdjustment,
+ ),
+ child: _defaultToolbarBuilder(
+ context,
+ Column(
+ mainAxisSize: MainAxisSize.min,
+ children: children,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart b/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart
new file mode 100644
index 0000000..1b0ca20
--- /dev/null
+++ b/packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart
@@ -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.
+
+import 'package:flutter/services.dart';
+import 'package:flutter/widgets.dart';
+
+import 'colors.dart';
+import 'constants.dart';
+import 'text_button.dart';
+import 'theme.dart';
+
+const TextStyle _kToolbarButtonFontStyle = TextStyle(
+ inherit: false,
+ fontSize: 14.0,
+ letterSpacing: -0.15,
+ fontWeight: FontWeight.w400,
+);
+
+const EdgeInsets _kToolbarButtonPadding = EdgeInsets.fromLTRB(
+ 20.0,
+ 0.0,
+ 20.0,
+ 3.0,
+);
+
+/// A [TextButton] for the Material desktop text selection toolbar.
+class DesktopTextSelectionToolbarButton extends StatelessWidget {
+ /// Creates an instance of DesktopTextSelectionToolbarButton.
+ const DesktopTextSelectionToolbarButton({
+ super.key,
+ required this.onPressed,
+ required this.child,
+ });
+
+ /// Create an instance of [DesktopTextSelectionToolbarButton] whose child is
+ /// a [Text] widget in the style of the Material text selection toolbar.
+ DesktopTextSelectionToolbarButton.text({
+ super.key,
+ required BuildContext context,
+ required this.onPressed,
+ required String text,
+ }) : child = Text(
+ text,
+ overflow: TextOverflow.ellipsis,
+ style: _kToolbarButtonFontStyle.copyWith(
+ color: Theme.of(context).colorScheme.brightness == Brightness.dark
+ ? Colors.white
+ : Colors.black87,
+ ),
+ );
+
+ /// {@macro flutter.material.TextSelectionToolbarTextButton.onPressed}
+ final VoidCallback onPressed;
+
+ /// {@macro flutter.material.TextSelectionToolbarTextButton.child}
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ // TODO(hansmuller): Should be colorScheme.onSurface
+ final ThemeData theme = Theme.of(context);
+ final bool isDark = theme.colorScheme.brightness == Brightness.dark;
+ final Color foregroundColor = isDark ? Colors.white : Colors.black87;
+
+ return SizedBox(
+ width: double.infinity,
+ child: TextButton(
+ style: TextButton.styleFrom(
+ alignment: Alignment.centerLeft,
+ enabledMouseCursor: SystemMouseCursors.basic,
+ disabledMouseCursor: SystemMouseCursors.basic,
+ foregroundColor: foregroundColor,
+ shape: const RoundedRectangleBorder(),
+ minimumSize: const Size(kMinInteractiveDimension, 36.0),
+ padding: _kToolbarButtonPadding,
+ ),
+ onPressed: onPressed,
+ child: child,
+ ),
+ );
+ }
+}
diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart
index 24e6636..ccbe7a8 100644
--- a/packages/flutter/lib/src/material/selectable_text.dart
+++ b/packages/flutter/lib/src/material/selectable_text.dart
@@ -9,6 +9,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'desktop_text_selection.dart';
import 'feedback.dart';
import 'magnifier.dart';
@@ -190,7 +191,11 @@
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
- ToolbarOptions? toolbarOptions,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ this.toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
@@ -208,6 +213,7 @@
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
}) : assert(showCursor != null),
assert(autofocus != null),
@@ -224,12 +230,7 @@
data != null,
'A non-null String must be provided to a SelectableText widget.',
),
- textSpan = null,
- toolbarOptions = toolbarOptions ??
- const ToolbarOptions(
- selectAll: true,
- copy: true,
- );
+ textSpan = null;
/// Creates a selectable text widget with a [TextSpan].
///
@@ -248,7 +249,11 @@
this.textScaleFactor,
this.showCursor = false,
this.autofocus = false,
- ToolbarOptions? toolbarOptions,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ this.toolbarOptions,
this.minLines,
this.maxLines,
this.cursorWidth = 2.0,
@@ -266,6 +271,7 @@
this.textHeightBehavior,
this.textWidthBasis,
this.onSelectionChanged,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
}) : assert(showCursor != null),
assert(autofocus != null),
@@ -280,12 +286,7 @@
textSpan != null,
'A non-null TextSpan must be provided to a SelectableText.rich widget.',
),
- data = null,
- toolbarOptions = toolbarOptions ??
- const ToolbarOptions(
- selectAll: true,
- copy: true,
- );
+ data = null;
/// The text to display.
///
@@ -397,7 +398,11 @@
/// Paste and cut will be disabled regardless.
///
/// If not set, select all and copy will be enabled by default.
- final ToolbarOptions toolbarOptions;
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ final ToolbarOptions? toolbarOptions;
/// {@macro flutter.widgets.editableText.selectionEnabled}
bool get selectionEnabled => enableInteractiveSelection;
@@ -434,6 +439,15 @@
/// {@macro flutter.widgets.editableText.onSelectionChanged}
final SelectionChangedCallback? onSelectionChanged;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+ static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+ return AdaptiveTextSelectionToolbar.editableText(
+ editableTextState: editableTextState,
+ );
+ }
+
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
/// {@macro flutter.widgets.magnifier.intro}
@@ -639,7 +653,7 @@
case TargetPlatform.iOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
- textSelectionControls ??= cupertinoTextSelectionControls;
+ textSelectionControls ??= cupertinoTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -651,7 +665,7 @@
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
- textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+ textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -663,7 +677,7 @@
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
- textSelectionControls ??= materialTextSelectionControls;
+ textSelectionControls ??= materialTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -673,7 +687,7 @@
case TargetPlatform.linux:
case TargetPlatform.windows:
forcePressEnabled = false;
- textSelectionControls ??= desktopTextSelectionControls;
+ textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -694,6 +708,7 @@
key: editableTextKey,
style: effectiveTextStyle,
readOnly: true,
+ toolbarOptions: widget.toolbarOptions,
textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
showSelectionHandles: _showSelectionHandles,
@@ -706,7 +721,6 @@
textScaleFactor: widget.textScaleFactor,
autofocus: widget.autofocus,
forceLine: false,
- toolbarOptions: widget.toolbarOptions,
minLines: widget.minLines,
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
selectionColor: selectionColor,
@@ -729,6 +743,7 @@
dragStartBehavior: widget.dragStartBehavior,
scrollPhysics: widget.scrollPhysics,
autofillHints: null,
+ contextMenuBuilder: widget.contextMenuBuilder,
),
);
diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart
index a6312a6..609beef 100644
--- a/packages/flutter/lib/src/material/selection_area.dart
+++ b/packages/flutter/lib/src/material/selection_area.dart
@@ -5,6 +5,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'debug.dart';
import 'desktop_text_selection.dart';
import 'magnifier.dart';
@@ -41,6 +42,7 @@
super.key,
this.focusNode,
this.selectionControls,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.magnifierConfiguration,
this.onSelectionChanged,
required this.child,
@@ -65,6 +67,23 @@
/// If it is null, the platform specific selection control is used.
final TextSelectionControls? selectionControls;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ ///
+ /// If not provided, will build a default menu based on the ambient
+ /// [ThemeData.platform].
+ ///
+ /// {@tool dartpad}
+ /// This example shows how to build a custom context menu for any selected
+ /// content in a SelectionArea.
+ ///
+ /// ** See code in examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart **
+ /// {@end-tool}
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar], which is built by default.
+ final SelectableRegionContextMenuBuilder? contextMenuBuilder;
+
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
@@ -73,6 +92,12 @@
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
+ static Widget _defaultContextMenuBuilder(BuildContext context, SelectableRegionState selectableRegionState) {
+ return AdaptiveTextSelectionToolbar.selectableRegion(
+ selectableRegionState: selectableRegionState,
+ );
+ }
+
@override
State<StatefulWidget> createState() => _SelectionAreaState();
}
@@ -100,22 +125,24 @@
switch (Theme.of(context).platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
- controls ??= materialTextSelectionControls;
+ controls ??= materialTextSelectionHandleControls;
break;
case TargetPlatform.iOS:
- controls ??= cupertinoTextSelectionControls;
+ controls ??= cupertinoTextSelectionHandleControls;
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
- controls ??= desktopTextSelectionControls;
+ controls ??= desktopTextSelectionHandleControls;
break;
case TargetPlatform.macOS:
- controls ??= cupertinoDesktopTextSelectionControls;
+ controls ??= cupertinoDesktopTextSelectionHandleControls;
break;
}
+
return SelectableRegion(
- focusNode: _effectiveFocusNode,
selectionControls: controls,
+ focusNode: _effectiveFocusNode,
+ contextMenuBuilder: widget.contextMenuBuilder,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
onSelectionChanged: widget.onSelectionChanged,
child: widget.child,
diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart
index e0256d4..045b057 100644
--- a/packages/flutter/lib/src/material/text_field.dart
+++ b/packages/flutter/lib/src/material/text_field.dart
@@ -10,6 +10,7 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'debug.dart';
import 'desktop_text_selection.dart';
@@ -263,7 +264,11 @@
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
- ToolbarOptions? toolbarOptions,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ this.toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '•',
@@ -305,6 +310,7 @@
this.restorationId,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.contextMenuBuilder = _defaultContextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration,
}) : assert(textAlign != null),
@@ -343,31 +349,7 @@
assert(clipBehavior != null),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
- enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
- toolbarOptions = toolbarOptions ??
- (obscureText
- ? (readOnly
- // No point in even offering "Select All" in a read-only obscured
- // field.
- ? const ToolbarOptions()
- // Writable, but obscured.
- : const ToolbarOptions(
- selectAll: true,
- paste: true,
- ))
- : (readOnly
- // Read-only, not obscured.
- ? const ToolbarOptions(
- selectAll: true,
- copy: true,
- )
- // Writable, not obscured.
- : const ToolbarOptions(
- copy: true,
- cut: true,
- selectAll: true,
- paste: true,
- )));
+ enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText);
/// {@macro flutter.widgets.magnifier.TextMagnifierConfiguration.intro}
///
@@ -513,7 +495,11 @@
/// If not set, select all and paste will default to be enabled. Copy and cut
/// will be disabled if [obscureText] is true. If [readOnly] is true,
/// paste and cut will be disabled regardless.
- final ToolbarOptions toolbarOptions;
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ final ToolbarOptions? toolbarOptions;
/// {@macro flutter.widgets.editableText.showCursor}
final bool? showCursor;
@@ -779,6 +765,21 @@
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ ///
+ /// If not provided, will build a default menu based on the platform.
+ ///
+ /// See also:
+ ///
+ /// * [AdaptiveTextSelectionToolbar], which is built by default.
+ final EditableTextContextMenuBuilder? contextMenuBuilder;
+
+ static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+ return AdaptiveTextSelectionToolbar.editableText(
+ editableTextState: editableTextState,
+ );
+ }
+
/// {@macro flutter.widgets.EditableText.spellCheckConfiguration}
///
/// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this
@@ -1208,7 +1209,7 @@
case TargetPlatform.iOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = true;
- textSelectionControls ??= cupertinoTextSelectionControls;
+ textSelectionControls ??= cupertinoTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = true;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -1221,7 +1222,7 @@
case TargetPlatform.macOS:
final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context);
forcePressEnabled = false;
- textSelectionControls ??= cupertinoDesktopTextSelectionControls;
+ textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls;
paintCursorAboveText = true;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? cupertinoTheme.primaryColor;
@@ -1239,7 +1240,7 @@
case TargetPlatform.android:
case TargetPlatform.fuchsia:
forcePressEnabled = false;
- textSelectionControls ??= materialTextSelectionControls;
+ textSelectionControls ??= materialTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1248,7 +1249,7 @@
case TargetPlatform.linux:
forcePressEnabled = false;
- textSelectionControls ??= desktopTextSelectionControls;
+ textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1257,7 +1258,7 @@
case TargetPlatform.windows:
forcePressEnabled = false;
- textSelectionControls ??= desktopTextSelectionControls;
+ textSelectionControls ??= desktopTextSelectionHandleControls;
paintCursorAboveText = false;
cursorOpacityAnimates = false;
cursorColor = widget.cursorColor ?? selectionStyle.cursorColor ?? theme.colorScheme.primary;
@@ -1334,6 +1335,7 @@
restorationId: 'editable',
scribbleEnabled: widget.scribbleEnabled,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
+ contextMenuBuilder: widget.contextMenuBuilder,
spellCheckConfiguration: spellCheckConfiguration,
magnifierConfiguration: widget.magnifierConfiguration ?? TextMagnifier.adaptiveMagnifierConfiguration,
),
diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart
index 1b1db94..a9addc2 100644
--- a/packages/flutter/lib/src/material/text_form_field.dart
+++ b/packages/flutter/lib/src/material/text_form_field.dart
@@ -5,6 +5,7 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
+import 'adaptive_text_selection_toolbar.dart';
import 'input_decorator.dart';
import 'text_field.dart';
import 'theme.dart';
@@ -110,6 +111,10 @@
TextAlignVertical? textAlignVertical,
bool autofocus = false,
bool readOnly = false,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
ToolbarOptions? toolbarOptions,
bool? showCursor,
String obscuringCharacter = '•',
@@ -148,6 +153,7 @@
super.restorationId,
bool enableIMEPersonalizedLearning = true,
MouseCursor? mouseCursor,
+ EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder,
}) : assert(initialValue == null || controller == null),
assert(textAlign != null),
assert(autofocus != null),
@@ -236,6 +242,7 @@
scrollController: scrollController,
enableIMEPersonalizedLearning: enableIMEPersonalizedLearning,
mouseCursor: mouseCursor,
+ contextMenuBuilder: contextMenuBuilder,
),
);
},
@@ -247,6 +254,12 @@
/// initialize its [TextEditingController.text] with [initialValue].
final TextEditingController? controller;
+ static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) {
+ return AdaptiveTextSelectionToolbar.editableText(
+ editableTextState: editableTextState,
+ );
+ }
+
@override
FormFieldState<String> createState() => _TextFormFieldState();
}
diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart
index 9b4aa1e..9c31349 100644
--- a/packages/flutter/lib/src/material/text_selection.dart
+++ b/packages/flutter/lib/src/material/text_selection.dart
@@ -19,6 +19,17 @@
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
+/// Android Material styled text selection handle controls.
+///
+/// Specifically does not manage the toolbar, which is left to
+/// [EditableText.contextMenuBuilder].
+@Deprecated(
+ 'Use `MaterialTextSelectionControls`. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+class MaterialTextSelectionHandleControls extends MaterialTextSelectionControls with TextSelectionHandleControls {
+}
+
/// Android Material styled text selection controls.
///
/// The [materialTextSelectionControls] global variable has a
@@ -29,6 +40,10 @@
Size getHandleSize(double textLineHeight) => const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style copy/paste text selection toolbar.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
Widget buildToolbar(
BuildContext context,
@@ -40,7 +55,7 @@
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
- return _TextSelectionControlsToolbar(
+ return _TextSelectionControlsToolbar(
globalEditableRegion: globalEditableRegion,
textLineHeight: textLineHeight,
selectionMidpoint: selectionMidpoint,
@@ -107,6 +122,10 @@
}
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless
@@ -183,8 +202,8 @@
@override
void dispose() {
- super.dispose();
widget.clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ super.dispose();
}
@override
@@ -289,5 +308,12 @@
}
}
+/// Text selection handle controls that follow the Material Design specification.
+@Deprecated(
+ 'Use `materialTextSelectionControls` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+final TextSelectionControls materialTextSelectionHandleControls = MaterialTextSelectionHandleControls();
+
/// Text selection controls that follow the Material Design specification.
final TextSelectionControls materialTextSelectionControls = MaterialTextSelectionControls();
diff --git a/packages/flutter/lib/src/material/text_selection_toolbar.dart b/packages/flutter/lib/src/material/text_selection_toolbar.dart
index d014bbc..a024968 100644
--- a/packages/flutter/lib/src/material/text_selection_toolbar.dart
+++ b/packages/flutter/lib/src/material/text_selection_toolbar.dart
@@ -19,6 +19,12 @@
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
+const double _kHandleSize = 22.0;
+
+// Padding between the toolbar and the anchor.
+const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
+const double _kToolbarContentDistance = 8.0;
+
/// A fully-functional Material-style text selection toolbar.
///
/// Tries to position itself above [anchorAbove], but if it doesn't fit, then
@@ -29,8 +35,8 @@
///
/// See also:
///
-/// * [TextSelectionControls.buildToolbar], where this is used by default to
-/// build an Android-style toolbar.
+/// * [AdaptiveTextSelectionToolbar], which builds the toolbar for the current
+/// platform.
/// * [CupertinoTextSelectionToolbar], which is similar, but builds an iOS-
/// style toolbar.
class TextSelectionToolbar extends StatelessWidget {
@@ -87,10 +93,17 @@
@override
Widget build(BuildContext context) {
+ // Incorporate the padding distance between the content and toolbar.
+ final Offset anchorAbovePadded =
+ anchorAbove - const Offset(0.0, _kToolbarContentDistance);
+ final Offset anchorBelowPadded =
+ anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow);
+
final double paddingAbove = MediaQuery.of(context).padding.top
+ _kToolbarScreenPadding;
- final double availableHeight = anchorAbove.dy - paddingAbove;
+ final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight;
+ // Makes up for the Padding above the Stack.
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
return Padding(
@@ -100,21 +113,17 @@
_kToolbarScreenPadding,
_kToolbarScreenPadding,
),
- child: Stack(
- children: <Widget>[
- CustomSingleChildLayout(
- delegate: TextSelectionToolbarLayoutDelegate(
- anchorAbove: anchorAbove - localAdjustment,
- anchorBelow: anchorBelow - localAdjustment,
- fitsAbove: fitsAbove,
- ),
- child: _TextSelectionToolbarOverflowable(
- isAbove: fitsAbove,
- toolbarBuilder: toolbarBuilder,
- children: children,
- ),
- ),
- ],
+ child: CustomSingleChildLayout(
+ delegate: TextSelectionToolbarLayoutDelegate(
+ anchorAbove: anchorAbovePadded - localAdjustment,
+ anchorBelow: anchorBelowPadded - localAdjustment,
+ fitsAbove: fitsAbove,
+ ),
+ child: _TextSelectionToolbarOverflowable(
+ isAbove: fitsAbove,
+ toolbarBuilder: toolbarBuilder,
+ children: children,
+ ),
),
);
}
@@ -156,8 +165,8 @@
// changed and saved values are no longer relevant. This should be called in
// setState or another context where a rebuild is happening.
void _reset() {
- // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes in
- // order to cause it to rebuild. This lets it recalculate its
+ // Change _TextSelectionToolbarTrailingEdgeAlign's key when the menu changes
+ // in order to cause it to rebuild. This lets it recalculate its
// saved width for the new set of children, and it prevents AnimatedSize
// from animating the size change.
_containerKey = UniqueKey();
diff --git a/packages/flutter/lib/src/painting/text_painter.dart b/packages/flutter/lib/src/painting/text_painter.dart
index da314d6..5ed85d4 100644
--- a/packages/flutter/lib/src/painting/text_painter.dart
+++ b/packages/flutter/lib/src/painting/text_painter.dart
@@ -1100,7 +1100,7 @@
/// visually contiguous.
///
/// Leading or trailing newline characters will be represented by zero-width
- /// `Textbox`es.
+ /// `TextBox`es.
///
/// The method only returns `TextBox`es of glyphs that are entirely enclosed by
/// the given `selection`: a multi-code-unit glyph will be excluded if only
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 573ae8b..0a050f2 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -1981,8 +1981,10 @@
Offset? _lastTapDownPosition;
Offset? _lastSecondaryTapDownPosition;
+ /// {@template flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
/// The position of the most recent secondary tap down event on this text
/// input.
+ /// {@endtemplate}
Offset? get lastSecondaryTapDownPosition => _lastSecondaryTapDownPosition;
/// Tracks the position of a secondary tap event.
diff --git a/packages/flutter/lib/src/widgets/context_menu_button_item.dart b/packages/flutter/lib/src/widgets/context_menu_button_item.dart
new file mode 100644
index 0000000..2c5e723
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/context_menu_button_item.dart
@@ -0,0 +1,89 @@
+// 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.
+
+import 'framework.dart';
+
+/// The buttons that can appear in a context menu by default.
+///
+/// See also:
+///
+/// * [ContextMenuButtonItem], which uses this enum to describe a button in a
+/// context menu.
+enum ContextMenuButtonType {
+ /// A button that cuts the current text selection.
+ cut,
+
+ /// A button that copies the current text selection.
+ copy,
+
+ /// A button that pastes the clipboard contents into the focused text field.
+ paste,
+
+ /// A button that selects all the contents of the focused text field.
+ selectAll,
+
+ /// Anything other than the default button types.
+ custom,
+}
+
+/// The type and callback for a context menu button.
+///
+/// See also:
+///
+/// * [AdaptiveTextSelectionToolbar], which can take a list of
+/// ContextMenuButtonItems and create a platform-specific context menu with
+/// the indicated buttons.
+@immutable
+class ContextMenuButtonItem {
+ /// Creates a const instance of [ContextMenuButtonItem].
+ const ContextMenuButtonItem({
+ required this.onPressed,
+ this.type = ContextMenuButtonType.custom,
+ this.label,
+ });
+
+ /// The callback to be called when the button is pressed.
+ final VoidCallback onPressed;
+
+ /// The type of button this represents.
+ final ContextMenuButtonType type;
+
+ /// The label to display on the button.
+ ///
+ /// If a [type] other than [ContextMenuButtonType.custom] is given
+ /// and a label is not provided, then the default label for that type for the
+ /// platform will be looked up.
+ final String? label;
+
+ /// Creates a new [ContextMenuButtonItem] with the provided parameters
+ /// overridden.
+ ContextMenuButtonItem copyWith({
+ VoidCallback? onPressed,
+ ContextMenuButtonType? type,
+ String? label,
+ }) {
+ return ContextMenuButtonItem(
+ onPressed: onPressed ?? this.onPressed,
+ type: type ?? this.type,
+ label: label ?? this.label,
+ );
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (other.runtimeType != runtimeType) {
+ return false;
+ }
+ return other is ContextMenuButtonItem
+ && other.label == label
+ && other.onPressed == onPressed
+ && other.type == type;
+ }
+
+ @override
+ int get hashCode => Object.hash(label, onPressed, type);
+
+ @override
+ String toString() => 'ContextMenuButtonItem $type, $label';
+}
diff --git a/packages/flutter/lib/src/widgets/context_menu_controller.dart b/packages/flutter/lib/src/widgets/context_menu_controller.dart
new file mode 100644
index 0000000..bfc6f96
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/context_menu_controller.dart
@@ -0,0 +1,123 @@
+// 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.
+
+import 'framework.dart';
+import 'inherited_theme.dart';
+import 'navigator.dart';
+import 'overlay.dart';
+
+/// Builds and manages a context menu at a given location.
+///
+/// There can only ever be one context menu shown at a given time in the entire
+/// app.
+///
+/// {@tool dartpad}
+/// This example shows how to use a GestureDetector to show a context menu
+/// anywhere in a widget subtree that receives a right click or long press.
+///
+/// ** See code in examples/api/lib/material/context_menu/context_menu_controller.0.dart **
+/// {@end-tool}
+class ContextMenuController {
+ /// Creates a context menu that can be shown with [show].
+ ContextMenuController({
+ this.onRemove,
+ });
+
+ /// Called when this menu is removed.
+ final VoidCallback? onRemove;
+
+ /// The currently shown instance, if any.
+ static ContextMenuController? _shownInstance;
+
+ // The OverlayEntry is static because only one context menu can be displayed
+ // at one time.
+ static OverlayEntry? _menuOverlayEntry;
+
+ /// Shows the given context menu.
+ ///
+ /// Since there can only be one shown context menu at a time, calling this
+ /// will also remove any other context menu that is visible.
+ void show({
+ required BuildContext context,
+ required WidgetBuilder contextMenuBuilder,
+ Widget? debugRequiredFor,
+ }) {
+ removeAny();
+ final OverlayState overlayState = Overlay.of(
+ context,
+ rootOverlay: true,
+ debugRequiredFor: debugRequiredFor,
+ );
+ final CapturedThemes capturedThemes = InheritedTheme.capture(
+ from: context,
+ to: Navigator.maybeOf(context)?.context,
+ );
+
+ _menuOverlayEntry = OverlayEntry(
+ builder: (BuildContext context) {
+ return capturedThemes.wrap(contextMenuBuilder(context));
+ },
+ );
+ overlayState.insert(_menuOverlayEntry!);
+ _shownInstance = this;
+ }
+
+ /// Remove the currently shown context menu from the UI.
+ ///
+ /// Does nothing if no context menu is currently shown.
+ ///
+ /// If a menu is removed, and that menu provided an [onRemove] callback when
+ /// it was created, then that callback will be called.
+ ///
+ /// See also:
+ ///
+ /// * [remove], which removes only the current instance.
+ static void removeAny() {
+ _menuOverlayEntry?.remove();
+ _menuOverlayEntry = null;
+ if (_shownInstance != null) {
+ _shownInstance!.onRemove?.call();
+ _shownInstance = null;
+ }
+ }
+
+ /// True if and only if this menu is currently being shown.
+ bool get isShown => _shownInstance == this;
+
+ /// Cause the underlying [OverlayEntry] to rebuild during the next pipeline
+ /// flush.
+ ///
+ /// It's necessary to call this function if the output of [contextMenuBuilder]
+ /// has changed.
+ ///
+ /// Errors if the context menu is not currently shown.
+ ///
+ /// See also:
+ ///
+ /// * [OverlayEntry.markNeedsBuild]
+ void markNeedsBuild() {
+ assert(isShown);
+ _menuOverlayEntry?.markNeedsBuild();
+ }
+
+ /// Remove this menu from the UI.
+ ///
+ /// Does nothing if this instance is not currently shown. In other words, if
+ /// another context menu is currently shown, that menu will not be removed.
+ ///
+ /// This method should only be called once. The instance cannot be shown again
+ /// after removing. Create a new instance.
+ ///
+ /// If an [onRemove] method was given to this instance, it will be called.
+ ///
+ /// See also:
+ ///
+ /// * [removeAny], which removes any shown instance of the context menu.
+ void remove() {
+ if (!isShown) {
+ return;
+ }
+ removeAny();
+ }
+}
diff --git a/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart b/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
index 471b011..706c28f 100644
--- a/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
+++ b/packages/flutter/lib/src/widgets/desktop_text_selection_toolbar_layout_delegate.dart
@@ -4,7 +4,6 @@
import 'package:flutter/rendering.dart';
-
/// Positions the toolbar at [anchor] if it fits, otherwise moves it so that it
/// just fits fully on-screen.
///
diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart
index 8307aa7..a8a5b3b 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -19,6 +19,7 @@
import 'basic.dart';
import 'binding.dart';
import 'constants.dart';
+import 'context_menu_button_item.dart';
import 'debug.dart';
import 'default_selection_style.dart';
import 'default_text_editing_shortcuts.dart';
@@ -39,6 +40,7 @@
import 'text.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
+import 'text_selection_toolbar_anchors.dart';
import 'ticker_provider.dart';
import 'widget_span.dart';
@@ -54,6 +56,18 @@
/// Signature for the callback that reports the app private command results.
typedef AppPrivateCommandCallback = void Function(String, Map<String, dynamic>);
+/// Signature for a widget builder that builds a context menu for the given
+/// [EditableTextState].
+///
+/// See also:
+///
+/// * [SelectableRegionContextMenuBuilder], which performs the same role for
+/// [SelectableRegion].
+typedef EditableTextContextMenuBuilder = Widget Function(
+ BuildContext context,
+ EditableTextState editableTextState,
+);
+
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
@@ -264,10 +278,18 @@
/// [EditableText] and its derived widgets have their own default [ToolbarOptions].
/// Create a custom [ToolbarOptions] if you want explicit control over the toolbar
/// option.
+@Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
class ToolbarOptions {
/// Create a toolbar configuration with given options.
///
/// All options default to false if they are not explicitly set.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
const ToolbarOptions({
this.copy = false,
this.cut = false,
@@ -278,6 +300,9 @@
assert(paste != null),
assert(selectAll != null);
+ /// An instance of [ToolbarOptions] with no options enabled.
+ static const ToolbarOptions empty = ToolbarOptions();
+
/// Whether to show copy option in toolbar.
///
/// Defaults to false. Must not be null.
@@ -637,6 +662,10 @@
this.scrollController,
this.scrollPhysics,
this.autocorrectionTextRectColor,
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
ToolbarOptions? toolbarOptions,
this.autofillHints = const <String>[],
this.autofillClient,
@@ -645,6 +674,7 @@
this.scrollBehavior,
this.scribbleEnabled = true,
this.enableIMEPersonalizedLearning = true,
+ this.contextMenuBuilder,
this.spellCheckConfiguration,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : assert(controller != null),
@@ -683,12 +713,12 @@
assert(scrollPadding != null),
assert(dragStartBehavior != null),
enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText),
- toolbarOptions = toolbarOptions ??
+ toolbarOptions = selectionControls is TextSelectionHandleControls && toolbarOptions == null ? ToolbarOptions.empty : toolbarOptions ??
(obscureText
? (readOnly
// No point in even offering "Select All" in a read-only obscured
// field.
- ? const ToolbarOptions()
+ ? ToolbarOptions.empty
// Writable, but obscured.
: const ToolbarOptions(
selectAll: true,
@@ -1564,6 +1594,41 @@
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
+ /// {@template flutter.widgets.EditableText.contextMenuBuilder}
+ /// Builds the text selection toolbar when requested by the user.
+ ///
+ /// `primaryAnchor` is the desired anchor position for the context menu, while
+ /// `secondaryAnchor` is the fallback location if the menu doesn't fit.
+ ///
+ /// `buttonItems` represents the buttons that would be built by default for
+ /// this widget.
+ ///
+ /// {@tool dartpad}
+ /// This example shows how to customize the menu, in this case by keeping the
+ /// default buttons for the platform but modifying their appearance.
+ ///
+ /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart **
+ /// {@end-tool}
+ ///
+ /// {@tool dartpad}
+ /// This example shows how to show a custom button only when an email address
+ /// is currently selected.
+ ///
+ /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart **
+ /// {@end-tool}
+ ///
+ /// See also:
+ /// * [AdaptiveTextSelectionToolbar], which builds the default text selection
+ /// toolbar for the current platform, but allows customization of the
+ /// buttons.
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+ /// button Widgets for the current platform given
+ /// [ContextMenuButtonItem]s.
+ /// {@endtemplate}
+ ///
+ /// If not provided, no context menu will be shown.
+ final EditableTextContextMenuBuilder? contextMenuBuilder;
+
/// {@template flutter.widgets.EditableText.spellCheckConfiguration}
/// Configuration that details how spell check should be performed.
///
@@ -1587,6 +1652,61 @@
bool get _userSelectionEnabled => enableInteractiveSelection && (!readOnly || !obscureText);
+ /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+ /// platform's default selection menu for an editable field.
+ ///
+ /// For example, [EditableText] uses this to generate the default buttons for
+ /// its context menu.
+ ///
+ /// See also:
+ ///
+ /// * [EditableTextState.contextMenuButtonItems], which gives the
+ /// [ContextMenuButtonItem]s for a specific EditableText.
+ /// * [SelectableRegion.getSelectableButtonItems], which performs a similar
+ /// role but for content that is selectable but not editable.
+ /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+ /// take a list of [ContextMenuButtonItem]s with
+ /// [AdaptiveTextSelectionToolbar.buttonItems].
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+ /// Widgets for the current platform given [ContextMenuButtonItem]s.
+ static List<ContextMenuButtonItem> getEditableButtonItems({
+ required final ClipboardStatus? clipboardStatus,
+ required final VoidCallback? onCopy,
+ required final VoidCallback? onCut,
+ required final VoidCallback? onPaste,
+ required final VoidCallback? onSelectAll,
+ }) {
+ // If the paste button is enabled, don't render anything until the state
+ // of the clipboard is known, since it's used to determine if paste is
+ // shown.
+ if (onPaste != null && clipboardStatus == ClipboardStatus.unknown) {
+ return <ContextMenuButtonItem>[];
+ }
+
+ return <ContextMenuButtonItem>[
+ if (onCut != null)
+ ContextMenuButtonItem(
+ onPressed: onCut,
+ type: ContextMenuButtonType.cut,
+ ),
+ if (onCopy != null)
+ ContextMenuButtonItem(
+ onPressed: onCopy,
+ type: ContextMenuButtonType.copy,
+ ),
+ if (onPaste != null)
+ ContextMenuButtonItem(
+ onPressed: onPaste,
+ type: ContextMenuButtonType.paste,
+ ),
+ if (onSelectAll != null)
+ ContextMenuButtonItem(
+ onPressed: onSelectAll,
+ type: ContextMenuButtonType.selectAll,
+ ),
+ ];
+ }
+
// Infer the keyboard type of an `EditableText` if it's not specified.
static TextInputType _inferKeyboardType({
required Iterable<String>? autofillHints,
@@ -1778,7 +1898,9 @@
final ValueNotifier<bool> _cursorVisibilityNotifier = ValueNotifier<bool>(true);
final GlobalKey _editableKey = GlobalKey();
- final ClipboardStatusNotifier? _clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
+
+ /// Detects whether the clipboard can paste.
+ final ClipboardStatusNotifier? clipboardStatus = kIsWeb ? null : ClipboardStatusNotifier();
TextInputConnection? _textInputConnection;
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
@@ -1854,16 +1976,61 @@
Color get _cursorColor => widget.cursorColor.withOpacity(_cursorBlinkOpacityController.value);
@override
- bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
+ bool get cutEnabled {
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ return widget.toolbarOptions.cut && !widget.readOnly && !widget.obscureText;
+ }
+ return !widget.readOnly
+ && !widget.obscureText
+ && !textEditingValue.selection.isCollapsed;
+ }
@override
- bool get copyEnabled => widget.toolbarOptions.copy && !widget.obscureText;
+ bool get copyEnabled {
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ return widget.toolbarOptions.copy && !widget.obscureText;
+ }
+ return !widget.obscureText
+ && !textEditingValue.selection.isCollapsed;
+ }
@override
- bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
+ bool get pasteEnabled {
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ return widget.toolbarOptions.paste && !widget.readOnly;
+ }
+ return !widget.readOnly
+ && (clipboardStatus == null
+ || clipboardStatus!.value == ClipboardStatus.pasteable);
+ }
@override
- bool get selectAllEnabled => widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
+ bool get selectAllEnabled {
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ return widget.toolbarOptions.selectAll && (!widget.readOnly || !widget.obscureText) && widget.enableInteractiveSelection;
+ }
+
+ if (!widget.enableInteractiveSelection
+ || (widget.readOnly
+ && widget.obscureText)) {
+ return false;
+ }
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.macOS:
+ return false;
+ case TargetPlatform.iOS:
+ return textEditingValue.text.isNotEmpty
+ && textEditingValue.selection.isCollapsed;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ return textEditingValue.text.isNotEmpty
+ && !(textEditingValue.selection.start == 0
+ && textEditingValue.selection.end == textEditingValue.text.length);
+ }
+ }
void _onChangedClipboardStatus() {
setState(() {
@@ -1912,7 +2079,7 @@
break;
}
}
- _clipboardStatus?.update();
+ clipboardStatus?.update();
}
/// Cut current selection to [Clipboard].
@@ -1938,7 +2105,7 @@
});
hideToolbar();
}
- _clipboardStatus?.update();
+ clipboardStatus?.update();
}
/// Paste text from [Clipboard].
@@ -1999,6 +2166,16 @@
if (cause == SelectionChangedCause.toolbar) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
+ case TargetPlatform.iOS:
+ case TargetPlatform.fuchsia:
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ hideToolbar();
+ }
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
@@ -2033,12 +2210,154 @@
return configuration.copyWith(spellCheckService: spellCheckService);
}
+ /// Returns the [ContextMenuButtonItem]s for the given [ToolbarOptions].
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead of `toolbarOptions`. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ List<ContextMenuButtonItem>? buttonItemsForToolbarOptions([TargetPlatform? targetPlatform]) {
+ final ToolbarOptions toolbarOptions = widget.toolbarOptions;
+ if (toolbarOptions == ToolbarOptions.empty) {
+ return null;
+ }
+ return <ContextMenuButtonItem>[
+ if (toolbarOptions.cut && cutEnabled)
+ ContextMenuButtonItem(
+ onPressed: () {
+ selectAll(SelectionChangedCause.toolbar);
+ },
+ type: ContextMenuButtonType.selectAll,
+ ),
+ if (toolbarOptions.copy && copyEnabled)
+ ContextMenuButtonItem(
+ onPressed: () {
+ copySelection(SelectionChangedCause.toolbar);
+ },
+ type: ContextMenuButtonType.copy,
+ ),
+ if (toolbarOptions.paste && clipboardStatus != null && pasteEnabled)
+ ContextMenuButtonItem(
+ onPressed: () {
+ pasteText(SelectionChangedCause.toolbar);
+ },
+ type: ContextMenuButtonType.paste,
+ ),
+ if (toolbarOptions.selectAll && selectAllEnabled)
+ ContextMenuButtonItem(
+ onPressed: () {
+ selectAll(SelectionChangedCause.toolbar);
+ },
+ type: ContextMenuButtonType.selectAll,
+ ),
+ ];
+ }
+
+ /// Gets the line heights at the start and end of the selection for the given
+ /// [EditableTextState].
+ _GlyphHeights _getGlyphHeights() {
+ final TextSelection selection = textEditingValue.selection;
+
+ // Only calculate handle rects if the text in the previous frame
+ // is the same as the text in the current frame. This is done because
+ // widget.renderObject contains the renderEditable from the previous frame.
+ // If the text changed between the current and previous frames then
+ // widget.renderObject.getRectForComposingRange might fail. In cases where
+ // the current frame is different from the previous we fall back to
+ // renderObject.preferredLineHeight.
+ final InlineSpan span = renderEditable.text!;
+ final String prevText = span.toPlainText();
+ final String currText = textEditingValue.text;
+ if (prevText != currText || selection == null || !selection.isValid || selection.isCollapsed) {
+ return _GlyphHeights(
+ start: renderEditable.preferredLineHeight,
+ end: renderEditable.preferredLineHeight,
+ );
+ }
+
+ final String selectedGraphemes = selection.textInside(currText);
+ final int firstSelectedGraphemeExtent = selectedGraphemes.characters.first.length;
+ final Rect? startCharacterRect = renderEditable.getRectForComposingRange(TextRange(
+ start: selection.start,
+ end: selection.start + firstSelectedGraphemeExtent,
+ ));
+ final int lastSelectedGraphemeExtent = selectedGraphemes.characters.last.length;
+ final Rect? endCharacterRect = renderEditable.getRectForComposingRange(TextRange(
+ start: selection.end - lastSelectedGraphemeExtent,
+ end: selection.end,
+ ));
+ return _GlyphHeights(
+ start: startCharacterRect?.height ?? renderEditable.preferredLineHeight,
+ end: endCharacterRect?.height ?? renderEditable.preferredLineHeight,
+ );
+ }
+
+ /// {@template flutter.widgets.EditableText.getAnchors}
+ /// Returns the anchor points for the default context menu.
+ /// {@endtemplate}
+ ///
+ /// See also:
+ ///
+ /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
+ /// for the default context menu buttons.
+ TextSelectionToolbarAnchors get contextMenuAnchors {
+ if (renderEditable.lastSecondaryTapDownPosition != null) {
+ return TextSelectionToolbarAnchors(
+ primaryAnchor: renderEditable.lastSecondaryTapDownPosition!,
+ );
+ }
+
+ final _GlyphHeights glyphHeights = _getGlyphHeights();
+ final TextSelection selection = textEditingValue.selection;
+ final List<TextSelectionPoint> points =
+ renderEditable.getEndpointsForSelection(selection);
+ return TextSelectionToolbarAnchors.fromSelection(
+ renderBox: renderEditable,
+ startGlyphHeight: glyphHeights.start,
+ endGlyphHeight: glyphHeights.end,
+ selectionEndpoints: points,
+ );
+ }
+
+ /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+ /// platform's default selection menu for [EditableText].
+ ///
+ /// See also:
+ ///
+ /// * [EditableText.getEditableButtonItems], which performs a similar role,
+ /// but for any editable field, not just specifically EditableText.
+ /// * [SelectableRegionState.contextMenuButtonItems], which peforms a similar
+ /// role but for content that is selectable but not editable.
+ /// * [contextMenuAnchors], which provides the anchor points for the default
+ /// context menu.
+ /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+ /// take a list of [ContextMenuButtonItem]s with
+ /// [AdaptiveTextSelectionToolbar.buttonItems].
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+ /// button Widgets for the current platform given [ContextMenuButtonItem]s.
+ List<ContextMenuButtonItem> get contextMenuButtonItems {
+ return buttonItemsForToolbarOptions() ?? EditableText.getEditableButtonItems(
+ clipboardStatus: clipboardStatus?.value,
+ onCopy: copyEnabled
+ ? () => copySelection(SelectionChangedCause.toolbar)
+ : null,
+ onCut: cutEnabled
+ ? () => cutSelection(SelectionChangedCause.toolbar)
+ : null,
+ onPaste: pasteEnabled
+ ? () => pasteText(SelectionChangedCause.toolbar)
+ : null,
+ onSelectAll: selectAllEnabled
+ ? () => selectAll(SelectionChangedCause.toolbar)
+ : null,
+ );
+ }
+
// State lifecycle:
@override
void initState() {
super.initState();
- _clipboardStatus?.addListener(_onChangedClipboardStatus);
+ clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_onEditableScroll);
@@ -2159,8 +2478,11 @@
);
}
}
- if (widget.selectionEnabled && pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false)) {
- _clipboardStatus?.update();
+ final bool canPaste = widget.selectionControls is TextSelectionHandleControls
+ ? pasteEnabled
+ : widget.selectionControls?.canPaste(this) ?? false;
+ if (widget.selectionEnabled && pasteEnabled && clipboardStatus != null && canPaste) {
+ clipboardStatus!.update();
}
}
@@ -2181,8 +2503,8 @@
_selectionOverlay = null;
widget.focusNode.removeListener(_handleFocusChanged);
WidgetsBinding.instance.removeObserver(this);
- _clipboardStatus?.removeListener(_onChangedClipboardStatus);
- _clipboardStatus?.dispose();
+ clipboardStatus?.removeListener(_onChangedClipboardStatus);
+ clipboardStatus?.dispose();
_cursorVisibilityNotifier.dispose();
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
@@ -2733,7 +3055,7 @@
TextSelectionOverlay _createSelectionOverlay() {
final TextSelectionOverlay selectionOverlay = TextSelectionOverlay(
- clipboardStatus: _clipboardStatus,
+ clipboardStatus: clipboardStatus,
context: context,
value: _value,
debugRequiredFor: widget,
@@ -2745,6 +3067,14 @@
selectionDelegate: this,
dragStartBehavior: widget.dragStartBehavior,
onSelectionHandleTapped: widget.onSelectionHandleTapped,
+ contextMenuBuilder: widget.contextMenuBuilder == null
+ ? null
+ : (BuildContext context) {
+ return widget.contextMenuBuilder!(
+ context,
+ this,
+ );
+ },
magnifierConfiguration: widget.magnifierConfiguration,
);
@@ -2784,7 +3114,7 @@
}
break;
}
- if (widget.selectionControls == null) {
+ if (widget.selectionControls == null && widget.contextMenuBuilder == null) {
_selectionOverlay?.dispose();
_selectionOverlay = null;
} else {
@@ -3305,10 +3635,10 @@
return false;
}
- if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) {
+ if (_selectionOverlay == null) {
return false;
}
- _clipboardStatus?.update();
+ clipboardStatus?.update();
_selectionOverlay!.showToolbar();
return true;
}
@@ -3356,13 +3686,13 @@
}
/// Hides the magnifier if it is visible.
- void hideMagnifier({required bool shouldShowToolbar}) {
+ void hideMagnifier() {
if (_selectionOverlay == null) {
return;
}
if (_selectionOverlay!.magnifierIsVisible) {
- _selectionOverlay!.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
+ _selectionOverlay!.hideMagnifier();
}
}
@@ -3427,29 +3757,41 @@
VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) {
return widget.selectionEnabled
- && copyEnabled
&& _hasFocus
- && (controls?.canCopy(this) ?? false)
- ? () => controls!.handleCopy(this)
+ && (widget.selectionControls is TextSelectionHandleControls
+ ? copyEnabled
+ : copyEnabled && (widget.selectionControls?.canCopy(this) ?? false))
+ ? () {
+ controls?.handleCopy(this);
+ copySelection(SelectionChangedCause.toolbar);
+ }
: null;
}
VoidCallback? _semanticsOnCut(TextSelectionControls? controls) {
return widget.selectionEnabled
- && cutEnabled
&& _hasFocus
- && (controls?.canCut(this) ?? false)
- ? () => controls!.handleCut(this)
+ && (widget.selectionControls is TextSelectionHandleControls
+ ? cutEnabled
+ : cutEnabled && (widget.selectionControls?.canCut(this) ?? false))
+ ? () {
+ controls?.handleCut(this);
+ cutSelection(SelectionChangedCause.toolbar);
+ }
: null;
}
VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) {
return widget.selectionEnabled
- && pasteEnabled
&& _hasFocus
- && (controls?.canPaste(this) ?? false)
- && (_clipboardStatus == null || _clipboardStatus!.value == ClipboardStatus.pasteable)
- ? () => controls!.handlePaste(this)
+ && (widget.selectionControls is TextSelectionHandleControls
+ ? pasteEnabled
+ : pasteEnabled && (widget.selectionControls?.canPaste(this) ?? false))
+ && (clipboardStatus == null || clipboardStatus!.value == ClipboardStatus.pasteable)
+ ? () {
+ controls?.handlePaste(this);
+ pasteText(SelectionChangedCause.toolbar);
+ }
: null;
}
@@ -4983,3 +5325,18 @@
return timer!;
};
}
+
+/// The start and end glyph heights of some range of text.
+@immutable
+class _GlyphHeights {
+ const _GlyphHeights({
+ required this.start,
+ required this.end,
+ });
+
+ /// The glyph height of the first line.
+ final double start;
+
+ /// The glyph height of the last line.
+ final double end;
+}
diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart
index 2b2f049..e3a19da 100644
--- a/packages/flutter/lib/src/widgets/selectable_region.dart
+++ b/packages/flutter/lib/src/widgets/selectable_region.dart
@@ -13,6 +13,7 @@
import 'actions.dart';
import 'basic.dart';
+import 'context_menu_button_item.dart';
import 'debug.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
@@ -25,6 +26,7 @@
import 'selection_container.dart';
import 'text_editing_intents.dart';
import 'text_selection.dart';
+import 'text_selection_toolbar_anchors.dart';
// Examples can assume:
// FocusNode _focusNode = FocusNode();
@@ -200,6 +202,7 @@
/// toolbar for mobile devices.
const SelectableRegion({
super.key,
+ this.contextMenuBuilder,
required this.focusNode,
required this.selectionControls,
required this.child,
@@ -224,6 +227,9 @@
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ final SelectableRegionContextMenuBuilder? contextMenuBuilder;
+
/// The delegate to build the selection handles and toolbar for mobile
/// devices.
///
@@ -234,11 +240,54 @@
/// Called when the selected content changes.
final ValueChanged<SelectedContent?>? onSelectionChanged;
+ /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+ /// platform's default selection menu.
+ ///
+ /// For example, [SelectableRegion] uses this to generate the default buttons
+ /// for its context menu.
+ ///
+ /// See also:
+ ///
+ /// * [SelectableRegionState.contextMenuButtonItems], which gives the
+ /// [ContextMenuButtonItem]s for a specific SelectableRegion.
+ /// * [EditableText.getEditableButtonItems], which performs a similar role but
+ /// for content that is both selectable and editable.
+ /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+ /// take a list of [ContextMenuButtonItem]s with
+ /// [AdaptiveTextSelectionToolbar.buttonItems].
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button
+ /// Widgets for the current platform given [ContextMenuButtonItem]s.
+ static List<ContextMenuButtonItem> getSelectableButtonItems({
+ required final SelectionGeometry selectionGeometry,
+ required final VoidCallback onCopy,
+ required final VoidCallback onSelectAll,
+ }) {
+ final bool canCopy = selectionGeometry.hasSelection;
+ final bool canSelectAll = selectionGeometry.hasContent;
+
+ // Determine which buttons will appear so that the order and total number is
+ // known. A button's position in the menu can slightly affect its
+ // appearance.
+ return <ContextMenuButtonItem>[
+ if (canCopy)
+ ContextMenuButtonItem(
+ onPressed: onCopy,
+ type: ContextMenuButtonType.copy,
+ ),
+ if (canSelectAll)
+ ContextMenuButtonItem(
+ onPressed: onSelectAll,
+ type: ContextMenuButtonType.selectAll,
+ ),
+ ];
+ }
+
@override
- State<StatefulWidget> createState() => _SelectableRegionState();
+ State<StatefulWidget> createState() => SelectableRegionState();
}
-class _SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
+/// State for a [SelectableRegion].
+class SelectableRegionState extends State<SelectableRegion> with TextSelectionDelegate implements SelectionRegistrar {
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)),
CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)),
@@ -258,6 +307,9 @@
Orientation? _lastOrientation;
SelectedContent? _lastSelectedContent;
+ /// {@macro flutter.rendering.RenderEditable.lastSecondaryTapDownPosition}
+ Offset? lastSecondaryTapDownPosition;
+
@override
void initState() {
super.initState();
@@ -424,6 +476,7 @@
}
void _handleRightClickDown(TapDownDetails details) {
+ lastSecondaryTapDownPosition = details.globalPosition;
widget.focusNode.requestFocus();
_selectWordAt(offset: details.globalPosition);
_showHandles();
@@ -465,7 +518,17 @@
}
void _onAnyDragEnd(DragEndDetails details) {
- _selectionOverlay!.hideMagnifier(shouldShowToolbar: true);
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay!.hideMagnifier();
+ _selectionOverlay!.showToolbar();
+ } else {
+ _selectionOverlay!.hideMagnifier();
+ _selectionOverlay!.showToolbar(
+ contextMenuBuilder: (BuildContext context) {
+ return widget.contextMenuBuilder!(context, this);
+ },
+ );
+ }
_stopSelectionEndEdgeUpdate();
_updateSelectedContentIfNeeded();
}
@@ -516,8 +579,6 @@
late Offset _selectionStartHandleDragPosition;
late Offset _selectionEndHandleDragPosition;
- late List<TextSelectionPoint> points;
-
void _handleSelectionStartHandleDragStart(DragStartDetails details) {
assert(_selectionDelegate.value.startSelectionPoint != null);
@@ -595,19 +656,6 @@
}
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
- final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
- final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
- if (startLocalPosition.dy > endLocalPosition.dy) {
- points = <TextSelectionPoint>[
- TextSelectionPoint(endLocalPosition, TextDirection.ltr),
- TextSelectionPoint(startLocalPosition, TextDirection.ltr),
- ];
- } else {
- points = <TextSelectionPoint>[
- TextSelectionPoint(startLocalPosition, TextDirection.ltr),
- TextSelectionPoint(endLocalPosition, TextDirection.ltr),
- ];
- }
_selectionOverlay = SelectionOverlay(
context: context,
debugRequiredFor: widget,
@@ -621,7 +669,7 @@
onEndHandleDragStart: _handleSelectionEndHandleDragStart,
onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate,
onEndHandleDragEnd: _onAnyDragEnd,
- selectionEndpoints: points,
+ selectionEndpoints: selectionEndpoints,
selectionControls: widget.selectionControls,
selectionDelegate: this,
clipboardStatus: null,
@@ -639,26 +687,12 @@
assert(_hasSelectionOverlayGeometry);
final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
- late List<TextSelectionPoint> points;
- final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
- final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
- if (startLocalPosition.dy > endLocalPosition.dy) {
- points = <TextSelectionPoint>[
- TextSelectionPoint(endLocalPosition, TextDirection.ltr),
- TextSelectionPoint(startLocalPosition, TextDirection.ltr),
- ];
- } else {
- points = <TextSelectionPoint>[
- TextSelectionPoint(startLocalPosition, TextDirection.ltr),
- TextSelectionPoint(endLocalPosition, TextDirection.ltr),
- ];
- }
_selectionOverlay!
..startHandleType = start?.handleType ?? TextSelectionHandleType.left
..lineHeightAtStart = start?.lineHeight ?? end!.lineHeight
..endHandleType = end?.handleType ?? TextSelectionHandleType.right
..lineHeightAtEnd = end?.lineHeight ?? start!.lineHeight
- ..selectionEndpoints = points;
+ ..selectionEndpoints = selectionEndpoints;
}
/// Shows the selection handles.
@@ -706,7 +740,19 @@
}
_selectionOverlay!.toolbarLocation = location;
- _selectionOverlay!.showToolbar();
+ if (widget.selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay!.showToolbar();
+ return true;
+ }
+
+ _selectionOverlay!.hideToolbar();
+
+ _selectionOverlay!.showToolbar(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return widget.contextMenuBuilder!(context, this);
+ },
+ );
return true;
}
@@ -830,11 +876,104 @@
await Clipboard.setData(ClipboardData(text: data.plainText));
}
- // [TextSelectionDelegate] overrides.
+ /// {@macro flutter.widgets.EditableText.getAnchors}
+ ///
+ /// See also:
+ ///
+ /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s
+ /// for the default context menu buttons.
+ TextSelectionToolbarAnchors get contextMenuAnchors {
+ if (lastSecondaryTapDownPosition != null) {
+ return TextSelectionToolbarAnchors(
+ primaryAnchor: lastSecondaryTapDownPosition!,
+ );
+ }
+ final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+ return TextSelectionToolbarAnchors.fromSelection(
+ renderBox: renderBox,
+ startGlyphHeight: startGlyphHeight,
+ endGlyphHeight: endGlyphHeight,
+ selectionEndpoints: selectionEndpoints,
+ );
+ }
+ /// Returns the [ContextMenuButtonItem]s representing the buttons in this
+ /// platform's default selection menu.
+ ///
+ /// See also:
+ ///
+ /// * [SelectableRegion.getSelectableButtonItems], which performs a similar role,
+ /// but for any selectable text, not just specifically SelectableRegion.
+ /// * [EditableTextState.contextMenuButtonItems], which peforms a similar role
+ /// but for content that is not just selectable but also editable.
+ /// * [contextMenuAnchors], which provides the anchor points for the default
+ /// context menu.
+ /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can
+ /// take a list of [ContextMenuButtonItem]s with
+ /// [AdaptiveTextSelectionToolbar.buttonItems].
+ /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the
+ /// button Widgets for the current platform given [ContextMenuButtonItem]s.
+ List<ContextMenuButtonItem> get contextMenuButtonItems {
+ return SelectableRegion.getSelectableButtonItems(
+ selectionGeometry: _selectionDelegate.value,
+ onCopy: () {
+ _copy();
+ hideToolbar();
+ },
+ onSelectAll: () {
+ selectAll();
+ hideToolbar();
+ },
+ );
+ }
+
+ /// The line height at the start of the current selection.
+ double get startGlyphHeight {
+ return _selectionDelegate.value.startSelectionPoint!.lineHeight;
+ }
+
+ /// The line height at the end of the current selection.
+ double get endGlyphHeight {
+ return _selectionDelegate.value.endSelectionPoint!.lineHeight;
+ }
+
+ /// Returns the local coordinates of the endpoints of the current selection.
+ List<TextSelectionPoint> get selectionEndpoints {
+ final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint;
+ final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint;
+ late List<TextSelectionPoint> points;
+ final Offset startLocalPosition = start?.localPosition ?? end!.localPosition;
+ final Offset endLocalPosition = end?.localPosition ?? start!.localPosition;
+ if (startLocalPosition.dy > endLocalPosition.dy) {
+ points = <TextSelectionPoint>[
+ TextSelectionPoint(endLocalPosition, TextDirection.ltr),
+ TextSelectionPoint(startLocalPosition, TextDirection.ltr),
+ ];
+ } else {
+ points = <TextSelectionPoint>[
+ TextSelectionPoint(startLocalPosition, TextDirection.ltr),
+ TextSelectionPoint(endLocalPosition, TextDirection.ltr),
+ ];
+ }
+ return points;
+ }
+
+ // [TextSelectionDelegate] overrides.
+ // TODO(justinmc): After deprecations have been removed, remove
+ // TextSelectionDelegate from this class.
+ // https://github.com/flutter/flutter/issues/111213
+
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
bool get cutEnabled => false;
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
bool get pasteEnabled => false;
@@ -857,28 +996,50 @@
_updateSelectedContentIfNeeded();
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void copySelection(SelectionChangedCause cause) {
_copy();
_clearSelection();
}
- // TODO(chunhtai): remove this workaround after decoupling text selection
- // from text editing in TextSelectionDelegate.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
TextEditingValue textEditingValue = const TextEditingValue(text: '_');
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void bringIntoView(TextPosition position) {/* SelectableRegion must be in view at this point. */}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void cutSelection(SelectionChangedCause cause) {
assert(false);
}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
void userUpdateTextEditingValue(TextEditingValue value, SelectionChangedCause cause) {/* SelectableRegion maintains its own state */}
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
@override
Future<void> pasteText(SelectionChangedCause cause) async {
assert(false);
@@ -909,7 +1070,7 @@
_selectionDelegate.dispose();
// In case dispose was triggered before gesture end, remove the magnifier
// so it doesn't remain stuck in the overlay forever.
- _selectionOverlay?.hideMagnifier(shouldShowToolbar: false);
+ _selectionOverlay?.hideMagnifier();
_selectionOverlay?.dispose();
_selectionOverlay = null;
super.dispose();
@@ -967,7 +1128,7 @@
class _SelectAllAction extends _NonOverrideAction<SelectAllTextIntent> {
_SelectAllAction(this.state);
- final _SelectableRegionState state;
+ final SelectableRegionState state;
@override
void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) {
@@ -978,7 +1139,7 @@
class _CopySelectionAction extends _NonOverrideAction<CopySelectionTextIntent> {
_CopySelectionAction(this.state);
- final _SelectableRegionState state;
+ final SelectableRegionState state;
@override
void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) {
@@ -1795,3 +1956,15 @@
return finalResult!;
}
}
+
+/// Signature for a widget builder that builds a context menu for the given
+/// [SelectableRegionState].
+///
+/// See also:
+///
+/// * [EditableTextContextMenuBuilder], which performs the same role for
+/// [EditableText].
+typedef SelectableRegionContextMenuBuilder = Widget Function(
+ BuildContext context,
+ SelectableRegionState selectableRegionState,
+);
diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart
index a9c81f3..959d6b8 100644
--- a/packages/flutter/lib/src/widgets/text_selection.dart
+++ b/packages/flutter/lib/src/widgets/text_selection.dart
@@ -16,6 +16,7 @@
import 'binding.dart';
import 'constants.dart';
import 'container.dart';
+import 'context_menu_controller.dart';
import 'debug.dart';
import 'editable_text.dart';
import 'framework.dart';
@@ -113,6 +114,10 @@
/// The [selectionMidpoint] parameter is a general calculation midpoint
/// parameter of the toolbar. More detailed position information
/// is computable from the [endpoints] parameter.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
@@ -137,6 +142,10 @@
///
/// Subclasses can use this to decide if they should expose the cut
/// functionality to the user.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
bool canCut(TextSelectionDelegate delegate) {
return delegate.cutEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
@@ -148,6 +157,10 @@
///
/// Subclasses can use this to decide if they should expose the copy
/// functionality to the user.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
bool canCopy(TextSelectionDelegate delegate) {
return delegate.copyEnabled && !delegate.textEditingValue.selection.isCollapsed;
}
@@ -161,6 +174,10 @@
/// This does not consider the contents of the clipboard. Subclasses may want
/// to, for example, disallow pasting when the clipboard contains an empty
/// string.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
bool canPaste(TextSelectionDelegate delegate) {
return delegate.pasteEnabled;
}
@@ -171,6 +188,10 @@
///
/// Subclasses can use this to decide if they should expose the select all
/// functionality to the user.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
bool canSelectAll(TextSelectionDelegate delegate) {
return delegate.selectAllEnabled && delegate.textEditingValue.text.isNotEmpty && delegate.textEditingValue.selection.isCollapsed;
}
@@ -181,6 +202,10 @@
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.cutSelection(SelectionChangedCause.toolbar);
}
@@ -191,6 +216,10 @@
/// the user.
// TODO(chunhtai): remove optional parameter once migration is done.
// https://github.com/flutter/flutter/issues/99360
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {
delegate.copySelection(SelectionChangedCause.toolbar);
}
@@ -204,6 +233,10 @@
/// asynchronous. Race conditions may exist with this API as currently
/// implemented.
// TODO(ianh): https://github.com/flutter/flutter/issues/11427
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
Future<void> handlePaste(TextSelectionDelegate delegate) async {
delegate.pasteText(SelectionChangedCause.toolbar);
}
@@ -215,6 +248,10 @@
///
/// This is called by subclasses when their select-all affordance is activated
/// by the user.
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
void handleSelectAll(TextSelectionDelegate delegate) {
delegate.selectAll(SelectionChangedCause.toolbar);
}
@@ -293,6 +330,7 @@
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
VoidCallback? onSelectionHandleTapped,
ClipboardStatusNotifier? clipboardStatus,
+ this.contextMenuBuilder,
required TextMagnifierConfiguration magnifierConfiguration,
}) : assert(value != null),
assert(context != null),
@@ -333,6 +371,14 @@
);
}
+ /// {@template flutter.widgets.SelectionOverlay.context}
+ /// The context in which the selection UI should appear.
+ ///
+ /// This context must have an [Overlay] as an ancestor because this object
+ /// will display the text selection handles in that [Overlay].
+ /// {@endtemplate}
+ final BuildContext context;
+
/// Controls the fade-in and fade-out animations for the toolbar and handles.
@Deprecated(
'Use `SelectionOverlay.fadeDuration` instead. '
@@ -353,6 +399,11 @@
late final SelectionOverlay _selectionOverlay;
+ /// {@macro flutter.widgets.EditableText.contextMenuBuilder}
+ ///
+ /// If not provided, no context menu will be built.
+ final WidgetBuilder? contextMenuBuilder;
+
/// Retrieve current value.
@visibleForTesting
TextEditingValue get value => _value;
@@ -365,12 +416,6 @@
final ValueNotifier<bool> _effectiveEndHandleVisibility = ValueNotifier<bool>(false);
final ValueNotifier<bool> _effectiveToolbarVisibility = ValueNotifier<bool>(false);
- /// The context in which the selection handles should appear.
- ///
- /// This context must have an [Overlay] as an ancestor because this object
- /// will display the text selection handles in that [Overlay].
- final BuildContext context;
-
void _updateTextSelectionOverlayVisibilities() {
_effectiveStartHandleVisibility.value = _handlesVisible && renderObject.selectionStartInViewport.value;
_effectiveEndHandleVisibility.value = _handlesVisible && renderObject.selectionEndInViewport.value;
@@ -406,7 +451,22 @@
/// {@macro flutter.widgets.SelectionOverlay.showToolbar}
void showToolbar() {
_updateSelectionOverlay();
- _selectionOverlay.showToolbar();
+
+ if (selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay.showToolbar();
+ return;
+ }
+
+ if (contextMenuBuilder == null) {
+ return;
+ }
+
+ assert(context.mounted);
+ _selectionOverlay.showToolbar(
+ context: context,
+ contextMenuBuilder: contextMenuBuilder,
+ );
+ return;
}
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
@@ -436,8 +496,8 @@
}
/// {@macro flutter.widgets.SelectionOverlay.hideMagnifier}
- void hideMagnifier({required bool shouldShowToolbar}) {
- _selectionOverlay.hideMagnifier(shouldShowToolbar: shouldShowToolbar);
+ void hideMagnifier() {
+ _selectionOverlay.hideMagnifier();
}
/// Updates the overlay after the selection has changed.
@@ -487,7 +547,11 @@
bool get handlesAreVisible => _selectionOverlay._handles != null && handlesVisible;
/// Whether the toolbar is currently visible.
- bool get toolbarIsVisible => _selectionOverlay._toolbar != null;
+ bool get toolbarIsVisible {
+ return selectionControls is TextSelectionHandleControls
+ ? _selectionOverlay._contextMenuControllerIsShown
+ : _selectionOverlay._toolbar != null;
+ }
/// Whether the magnifier is currently visible.
bool get magnifierIsVisible => _selectionOverlay._magnifierController.shown;
@@ -506,6 +570,7 @@
_effectiveToolbarVisibility.dispose();
_effectiveStartHandleVisibility.dispose();
_effectiveEndHandleVisibility.dispose();
+ hideToolbar();
}
double _getStartGlyphHeight() {
@@ -728,7 +793,25 @@
_handleSelectionHandleChanged(newSelection, isEnd: false);
}
- void _handleAnyDragEnd(DragEndDetails details) => _selectionOverlay.hideMagnifier(shouldShowToolbar: !_selection.isCollapsed);
+ void _handleAnyDragEnd(DragEndDetails details) {
+ if (!context.mounted) {
+ return;
+ }
+ if (selectionControls is! TextSelectionHandleControls) {
+ _selectionOverlay.hideMagnifier();
+ if (!_selection.isCollapsed) {
+ _selectionOverlay.showToolbar();
+ }
+ return;
+ }
+ _selectionOverlay.hideMagnifier();
+ if (!_selection.isCollapsed) {
+ _selectionOverlay.showToolbar(
+ context: context,
+ contextMenuBuilder: contextMenuBuilder,
+ );
+ }
+ }
// Returns the offset that locates a drag on a handle to the correct line of text.
Offset _getOffsetToTextPositionPoint(TextSelectionHandleType type) {
@@ -810,6 +893,10 @@
this.toolbarVisible,
required List<TextSelectionPoint> selectionEndpoints,
required this.selectionControls,
+ @Deprecated(
+ 'Use `contextMenuBuilder` in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
required this.selectionDelegate,
required this.clipboardStatus,
required this.startHandleLayerLink,
@@ -817,6 +904,10 @@
required this.toolbarLayerLink,
this.dragStartBehavior = DragStartBehavior.start,
this.onSelectionHandleTapped,
+ @Deprecated(
+ 'Use `contextMenuBuilder` in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
Offset? toolbarLocation,
this.magnifierConfiguration = TextMagnifierConfiguration.disabled,
}) : _startHandleType = startHandleType,
@@ -827,13 +918,9 @@
_toolbarLocation = toolbarLocation,
assert(debugCheckHasOverlay(context));
- /// The context in which the selection handles should appear.
- ///
- /// This context must have an [Overlay] as an ancestor because this object
- /// will display the text selection handles in that [Overlay].
+ /// {@macro flutter.widgets.SelectionOverlay.context}
final BuildContext context;
-
final ValueNotifier<MagnifierInfo> _magnifierInfo =
ValueNotifier<MagnifierInfo>(MagnifierInfo.empty);
@@ -863,7 +950,7 @@
/// [MagnifierController.shown].
/// {@endtemplate}
void showMagnifier(MagnifierInfo initalMagnifierInfo) {
- if (_toolbar != null) {
+ if (_toolbar != null || _contextMenuControllerIsShown) {
hideToolbar();
}
@@ -892,12 +979,11 @@
}
/// {@template flutter.widgets.SelectionOverlay.hideMagnifier}
- /// Hide the current magnifier, optionally immediately showing
- /// the toolbar.
+ /// Hide the current magnifier.
///
/// This does nothing if there is no magnifier.
/// {@endtemplate}
- void hideMagnifier({required bool shouldShowToolbar}) {
+ void hideMagnifier() {
// This cannot be a check on `MagnifierController.shown`, since
// it's possible that the magnifier is still in the overlay, but
// not shown in cases where the magnifier hides itself.
@@ -906,10 +992,6 @@
}
_magnifierController.hide();
-
- if (shouldShowToolbar) {
- showToolbar();
- }
}
/// The type of start selection handle.
@@ -1046,7 +1128,11 @@
/// The delegate for manipulating the current selection in the owning
/// text field.
/// {@endtemplate}
- final TextSelectionDelegate selectionDelegate;
+ @Deprecated(
+ 'Use `contextMenuBuilder` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
+ final TextSelectionDelegate? selectionDelegate;
/// Determines the way that drag start behavior is handled.
///
@@ -1097,6 +1183,10 @@
///
/// This is useful for displaying toolbars at the mouse right-click locations
/// in desktop devices.
+ @Deprecated(
+ 'Use the `contextMenuBuilder` parameter in `showToolbar` instead. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+ )
Offset? get toolbarLocation => _toolbarLocation;
Offset? _toolbarLocation;
set toolbarLocation(Offset? value) {
@@ -1117,6 +1207,11 @@
/// A copy/paste toolbar.
OverlayEntry? _toolbar;
+ // Manages the context menu. Not necessarily visible when non-null.
+ final ContextMenuController _contextMenuController = ContextMenuController();
+
+ bool get _contextMenuControllerIsShown => _contextMenuController.isShown;
+
/// {@template flutter.widgets.SelectionOverlay.showHandles}
/// Builds the handles by inserting them into the [context]'s overlay.
/// {@endtemplate}
@@ -1147,12 +1242,34 @@
/// {@template flutter.widgets.SelectionOverlay.showToolbar}
/// Shows the toolbar by inserting it into the [context]'s overlay.
/// {@endtemplate}
- void showToolbar() {
- if (_toolbar != null) {
+ void showToolbar({
+ BuildContext? context,
+ WidgetBuilder? contextMenuBuilder,
+ }) {
+ if (contextMenuBuilder == null) {
+ if (_toolbar != null) {
+ return;
+ }
+ _toolbar = OverlayEntry(builder: _buildToolbar);
+ Overlay.of(this.context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
return;
}
- _toolbar = OverlayEntry(builder: _buildToolbar);
- Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor).insert(_toolbar!);
+
+ if (context == null) {
+ return;
+ }
+
+ final RenderBox renderBox = context.findRenderObject()! as RenderBox;
+ _contextMenuController.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return _SelectionToolbarWrapper(
+ layerLink: toolbarLayerLink,
+ offset: -renderBox.localToGlobal(Offset.zero),
+ child: contextMenuBuilder(context),
+ );
+ },
+ );
}
bool _buildScheduled = false;
@@ -1174,6 +1291,9 @@
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
+ if (_contextMenuController.isShown) {
+ _contextMenuController.markNeedsBuild();
+ }
});
} else {
if (_handles != null) {
@@ -1181,6 +1301,9 @@
_handles![1].markNeedsBuild();
}
_toolbar?.markNeedsBuild();
+ if (_contextMenuController.isShown) {
+ _contextMenuController.markNeedsBuild();
+ }
}
}
@@ -1194,7 +1317,7 @@
_handles![1].remove();
_handles = null;
}
- if (_toolbar != null) {
+ if (_toolbar != null || _contextMenuControllerIsShown) {
hideToolbar();
}
}
@@ -1205,6 +1328,7 @@
/// To hide the whole overlay, see [hide].
/// {@endtemplate}
void hideToolbar() {
+ _contextMenuController.remove();
if (_toolbar == null) {
return;
}
@@ -1272,10 +1396,12 @@
);
}
+ // Build the toolbar via TextSelectionControls.
Widget _buildToolbar(BuildContext context) {
if (selectionControls == null) {
return const SizedBox.shrink();
}
+ assert(selectionDelegate != null, 'If not using contextMenuBuilder, must pass selectionDelegate.');
final RenderBox renderBox = this.context.findRenderObject()! as RenderBox;
@@ -1299,21 +1425,23 @@
selectionEndpoints.first.point.dy - lineHeightAtStart,
);
- return TextFieldTapRegion(
- child: Directionality(
- textDirection: Directionality.of(this.context),
- child: _SelectionToolbarOverlay(
- preferredLineHeight: lineHeightAtStart,
- toolbarLocation: toolbarLocation,
- layerLink: toolbarLayerLink,
- editingRegion: editingRegion,
- selectionControls: selectionControls,
- midpoint: midpoint,
- selectionEndpoints: selectionEndpoints,
- visibility: toolbarVisible,
- selectionDelegate: selectionDelegate,
- clipboardStatus: clipboardStatus,
- ),
+ return _SelectionToolbarWrapper(
+ visibility: toolbarVisible,
+ layerLink: toolbarLayerLink,
+ offset: -editingRegion.topLeft,
+ child: Builder(
+ builder: (BuildContext context) {
+ return selectionControls!.buildToolbar(
+ context,
+ editingRegion,
+ lineHeightAtStart,
+ midpoint,
+ selectionEndpoints,
+ selectionDelegate!,
+ clipboardStatus,
+ toolbarLocation,
+ );
+ },
),
);
}
@@ -1337,38 +1465,32 @@
}
}
-/// This widget represents a selection toolbar.
-class _SelectionToolbarOverlay extends StatefulWidget {
- /// Creates a toolbar overlay.
- const _SelectionToolbarOverlay({
- required this.preferredLineHeight,
- required this.toolbarLocation,
- required this.layerLink,
- required this.editingRegion,
- required this.selectionControls,
+// TODO(justinmc): Currently this fades in but not out on all platforms. It
+// should follow the correct fading behavior for the current platform, then be
+// made public and de-duplicated with widgets/selectable_region.dart.
+// https://github.com/flutter/flutter/issues/107732
+// Wrap the given child in the widgets common to both contextMenuBuilder and
+// TextSelectionControls.buildToolbar.
+class _SelectionToolbarWrapper extends StatefulWidget {
+ const _SelectionToolbarWrapper({
this.visibility,
- required this.midpoint,
- required this.selectionEndpoints,
- required this.selectionDelegate,
- required this.clipboardStatus,
- });
+ required this.layerLink,
+ required this.offset,
+ required this.child,
+ }) : assert(layerLink != null),
+ assert(offset != null),
+ assert(child != null);
- final double preferredLineHeight;
- final Offset? toolbarLocation;
+ final Widget child;
+ final Offset offset;
final LayerLink layerLink;
- final Rect editingRegion;
- final TextSelectionControls? selectionControls;
final ValueListenable<bool>? visibility;
- final Offset midpoint;
- final List<TextSelectionPoint> selectionEndpoints;
- final TextSelectionDelegate? selectionDelegate;
- final ClipboardStatusNotifier? clipboardStatus;
@override
- _SelectionToolbarOverlayState createState() => _SelectionToolbarOverlayState();
+ State<_SelectionToolbarWrapper> createState() => _SelectionToolbarWrapperState();
}
-class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with SingleTickerProviderStateMixin {
+class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper> with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@@ -1383,7 +1505,7 @@
}
@override
- void didUpdateWidget(_SelectionToolbarOverlay oldWidget) {
+ void didUpdateWidget(_SelectionToolbarWrapper oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.visibility == widget.visibility) {
return;
@@ -1410,25 +1532,17 @@
@override
Widget build(BuildContext context) {
- return FadeTransition(
- opacity: _opacity,
- child: CompositedTransformFollower(
- link: widget.layerLink,
- showWhenUnlinked: false,
- offset: -widget.editingRegion.topLeft,
- child: Builder(
- builder: (BuildContext context) {
- return widget.selectionControls!.buildToolbar(
- context,
- widget.editingRegion,
- widget.preferredLineHeight,
- widget.midpoint,
- widget.selectionEndpoints,
- widget.selectionDelegate!,
- widget.clipboardStatus,
- widget.toolbarLocation,
- );
- },
+ return TextFieldTapRegion(
+ child: Directionality(
+ textDirection: Directionality.of(this.context),
+ child: FadeTransition(
+ opacity: _opacity,
+ child: CompositedTransformFollower(
+ link: widget.layerLink,
+ showWhenUnlinked: false,
+ offset: widget.offset,
+ child: widget.child,
+ ),
),
),
);
@@ -1464,7 +1578,6 @@
@override
State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState();
-
}
class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin {
@@ -1813,6 +1926,9 @@
// trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
final PointerDeviceKind? kind = details.kind;
+ // TODO(justinmc): Should a desktop platform show its selection toolbar when
+ // receiving a tap event? Say a Windows device with a touchscreen.
+ // https://github.com/flutter/flutter/issues/106586
_shouldShowSelectionToolbar = kind == null
|| kind == PointerDeviceKind.touch
|| kind == PointerDeviceKind.stylus;
@@ -2122,7 +2238,7 @@
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
- editableText.hideMagnifier(shouldShowToolbar: false);
+ editableText.hideMagnifier();
break;
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
@@ -2781,3 +2897,47 @@
/// The content on the clipboard is not pastable, such as when it is empty.
notPasteable,
}
+
+/// [TextSelectionControls] that specifically do not manage the toolbar in order
+/// to leave that to [EditableText.contextMenuBuilder].
+@Deprecated(
+ 'Use `TextSelectionControls`. '
+ 'This feature was deprecated after v3.3.0-0.5.pre.',
+)
+mixin TextSelectionHandleControls on TextSelectionControls {
+ @override
+ Widget buildToolbar(
+ BuildContext context,
+ Rect globalEditableRegion,
+ double textLineHeight,
+ Offset selectionMidpoint,
+ List<TextSelectionPoint> endpoints,
+ TextSelectionDelegate delegate,
+ ValueNotifier<ClipboardStatus>? clipboardStatus,
+ Offset? lastSecondaryTapDownPosition,
+ ) => const SizedBox.shrink();
+
+ @override
+ bool canCut(TextSelectionDelegate delegate) => false;
+
+ @override
+ bool canCopy(TextSelectionDelegate delegate) => false;
+
+ @override
+ bool canPaste(TextSelectionDelegate delegate) => false;
+
+ @override
+ bool canSelectAll(TextSelectionDelegate delegate) => false;
+
+ @override
+ void handleCut(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
+
+ @override
+ void handleCopy(TextSelectionDelegate delegate, [ClipboardStatusNotifier? clipboardStatus]) {}
+
+ @override
+ Future<void> handlePaste(TextSelectionDelegate delegate) async {}
+
+ @override
+ void handleSelectAll(TextSelectionDelegate delegate) {}
+}
diff --git a/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
new file mode 100644
index 0000000..9f51264
--- /dev/null
+++ b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
@@ -0,0 +1,71 @@
+// 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.
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+
+/// The position information for a text selection toolbar.
+///
+/// Typically, a menu will attempt to position itself at [primaryAnchor], and
+/// if that's not possible, then it will use [secondaryAnchor] instead, if it
+/// exists.
+///
+/// See also:
+///
+/// * [AdaptiveTextSelectionToolbar.anchors], which is of this type.
+@immutable
+class TextSelectionToolbarAnchors {
+ /// Creates an instance of [TextSelectionToolbarAnchors] directly from the
+ /// anchor points.
+ const TextSelectionToolbarAnchors({
+ required this.primaryAnchor,
+ this.secondaryAnchor,
+ });
+
+ /// Creates an instance of [TextSelectionToolbarAnchors] for some selection.
+ factory TextSelectionToolbarAnchors.fromSelection({
+ required RenderBox renderBox,
+ required double startGlyphHeight,
+ required double endGlyphHeight,
+ required List<TextSelectionPoint> selectionEndpoints,
+ }) {
+ final Rect editingRegion = Rect.fromPoints(
+ renderBox.localToGlobal(Offset.zero),
+ renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
+ );
+ final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
+ endGlyphHeight / 2;
+
+ final Rect selectionRect = Rect.fromLTRB(
+ isMultiline
+ ? editingRegion.left
+ : editingRegion.left + selectionEndpoints.first.point.dx,
+ editingRegion.top + selectionEndpoints.first.point.dy - startGlyphHeight,
+ isMultiline
+ ? editingRegion.right
+ : editingRegion.left + selectionEndpoints.last.point.dx,
+ editingRegion.top + selectionEndpoints.last.point.dy,
+ );
+
+ return TextSelectionToolbarAnchors(
+ primaryAnchor: Offset(
+ selectionRect.left + selectionRect.width / 2,
+ clampDouble(selectionRect.top, editingRegion.top, editingRegion.bottom),
+ ),
+ secondaryAnchor: Offset(
+ selectionRect.left + selectionRect.width / 2,
+ clampDouble(selectionRect.bottom, editingRegion.top, editingRegion.bottom),
+ ),
+ );
+ }
+
+ /// The location that the toolbar should attempt to position itself at.
+ ///
+ /// If the toolbar doesn't fit at this location, use [secondaryAnchor] if it
+ /// exists.
+ final Offset primaryAnchor;
+
+ /// The fallback position that should be used if [primaryAnchor] doesn't work.
+ final Offset? secondaryAnchor;
+}
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart
index cbf8e9b..cf29bdd 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -35,6 +35,8 @@
export 'src/widgets/bottom_navigation_bar_item.dart';
export 'src/widgets/color_filter.dart';
export 'src/widgets/container.dart';
+export 'src/widgets/context_menu_button_item.dart';
+export 'src/widgets/context_menu_controller.dart';
export 'src/widgets/debug.dart';
export 'src/widgets/default_selection_style.dart';
export 'src/widgets/default_text_editing_shortcuts.dart';
@@ -137,6 +139,7 @@
export 'src/widgets/text.dart';
export 'src/widgets/text_editing_intents.dart';
export 'src/widgets/text_selection.dart';
+export 'src/widgets/text_selection_toolbar_anchors.dart';
export 'src/widgets/text_selection_toolbar_layout_delegate.dart';
export 'src/widgets/texture.dart';
export 'src/widgets/ticker_provider.dart';
diff --git a/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..6c150d1
--- /dev/null
+++ b/packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart
@@ -0,0 +1,245 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../widgets/clipboard_utils.dart';
+
+void main() {
+ final MockClipboard mockClipboard = MockClipboard();
+
+ setUp(() async {
+ TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ mockClipboard.handleMethodCall,
+ );
+ // Fill the clipboard so that the Paste option is available in the text
+ // selection menu.
+ await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+ });
+
+ tearDown(() {
+ TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ null,
+ );
+ });
+
+ testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
+ const String buttonText = 'Click me';
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoAdaptiveTextSelectionToolbar.buttonItems(
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ buttonItems: <ContextMenuButtonItem>[
+ ContextMenuButtonItem(
+ label: buttonText,
+ onPressed: () {
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text(buttonText), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
+ break;
+ }
+ },
+ variant: TargetPlatformVariant.all(),
+ skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+ );
+
+ testWidgets('Can build children directly as well', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoAdaptiveTextSelectionToolbar(
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ children: <Widget>[
+ Container(key: key),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(key), findsOneWidget);
+ },
+ skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+ );
+
+ testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(CupertinoApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 400,
+ child: EditableText(
+ controller: TextEditingController(),
+ backgroundCursorColor: const Color(0xff00ffff),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: const Color(0xff00ffff),
+ selectionControls: cupertinoTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return CupertinoAdaptiveTextSelectionToolbar.editableText(
+ key: key,
+ editableTextState: editableTextState,
+ );
+ },
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byKey(key), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(key), findsOneWidget);
+ expect(find.text('Copy'), findsNothing);
+ expect(find.text('Cut'), findsNothing);
+ expect(find.text('Select all'), findsNothing);
+ expect(find.text('Paste'), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+ break;
+ }
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ variant: TargetPlatformVariant.all(),
+ );
+
+ testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(CupertinoApp(
+ home: Center(
+ child: CupertinoAdaptiveTextSelectionToolbar.editable(
+ key: key,
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ clipboardStatus: ClipboardStatus.pasteable,
+ onCopy: () {},
+ onCut: () {},
+ onPaste: () {},
+ onSelectAll: () {},
+ ),
+ ),
+ ));
+
+ expect(find.byKey(key), findsOneWidget);
+ expect(find.text('Copy'), findsOneWidget);
+ expect(find.text('Cut'), findsOneWidget);
+ expect(find.text('Select All'), findsOneWidget);
+ expect(find.text('Paste'), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
+ break;
+ }
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ variant: TargetPlatformVariant.all(),
+ );
+
+ testWidgets('Builds the correct button per-platform', (WidgetTester tester) async {
+ const String buttonText = 'Click me';
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: Builder(
+ builder: (BuildContext context) {
+ return Column(
+ children: CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons(
+ context,
+ <ContextMenuButtonItem>[
+ ContextMenuButtonItem(
+ label: buttonText,
+ onPressed: () {
+ },
+ ),
+ ],
+ ).toList(),
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text(buttonText), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+ break;
+ case TargetPlatform.macOS:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+ break;
+ }
+ },
+ variant: TargetPlatformVariant.all(),
+ );
+}
diff --git a/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart
new file mode 100644
index 0000000..3a3af2e
--- /dev/null
+++ b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart
@@ -0,0 +1,74 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('can press', (WidgetTester tester) async {
+ bool pressed = false;
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoDesktopTextSelectionToolbarButton(
+ child: const Text('Tap me'),
+ onPressed: () {
+ pressed = true;
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(pressed, false);
+
+ await tester.tap(find.byType(CupertinoDesktopTextSelectionToolbarButton));
+ expect(pressed, true);
+ });
+
+ testWidgets('pressedOpacity defaults to 0.1', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoDesktopTextSelectionToolbarButton(
+ child: const Text('Tap me'),
+ onPressed: () { },
+ ),
+ ),
+ ),
+ );
+
+ // Original at full opacity.
+ FadeTransition opacity = tester.widget(find.descendant(
+ of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+ matching: find.byType(FadeTransition),
+ ));
+ expect(opacity.opacity.value, 1.0);
+
+ // Make a "down" gesture on the button.
+ final Offset center = tester.getCenter(find.byType(CupertinoDesktopTextSelectionToolbarButton));
+ final TestGesture gesture = await tester.startGesture(center);
+ await tester.pumpAndSettle();
+
+ // Opacity reduces during the down gesture.
+ opacity = tester.widget(find.descendant(
+ of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+ matching: find.byType(FadeTransition),
+ ));
+ expect(opacity.opacity.value, 0.7);
+
+ // Release the down gesture.
+ await gesture.up();
+ await tester.pumpAndSettle();
+
+ // Opacity is back to normal.
+ opacity = tester.widget(find.descendant(
+ of: find.byType(CupertinoDesktopTextSelectionToolbarButton),
+ matching: find.byType(FadeTransition),
+ ));
+ expect(opacity.opacity.value, 1.0);
+ });
+}
diff --git a/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..bde5250
--- /dev/null
+++ b/packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart
@@ -0,0 +1,37 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('positions itself at the anchor', (WidgetTester tester) async {
+ // An arbitrary point on the screen to position at.
+ const Offset anchor = Offset(30.0, 40.0);
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: CupertinoDesktopTextSelectionToolbar(
+ anchor: anchor,
+ children: <Widget>[
+ CupertinoDesktopTextSelectionToolbarButton(
+ child: const Text('Tap me'),
+ onPressed: () {},
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getTopLeft(find.byType(CupertinoDesktopTextSelectionToolbarButton)),
+ // Greater than due to padding internal to the toolbar.
+ greaterThan(anchor),
+ );
+ });
+}
diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart
index f646dee..2aedb7b 100644
--- a/packages/flutter/test/cupertino/text_field_test.dart
+++ b/packages/flutter/test/cupertino/text_field_test.dart
@@ -1578,30 +1578,30 @@
),
);
- // Long press to put the cursor after the "w".
- const int index = 3;
- await tester.longPressAt(textOffsetToPosition(tester, index));
- await tester.pump();
- expect(
- controller.selection,
- const TextSelection.collapsed(offset: index),
- );
+ // Long press to put the cursor after the "w".
+ const int index = 3;
+ await tester.longPressAt(textOffsetToPosition(tester, index));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: index),
+ );
- // Double tap on the same location to select the word around the cursor.
- await tester.tapAt(textOffsetToPosition(tester, index));
- await tester.pump(const Duration(milliseconds: 50));
- await tester.tapAt(textOffsetToPosition(tester, index));
- await tester.pump();
- expect(
- controller.selection,
- const TextSelection(baseOffset: 0, extentOffset: 7),
- );
+ // Double tap on the same location to select the word around the cursor.
+ await tester.tapAt(textOffsetToPosition(tester, index));
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tapAt(textOffsetToPosition(tester, index));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection(baseOffset: 0, extentOffset: 7),
+ );
- // Selected text shows 'Copy'.
- expect(find.text('Paste'), findsNothing);
- expect(find.text('Copy'), findsOneWidget);
- expect(find.text('Cut'), findsNothing);
- expect(find.text('Select All'), findsNothing);
+ // Selected text shows 'Copy'.
+ expect(find.text('Paste'), findsNothing);
+ expect(find.text('Copy'), findsOneWidget);
+ expect(find.text('Cut'), findsNothing);
+ expect(find.text('Select All'), findsNothing);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
@@ -1633,6 +1633,15 @@
const TextSelection(baseOffset: 0, extentOffset: 7),
);
+ // Tap elsewhere to hide the context menu so that subsequent taps don't
+ // collide with it.
+ await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
+ );
+
// Double tap on the same location to select the word around the cursor.
await tester.tapAt(textOffsetToPosition(tester, 10));
await tester.pump(const Duration(milliseconds: 50));
@@ -1966,7 +1975,7 @@
);
testWidgets(
- 'double tap selects word and first tap of double tap moves cursor for non-Apple platforms',
+ 'double tap selects word for non-Apple platforms',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@@ -1990,6 +1999,15 @@
const TextSelection(baseOffset: 0, extentOffset: 7),
);
+ // Tap elsewhere to hide the context menu so that subsequent taps don't
+ // collide with it.
+ await tester.tapAt(textOffsetToPosition(tester, controller.text.length));
+ await tester.pump();
+ expect(
+ controller.selection,
+ const TextSelection.collapsed(offset: 35, affinity: TextAffinity.upstream),
+ );
+
// Double tap in the middle of 'Peel' to select the word.
await tester.tapAt(textOffsetToPosition(tester, 10));
await tester.pump(const Duration(milliseconds: 50));
@@ -2001,7 +2019,7 @@
);
// Selected text shows 3 toolbar buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
// Tap somewhere else to move the cursor.
await tester.tapAt(textOffsetToPosition(tester, index));
@@ -2012,7 +2030,7 @@
);
testWidgets(
- 'double tap selects word and first tap of double tap moves cursor for Apple platforms',
+ 'double tap selects word for Apple platforms',
(WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
@@ -2036,8 +2054,10 @@
const TextSelection.collapsed(offset: index),
);
- // Double tap on the same location to select the word around the cursor.
- await tester.tapAt(textOffsetToPosition(tester, index));
+ // Double tap to select the word around the cursor. Move slightly left of
+ // the previous tap in order to avoid hitting the text selection toolbar
+ // on Mac.
+ await tester.tapAt(textOffsetToPosition(tester, index) - const Offset(1.0, 0.0));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, index));
await tester.pumpAndSettle();
@@ -2046,8 +2066,22 @@
const TextSelection(baseOffset: 0, extentOffset: 7),
);
- // Selected text shows 3 toolbar buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ if (isContextMenuProvidedByPlatform) {
+ expect(find.byType(CupertinoButton), findsNothing);
+ } else {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.macOS:
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoButton), findsNWidgets(3));
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(CupertinoButton), findsNWidgets(4));
+ break;
+ }
+ }
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -2184,8 +2218,24 @@
const TextSelection(baseOffset: 8, extentOffset: 12),
);
- // Selected text shows 3 toolbar buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ final Matcher matchToolbarButtons;
+ if (isContextMenuProvidedByPlatform) {
+ matchToolbarButtons = findsNothing;
+ } else {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.macOS:
+ case TargetPlatform.iOS:
+ matchToolbarButtons = findsNWidgets(3);
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ matchToolbarButtons = findsNWidgets(4);
+ break;
+ }
+ }
+ expect(find.byType(CupertinoButton), matchToolbarButtons);
await gesture.up();
await tester.pump();
@@ -2195,7 +2245,7 @@
controller.selection,
const TextSelection(baseOffset: 8, extentOffset: 12),
);
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ expect(find.byType(CupertinoButton), matchToolbarButtons);
},
);
@@ -2348,8 +2398,10 @@
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
- // Put the cursor at the end of the field.
- await tester.tapAt(textOffsetToPosition(tester, 10));
+ // Tap at the end of the text to move the selection to the end. On some
+ // platforms, the context menu "Cut" button blocks this tap, so move it out
+ // of the way by an Offset.
+ await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
@@ -2646,8 +2698,8 @@
const TextSelection(baseOffset: 0, extentOffset: 7, affinity: TextAffinity.upstream),
);
- // Non-Collapsed toolbar shows 3 buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ // Non-Collapsed toolbar shows 4 buttons.
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -2680,7 +2732,14 @@
);
// Collapsed toolbar shows 2 buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform
+ ? findsNothing
+ : defaultTargetPlatform == TargetPlatform.iOS
+ ? findsNWidgets(2)
+ : findsNWidgets(1),
+ );
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -2706,7 +2765,21 @@
await tester.longPressAt(ePos);
await tester.pump(const Duration(milliseconds: 50));
- await tester.tapAt(ePos);
+ final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
+ if (kIsWeb) {
+ expect(find.byType(CupertinoButton), findsNothing);
+ } else {
+ expect(find.byType(CupertinoButton), findsNWidgets(isTargetPlatformMobile ? 2 : 1));
+ }
+ expect(controller.selection.isCollapsed, isTrue);
+ expect(controller.selection.baseOffset, 6);
+
+ // Tap in a slightly different position to avoid hitting the context menu
+ // on desktop.
+ final Offset secondTapPos = isTargetPlatformMobile
+ ? ePos
+ : ePos + const Offset(-1.0, 0.0);
+ await tester.tapAt(secondTapPos);
await tester.pump();
// The cursor does not move and the toolbar is toggled.
@@ -2776,7 +2849,7 @@
const TextSelection(baseOffset: 0, extentOffset: 23, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(4));
},
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -2840,7 +2913,14 @@
const TextSelection.collapsed(offset: 9, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform
+ ? findsNothing
+ : defaultTargetPlatform == TargetPlatform.iOS
+ ? findsNWidgets(2)
+ : findsNWidgets(1),
+ );
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -3001,7 +3081,16 @@
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int toolbarButtons;
+ if (isContextMenuProvidedByPlatform) {
+ toolbarButtons = 0;
+ } else if (defaultTargetPlatform == TargetPlatform.iOS) {
+ toolbarButtons = 2;
+ } else {
+ // MacOS has no 'Select all' button.
+ toolbarButtons = 1;
+ }
+ expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
@@ -3059,7 +3148,16 @@
);
// Long press toolbar.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int toolbarButtons;
+ if (isContextMenuProvidedByPlatform) {
+ toolbarButtons = 0;
+ } else if (defaultTargetPlatform == TargetPlatform.iOS) {
+ toolbarButtons = 2;
+ } else {
+ // MacOS has no 'Select all' button.
+ toolbarButtons = 1;
+ }
+ expect(find.byType(CupertinoButton), findsNWidgets(toolbarButtons));
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets(
@@ -3081,17 +3179,30 @@
),
);
- final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
+ // Use a position higher than wPos to avoid tapping the context menu on
+ // desktop.
+ final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
await tester.longPressAt(wPos);
await tester.pump(const Duration(milliseconds: 50));
+ expect(controller.selection.isCollapsed, isTrue);
+ expect(controller.selection.baseOffset, 3);
+ if (isContextMenuProvidedByPlatform) {
+ expect(find.byType(CupertinoButton), findsNothing);
+ } else {
+ expect(find.byType(CupertinoButton), isTargetPlatformMobile ? findsNWidgets(2) : findsNWidgets(1));
+ }
+
await tester.tapAt(pPos);
await tester.pump(const Duration(milliseconds: 50));
+
// First tap moved the cursor.
+ expect(find.byType(CupertinoButton), findsNothing);
expect(controller.selection.isCollapsed, isTrue);
expect(controller.selection.baseOffset, isTargetPlatformMobile ? 8 : 9);
+
await tester.tapAt(pPos);
await tester.pumpAndSettle();
@@ -3208,7 +3319,24 @@
await gesture.up();
await tester.pumpAndSettle();
// Shows toolbar.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(3));
+ final Matcher matchToolbarButtons;
+ if (isContextMenuProvidedByPlatform) {
+ matchToolbarButtons = findsNothing;
+ } else {
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.macOS:
+ case TargetPlatform.iOS:
+ matchToolbarButtons = findsNWidgets(3);
+ break;
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ matchToolbarButtons = findsNWidgets(4);
+ break;
+ }
+ }
+ expect(find.byType(CupertinoButton), matchToolbarButtons);
});
testWidgets('force press on unsupported devices falls back to tap', (WidgetTester tester) async {
@@ -3295,6 +3423,11 @@
);
expect(endpoints.length, 2);
+ // On Mac, the toolbar blocks the drag on the right handle, so hide it.
+ final EditableTextState editableTextState = tester.state(find.byType(EditableText));
+ editableTextState.hideToolbar(false);
+ await tester.pumpAndSettle();
+
// Drag the right handle until there's only 1 char selected.
// We use a small offset because the endpoint is on the very corner
// of the handle.
@@ -6269,6 +6402,9 @@
key: key1,
focusNode: focusNode1,
),
+ // This spacer prevents the context menu in one field from
+ // overlapping with the other field.
+ const SizedBox(height: 100.0),
CupertinoTextField(
key: key2,
focusNode: focusNode2,
@@ -6406,6 +6542,75 @@
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
});
+ group('context menu', () {
+ testWidgets('builds CupertinoAdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(text: '');
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ CupertinoTextField(
+ controller: controller,
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ );
+
+ testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ final TextEditingController controller = TextEditingController(text: '');
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: <Widget>[
+ CupertinoTextField(
+ controller: controller,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return Placeholder(key: key);
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byKey(key), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(key), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ );
+ });
+
group('magnifier', () {
late ValueNotifier<MagnifierInfo> magnifierInfo;
final Widget fakeMagnifier = Container(key: UniqueKey());
@@ -6500,7 +6705,6 @@
),
);
-
const String testValue = 'abc def ghi';
await tester.enterText(find.byType(CupertinoTextField), testValue);
@@ -6662,6 +6866,7 @@
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS }));
+ });
group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@@ -6776,34 +6981,33 @@
skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser.
);
- testWidgets("Tapping on border doesn't lose focus",
- (WidgetTester tester) async {
- final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
- await tester.pumpWidget(
- CupertinoApp(
- home: Center(
- child: SizedBox(
- width: 100,
- height: 100,
- child: CupertinoTextField(
- autofocus: true,
- focusNode: focusNode,
- ),
+ testWidgets("Tapping on border doesn't lose focus",
+ (WidgetTester tester) async {
+ final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Center(
+ child: SizedBox(
+ width: 100,
+ height: 100,
+ child: CupertinoTextField(
+ autofocus: true,
+ focusNode: focusNode,
),
),
),
- );
- await tester.pump();
- expect(focusNode.hasPrimaryFocus, isTrue);
+ ),
+ );
+ await tester.pump();
+ expect(focusNode.hasPrimaryFocus, isTrue);
- final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
- // Tap just inside the border, but not inside the EditableText.
- await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
- await tester.pump();
+ final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
+ // Tap just inside the border, but not inside the EditableText.
+ await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
+ await tester.pump();
- expect(focusNode.hasPrimaryFocus, isTrue);
- }, variant: TargetPlatformVariant.all());
- });
+ expect(focusNode.hasPrimaryFocus, isTrue);
+ }, variant: TargetPlatformVariant.all());
});
testWidgets('Can drag handles to change selection correctly in multiline', (WidgetTester tester) async {
diff --git a/packages/flutter/test/cupertino/text_selection_toolbar_test.dart b/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
index 9a46e76..5052d1e 100644
--- a/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
+++ b/packages/flutter/test/cupertino/text_selection_toolbar_test.dart
@@ -23,7 +23,7 @@
Offset selectionMidpoint,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
- ClipboardStatusNotifier? clipboardStatus,
+ ValueNotifier<ClipboardStatus>? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final MediaQueryData mediaQuery = MediaQuery.of(context);
diff --git a/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart b/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..067de6e
--- /dev/null
+++ b/packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart
@@ -0,0 +1,393 @@
+// 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.
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../widgets/clipboard_utils.dart';
+import '../widgets/editable_text_utils.dart';
+
+void main() {
+ final MockClipboard mockClipboard = MockClipboard();
+
+ setUp(() async {
+ TestWidgetsFlutterBinding.ensureInitialized();
+ TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger.setMockMethodCallHandler(
+ SystemChannels.platform,
+ mockClipboard.handleMethodCall,
+ );
+ // Fill the clipboard so that the Paste option is available in the text
+ // selection menu.
+ await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+ });
+
+ testWidgets('Builds the right toolbar on each platform, including web, and shows buttonItems', (WidgetTester tester) async {
+ const String buttonText = 'Click me';
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: AdaptiveTextSelectionToolbar.buttonItems(
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ buttonItems: <ContextMenuButtonItem>[
+ ContextMenuButtonItem(
+ label: buttonText,
+ onPressed: () {
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text(buttonText), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ expect(find.byType(TextSelectionToolbar), findsOneWidget);
+ expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+ break;
+ case TargetPlatform.iOS:
+ expect(find.byType(TextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbar), findsOneWidget);
+ expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+ break;
+ case TargetPlatform.macOS:
+ expect(find.byType(TextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsOneWidget);
+ break;
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(TextSelectionToolbar), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbar), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbar), findsOneWidget);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbar), findsNothing);
+ break;
+ }
+ },
+ variant: TargetPlatformVariant.all(),
+ skip: isBrowser, // [intended] see https://github.com/flutter/flutter/issues/108382
+ );
+
+ testWidgets('Can build children directly as well', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: AdaptiveTextSelectionToolbar(
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ children: <Widget>[
+ Container(key: key),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(key), findsOneWidget);
+ });
+
+ testWidgets('Can build from EditableTextState', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: SizedBox(
+ width: 400,
+ child: EditableText(
+ controller: TextEditingController(),
+ backgroundCursorColor: const Color(0xff00ffff),
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: const Color(0xff00ffff),
+ selectionControls: materialTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return AdaptiveTextSelectionToolbar.editableText(
+ key: key,
+ editableTextState: editableTextState,
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byKey(key), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(key), findsOneWidget);
+ expect(find.text('Copy'), findsNothing);
+ expect(find.text('Cut'), findsNothing);
+ expect(find.text('Select all'), findsNothing);
+ expect(find.text('Paste'), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
+ break;
+ case TargetPlatform.iOS:
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+ break;
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
+ break;
+ case TargetPlatform.macOS:
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+ break;
+ }
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ variant: TargetPlatformVariant.all(),
+ );
+
+ testWidgets('Can build for editable text from raw parameters', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: AdaptiveTextSelectionToolbar.editable(
+ key: key,
+ anchors: const TextSelectionToolbarAnchors(
+ primaryAnchor: Offset.zero,
+ ),
+ clipboardStatus: ClipboardStatus.pasteable,
+ onCopy: () {},
+ onCut: () {},
+ onPaste: () {},
+ onSelectAll: () {},
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(key), findsOneWidget);
+ expect(find.text('Copy'), findsOneWidget);
+ expect(find.text('Cut'), findsOneWidget);
+ expect(find.text('Paste'), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ expect(find.text('Select all'), findsOneWidget);
+ expect(find.byType(TextSelectionToolbarTextButton), findsNWidgets(4));
+ break;
+ case TargetPlatform.iOS:
+ expect(find.text('Select All'), findsOneWidget);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNWidgets(4));
+ break;
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.text('Select all'), findsOneWidget);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsNWidgets(4));
+ break;
+ case TargetPlatform.macOS:
+ expect(find.text('Select All'), findsOneWidget);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNWidgets(4));
+ break;
+ }
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ variant: TargetPlatformVariant.all(),
+ );
+
+ group('buttonItems', () {
+ testWidgets('getEditableTextButtonItems builds the correct button items per-platform', (WidgetTester tester) async {
+ // Fill the clipboard so that the Paste option is available in the text
+ // selection menu.
+ await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+
+ Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
+ final TextEditingController controller = TextEditingController();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: EditableText(
+ controller: controller,
+ backgroundCursorColor: Colors.grey,
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.red,
+ selectionControls: materialTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ buttonTypes = editableTextState.contextMenuButtonItems
+ .map((ContextMenuButtonItem buttonItem) => buttonItem.type)
+ .toSet();
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+
+ final EditableTextState state =
+ tester.state<EditableTextState>(find.byType(EditableText));
+
+ // With no text in the field.
+ await tester.tapAt(textOffsetToPosition(tester, 0));
+ await tester.pump();
+ expect(state.showToolbar(), true);
+ await tester.pump();
+
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
+ expect(buttonTypes, contains(ContextMenuButtonType.paste));
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+
+ // With text but no selection.
+ controller.text = 'lorem ipsum';
+ await tester.pump();
+
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.cut)));
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.copy)));
+ expect(buttonTypes, contains(ContextMenuButtonType.paste));
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.iOS:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+ break;
+ case TargetPlatform.macOS:
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+ break;
+ }
+
+ // With text and selection.
+ controller.value = controller.value.copyWith(
+ selection: const TextSelection(
+ baseOffset: 0,
+ extentOffset: 'lorem'.length,
+ ),
+ );
+ await tester.pump();
+
+ expect(buttonTypes, contains(ContextMenuButtonType.cut));
+ expect(buttonTypes, contains(ContextMenuButtonType.copy));
+ expect(buttonTypes, contains(ContextMenuButtonType.paste));
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.android:
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+ break;
+ case TargetPlatform.iOS:
+ case TargetPlatform.macOS:
+ expect(buttonTypes, isNot(contains(ContextMenuButtonType.selectAll)));
+ break;
+ }
+ },
+ variant: TargetPlatformVariant.all(),
+ skip: kIsWeb, // [intended]
+ );
+
+ testWidgets('getAdaptiveButtons builds the correct button widgets per-platform', (WidgetTester tester) async {
+ const String buttonText = 'Click me';
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Center(
+ child: Builder(
+ builder: (BuildContext context) {
+ final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[
+ ContextMenuButtonItem(
+ label: buttonText,
+ onPressed: () {
+ },
+ ),
+ ];
+ return ListView(
+ children: AdaptiveTextSelectionToolbar.getAdaptiveButtons(
+ context,
+ buttonItems,
+ ).toList(),
+ );
+ },
+ ),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.text(buttonText), findsOneWidget);
+
+ switch (defaultTargetPlatform) {
+ case TargetPlatform.fuchsia:
+ case TargetPlatform.android:
+ expect(find.byType(TextSelectionToolbarTextButton), findsOneWidget);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+ break;
+ case TargetPlatform.iOS:
+ expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsOneWidget);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+ break;
+ case TargetPlatform.macOS:
+ expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsOneWidget);
+ break;
+ case TargetPlatform.linux:
+ case TargetPlatform.windows:
+ expect(find.byType(TextSelectionToolbarTextButton), findsNothing);
+ expect(find.byType(CupertinoTextSelectionToolbarButton), findsNothing);
+ expect(find.byType(DesktopTextSelectionToolbarButton), findsOneWidget);
+ expect(find.byType(CupertinoDesktopTextSelectionToolbarButton), findsNothing);
+ break;
+ }
+ },
+ variant: TargetPlatformVariant.all(),
+ );
+ });
+}
diff --git a/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart b/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart
new file mode 100644
index 0000000..41350ab
--- /dev/null
+++ b/packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart
@@ -0,0 +1,31 @@
+// 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.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('can press', (WidgetTester tester) async {
+ bool pressed = false;
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: DesktopTextSelectionToolbarButton(
+ child: const Text('Tap me'),
+ onPressed: () {
+ pressed = true;
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(pressed, false);
+
+ await tester.tap(find.byType(DesktopTextSelectionToolbarButton));
+ expect(pressed, true);
+ });
+}
diff --git a/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart b/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart
new file mode 100644
index 0000000..3052356
--- /dev/null
+++ b/packages/flutter/test/material/desktop_text_selection_toolbar_test.dart
@@ -0,0 +1,36 @@
+// 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.
+
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ testWidgets('positions itself at the anchor', (WidgetTester tester) async {
+ // An arbitrary point on the screen to position at.
+ const Offset anchor = Offset(30.0, 40.0);
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: DesktopTextSelectionToolbar(
+ anchor: anchor,
+ children: <Widget>[
+ DesktopTextSelectionToolbarButton(
+ child: const Text('Tap me'),
+ onPressed: () {},
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ tester.getTopLeft(find.byType(DesktopTextSelectionToolbarButton)),
+ anchor,
+ );
+ });
+}
diff --git a/packages/flutter/test/material/selection_area_test.dart b/packages/flutter/test/material/selection_area_test.dart
index 4c697af..0310c16 100644
--- a/packages/flutter/test/material/selection_area_test.dart
+++ b/packages/flutter/test/material/selection_area_test.dart
@@ -27,21 +27,82 @@
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
- expect(region.selectionControls, materialTextSelectionControls);
+ expect(region.selectionControls, materialTextSelectionHandleControls);
break;
case TargetPlatform.iOS:
- expect(region.selectionControls, cupertinoTextSelectionControls);
+ expect(region.selectionControls, cupertinoTextSelectionHandleControls);
break;
case TargetPlatform.linux:
case TargetPlatform.windows:
- expect(region.selectionControls, desktopTextSelectionControls);
+ expect(region.selectionControls, desktopTextSelectionHandleControls);
break;
case TargetPlatform.macOS:
- expect(region.selectionControls, cupertinoDesktopTextSelectionControls);
+ expect(region.selectionControls, cupertinoDesktopTextSelectionHandleControls);
break;
}
}, variant: TargetPlatformVariant.all());
+ testWidgets('builds the default context menu by default', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SelectionArea(
+ focusNode: FocusNode(),
+ child: const Text('How are you?'),
+ ),
+ ),
+ );
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Show the toolbar by longpressing.
+ final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+ final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+ addTearDown(gesture.removePointer);
+ await tester.pump(const Duration(milliseconds: 500));
+ // `are` is selected.
+ expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended]
+ );
+
+ testWidgets('builds a custom context menu if provided', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SelectionArea(
+ focusNode: FocusNode(),
+ contextMenuBuilder: (
+ BuildContext context,
+ SelectableRegionState selectableRegionState,
+ ) {
+ return Placeholder(key: key);
+ },
+ child: const Text('How are you?'),
+ ),
+ ),
+ );
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ expect(find.byKey(key), findsNothing);
+
+ // Show the toolbar by longpressing.
+ final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+ final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+ addTearDown(gesture.removePointer);
+ await tester.pump(const Duration(milliseconds: 500));
+ // `are` is selected.
+ expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+ expect(find.byKey(key), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended]
+ );
+
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
SelectedContent? content;
diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart
index 0b0fd27..cf07039 100644
--- a/packages/flutter/test/material/text_field_test.dart
+++ b/packages/flutter/test/material/text_field_test.dart
@@ -24,15 +24,12 @@
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
-import '../widgets/editable_text_utils.dart' show OverflowWidgetTextEditingController, findRenderEditable, globalize, textOffsetToPosition;
+import '../widgets/editable_text_utils.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
typedef FormatEditUpdateCallback = void Function(TextEditingValue, TextEditingValue);
-// On web, the context menu (aka toolbar) is provided by the browser.
-const bool isContextMenuProvidedByPlatform = isBrowser;
-
// On web, key events in text fields are handled by the browser.
const bool areKeyEventsHandledByPlatform = isBrowser;
@@ -1160,8 +1157,9 @@
expect(controller.selection.baseOffset, 0);
expect(controller.selection.extentOffset, 7);
- // Use toolbar to select all text.
- if (isContextMenuProvidedByPlatform) {
+ // Select all text. Use the toolbar if possible. iOS only shows the toolbar
+ // when the selection is collapsed.
+ if (isContextMenuProvidedByPlatform || defaultTargetPlatform == TargetPlatform.iOS) {
controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
expect(controller.selection.extentOffset, controller.text.length);
} else {
@@ -2914,6 +2912,7 @@
await tester.pump();
// Toolbar should fade in. Starting at 0% opacity.
+ expect(find.text('Select all'), findsOneWidget);
final Element target = tester.element(find.text('Select all'));
final FadeTransition opacity = target.findAncestorWidgetOfExactType<FadeTransition>()!;
expect(opacity.opacity.value, equals(0.0));
@@ -8474,7 +8473,11 @@
);
// Collapsed toolbar shows 2 buttons.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+ );
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -8535,7 +8538,14 @@
await tester.longPressAt(ePos);
await tester.pump(const Duration(milliseconds: 50));
- await tester.tapAt(ePos);
+
+ // Tap slightly behind the previous tap to avoid tapping the context menu
+ // on desktop.
+ final bool isTargetPlatformMobile = defaultTargetPlatform == TargetPlatform.iOS;
+ final Offset secondTapPos = isTargetPlatformMobile
+ ? ePos
+ : ePos + const Offset(-1.0, 0.0);
+ await tester.tapAt(secondTapPos);
await tester.pump();
// The cursor does not move and the toolbar is toggled.
@@ -8689,7 +8699,11 @@
const TextSelection.collapsed(offset: 9),
);
// The toolbar now shows up.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+ );
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -8852,7 +8866,11 @@
const TextSelection.collapsed(offset: 66, affinity: TextAffinity.upstream),
);
// The toolbar now shows up.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+ );
lastCharEndpoint = renderEditable.getEndpointsForSelection(
const TextSelection.collapsed(offset: 66), // Last character's position.
@@ -9038,7 +9056,6 @@
// Start long pressing on the first line.
final TestGesture gesture =
await tester.startGesture(textOffsetToPosition(tester, 19));
- // TODO(justinmc): Make sure you've got all things torn down.
await tester.pump(const Duration(milliseconds: 500));
expect(
controller.selection,
@@ -9281,7 +9298,11 @@
);
// Long press toolbar.
- expect(find.byType(CupertinoButton), isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(2));
+ final int buttons = defaultTargetPlatform == TargetPlatform.iOS ? 2 : 1;
+ expect(
+ find.byType(CupertinoButton),
+ isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(buttons),
+ );
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }),
);
@@ -9307,7 +9328,9 @@
),
);
- final Offset pPos = textOffsetToPosition(tester, 9); // Index of 'P|eel'
+ // The second tap is slightly higher to avoid tapping the context menu on
+ // desktop.
+ final Offset pPos = textOffsetToPosition(tester, 9) + const Offset(0.0, -20.0); // Index of 'P|eel'
final Offset wPos = textOffsetToPosition(tester, 3); // Index of 'Atw|ater'
await tester.longPressAt(wPos);
@@ -9662,8 +9685,10 @@
expect(controller.value.selection.baseOffset, 5);
expect(controller.value.selection.extentOffset, 6);
- // Put the cursor at the end of the field.
- await tester.tapAt(textOffsetToPosition(tester, 10));
+ // Tap at the end of the text to move the selection to the end. On some
+ // platforms, the context menu "Cut" button blocks this tap, so move it out
+ // of the way by an Offset.
+ await tester.tapAt(textOffsetToPosition(tester, 10) + const Offset(200.0, 0.0));
expect(controller.value.selection, isNotNull);
expect(controller.value.selection.baseOffset, 10);
expect(controller.value.selection.extentOffset, 10);
@@ -10679,10 +10704,12 @@
// Tap the handle to show the toolbar.
final Offset handlePos = endpoints[0].point + const Offset(0.0, 1.0);
await tester.tapAt(handlePos, pointer: 7);
+ await tester.pump();
expect(editableText.selectionOverlay!.toolbarIsVisible, isContextMenuProvidedByPlatform ? isFalse : isTrue);
// Tap the handle again to hide the toolbar.
await tester.tapAt(handlePos, pointer: 7);
+ await tester.pump();
expect(editableText.selectionOverlay!.toolbarIsVisible, isFalse);
});
@@ -11405,7 +11432,8 @@
expect(find.text(selectAll), findsNothing);
expect(find.text('Copy'), findsNothing);
},
- variant: TargetPlatformVariant.desktop(),
+ // All desktop platforms except MacOS, which has no select all button.
+ variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.linux, TargetPlatform.windows }),
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
);
@@ -12455,6 +12483,7 @@
key: key1,
focusNode: focusNode1,
),
+ const SizedBox(height: 100.0),
TextField(
key: key2,
focusNode: focusNode2,
@@ -12594,6 +12623,77 @@
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
});
+ group('context menu', () {
+ testWidgets('builds AdaptiveTextSelectionToolbar by default', (WidgetTester tester) async {
+ final TextEditingController controller = TextEditingController(text: '');
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Column(
+ children: <Widget>[
+ TextField(
+ controller: controller,
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ );
+
+ testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ final TextEditingController controller = TextEditingController(text: '');
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Material(
+ child: Column(
+ children: <Widget>[
+ TextField(
+ controller: controller,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return Placeholder(key: key);
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byKey(key), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(key), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ );
+ });
+
group('magnifier builder', () {
testWidgets('should build custom magnifier if given',
(WidgetTester tester) async {
@@ -12816,6 +12916,7 @@
await tester.pumpAndSettle();
expect(find.byKey(fakeMagnifier.key!), findsNothing);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }));
+ });
group('TapRegion integration', () {
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
@@ -12966,6 +13067,7 @@
),
),
);
+
await tester.pump();
expect(focusNode.hasPrimaryFocus, isTrue);
@@ -13033,10 +13135,9 @@
case PointerDeviceKind.unknown:
expect(focusNode.hasPrimaryFocus, isFalse);
break;
- }
- }, variant: TargetPlatformVariant.all());
- }
- });
+ }
+ }, variant: TargetPlatformVariant.all());
+ }
});
}
diff --git a/packages/flutter/test/material/text_selection_test.dart b/packages/flutter/test/material/text_selection_test.dart
index faf88de..230ca07 100644
--- a/packages/flutter/test/material/text_selection_test.dart
+++ b/packages/flutter/test/material/text_selection_test.dart
@@ -611,7 +611,6 @@
expect(find.text('Select all'), findsNothing);
expect(find.byType(IconButton), findsNothing);
-
// The menu appears at the top of the visible selection.
final Offset selectionOffset = tester
.getTopLeft(find.byType(TextSelectionToolbarTextButton).first);
diff --git a/packages/flutter/test/material/text_selection_toolbar_test.dart b/packages/flutter/test/material/text_selection_toolbar_test.dart
index 8ab72e6..0274d1c 100644
--- a/packages/flutter/test/material/text_selection_toolbar_test.dart
+++ b/packages/flutter/test/material/text_selection_toolbar_test.dart
@@ -8,11 +8,12 @@
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
+const double _kHandleSize = 22.0;
+const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
+const double _kToolbarContentDistance = 8.0;
+
// A custom text selection menu that just displays a single custom button.
class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls {
- static const double _kToolbarContentDistanceBelow = 20.0;
- static const double _kToolbarContentDistance = 8.0;
-
@override
Widget buildToolbar(
BuildContext context,
@@ -154,23 +155,23 @@
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(findToolbar()).dy;
- expect(toolbarY, equals(anchorBelowY));
+ expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
// Even when it barely doesn't fit.
setState(() {
- anchorAboveY = 50.0;
- });
- await tester.pump();
- toolbarY = tester.getTopLeft(findToolbar()).dy;
- expect(toolbarY, equals(anchorBelowY));
-
- // When it does fit above aboveAnchor, it positions itself there.
- setState(() {
anchorAboveY = 60.0;
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
- expect(toolbarY, equals(anchorAboveY - height));
+ expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
+
+ // When it does fit above aboveAnchor, it positions itself there.
+ setState(() {
+ anchorAboveY = 70.0;
+ });
+ await tester.pump();
+ toolbarY = tester.getTopLeft(findToolbar()).dy;
+ expect(toolbarY, equals(anchorAboveY - height - _kToolbarContentDistance));
});
testWidgets('can create and use a custom toolbar', (WidgetTester tester) async {
diff --git a/packages/flutter/test/widgets/context_menu_controller_test.dart b/packages/flutter/test/widgets/context_menu_controller_test.dart
new file mode 100644
index 0000000..1a61558
--- /dev/null
+++ b/packages/flutter/test/widgets/context_menu_controller_test.dart
@@ -0,0 +1,263 @@
+// 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.
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'clipboard_utils.dart';
+import 'editable_text_utils.dart';
+
+void main() {
+ final MockClipboard mockClipboard = MockClipboard();
+ TestWidgetsFlutterBinding.ensureInitialized()
+ .defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, mockClipboard.handleMethodCall);
+
+ setUp(() async {
+ // Fill the clipboard so that the Paste option is available in the text
+ // selection menu.
+ await Clipboard.setData(const ClipboardData(text: 'Clipboard data'));
+ });
+
+ testWidgets('Hides and shows only a single menu', (WidgetTester tester) async {
+ final GlobalKey key1 = GlobalKey();
+ final GlobalKey key2 = GlobalKey();
+ late final BuildContext context;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (BuildContext localContext) {
+ context = localContext;
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(key1), findsNothing);
+ expect(find.byKey(key2), findsNothing);
+
+ final ContextMenuController controller1 = ContextMenuController();
+ await tester.pump();
+ expect(find.byKey(key1), findsNothing);
+ expect(find.byKey(key2), findsNothing);
+
+ controller1.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: key1);
+ },
+ );
+ await tester.pump();
+
+ expect(find.byKey(key1), findsOneWidget);
+ expect(find.byKey(key2), findsNothing);
+
+ // Showing the same thing again does nothing and is not an error.
+ controller1.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: key1);
+ },
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), null);
+ expect(find.byKey(key1), findsOneWidget);
+ expect(find.byKey(key2), findsNothing);
+
+ // Showing a new menu hides the first.
+ final ContextMenuController controller2 = ContextMenuController();
+ controller2.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: key2);
+ },
+ );
+ await tester.pump();
+
+ expect(find.byKey(key1), findsNothing);
+ expect(find.byKey(key2), findsOneWidget);
+
+ controller2.remove();
+ await tester.pump();
+
+ expect(find.byKey(key1), findsNothing);
+ expect(find.byKey(key2), findsNothing);
+ });
+
+ testWidgets('A menu can be hidden and then reshown', (WidgetTester tester) async {
+ final GlobalKey key1 = GlobalKey();
+ late final BuildContext context;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (BuildContext localContext) {
+ context = localContext;
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(key1), findsNothing);
+
+ final ContextMenuController controller = ContextMenuController();
+
+ // Instantiating the controller does not shown it.
+ await tester.pump();
+ expect(find.byKey(key1), findsNothing);
+
+ controller.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: key1);
+ },
+ );
+ await tester.pump();
+
+ expect(find.byKey(key1), findsOneWidget);
+
+ controller.remove();
+ await tester.pump();
+
+ expect(find.byKey(key1), findsNothing);
+
+ controller.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: key1);
+ },
+ );
+ await tester.pump();
+
+ expect(find.byKey(key1), findsOneWidget);
+ });
+
+ testWidgets('markNeedsBuild causes the builder to update', (WidgetTester tester) async {
+ int buildCount = 0;
+ late final BuildContext context;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (BuildContext localContext) {
+ context = localContext;
+ return const SizedBox.shrink();
+ },
+ ),
+ ),
+ ),
+ );
+
+ final ContextMenuController controller = ContextMenuController();
+ controller.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ buildCount++;
+ return const Placeholder();
+ },
+ );
+ expect(buildCount, 0);
+ await tester.pump();
+ expect(buildCount, 1);
+
+ controller.markNeedsBuild();
+ expect(buildCount, 1);
+ await tester.pump();
+ expect(buildCount, 2);
+
+ controller.remove();
+ });
+
+ testWidgets('Calling show when a built-in widget is already showing its context menu hides the built-in menu', (WidgetTester tester) async {
+ final GlobalKey builtInKey = GlobalKey();
+ final GlobalKey directKey = GlobalKey();
+ late final BuildContext context;
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Builder(
+ builder: (BuildContext localContext) {
+ context = localContext;
+ return EditableText(
+ controller: TextEditingController(),
+ backgroundCursorColor: Colors.grey,
+ focusNode: FocusNode(),
+ style: const TextStyle(),
+ cursorColor: Colors.red,
+ selectionControls: materialTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return Placeholder(key: builtInKey);
+ },
+ );
+ },
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(builtInKey), findsNothing);
+ expect(find.byKey(directKey), findsNothing);
+
+ final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
+
+ await tester.tapAt(textOffsetToPosition(tester, 0));
+ await tester.pump();
+ expect(state.showToolbar(), true);
+ await tester.pump();
+
+ expect(find.byKey(builtInKey), findsOneWidget);
+ expect(find.byKey(directKey), findsNothing);
+
+ final ContextMenuController controller = ContextMenuController();
+ controller.show(
+ context: context,
+ contextMenuBuilder: (BuildContext context) {
+ return Placeholder(key: directKey);
+ },
+ );
+ await tester.pump();
+
+ expect(find.byKey(builtInKey), findsNothing);
+ expect(find.byKey(directKey), findsOneWidget);
+ expect(controller.isShown, isTrue);
+
+ // And showing the built-in menu hides the directly shown menu.
+ expect(state.showToolbar(), isTrue);
+ await tester.pump();
+
+ expect(find.byKey(builtInKey), findsOneWidget);
+ expect(find.byKey(directKey), findsNothing);
+ expect(controller.isShown, isFalse);
+
+ // Calling remove on the hidden ContextMenuController does not hide the
+ // built-in menu.
+ controller.remove();
+ await tester.pump();
+
+ expect(find.byKey(builtInKey), findsOneWidget);
+ expect(find.byKey(directKey), findsNothing);
+ expect(controller.isShown, isFalse);
+
+ state.hideToolbar();
+ await tester.pump();
+ expect(find.byKey(builtInKey), findsNothing);
+ expect(find.byKey(directKey), findsNothing);
+ expect(controller.isShown, isFalse);
+ },
+ skip: isContextMenuProvidedByPlatform, // [intended] no Flutter-drawn text selection toolbar on web.
+ );
+}
diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart
index 632e4a3..51ebfa8 100644
--- a/packages/flutter/test/widgets/editable_text_test.dart
+++ b/packages/flutter/test/widgets/editable_text_test.dart
@@ -1834,7 +1834,7 @@
backgroundCursorColor: Colors.grey,
controller: TextEditingController(text: 'blah blah'),
focusNode: focusNode,
- toolbarOptions: const ToolbarOptions(),
+ toolbarOptions: ToolbarOptions.empty,
style: textStyle,
cursorColor: cursorColor,
selectionControls: cupertinoTextSelectionControls,
@@ -3293,7 +3293,7 @@
);
controller.selection =
- TextSelection.collapsed(offset:controller.text.length);
+ TextSelection.collapsed(offset: controller.text.length);
await tester.pumpAndSettle();
// At end, can only go backwards.
@@ -5306,7 +5306,7 @@
// Find the toolbar fade transition while the toolbar is still visible.
final List<FadeTransition> transitionsBefore = find.descendant(
- of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
+ of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
@@ -5322,7 +5322,7 @@
// Find the toolbar fade transition after the toolbar has been hidden.
final List<FadeTransition> transitionsAfter = find.descendant(
- of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarOverlay'),
+ of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
@@ -13575,6 +13575,56 @@
});
});
+ testWidgets('contextMenuBuilder is used in place of the default text selection toolbar', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ final TextEditingController controller = TextEditingController(text: '');
+ await tester.pumpWidget(MaterialApp(
+ home: Align(
+ alignment: Alignment.topLeft,
+ child: SizedBox(
+ width: 400,
+ child: EditableText(
+ maxLines: 10,
+ controller: controller,
+ showSelectionHandles: true,
+ autofocus: true,
+ focusNode: FocusNode(),
+ style: Typography.material2018().black.subtitle1!,
+ cursorColor: Colors.blue,
+ backgroundCursorColor: Colors.grey,
+ keyboardType: TextInputType.text,
+ textAlign: TextAlign.right,
+ selectionControls: materialTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) {
+ return SizedBox(
+ key: key,
+ width: 10.0,
+ height: 10.0,
+ );
+ },
+ ),
+ ),
+ ),
+ ));
+
+ await tester.pump(); // Wait for autofocus to take effect.
+
+ expect(find.byKey(key), findsNothing);
+
+ // Long-press to bring up the context menu.
+ final Finder textFinder = find.byType(EditableText);
+ await tester.longPress(textFinder);
+ tester.state<EditableTextState>(textFinder).showToolbar();
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(key), findsOneWidget);
+ },
+ skip: kIsWeb, // [intended] on web the browser handles the context menu.
+ );
+
group('Spell check', () {
testWidgets(
'Spell check configured properly when spell check disabled by default',
diff --git a/packages/flutter/test/widgets/editable_text_utils.dart b/packages/flutter/test/widgets/editable_text_utils.dart
index 505e104..325e13b 100644
--- a/packages/flutter/test/widgets/editable_text_utils.dart
+++ b/packages/flutter/test/widgets/editable_text_utils.dart
@@ -7,6 +7,9 @@
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
+/// On web, the context menu (aka toolbar) is provided by the browser.
+const bool isContextMenuProvidedByPlatform = isBrowser;
+
// Returns the first RenderEditable.
RenderEditable findRenderEditable(WidgetTester tester) {
final RenderObject root = tester.renderObject(find.byType(EditableText));
diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart
index 42f749c..8e3c6f74 100644
--- a/packages/flutter/test/widgets/selectable_region_test.dart
+++ b/packages/flutter/test/widgets/selectable_region_test.dart
@@ -1209,6 +1209,44 @@
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.android }),
);
+ testWidgets('builds the correct button items', (WidgetTester tester) async {
+ Set<ContextMenuButtonType> buttonTypes = <ContextMenuButtonType>{};
+ await tester.pumpWidget(
+ MaterialApp(
+ home: SelectableRegion(
+ focusNode: FocusNode(),
+ selectionControls: materialTextSelectionHandleControls,
+ contextMenuBuilder: (
+ BuildContext context,
+ SelectableRegionState selectableRegionState,
+ ) {
+ buttonTypes = selectableRegionState.contextMenuButtonItems
+ .map((ContextMenuButtonItem buttonItem) => buttonItem.type)
+ .toSet();
+ return const SizedBox.shrink();
+ },
+ child: const Text('How are you?'),
+ ),
+ ),
+ );
+
+ expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
+
+ final RenderParagraph paragraph1 = tester.renderObject<RenderParagraph>(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText)));
+ final TestGesture gesture = await tester.startGesture(textOffsetToPosition(paragraph1, 6)); // at the 'r'
+ addTearDown(gesture.removePointer);
+ await tester.pump(const Duration(milliseconds: 500));
+ // `are` is selected.
+ expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 7));
+ await tester.pumpAndSettle();
+
+ expect(buttonTypes, contains(ContextMenuButtonType.copy));
+ expect(buttonTypes, contains(ContextMenuButtonType.selectAll));
+ },
+ variant: TargetPlatformVariant.all(),
+ skip: kIsWeb, // [intended]
+ );
+
testWidgets('onSelectionChange is called when the selection changes', (WidgetTester tester) async {
SelectedContent? content;
diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart
index 9b6ff4c..e1e5ce0 100644
--- a/packages/flutter/test/widgets/selectable_text_test.dart
+++ b/packages/flutter/test/widgets/selectable_text_test.dart
@@ -3353,6 +3353,12 @@
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
+ // Hide the toolbar so it doesn't interfere with taps on the text.
+ final EditableTextState editableTextState =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ editableTextState.hideToolbar();
+ await tester.pumpAndSettle();
+
await tester.tapAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump();
@@ -3829,6 +3835,12 @@
await tester.longPressAt(selectableTextStart + const Offset(50.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
+ // Hide the toolbar so it doesn't interfere with taps on the text.
+ final EditableTextState editableTextState =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ editableTextState.hideToolbar();
+ await tester.pumpAndSettle();
+
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
@@ -3901,6 +3913,12 @@
);
expect(find.byType(CupertinoButton), findsNWidgets(1));
+ // Hide the toolbar so it doesn't interfere with taps on the text.
+ final EditableTextState editableTextState =
+ tester.state<EditableTextState>(find.byType(EditableText));
+ editableTextState.hideToolbar();
+ await tester.pumpAndSettle();
+
await tester.tapAt(selectableTextStart + const Offset(150.0, 5.0));
await tester.pump(const Duration(milliseconds: 50));
// First tap moved the cursor.
@@ -4411,7 +4429,7 @@
);
final EditableTextState state =
- tester.state<EditableTextState>(find.byType(EditableText));
+ tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
await tester.tapAt(const Offset(20, 10));
@@ -4971,7 +4989,11 @@
expect(selection!.baseOffset, 5);
expect(selection!.extentOffset, 6);
- // Put the cursor at the end of the field.
+ // Tap at the beginning of the text to hide the toolbar, then at the end to
+ // move the cursor to the end. On some platforms, the context menu would
+ // otherwise block a tap on the end of the field.
+ await tester.tapAt(textOffsetToPosition(tester, 0));
+ await tester.pumpAndSettle();
await tester.tapAt(textOffsetToPosition(tester, 10));
expect(selection, isNotNull);
expect(selection!.baseOffset, 10);