From 0b451b6dfd6de73ff89d89081c33d0f971db1872 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Fri, 28 Oct 2022 12:40:09 -0700 Subject: [PATCH] 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. --- .../context_menu_controller.0.dart | 159 +++++++ .../editable_text_toolbar_builder.0.dart | 64 +++ .../editable_text_toolbar_builder.1.dart | 83 ++++ .../editable_text_toolbar_builder.2.dart | 107 +++++ .../selectable_region_toolbar_builder.0.dart | 68 +++ .../context_menu_controller.0_test.dart | 44 ++ .../editable_text_toolbar_builder.0_test.dart | 36 ++ .../editable_text_toolbar_builder.1_test.dart | 69 +++ .../editable_text_toolbar_builder.2_test.dart | 64 +++ ...ectable_region_toolbar_builder.0_test.dart | 41 ++ packages/flutter/lib/cupertino.dart | 3 + packages/flutter/lib/material.dart | 3 + .../adaptive_text_selection_toolbar.dart | 226 ++++++++++ .../src/cupertino/desktop_text_selection.dart | 230 ++-------- .../desktop_text_selection_toolbar.dart | 114 +++++ ...desktop_text_selection_toolbar_button.dart | 131 ++++++ .../flutter/lib/src/cupertino/text_field.dart | 93 ++-- .../src/cupertino/text_form_field_row.dart | 13 + .../lib/src/cupertino/text_selection.dart | 302 +++++++------ .../src/cupertino/text_selection_toolbar.dart | 52 ++- .../text_selection_toolbar_button.dart | 61 ++- .../adaptive_text_selection_toolbar.dart | 319 ++++++++++++++ .../src/material/desktop_text_selection.dart | 191 ++------ .../desktop_text_selection_toolbar.dart | 93 ++++ ...desktop_text_selection_toolbar_button.dart | 83 ++++ .../lib/src/material/selectable_text.dart | 55 ++- .../lib/src/material/selection_area.dart | 37 +- .../flutter/lib/src/material/text_field.dart | 66 +-- .../lib/src/material/text_form_field.dart | 13 + .../lib/src/material/text_selection.dart | 30 +- .../src/material/text_selection_toolbar.dart | 49 +- .../lib/src/painting/text_painter.dart | 2 +- .../flutter/lib/src/rendering/editable.dart | 2 + .../src/widgets/context_menu_button_item.dart | 89 ++++ .../src/widgets/context_menu_controller.dart | 123 ++++++ ...ext_selection_toolbar_layout_delegate.dart | 1 - .../lib/src/widgets/editable_text.dart | 417 ++++++++++++++++-- .../lib/src/widgets/selectable_region.dart | 255 +++++++++-- .../lib/src/widgets/text_selection.dart | 342 ++++++++++---- .../text_selection_toolbar_anchors.dart | 71 +++ packages/flutter/lib/widgets.dart | 3 + .../adaptive_text_selection_toolbar_test.dart | 245 ++++++++++ ...op_text_selection_toolbar_button_test.dart | 74 ++++ .../desktop_text_selection_toolbar_test.dart | 37 ++ .../test/cupertino/text_field_test.dart | 340 +++++++++++--- .../text_selection_toolbar_test.dart | 2 +- .../adaptive_text_selection_toolbar_test.dart | 393 +++++++++++++++++ ...op_text_selection_toolbar_button_test.dart | 31 ++ .../desktop_text_selection_toolbar_test.dart | 36 ++ .../test/material/selection_area_test.dart | 69 ++- .../test/material/text_field_test.dart | 141 +++++- .../test/material/text_selection_test.dart | 1 - .../material/text_selection_toolbar_test.dart | 27 +- .../widgets/context_menu_controller_test.dart | 263 +++++++++++ .../test/widgets/editable_text_test.dart | 58 ++- .../test/widgets/editable_text_utils.dart | 3 + .../test/widgets/selectable_region_test.dart | 38 ++ .../test/widgets/selectable_text_test.dart | 26 +- 58 files changed, 5068 insertions(+), 920 deletions(-) create mode 100644 examples/api/lib/material/context_menu/context_menu_controller.0.dart create mode 100644 examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart create mode 100644 examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart create mode 100644 examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart create mode 100644 examples/api/lib/material/context_menu/selectable_region_toolbar_builder.0.dart create mode 100644 examples/api/test/material/context_menu/context_menu_controller.0_test.dart create mode 100644 examples/api/test/material/context_menu/editable_text_toolbar_builder.0_test.dart create mode 100644 examples/api/test/material/context_menu/editable_text_toolbar_builder.1_test.dart create mode 100644 examples/api/test/material/context_menu/editable_text_toolbar_builder.2_test.dart create mode 100644 examples/api/test/material/context_menu/selectable_region_toolbar_builder.0_test.dart create mode 100644 packages/flutter/lib/src/cupertino/adaptive_text_selection_toolbar.dart create mode 100644 packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar.dart create mode 100644 packages/flutter/lib/src/cupertino/desktop_text_selection_toolbar_button.dart create mode 100644 packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart create mode 100644 packages/flutter/lib/src/material/desktop_text_selection_toolbar.dart create mode 100644 packages/flutter/lib/src/material/desktop_text_selection_toolbar_button.dart create mode 100644 packages/flutter/lib/src/widgets/context_menu_button_item.dart create mode 100644 packages/flutter/lib/src/widgets/context_menu_controller.dart create mode 100644 packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart create mode 100644 packages/flutter/test/cupertino/adaptive_text_selection_toolbar_test.dart create mode 100644 packages/flutter/test/cupertino/desktop_text_selection_toolbar_button_test.dart create mode 100644 packages/flutter/test/cupertino/desktop_text_selection_toolbar_test.dart create mode 100644 packages/flutter/test/material/adaptive_text_selection_toolbar_test.dart create mode 100644 packages/flutter/test/material/desktop_text_selection_toolbar_button_test.dart create mode 100644 packages/flutter/test/material/desktop_text_selection_toolbar_test.dart create mode 100644 packages/flutter/test/widgets/context_menu_controller_test.dart 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 00000000000..0bf5ae58ef2 --- /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( + 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( + 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: [ + 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 00000000000..c932abcda9a --- /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: [ + 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 00000000000..4f599d8e059 --- /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( + 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: [ + Container(height: 20.0), + TextField( + controller: _controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + final List 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'(?[a-zA-Z0-9]+)' + r'@' + r'(?[a-zA-Z0-9]+)' + r'\.' + r'(?[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 00000000000..cd8c686dfe9 --- /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: [ + 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( + 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 children; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + 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 00000000000..9f40adcea7d --- /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( + 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: [ + ...selectableRegionState.contextMenuButtonItems, + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _showDialog(context); + }, + label: 'Print', + ), + ], + ); + }, + child: ListView( + children: const [ + 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 00000000000..48aee51d7f5 --- /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 00000000000..38c28eee05a --- /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 00000000000..bee046b8052 --- /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(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 00000000000..90f2a23de86 --- /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 00000000000..090eba45ece --- /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 72df249bca0..af3de265c00 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/context_menu_action.dart'; 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 33e77f891a4..795bb641207 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -22,6 +22,7 @@ library material; 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.dart'; 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 00000000000..98501791199 --- /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? children; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets for the current platform. + final List? 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 getAdaptiveButtons(BuildContext context, List 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 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 9711d66f3aa..88b9d9b3f7c 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 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls { 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 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls { 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 @@ class CupertinoDesktopTextSelectionControls extends TextSelectionControls { } } -/// 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 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin @override void dispose() { - super.dispose(); widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); } @override @@ -180,7 +181,7 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin items.add(onePhysicalPixelVerticalDivider); } - items.add(_CupertinoDesktopTextSelectionToolbarButton.text( + items.add(CupertinoDesktopTextSelectionToolbarButton.text( context: context, onPressed: onPressed, text: text, @@ -206,182 +207,9 @@ class _CupertinoDesktopTextSelectionControlsToolbarState extends State<_Cupertin 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 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 00000000000..990076500ee --- /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 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 00000000000..b035f622ec8 --- /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 createState() => _CupertinoDesktopTextSelectionToolbarButtonState(); +} + +class _CupertinoDesktopTextSelectionToolbarButtonState extends State { + 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 65806d1dd25..a4d0db07761 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/rendering.dart'; 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 @@ class CupertinoTextField extends StatefulWidget { 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 @@ class CupertinoTextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.spellCheckConfiguration, this.magnifierConfiguration, }) : assert(textAlign != null), @@ -313,31 +319,7 @@ class CupertinoTextField extends StatefulWidget { ), 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 @@ class CupertinoTextField extends StatefulWidget { 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 @@ class CupertinoTextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.spellCheckConfiguration, this.magnifierConfiguration, }) : assert(textAlign != null), @@ -477,31 +464,7 @@ class CupertinoTextField extends StatefulWidget { 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 @@ class CupertinoTextField extends StatefulWidget { /// 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 @@ class CupertinoTextField extends StatefulWidget { /// {@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 @@ class _CupertinoTextFieldState extends State with Restoratio 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 @@ class _CupertinoTextFieldState extends State with Restoratio 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 e46a62da4a9..a77669da5f1 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 @@ class CupertinoTextFormFieldRow extends FormField { 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 @@ class CupertinoTextFormFieldRow extends FormField { fontWeight: FontWeight.w400, color: CupertinoColors.placeholderText, ), + EditableTextContextMenuBuilder? contextMenuBuilder = _defaultContextMenuBuilder, }) : assert(initialValue == null || controller == null), assert(textAlign != null), assert(autofocus != null), @@ -234,6 +240,7 @@ class CupertinoTextFormFieldRow extends FormField { autofillHints: autofillHints, placeholder: placeholder, placeholderStyle: placeholderStyle, + contextMenuBuilder: contextMenuBuilder, ), ); }, @@ -262,6 +269,12 @@ class CupertinoTextFormFieldRow extends FormField { /// initialize its [TextEditingController.text] with [initialValue]. final TextEditingController? controller; + static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + return CupertinoAdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + @override FormFieldState createState() => _CupertinoTextFormFieldRowState(); } diff --git a/packages/flutter/lib/src/cupertino/text_selection.dart b/packages/flutter/lib/src/cupertino/text_selection.dart index 67d004a50ed..512ec71e788 100644 --- a/packages/flutter/lib/src/cupertino/text_selection.dart +++ b/packages/flutter/lib/src/cupertino/text_selection.dart @@ -22,142 +22,6 @@ const double _kSelectionHandleRadius = 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 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 items = []; - 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 @@ class _TextSelectionHandlePainter extends CustomPainter { 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 @@ class CupertinoTextSelectionControls extends TextSelectionControls { } /// 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 @@ class CupertinoTextSelectionControls extends TextSelectionControls { Offset selectionMidpoint, List endpoints, TextSelectionDelegate delegate, - ClipboardStatusNotifier? clipboardStatus, + ValueNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition, ) { return _CupertinoTextSelectionControlsToolbar( @@ -305,5 +184,150 @@ class CupertinoTextSelectionControls extends TextSelectionControls { } } -/// 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; + final List 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 items = []; + 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 82cece463fd..a630b3dddf9 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 _kToolbarContentDistance = 8.0; 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 @@ typedef CupertinoToolbarBuilder = Widget Function( 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 @@ typedef CupertinoToolbarBuilder = Widget Function( /// /// 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 @@ class CupertinoTextSelectionToolbar extends StatelessWidget { /// default Cupertino toolbar. final CupertinoToolbarBuilder toolbarBuilder; + // Add the visial vertical line spacer between children buttons. + static List _addChildrenSpacers(List children) { + final List nextChildren = []; + 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 @@ class CupertinoTextSelectionToolbar extends StatelessWidget { + _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 @@ class CupertinoTextSelectionToolbar extends StatelessWidget { ), 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 b934b6e179a..6cc1cc40e93 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 'package:flutter/widgets.dart'; import 'button.dart'; import 'colors.dart'; +import 'debug.dart'; +import 'localizations.dart'; const TextStyle _kToolbarButtonFontStyle = TextStyle( inherit: false, @@ -24,11 +26,14 @@ const EdgeInsets _kToolbarButtonPadding = EdgeInsets.symmetric(vertical: 16.0, h /// 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 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget { super.key, this.onPressed, required String text, - }) : child = Text( + }) : buttonItem = null, + child = Text( text, overflow: TextOverflow.ellipsis, style: _kToolbarButtonFontStyle.copyWith( @@ -44,20 +50,67 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget { ), ); + /// 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 00000000000..d0ec3eb7ead --- /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? buttonItems; + + /// The children of the toolbar, typically buttons. + final List? 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 getAdaptiveButtons(BuildContext context, List 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 buttons = []; + 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 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 fbe6848d451..b67c371f8f5 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 @@ class DesktopTextSelectionControls extends TextSelectionControls { } /// 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 @@ class DesktopTextSelectionControls extends TextSelectionControls { 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 @@ class DesktopTextSelectionControls extends TextSelectionControls { !(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 @@ class DesktopTextSelectionControls extends TextSelectionControls { } } -/// 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 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect @override void dispose() { - super.dispose(); widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); } @override @@ -173,7 +194,7 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect String text, VoidCallback onPressed, ) { - items.add(_DesktopTextSelectionToolbarButton.text( + items.add(DesktopTextSelectionToolbarButton.text( context: context, onPressed: onPressed, text: text, @@ -199,153 +220,9 @@ class _DesktopTextSelectionControlsToolbarState extends State<_DesktopTextSelect 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 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 00000000000..e1dbfe679a1 --- /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 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 00000000000..1b0ca204a41 --- /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 24e663656f8..ccbe7a8e1f9 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/foundation.dart'; 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 @@ class SelectableText extends StatefulWidget { 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 @@ class SelectableText extends StatefulWidget { this.textHeightBehavior, this.textWidthBasis, this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.magnifierConfiguration, }) : assert(showCursor != null), assert(autofocus != null), @@ -224,12 +230,7 @@ class SelectableText extends StatefulWidget { 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 @@ class SelectableText extends StatefulWidget { 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 @@ class SelectableText extends StatefulWidget { this.textHeightBehavior, this.textWidthBasis, this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.magnifierConfiguration, }) : assert(showCursor != null), assert(autofocus != null), @@ -280,12 +286,7 @@ class SelectableText extends StatefulWidget { 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 @@ class SelectableText extends StatefulWidget { /// 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 @@ class SelectableText extends StatefulWidget { /// {@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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 @@ class _SelectableTextState extends State implements TextSelectio 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 a6312a6a947..609beefc0a1 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 @@ class SelectionArea extends StatefulWidget { super.key, this.focusNode, this.selectionControls, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.magnifierConfiguration, this.onSelectionChanged, required this.child, @@ -65,6 +67,23 @@ class SelectionArea extends StatefulWidget { /// 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? onSelectionChanged; @@ -73,6 +92,12 @@ class SelectionArea extends StatefulWidget { /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; + static Widget _defaultContextMenuBuilder(BuildContext context, SelectableRegionState selectableRegionState) { + return AdaptiveTextSelectionToolbar.selectableRegion( + selectableRegionState: selectableRegionState, + ); + } + @override State createState() => _SelectionAreaState(); } @@ -100,22 +125,24 @@ class _SelectionAreaState extends State { 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 e0256d44f44..045b057b236 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/gestures.dart'; 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 @@ class TextField extends StatefulWidget { 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 @@ class TextField extends StatefulWidget { this.restorationId, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, this.spellCheckConfiguration, this.magnifierConfiguration, }) : assert(textAlign != null), @@ -343,31 +349,7 @@ class TextField extends StatefulWidget { 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 @@ class TextField extends StatefulWidget { /// 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 @@ class TextField extends StatefulWidget { /// {@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 @@ class _TextFieldState extends State with RestorationMixin implements 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 @@ class _TextFieldState extends State with RestorationMixin implements 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 @@ class _TextFieldState extends State with RestorationMixin implements 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 @@ class _TextFieldState extends State with RestorationMixin implements 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 @@ class _TextFieldState extends State with RestorationMixin implements 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 @@ class _TextFieldState extends State with RestorationMixin implements 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 1b1db946cae..a9addc22a84 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 @@ class TextFormField extends FormField { 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 @@ class TextFormField extends FormField { 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 @@ class TextFormField extends FormField { scrollController: scrollController, enableIMEPersonalizedLearning: enableIMEPersonalizedLearning, mouseCursor: mouseCursor, + contextMenuBuilder: contextMenuBuilder, ), ); }, @@ -247,6 +254,12 @@ class TextFormField extends FormField { /// initialize its [TextEditingController.text] with [initialValue]. final TextEditingController? controller; + static Widget _defaultContextMenuBuilder(BuildContext context, EditableTextState editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + @override FormFieldState createState() => _TextFormFieldState(); } diff --git a/packages/flutter/lib/src/material/text_selection.dart b/packages/flutter/lib/src/material/text_selection.dart index 9b4aa1e6fe0..9c31349f06a 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 _kHandleSize = 22.0; 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 @@ class MaterialTextSelectionControls extends TextSelectionControls { 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 @@ class MaterialTextSelectionControls extends TextSelectionControls { ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition, ) { - return _TextSelectionControlsToolbar( + return _TextSelectionControlsToolbar( globalEditableRegion: globalEditableRegion, textLineHeight: textLineHeight, selectionMidpoint: selectionMidpoint, @@ -107,6 +122,10 @@ class MaterialTextSelectionControls extends TextSelectionControls { } } + @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 @@ class _TextSelectionControlsToolbarState extends State<_TextSelectionControlsToo @override void dispose() { - super.dispose(); widget.clipboardStatus?.removeListener(_onChangedClipboardStatus); + super.dispose(); } @override @@ -289,5 +308,12 @@ class _TextSelectionHandlePainter extends CustomPainter { } } +/// 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 d014bbc30e8..a024968c389 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 @@ import 'material_localizations.dart'; 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 @@ const double _kToolbarHeight = 44.0; /// /// 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 @@ class TextSelectionToolbar extends StatelessWidget { @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 @@ class TextSelectionToolbar extends StatelessWidget { _kToolbarScreenPadding, _kToolbarScreenPadding, ), - child: Stack( - children: [ - 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 @@ class _TextSelectionToolbarOverflowableState extends State<_TextSelectionToolbar // 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 da314d62f9f..5ed85d40e4b 100644 --- a/packages/flutter/lib/src/painting/text_painter.dart +++ b/packages/flutter/lib/src/painting/text_painter.dart @@ -1100,7 +1100,7 @@ class TextPainter { /// 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 573ae8be210..0a050f229c6 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -1981,8 +1981,10 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, 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 00000000000..2c5e723078e --- /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 00000000000..bfc6f96b652 --- /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 471b0116b8c..706c28f4536 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 8307aa7bc99..a8a5b3bd41b 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -19,6 +19,7 @@ import 'automatic_keep_alive.dart'; 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 'tap_region.dart'; 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 @@ typedef SelectionChangedCallback = void Function(TextSelection selection, Select /// Signature for the callback that reports the app private command results. typedef AppPrivateCommandCallback = void Function(String, Map); +/// 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 @@ class TextEditingController extends ValueNotifier { /// [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 @@ class ToolbarOptions { 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 @@ class EditableText extends StatefulWidget { 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 [], this.autofillClient, @@ -645,6 +674,7 @@ class EditableText extends StatefulWidget { this.scrollBehavior, this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder, this.spellCheckConfiguration, this.magnifierConfiguration = TextMagnifierConfiguration.disabled, }) : assert(controller != null), @@ -683,12 +713,12 @@ class EditableText extends StatefulWidget { 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 @@ class EditableText extends StatefulWidget { /// {@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 @@ class EditableText extends StatefulWidget { 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 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 []; + } + + return [ + 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? autofillHints, @@ -1778,7 +1898,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien final ValueNotifier _cursorVisibilityNotifier = ValueNotifier(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 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien break; } } - _clipboardStatus?.update(); + clipboardStatus?.update(); } /// Cut current selection to [Clipboard]. @@ -1938,7 +2105,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien }); hideToolbar(); } - _clipboardStatus?.update(); + clipboardStatus?.update(); } /// Paste text from [Clipboard]. @@ -1997,6 +2164,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien ); 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: @@ -2033,12 +2210,154 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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? buttonItemsForToolbarOptions([TargetPlatform? targetPlatform]) { + final ToolbarOptions toolbarOptions = widget.toolbarOptions; + if (toolbarOptions == ToolbarOptions.empty) { + return null; + } + return [ + 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 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 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien ); } } - 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien _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 @@ class EditableTextState extends State with AutomaticKeepAliveClien TextSelectionOverlay _createSelectionOverlay() { final TextSelectionOverlay selectionOverlay = TextSelectionOverlay( - clipboardStatus: _clipboardStatus, + clipboardStatus: clipboardStatus, context: context, value: _value, debugRequiredFor: widget, @@ -2745,6 +3067,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien } break; } - if (widget.selectionControls == null) { + if (widget.selectionControls == null && widget.contextMenuBuilder == null) { _selectionOverlay?.dispose(); _selectionOverlay = null; } else { @@ -3305,10 +3635,10 @@ class EditableTextState extends State with AutomaticKeepAliveClien return false; } - if (_selectionOverlay == null || _selectionOverlay!.toolbarIsVisible) { + if (_selectionOverlay == null) { return false; } - _clipboardStatus?.update(); + clipboardStatus?.update(); _selectionOverlay!.showToolbar(); return true; } @@ -3356,13 +3686,13 @@ class EditableTextState extends State with AutomaticKeepAliveClien } /// 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 @@ class EditableTextState extends State with AutomaticKeepAliveClien 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 @@ _Throttled _throttle({ 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 2b2f049e71e..e3a19daa5d4 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -13,6 +13,7 @@ import 'package:vector_math/vector_math_64.dart'; 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 'platform_selectable_region_context_menu.dart'; 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 @@ class SelectableRegion extends StatefulWidget { /// toolbar for mobile devices. const SelectableRegion({ super.key, + this.contextMenuBuilder, required this.focusNode, required this.selectionControls, required this.child, @@ -224,6 +227,9 @@ class SelectableRegion extends StatefulWidget { /// {@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 @@ class SelectableRegion extends StatefulWidget { /// Called when the selected content changes. final ValueChanged? 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 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 [ + if (canCopy) + ContextMenuButtonItem( + onPressed: onCopy, + type: ContextMenuButtonType.copy, + ), + if (canSelectAll) + ContextMenuButtonItem( + onPressed: onSelectAll, + type: ContextMenuButtonType.selectAll, + ), + ]; + } + @override - State createState() => _SelectableRegionState(); + State createState() => SelectableRegionState(); } -class _SelectableRegionState extends State with TextSelectionDelegate implements SelectionRegistrar { +/// State for a [SelectableRegion]. +class SelectableRegionState extends State with TextSelectionDelegate implements SelectionRegistrar { late final Map> _actions = >{ SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), @@ -258,6 +307,9 @@ class _SelectableRegionState extends State with TextSelectionD Orientation? _lastOrientation; SelectedContent? _lastSelectedContent; + /// {@macro flutter.rendering.RenderEditable.lastSecondaryTapDownPosition} + Offset? lastSecondaryTapDownPosition; + @override void initState() { super.initState(); @@ -424,6 +476,7 @@ class _SelectableRegionState extends State with TextSelectionD } void _handleRightClickDown(TapDownDetails details) { + lastSecondaryTapDownPosition = details.globalPosition; widget.focusNode.requestFocus(); _selectWordAt(offset: details.globalPosition); _showHandles(); @@ -465,7 +518,17 @@ class _SelectableRegionState extends State with TextSelectionD } 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 @@ class _SelectableRegionState extends State with TextSelectionD late Offset _selectionStartHandleDragPosition; late Offset _selectionEndHandleDragPosition; - late List points; - void _handleSelectionStartHandleDragStart(DragStartDetails details) { assert(_selectionDelegate.value.startSelectionPoint != null); @@ -595,19 +656,6 @@ class _SelectableRegionState extends State with TextSelectionD } 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(endLocalPosition, TextDirection.ltr), - TextSelectionPoint(startLocalPosition, TextDirection.ltr), - ]; - } else { - points = [ - TextSelectionPoint(startLocalPosition, TextDirection.ltr), - TextSelectionPoint(endLocalPosition, TextDirection.ltr), - ]; - } _selectionOverlay = SelectionOverlay( context: context, debugRequiredFor: widget, @@ -621,7 +669,7 @@ class _SelectableRegionState extends State with TextSelectionD onEndHandleDragStart: _handleSelectionEndHandleDragStart, onEndHandleDragUpdate: _handleSelectionEndHandleDragUpdate, onEndHandleDragEnd: _onAnyDragEnd, - selectionEndpoints: points, + selectionEndpoints: selectionEndpoints, selectionControls: widget.selectionControls, selectionDelegate: this, clipboardStatus: null, @@ -639,26 +687,12 @@ class _SelectableRegionState extends State with TextSelectionD assert(_hasSelectionOverlayGeometry); final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; - late List points; - final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; - final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; - if (startLocalPosition.dy > endLocalPosition.dy) { - points = [ - TextSelectionPoint(endLocalPosition, TextDirection.ltr), - TextSelectionPoint(startLocalPosition, TextDirection.ltr), - ]; - } else { - points = [ - 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 @@ class _SelectableRegionState extends State with TextSelectionD } _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 @@ class _SelectableRegionState extends State with TextSelectionD 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 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 get selectionEndpoints { + final SelectionPoint? start = _selectionDelegate.value.startSelectionPoint; + final SelectionPoint? end = _selectionDelegate.value.endSelectionPoint; + late List points; + final Offset startLocalPosition = start?.localPosition ?? end!.localPosition; + final Offset endLocalPosition = end?.localPosition ?? start!.localPosition; + if (startLocalPosition.dy > endLocalPosition.dy) { + points = [ + TextSelectionPoint(endLocalPosition, TextDirection.ltr), + TextSelectionPoint(startLocalPosition, TextDirection.ltr), + ]; + } else { + points = [ + 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 @@ class _SelectableRegionState extends State with TextSelectionD _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 pasteText(SelectionChangedCause cause) async { assert(false); @@ -909,7 +1070,7 @@ class _SelectableRegionState extends State with TextSelectionD _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 @@ abstract class _NonOverrideAction extends ContextAction { class _SelectAllAction extends _NonOverrideAction { _SelectAllAction(this.state); - final _SelectableRegionState state; + final SelectableRegionState state; @override void invokeAction(SelectAllTextIntent intent, [BuildContext? context]) { @@ -978,7 +1139,7 @@ class _SelectAllAction extends _NonOverrideAction { class _CopySelectionAction extends _NonOverrideAction { _CopySelectionAction(this.state); - final _SelectableRegionState state; + final SelectableRegionState state; @override void invokeAction(CopySelectionTextIntent intent, [BuildContext? context]) { @@ -1795,3 +1956,15 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai 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 a9c81f3a49e..959d6b8cc42 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -16,6 +16,7 @@ import 'basic.dart'; 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 @@ abstract class TextSelectionControls { /// 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 @@ abstract class TextSelectionControls { /// /// 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 @@ abstract class TextSelectionControls { /// /// 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 @@ abstract class TextSelectionControls { /// 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 @@ abstract class TextSelectionControls { /// /// 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 @@ abstract class TextSelectionControls { /// 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 @@ abstract class TextSelectionControls { /// 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 @@ abstract class TextSelectionControls { /// 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 handlePaste(TextSelectionDelegate delegate) async { delegate.pasteText(SelectionChangedCause.toolbar); } @@ -215,6 +248,10 @@ abstract class TextSelectionControls { /// /// 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 @@ class TextSelectionOverlay { DragStartBehavior dragStartBehavior = DragStartBehavior.start, VoidCallback? onSelectionHandleTapped, ClipboardStatusNotifier? clipboardStatus, + this.contextMenuBuilder, required TextMagnifierConfiguration magnifierConfiguration, }) : assert(value != null), assert(context != null), @@ -333,6 +371,14 @@ class TextSelectionOverlay { ); } + /// {@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 @@ class TextSelectionOverlay { 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 @@ class TextSelectionOverlay { final ValueNotifier _effectiveEndHandleVisibility = ValueNotifier(false); final ValueNotifier _effectiveToolbarVisibility = ValueNotifier(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 @@ class TextSelectionOverlay { /// {@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 @@ class TextSelectionOverlay { } /// {@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 @@ class TextSelectionOverlay { 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 @@ class TextSelectionOverlay { _effectiveToolbarVisibility.dispose(); _effectiveStartHandleVisibility.dispose(); _effectiveEndHandleVisibility.dispose(); + hideToolbar(); } double _getStartGlyphHeight() { @@ -728,7 +793,25 @@ class TextSelectionOverlay { _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 @@ class SelectionOverlay { this.toolbarVisible, required List 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 @@ class SelectionOverlay { 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 @@ class SelectionOverlay { _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 = ValueNotifier(MagnifierInfo.empty); @@ -863,7 +950,7 @@ class SelectionOverlay { /// [MagnifierController.shown]. /// {@endtemplate} void showMagnifier(MagnifierInfo initalMagnifierInfo) { - if (_toolbar != null) { + if (_toolbar != null || _contextMenuControllerIsShown) { hideToolbar(); } @@ -892,12 +979,11 @@ class SelectionOverlay { } /// {@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 @@ class SelectionOverlay { } _magnifierController.hide(); - - if (shouldShowToolbar) { - showToolbar(); - } } /// The type of start selection handle. @@ -1046,7 +1128,11 @@ class SelectionOverlay { /// 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 @@ class SelectionOverlay { /// /// 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 @@ class SelectionOverlay { /// 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 @@ class SelectionOverlay { /// {@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 @@ class SelectionOverlay { _handles![1].markNeedsBuild(); } _toolbar?.markNeedsBuild(); + if (_contextMenuController.isShown) { + _contextMenuController.markNeedsBuild(); + } }); } else { if (_handles != null) { @@ -1181,6 +1301,9 @@ class SelectionOverlay { _handles![1].markNeedsBuild(); } _toolbar?.markNeedsBuild(); + if (_contextMenuController.isShown) { + _contextMenuController.markNeedsBuild(); + } } } @@ -1194,7 +1317,7 @@ class SelectionOverlay { _handles![1].remove(); _handles = null; } - if (_toolbar != null) { + if (_toolbar != null || _contextMenuControllerIsShown) { hideToolbar(); } } @@ -1205,6 +1328,7 @@ class SelectionOverlay { /// To hide the whole overlay, see [hide]. /// {@endtemplate} void hideToolbar() { + _contextMenuController.remove(); if (_toolbar == null) { return; } @@ -1272,10 +1396,12 @@ class SelectionOverlay { ); } + // 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 @@ class SelectionOverlay { 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 @@ class SelectionOverlay { } } -/// 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? visibility; - final Offset midpoint; - final List 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 get _opacity => _controller.view; @@ -1383,7 +1505,7 @@ class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with } @override - void didUpdateWidget(_SelectionToolbarOverlay oldWidget) { + void didUpdateWidget(_SelectionToolbarWrapper oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.visibility == widget.visibility) { return; @@ -1410,25 +1532,17 @@ class _SelectionToolbarOverlayState extends State<_SelectionToolbarOverlay> with @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 @@ class _SelectionHandleOverlay extends StatefulWidget { @override State<_SelectionHandleOverlay> createState() => _SelectionHandleOverlayState(); - } class _SelectionHandleOverlayState extends State<_SelectionHandleOverlay> with SingleTickerProviderStateMixin { @@ -1813,6 +1926,9 @@ class TextSelectionGestureDetectorBuilder { // 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 @@ class TextSelectionGestureDetectorBuilder { 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 @@ enum ClipboardStatus { /// 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 endpoints, + TextSelectionDelegate delegate, + ValueNotifier? 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 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 00000000000..9f51264fc8b --- /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 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 cbf8e9bdd19..cf29bdd4cea 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -35,6 +35,8 @@ export 'src/widgets/binding.dart'; 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/tap_region.dart'; 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 00000000000..6c150d1e287 --- /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( + 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: [ + 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(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( + 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 00000000000..3a3af2ebacf --- /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 00000000000..bde525069c7 --- /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: [ + 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 f646deec918..2aedb7b62d5 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -1578,30 +1578,30 @@ void main() { ), ); - // 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.iOS, TargetPlatform.macOS }), skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu. @@ -1633,6 +1633,15 @@ void main() { 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 @@ void main() { ); 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 @@ void main() { 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 @@ void main() { ); // 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 @@ void main() { ); 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 @@ void main() { 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 @@ void main() { 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.iOS, TargetPlatform.macOS }), ); @@ -2184,8 +2218,24 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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 @@ void main() { 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.iOS, TargetPlatform.macOS }), ); @@ -2680,7 +2732,14 @@ void main() { ); // 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.iOS, TargetPlatform.macOS }), ); @@ -2706,7 +2765,21 @@ void main() { 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 @@ void main() { 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.iOS, TargetPlatform.macOS }), ); @@ -2840,7 +2913,14 @@ void main() { 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.iOS, TargetPlatform.macOS }), ); @@ -3001,7 +3081,16 @@ void main() { 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 @@ void main() { ); // 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.iOS, TargetPlatform.macOS })); testWidgets( @@ -3081,17 +3179,30 @@ void main() { ), ); - 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 @@ void main() { 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 @@ void main() { ); 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 @@ void main() { 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 @@ void main() { }, variant: const TargetPlatformVariant({ 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: [ + 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(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: [ + 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(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; final Widget fakeMagnifier = Container(key: UniqueKey()); @@ -6500,7 +6705,6 @@ void main() { ), ); - const String testValue = 'abc def ghi'; await tester.enterText(find.byType(CupertinoTextField), testValue); @@ -6662,6 +6866,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(fakeMagnifier.key!), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS })); + }); group('TapRegion integration', () { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { @@ -6776,34 +6981,33 @@ void main() { 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 9a46e76f98e..5052d1ee912 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 @@ class _CustomCupertinoTextSelectionControls extends CupertinoTextSelectionContro Offset selectionMidpoint, List endpoints, TextSelectionDelegate delegate, - ClipboardStatusNotifier? clipboardStatus, + ValueNotifier? 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 00000000000..067de6e8fbf --- /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( + 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: [ + 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(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 buttonTypes = {}; + 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(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 buttonItems = [ + 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 00000000000..41350ab0252 --- /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 00000000000..3052356febf --- /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: [ + 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 4c697af43d0..0310c165548 100644 --- a/packages/flutter/test/material/selection_area_test.dart +++ b/packages/flutter/test/material/selection_area_test.dart @@ -27,21 +27,82 @@ void main() { 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(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(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 0b0fd273311..cf070391f4e 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/services.dart'; 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 @@ void main() { 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 @@ void main() { 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()!; expect(opacity.opacity.value, equals(0.0)); @@ -8474,7 +8473,11 @@ void main() { ); // 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.iOS, TargetPlatform.macOS }), ); @@ -8535,7 +8538,14 @@ void main() { 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 @@ void main() { 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.iOS, TargetPlatform.macOS }), ); @@ -8852,7 +8866,11 @@ void main() { 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 @@ void main() { // 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 @@ void main() { ); // 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.iOS, TargetPlatform.macOS }), ); @@ -9307,7 +9328,9 @@ void main() { ), ); - 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 @@ void main() { 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 @@ void main() { // 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 @@ void main() { 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.linux, TargetPlatform.windows }), skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu. ); @@ -12455,6 +12483,7 @@ void main() { key: key1, focusNode: focusNode1, ), + const SizedBox(height: 100.0), TextField( key: key2, focusNode: focusNode2, @@ -12594,6 +12623,77 @@ void main() { }, variant: const TargetPlatformVariant({ 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: [ + 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(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: [ + 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(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 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(fakeMagnifier.key!), findsNothing); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.iOS })); + }); group('TapRegion integration', () { testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { @@ -12966,6 +13067,7 @@ void main() { ), ), ); + await tester.pump(); expect(focusNode.hasPrimaryFocus, isTrue); @@ -13033,10 +13135,9 @@ void main() { 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 faf88de8af4..230ca071422 100644 --- a/packages/flutter/test/material/text_selection_test.dart +++ b/packages/flutter/test/material/text_selection_test.dart @@ -611,7 +611,6 @@ void main() { 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 8ab72e63073..0274d1c3090 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 'package:flutter_test/flutter_test.dart'; 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 @@ void main() { // 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 00000000000..1a6155881e2 --- /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(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 632e4a3c32b..51ebfa8e359 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1834,7 +1834,7 @@ void main() { 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 @@ void main() { ); 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 @@ void main() { // Find the toolbar fade transition while the toolbar is still visible. final List 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().toList(); @@ -5322,7 +5322,7 @@ void main() { // Find the toolbar fade transition after the toolbar has been hidden. final List 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().toList(); @@ -13575,6 +13575,56 @@ void main() { }); }); + 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(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 505e10489db..325e13b6a89 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/material.dart'; 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 42f749ccf45..8e3c6f74b78 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -1209,6 +1209,44 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.android }), ); + testWidgets('builds the correct button items', (WidgetTester tester) async { + Set buttonTypes = {}; + 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(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 9b6ff4ca4ff..e1e5ce00303 100644 --- a/packages/flutter/test/widgets/selectable_text_test.dart +++ b/packages/flutter/test/widgets/selectable_text_test.dart @@ -3353,6 +3353,12 @@ void main() { 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(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 @@ void main() { 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(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 @@ void main() { ); expect(find.byType(CupertinoButton), findsNWidgets(1)); + // Hide the toolbar so it doesn't interfere with taps on the text. + final EditableTextState editableTextState = + tester.state(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 @@ void main() { ); final EditableTextState state = - tester.state(find.byType(EditableText)); + tester.state(find.byType(EditableText)); final RenderEditable renderEditable = state.renderEditable; await tester.tapAt(const Offset(20, 10)); @@ -4971,7 +4989,11 @@ void main() { 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);