From 125543505d2608afccbbd1486bd3380d7c388893 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Mon, 13 May 2024 08:48:56 -0700 Subject: [PATCH] Native ios context menu (#143002) It's now possible to use the native-rendered text selection context menu on iOS. This sacrifices customizability in exchange for avoiding showing a notification when the user presses "Paste". It's off by default, but to enable, see the example system_context_menu.0.dart. --- .../system_context_menu.0.dart | 41 ++ .../system_context_menu.0_test.dart | 67 +++ .../flutter/lib/src/services/binding.dart | 43 +- .../flutter/lib/src/services/text_input.dart | 178 +++++++- .../lib/src/widgets/editable_text.dart | 40 +- .../flutter/lib/src/widgets/media_query.dart | 46 +- .../lib/src/widgets/system_context_menu.dart | 132 ++++++ .../text_selection_toolbar_anchors.dart | 53 ++- packages/flutter/lib/widgets.dart | 1 + .../system_context_menu_controller_test.dart | 257 +++++++++++ .../test/services/text_input_utils.dart | 78 ++++ .../widgets/system_context_menu_test.dart | 415 ++++++++++++++++++ packages/flutter_test/lib/src/window.dart | 13 + 13 files changed, 1321 insertions(+), 43 deletions(-) create mode 100644 examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart create mode 100644 examples/api/test/widgets/system_context_menu/system_context_menu.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/system_context_menu.dart create mode 100644 packages/flutter/test/services/system_context_menu_controller_test.dart create mode 100644 packages/flutter/test/widgets/system_context_menu_test.dart diff --git a/examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart b/examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart new file mode 100644 index 000000000000..b648c116b00e --- /dev/null +++ b/examples/api/lib/widgets/system_context_menu/system_context_menu.0.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/material.dart'; + +/// Flutter code sample for [SystemContextMenu]. + +void main() => runApp(const SystemContextMenuExampleApp()); + +class SystemContextMenuExampleApp extends StatelessWidget { + const SystemContextMenuExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('SystemContextMenu Basic Example'), + ), + body: Center( + child: TextField( + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + // If supported, show the system context menu. + if (SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + } + // Otherwise, show the flutter-rendered context menu for the current + // platform. + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/system_context_menu/system_context_menu.0_test.dart b/examples/api/test/widgets/system_context_menu/system_context_menu.0_test.dart new file mode 100644 index 000000000000..f8052e500b92 --- /dev/null +++ b/examples/api/test/widgets/system_context_menu/system_context_menu.0_test.dart @@ -0,0 +1,67 @@ +// 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/material.dart'; +import 'package:flutter_api_samples/widgets/system_context_menu/system_context_menu.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('only shows the system context menu on iOS when MediaQuery says it is supported', (WidgetTester tester) async { + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + // Faking this value, which is usually set to true only on + // devices running iOS 16+. + supportsShowingSystemContextMenu: defaultTargetPlatform == TargetPlatform.iOS, + ), + child: const example.SystemContextMenuExampleApp(), + ); + }, + ), + ); + + expect(find.byType(SystemContextMenu), findsNothing); + + // Show the context menu. + final Finder textFinder = find.byType(EditableText); + await tester.longPress(textFinder); + tester.state(textFinder).showToolbar(); + await tester.pumpAndSettle(); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + expect(find.byType(SystemContextMenu), findsOneWidget); + expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget); + expect(find.byType(SystemContextMenu), findsNothing); + } + }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] + + testWidgets('does not show the system context menu when not supported', (WidgetTester tester) async { + await tester.pumpWidget( + // By default, MediaQueryData.supportsShowingSystemContextMenu is false. + const example.SystemContextMenuExampleApp(), + ); + + expect(find.byType(SystemContextMenu), findsNothing); + + // Show 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); + expect(find.byType(SystemContextMenu), findsNothing); + }, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended] +} diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index 14825107a942..b513f1823807 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -357,8 +357,14 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { Future _handlePlatformMessage(MethodCall methodCall) async { final String method = methodCall.method; - assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit'); switch (method) { + // Called when the system dismisses the system context menu, such as when + // the user taps outside the menu. Not called when Flutter shows a new + // system context menu while an old one is still visible. + case 'ContextMenu.onDismissSystemContextMenu': + for (final SystemContextMenuClient client in _systemContextMenuClients) { + client.handleSystemHide(); + } case 'SystemChrome.systemUIChange': final List args = methodCall.arguments as List; if (_systemUiChangeCallback != null) { @@ -366,6 +372,8 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { } case 'System.requestAppExit': return {'response': (await handleRequestAppExit()).name}; + default: + throw AssertionError('Method "$method" not handled.'); } } @@ -510,6 +518,19 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { Future initializationComplete() async { await SystemChannels.platform.invokeMethod('System.initializationComplete'); } + + final Set _systemContextMenuClients = {}; + + /// Registers a [SystemContextMenuClient] that will receive system context + /// menu calls from the engine. + static void registerSystemContextMenuClient(SystemContextMenuClient client) { + instance._systemContextMenuClients.add(client); + } + + /// Unregisters a [SystemContextMenuClient] so that it is no longer called. + static void unregisterSystemContextMenuClient(SystemContextMenuClient client) { + instance._systemContextMenuClients.remove(client); + } } /// Signature for listening to changes in the [SystemUiMode]. @@ -588,3 +609,23 @@ class _DefaultBinaryMessenger extends BinaryMessenger { } } } + +/// An interface to receive calls related to the system context menu from the +/// engine. +/// +/// Currently this is only supported on iOS 16+. +/// +/// See also: +/// * [SystemContextMenuController], which uses this to provide a fully +/// featured way to control the system context menu. +/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates +/// whether the system context menu is supported. +/// * [SystemContextMenu], which provides a widget interface for displaying the +/// system context menu. +mixin SystemContextMenuClient { + /// Handles the system hiding a context menu. + /// + /// This is called for all instances of [SystemContextMenuController], so it's + /// not guaranteed that this instance was the one that was hidden. + void handleSystemHide(); +} diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index f46a753e30c8..ecea3a576aa5 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart'; import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'autofill.dart'; +import 'binding.dart'; import 'clipboard.dart' show Clipboard; import 'keyboard_inserted_content.dart'; import 'message_codec.dart'; @@ -1808,7 +1809,7 @@ class TextInput { Future _handleTextInputInvocation(MethodCall methodCall) async { final String method = methodCall.method; - switch (methodCall.method) { + switch (method) { case 'TextInputClient.focusElement': final List args = methodCall.arguments as List; _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble())); @@ -2403,3 +2404,178 @@ class _PlatformTextInputControl with TextInputControl { ); } } + +/// Allows access to the system context menu. +/// +/// The context menu is the menu that appears, for example, when doing text +/// selection. Flutter typically draws this menu itself, but this class deals +/// with the platform-rendered context menu. +/// +/// Only one instance can be visible at a time. Calling [show] while the system +/// context menu is already visible will hide it and show it again at the new +/// [Rect]. An instance that is hidden is informed via [onSystemHide]. +/// +/// Currently this system context menu is bound to text input. The buttons that +/// are shown and the actions they perform are dependent on the currently +/// active [TextInputConnection]. Using this without an active +/// [TextInputConnection] is a noop. +/// +/// Call [dispose] when no longer needed. +/// +/// See also: +/// +/// * [ContextMenuController], which controls Flutter-drawn context menus. +/// * [SystemContextMenu], which wraps this functionality in a widget. +/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates +/// whether the system context menu is supported. +class SystemContextMenuController with SystemContextMenuClient { + /// Creates an instance of [SystemContextMenuController]. + /// + /// Not shown until [show] is called. + SystemContextMenuController({ + this.onSystemHide, + }) { + ServicesBinding.registerSystemContextMenuClient(this); + } + + /// Called when the system has hidden the context menu. + /// + /// For example, tapping outside of the context menu typically causes the + /// system to hide it directly. Flutter is made aware that the context menu is + /// no longer visible through this callback. + /// + /// This is not called when [show]ing a new system context menu causes another + /// to be hidden. + final VoidCallback? onSystemHide; + + static const MethodChannel _channel = SystemChannels.platform; + + static SystemContextMenuController? _lastShown; + + /// The target [Rect] that was last given to [show]. + /// + /// Null if [show] has not been called. + Rect? _lastTargetRect; + + /// True when the instance most recently [show]n has been hidden by the + /// system. + bool _hiddenBySystem = false; + + bool get _isVisible => this == _lastShown && !_hiddenBySystem; + + /// After calling [dispose], this instance can no longer be used. + bool _isDisposed = false; + + // Begin SystemContextMenuClient. + + @override + void handleSystemHide() { + assert(!_isDisposed); + // If this instance wasn't being shown, then it wasn't the instance that was + // hidden. + if (!_isVisible) { + return; + } + if (_lastShown == this) { + _lastShown = null; + } + _hiddenBySystem = true; + onSystemHide?.call(); + } + + // End SystemContextMenuClient. + + /// Shows the system context menu anchored on the given [Rect]. + /// + /// The [Rect] represents what the context menu is pointing to. For example, + /// for some text selection, this would be the selection [Rect]. + /// + /// There can only be one system context menu visible at a time. Calling this + /// while another system context menu is already visible will remove the old + /// menu before showing the new menu. + /// + /// Currently this system context menu is bound to text input. The buttons + /// that are shown and the actions they perform are dependent on the + /// currently active [TextInputConnection]. Using this without an active + /// [TextInputConnection] will be a noop. + /// + /// This is only supported on iOS 16.0 and later. + /// + /// See also: + /// + /// * [hideSystemContextMenu], which hides the menu shown by this method. + /// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether + /// this method is supported on the current platform. + Future show(Rect targetRect) { + assert(!_isDisposed); + assert( + TextInput._instance._currentConnection != null, + 'Currently, the system context menu can only be shown for an active text input connection', + ); + + // Don't show the same thing that's already being shown. + if (_lastShown != null && _lastShown!._isVisible && _lastShown!._lastTargetRect == targetRect) { + return Future.value(); + } + + assert( + _lastShown == null || _lastShown == this || !_lastShown!._isVisible, + 'Attempted to show while another instance was still visible.', + ); + + _lastTargetRect = targetRect; + _lastShown = this; + _hiddenBySystem = false; + return _channel.invokeMethod>( + 'ContextMenu.showSystemContextMenu', + { + 'targetRect': { + 'x': targetRect.left, + 'y': targetRect.top, + 'width': targetRect.width, + 'height': targetRect.height, + }, + }, + ); + } + + /// Hides this system context menu. + /// + /// If this hasn't been shown, or if another instance has hidden this menu, + /// does nothing. + /// + /// Currently this is only supported on iOS 16.0 and later. + /// + /// See also: + /// + /// * [showSystemContextMenu], which shows the menu hidden by this method. + /// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether + /// the system context menu is supported on the current platform. + Future hide() async { + assert(!_isDisposed); + // This check prevents a given instance from accidentally hiding some other + // instance, since only one can be visible at a time. + if (this != _lastShown) { + return; + } + _lastShown = null; + // This may be called unnecessarily in the case where the user has already + // hidden the menu (for example by tapping the screen). + return _channel.invokeMethod( + 'ContextMenu.hideSystemContextMenu', + ); + } + + @override + String toString() { + return 'SystemContextMenuController(onSystemHide=$onSystemHide, _hiddenBySystem=$_hiddenBySystem, _isVisible=$_isVisible, _isDiposed=$_isDisposed)'; + } + + /// Used to release resources when this instance will never be used again. + void dispose() { + assert(!_isDisposed); + hide(); + ServicesBinding.unregisterSystemContextMenuClient(this); + _isDisposed = true; + } +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index f5b549e76303..069230666e9f 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2786,7 +2786,12 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// Gets the line heights at the start and end of the selection for the given /// [EditableTextState]. - _GlyphHeights _getGlyphHeights() { + /// + /// See also: + /// + /// * [TextSelectionToolbarAnchors.getSelectionRect], which depends on this + /// information. + ({double startGlyphHeight, double endGlyphHeight}) getGlyphHeights() { final TextSelection selection = textEditingValue.selection; // Only calculate handle rects if the text in the previous frame @@ -2800,9 +2805,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien final String prevText = span.toPlainText(); final String currText = textEditingValue.text; if (prevText != currText || !selection.isValid || selection.isCollapsed) { - return _GlyphHeights( - start: renderEditable.preferredLineHeight, - end: renderEditable.preferredLineHeight, + return ( + startGlyphHeight: renderEditable.preferredLineHeight, + endGlyphHeight: renderEditable.preferredLineHeight, ); } @@ -2817,9 +2822,9 @@ class EditableTextState extends State with AutomaticKeepAliveClien start: selection.end - lastSelectedGraphemeExtent, end: selection.end, )); - return _GlyphHeights( - start: startCharacterRect?.height ?? renderEditable.preferredLineHeight, - end: endCharacterRect?.height ?? renderEditable.preferredLineHeight, + return ( + startGlyphHeight: startCharacterRect?.height ?? renderEditable.preferredLineHeight, + endGlyphHeight: endCharacterRect?.height ?? renderEditable.preferredLineHeight, ); } @@ -2838,14 +2843,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien ); } - final _GlyphHeights glyphHeights = _getGlyphHeights(); + final (startGlyphHeight: double startGlyphHeight, endGlyphHeight: double endGlyphHeight) = getGlyphHeights(); final TextSelection selection = textEditingValue.selection; final List points = renderEditable.getEndpointsForSelection(selection); return TextSelectionToolbarAnchors.fromSelection( renderBox: renderEditable, - startGlyphHeight: glyphHeights.start, - endGlyphHeight: glyphHeights.end, + startGlyphHeight: startGlyphHeight, + endGlyphHeight: endGlyphHeight, selectionEndpoints: points, ); } @@ -6026,21 +6031,6 @@ class _CopySelectionAction extends ContextAction { bool get isActionEnabled => state._value.selection.isValid && !state._value.selection.isCollapsed; } -/// 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; -} - /// A [ClipboardStatusNotifier] whose [value] is hardcoded to /// [ClipboardStatus.pasteable]. /// diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart index 58a2bcf4440f..c0289d991430 100644 --- a/packages/flutter/lib/src/widgets/media_query.dart +++ b/packages/flutter/lib/src/widgets/media_query.dart @@ -74,6 +74,8 @@ enum _MediaQueryAspect { gestureSettings, /// Specifies the aspect corresponding to [MediaQueryData.displayFeatures]. displayFeatures, + /// Specifies the aspect corresponding to [MediaQueryData.supportsShowingSystemContextMenu]. + supportsShowingSystemContextMenu, } /// Information about a piece of media (e.g., a window). @@ -173,6 +175,7 @@ class MediaQueryData { this.navigationMode = NavigationMode.traditional, this.gestureSettings = const DeviceGestureSettings(touchSlop: kTouchSlop), this.displayFeatures = const [], + this.supportsShowingSystemContextMenu = false, }) : _textScaleFactor = textScaleFactor, _textScaler = textScaler, assert( @@ -250,7 +253,8 @@ class MediaQueryData { alwaysUse24HourFormat = platformData?.alwaysUse24HourFormat ?? view.platformDispatcher.alwaysUse24HourFormat, navigationMode = platformData?.navigationMode ?? NavigationMode.traditional, gestureSettings = DeviceGestureSettings.fromView(view), - displayFeatures = view.displayFeatures; + displayFeatures = view.displayFeatures, + supportsShowingSystemContextMenu = platformData?.supportsShowingSystemContextMenu ?? view.platformDispatcher.supportsShowingSystemContextMenu; static TextScaler _textScalerFromView(ui.FlutterView view, MediaQueryData? platformData) { final double scaleFactor = platformData?.textScaleFactor ?? view.platformDispatcher.textScaleFactor; @@ -562,6 +566,18 @@ class MediaQueryData { /// [dart:ui.DisplayFeatureType.hinge]). final List displayFeatures; + /// Whether showing the system context menu is supported. + /// + /// For example, on iOS 16.0 and above, the system text selection context menu + /// may be shown instead of the Flutter-drawn context menu in order to avoid + /// the iOS clipboard access notification when the "Paste" button is pressed. + /// + /// See also: + /// + /// * [TextInput.showSystemContextMenu], which may be used to show the system + /// context menu when this flag indicates it's supported. + final bool supportsShowingSystemContextMenu; + /// The orientation of the media (e.g., whether the device is in landscape or /// portrait mode). Orientation get orientation { @@ -598,6 +614,7 @@ class MediaQueryData { NavigationMode? navigationMode, DeviceGestureSettings? gestureSettings, List? displayFeatures, + bool? supportsShowingSystemContextMenu, }) { assert(textScaleFactor == null || textScaler == null); if (textScaleFactor != null) { @@ -622,6 +639,7 @@ class MediaQueryData { navigationMode: navigationMode ?? this.navigationMode, gestureSettings: gestureSettings ?? this.gestureSettings, displayFeatures: displayFeatures ?? this.displayFeatures, + supportsShowingSystemContextMenu: supportsShowingSystemContextMenu ?? this.supportsShowingSystemContextMenu, ); } @@ -814,7 +832,8 @@ class MediaQueryData { && other.boldText == boldText && other.navigationMode == navigationMode && other.gestureSettings == gestureSettings - && listEquals(other.displayFeatures, displayFeatures); + && listEquals(other.displayFeatures, displayFeatures) + && other.supportsShowingSystemContextMenu == supportsShowingSystemContextMenu; } @override @@ -836,6 +855,7 @@ class MediaQueryData { navigationMode, gestureSettings, Object.hashAll(displayFeatures), + supportsShowingSystemContextMenu, ); @override @@ -859,6 +879,7 @@ class MediaQueryData { 'navigationMode: ${navigationMode.name}', 'gestureSettings: $gestureSettings', 'displayFeatures: $displayFeatures', + 'supportsShowingSystemContextMenu: $supportsShowingSystemContextMenu', ]; return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})'; } @@ -1631,6 +1652,26 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { /// {@macro flutter.widgets.media_query.MediaQuery.dontUseMaybeOf} static List? maybeDisplayFeaturesOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.displayFeatures)?.displayFeatures; + /// Returns [MediaQueryData.supportsShowingSystemContextMenu] for the nearest + /// [MediaQuery] ancestor or throws an exception, if no such ancestor exists. + /// + /// Use of this method will cause the given [context] to rebuild any time that + /// the [MediaQueryData.supportsShowingSystemContextMenu] property of the + /// ancestor [MediaQuery] changes. + /// + /// {@macro flutter.widgets.media_query.MediaQuery.dontUseOf} + static bool supportsShowingSystemContextMenu(BuildContext context) => _of(context, _MediaQueryAspect.supportsShowingSystemContextMenu).supportsShowingSystemContextMenu; + + /// Returns [MediaQueryData.supportsShowingSystemContextMenu] for the nearest + /// [MediaQuery] ancestor or null, if no such ancestor exists. + /// + /// Use of this method will cause the given [context] to rebuild any time that + /// the [MediaQueryData.supportsShowingSystemContextMenu] property of the + /// ancestor [MediaQuery] changes. + /// + /// {@macro flutter.widgets.media_query.MediaQuery.dontUseMaybeOf} + static bool? maybeSupportsShowingSystemContextMenu(BuildContext context) => _maybeOf(context, _MediaQueryAspect.supportsShowingSystemContextMenu)?.supportsShowingSystemContextMenu; + @override bool updateShouldNotify(MediaQuery oldWidget) => data != oldWidget.data; @@ -1663,6 +1704,7 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { _MediaQueryAspect.systemGestureInsets => data.systemGestureInsets != oldWidget.data.systemGestureInsets, _MediaQueryAspect.accessibleNavigation => data.accessibleNavigation != oldWidget.data.accessibleNavigation, _MediaQueryAspect.alwaysUse24HourFormat => data.alwaysUse24HourFormat != oldWidget.data.alwaysUse24HourFormat, + _MediaQueryAspect.supportsShowingSystemContextMenu => data.supportsShowingSystemContextMenu != oldWidget.data.supportsShowingSystemContextMenu, }); } } diff --git a/packages/flutter/lib/src/widgets/system_context_menu.dart b/packages/flutter/lib/src/widgets/system_context_menu.dart new file mode 100644 index 000000000000..05fd4b412cca --- /dev/null +++ b/packages/flutter/lib/src/widgets/system_context_menu.dart @@ -0,0 +1,132 @@ +// 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/rendering.dart'; +import 'package:flutter/services.dart'; + +import 'basic.dart'; +import 'editable_text.dart'; +import 'framework.dart'; +import 'media_query.dart'; +import 'text_selection_toolbar_anchors.dart'; + +/// Displays the system context menu on top of the Flutter view. +/// +/// Currently, only supports iOS 16.0 and above and displays nothing on other +/// platforms. +/// +/// The context menu is the menu that appears, for example, when doing text +/// selection. Flutter typically draws this menu itself, but this class deals +/// with the platform-rendered context menu instead. +/// +/// There can only be one system context menu visible at a time. Building this +/// widget when the system context menu is already visible will hide the old one +/// and display this one. A system context menu that is hidden is informed via +/// [onSystemHide]. +/// +/// To check if the current device supports showing the system context menu, +/// call [isSupported]. +/// +/// {@tool dartpad} +/// This example shows how to create a [TextField] that uses the system context +/// menu where supported and does not show a system notification when the user +/// presses the "Paste" button. +/// +/// ** See code in examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SystemContextMenuController], which directly controls the hiding and +/// showing of the system context menu. +class SystemContextMenu extends StatefulWidget { + /// Creates an instance of [SystemContextMenu] that points to the given + /// [anchor]. + const SystemContextMenu._({ + super.key, + required this.anchor, + this.onSystemHide, + }); + + /// Creates an instance of [SystemContextMenu] for the field indicated by the + /// given [EditableTextState]. + factory SystemContextMenu.editableText({ + Key? key, + required EditableTextState editableTextState, + }) { + final ( + startGlyphHeight: double startGlyphHeight, + endGlyphHeight: double endGlyphHeight, + ) = editableTextState.getGlyphHeights(); + return SystemContextMenu._( + key: key, + anchor: TextSelectionToolbarAnchors.getSelectionRect( + editableTextState.renderEditable, + startGlyphHeight, + endGlyphHeight, + editableTextState.renderEditable.getEndpointsForSelection( + editableTextState.textEditingValue.selection, + ), + ), + onSystemHide: () { + editableTextState.hideToolbar(); + }, + ); + } + + /// The [Rect] that the context menu should point to. + final Rect anchor; + + /// Called when the system hides this context menu. + /// + /// For example, tapping outside of the context menu typically causes the + /// system to hide the menu. + /// + /// This is not called when showing a new system context menu causes another + /// to be hidden. + final VoidCallback? onSystemHide; + + /// Whether the current device supports showing the system context menu. + /// + /// Currently, this is only supported on newer versions of iOS. + static bool isSupported(BuildContext context) { + return MediaQuery.maybeSupportsShowingSystemContextMenu(context) ?? false; + } + + @override + State createState() => _SystemContextMenuState(); +} + +class _SystemContextMenuState extends State { + late final SystemContextMenuController _systemContextMenuController; + + @override + void initState() { + super.initState(); + _systemContextMenuController = SystemContextMenuController( + onSystemHide: widget.onSystemHide, + ); + _systemContextMenuController.show(widget.anchor); + } + + @override + void didUpdateWidget(SystemContextMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.anchor != oldWidget.anchor) { + _systemContextMenuController.show(widget.anchor); + } + } + + @override + void dispose() { + _systemContextMenuController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(SystemContextMenu.isSupported(context)); + return const SizedBox.shrink(); + } +} diff --git a/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart index 616a13aea1a5..f6072b2b498f 100644 --- a/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart +++ b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart @@ -30,20 +30,56 @@ class TextSelectionToolbarAnchors { required double endGlyphHeight, required List selectionEndpoints, }) { - final Rect editingRegion = Rect.fromPoints( + final Rect selectionRect = getSelectionRect( + renderBox, + startGlyphHeight, + endGlyphHeight, + selectionEndpoints, + ); + if (selectionRect == Rect.zero) { + return const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero); + } + + final Rect editingRegion = _getEditingRegion(renderBox); + 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), + ), + ); + } + + /// Returns the [Rect] of the [RenderBox] in global coordinates. + static Rect _getEditingRegion(RenderBox renderBox) { + return Rect.fromPoints( renderBox.localToGlobal(Offset.zero), renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)), ); + } + + /// Returns the [Rect] covering the given selection in the given [RenderBox] + /// in global coordinates. + static Rect getSelectionRect( + RenderBox renderBox, + double startGlyphHeight, + double endGlyphHeight, + List selectionEndpoints, + ) { + final Rect editingRegion = _getEditingRegion(renderBox); if (editingRegion.left.isNaN || editingRegion.top.isNaN || editingRegion.right.isNaN || editingRegion.bottom.isNaN) { - return const TextSelectionToolbarAnchors(primaryAnchor: Offset.zero); + return Rect.zero; } final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy > endGlyphHeight / 2; - final Rect selectionRect = Rect.fromLTRB( + return Rect.fromLTRB( isMultiline ? editingRegion.left : editingRegion.left + selectionEndpoints.first.point.dx, @@ -53,17 +89,6 @@ class TextSelectionToolbarAnchors { : 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. diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 41eb3e9a770d..c53eed2261ce 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -141,6 +141,7 @@ export 'src/widgets/snapshot_widget.dart'; export 'src/widgets/spacer.dart'; export 'src/widgets/spell_check.dart'; export 'src/widgets/status_transitions.dart'; +export 'src/widgets/system_context_menu.dart'; export 'src/widgets/table.dart'; export 'src/widgets/tap_region.dart'; export 'src/widgets/text.dart'; diff --git a/packages/flutter/test/services/system_context_menu_controller_test.dart b/packages/flutter/test/services/system_context_menu_controller_test.dart new file mode 100644 index 000000000000..38fe480be61d --- /dev/null +++ b/packages/flutter/test/services/system_context_menu_controller_test.dart @@ -0,0 +1,257 @@ +// 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_test/flutter_test.dart'; + +import './text_input_utils.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + test('showing and hiding one controller', () { + // Create an active connection, which is required to show the system menu. + final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test1')); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + addTearDown(() { + connection.close(); + }); + + final List> targetRects = >[]; + int hideCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'ContextMenu.showSystemContextMenu': + final Map arguments = methodCall.arguments as Map; + final Map untypedTargetRect = arguments['targetRect'] as Map; + final Map lastTargetRect = untypedTargetRect.map((String key, dynamic value) { + return MapEntry(key, value as double); + }); + targetRects.add(lastTargetRect); + case 'ContextMenu.hideSystemContextMenu': + hideCount += 1; + } + return; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + final SystemContextMenuController controller = SystemContextMenuController(); + addTearDown(() { + controller.dispose(); + }); + + expect(targetRects, isEmpty); + expect(hideCount, 0); + + // Showing calls the platform. + const Rect rect1 = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + controller.show(rect1); + expect(targetRects, hasLength(1)); + expect(targetRects.last['x'], rect1.left); + expect(targetRects.last['y'], rect1.top); + expect(targetRects.last['width'], rect1.width); + expect(targetRects.last['height'], rect1.height); + + // Showing the same thing again does nothing. + controller.show(rect1); + expect(targetRects, hasLength(1)); + + // Showing a new rect calls the platform. + const Rect rect2 = Rect.fromLTWH(1.0, 1.0, 200.0, 200.0); + controller.show(rect2); + expect(targetRects, hasLength(2)); + expect(targetRects.last['x'], rect2.left); + expect(targetRects.last['y'], rect2.top); + expect(targetRects.last['width'], rect2.width); + expect(targetRects.last['height'], rect2.height); + + // Hiding calls the platform. + controller.hide(); + expect(hideCount, 1); + + // Hiding again does nothing. + controller.hide(); + expect(hideCount, 1); + + // Showing the last shown rect calls the platform. + controller.show(rect2); + expect(targetRects, hasLength(3)); + expect(targetRects.last['x'], rect2.left); + expect(targetRects.last['y'], rect2.top); + expect(targetRects.last['width'], rect2.width); + expect(targetRects.last['height'], rect2.height); + + controller.hide(); + expect(hideCount, 2); + }); + + test('the system can hide the menu with handleSystemHide', () async { + // Create an active connection, which is required to show the system menu. + final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test1')); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + addTearDown(() { + connection.close(); + }); + + final List> targetRects = >[]; + int hideCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'ContextMenu.showSystemContextMenu': + final Map arguments = methodCall.arguments as Map; + final Map untypedTargetRect = arguments['targetRect'] as Map; + final Map lastTargetRect = untypedTargetRect.map((String key, dynamic value) { + return MapEntry(key, value as double); + }); + targetRects.add(lastTargetRect); + case 'ContextMenu.hideSystemContextMenu': + hideCount += 1; + } + return; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + int systemHideCount = 0; + final SystemContextMenuController controller = SystemContextMenuController( + onSystemHide: () { + systemHideCount += 1; + }, + ); + addTearDown(() { + controller.dispose(); + }); + + expect(targetRects, isEmpty); + expect(hideCount, 0); + expect(systemHideCount, 0); + + // Showing calls the platform. + const Rect rect1 = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + controller.show(rect1); + expect(targetRects, hasLength(1)); + expect(targetRects.last['x'], rect1.left); + expect(targetRects.last['y'], rect1.top); + expect(targetRects.last['width'], rect1.width); + expect(targetRects.last['height'], rect1.height); + + // If the system hides the menu, onSystemHide is called. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'method': 'ContextMenu.onDismissSystemContextMenu', + }); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/platform', + messageBytes, + (ByteData? data) {}, + ); + expect(hideCount, 0); + expect(systemHideCount, 1); + + // Hiding does not call the platform, since the menu was already hidden. + controller.hide(); + expect(hideCount, 0); + }); + + test('showing a second controller while one is visible is an error', () { + // Create an active connection, which is required to show the system menu. + final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test1')); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + addTearDown(() { + connection.close(); + }); + + final SystemContextMenuController controller1 = SystemContextMenuController(); + addTearDown(() { + controller1.dispose(); + }); + const Rect rect1 = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + expect(() { controller1.show(rect1); }, isNot(throwsAssertionError)); + + final SystemContextMenuController controller2 = SystemContextMenuController(); + addTearDown(() { + controller2.dispose(); + }); + const Rect rect2 = Rect.fromLTWH(1.0, 1.0, 200.0, 200.0); + expect(() { controller2.show(rect2); }, throwsAssertionError); + + controller1.hide(); + }); + + test('showing and hiding two controllers', () { + // Create an active connection, which is required to show the system menu. + final FakeTextInputClient client = FakeTextInputClient(const TextEditingValue(text: 'test1')); + final TextInputConnection connection = TextInput.attach(client, client.configuration); + addTearDown(() { + connection.close(); + }); + + final List> targetRects = >[]; + int hideCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'ContextMenu.showSystemContextMenu': + final Map arguments = methodCall.arguments as Map; + final Map untypedTargetRect = arguments['targetRect'] as Map; + final Map lastTargetRect = untypedTargetRect.map((String key, dynamic value) { + return MapEntry(key, value as double); + }); + targetRects.add(lastTargetRect); + case 'ContextMenu.hideSystemContextMenu': + hideCount += 1; + } + return; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + final SystemContextMenuController controller1 = SystemContextMenuController(); + addTearDown(() { + controller1.dispose(); + }); + + expect(targetRects, isEmpty); + expect(hideCount, 0); + + // Showing calls the platform. + const Rect rect1 = Rect.fromLTWH(0.0, 0.0, 100.0, 100.0); + controller1.show(rect1); + expect(targetRects, hasLength(1)); + expect(targetRects.last['x'], rect1.left); + + // Hiding calls the platform. + controller1.hide(); + expect(hideCount, 1); + + // Showing a new controller calls the platform. + final SystemContextMenuController controller2 = SystemContextMenuController(); + addTearDown(() { + controller2.dispose(); + }); + const Rect rect2 = Rect.fromLTWH(1.0, 1.0, 200.0, 200.0); + controller2.show(rect2); + expect(targetRects, hasLength(2)); + expect(targetRects.last['x'], rect2.left); + expect(targetRects.last['y'], rect2.top); + expect(targetRects.last['width'], rect2.width); + expect(targetRects.last['height'], rect2.height); + + // Hiding the old controller does nothing. + controller1.hide(); + expect(hideCount, 1); + + // Hiding the new controller calls the platform. + controller2.hide(); + expect(hideCount, 2); + }); +} diff --git a/packages/flutter/test/services/text_input_utils.dart b/packages/flutter/test/services/text_input_utils.dart index e1644e564811..3de6786540ae 100644 --- a/packages/flutter/test/services/text_input_utils.dart +++ b/packages/flutter/test/services/text_input_utils.dart @@ -90,3 +90,81 @@ class FakeScribbleElement implements ScribbleClient { latestMethodCall = 'onScribbleFocus'; } } + +class FakeTextInputClient with TextInputClient { + FakeTextInputClient(this.currentTextEditingValue); + + String latestMethodCall = ''; + final List performedSelectors = []; + late Map? latestPrivateCommandData; + + @override + TextEditingValue currentTextEditingValue; + + @override + AutofillScope? get currentAutofillScope => null; + + @override + void performAction(TextInputAction action) { + latestMethodCall = 'performAction'; + } + + @override + void performPrivateCommand(String action, Map? data) { + latestMethodCall = 'performPrivateCommand'; + latestPrivateCommandData = data; + } + + @override + void insertContent(KeyboardInsertedContent content) { + latestMethodCall = 'commitContent'; + } + + @override + void updateEditingValue(TextEditingValue value) { + latestMethodCall = 'updateEditingValue'; + } + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + latestMethodCall = 'updateFloatingCursor'; + } + + @override + void connectionClosed() { + latestMethodCall = 'connectionClosed'; + } + + @override + void showAutocorrectionPromptRect(int start, int end) { + latestMethodCall = 'showAutocorrectionPromptRect'; + } + + @override + void showToolbar() { + latestMethodCall = 'showToolbar'; + } + + TextInputConfiguration get configuration => const TextInputConfiguration(); + + @override + void didChangeInputControl(TextInputControl? oldControl, TextInputControl? newControl) { + latestMethodCall = 'didChangeInputControl'; + } + + @override + void insertTextPlaceholder(Size size) { + latestMethodCall = 'insertTextPlaceholder'; + } + + @override + void removeTextPlaceholder() { + latestMethodCall = 'removeTextPlaceholder'; + } + + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + performedSelectors.add(selectorName); + } +} diff --git a/packages/flutter/test/widgets/system_context_menu_test.dart b/packages/flutter/test/widgets/system_context_menu_test.dart new file mode 100644 index 000000000000..a7a7c474d2be --- /dev/null +++ b/packages/flutter/test/widgets/system_context_menu_test.dart @@ -0,0 +1,415 @@ +// 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/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('asserts when built on an unsupported device', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'one two three', + ); + await tester.pumpWidget( + // By default, MediaQueryData.supportsShowingSystemContextMenu is false. + MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.byType(TextField)); + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.showToolbar(), true); + await tester.pump(); + + expect(tester.takeException(), isAssertionError); + }, variant: TargetPlatformVariant.all()); + + testWidgets('can be shown and hidden like a normal context menu', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'one two three', + ); + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ); + }, + ), + ); + + expect(find.byType(SystemContextMenu), findsNothing); + + await tester.tap(find.byType(TextField)); + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.showToolbar(), true); + await tester.pump(); + expect(find.byType(SystemContextMenu), findsOneWidget); + + state.hideToolbar(); + await tester.pump(); + expect(find.byType(SystemContextMenu), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('can be updated.', (WidgetTester tester) async { + final List> targetRects = >[]; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async { + if (methodCall.method == 'ContextMenu.showSystemContextMenu') { + final Map arguments = methodCall.arguments as Map; + final Map untypedTargetRect = arguments['targetRect'] as Map; + final Map lastTargetRect = untypedTargetRect.map((String key, dynamic value) { + return MapEntry(key, value as double); + }); + targetRects.add(lastTargetRect); + } + return; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + final TextEditingController controller = TextEditingController( + text: 'one two three', + ); + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: TextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + }, + ), + ), + ), + ), + ); + }, + ), + ); + + expect(targetRects, isEmpty); + + await tester.tap(find.byType(TextField)); + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.showToolbar(), true); + await tester.pump(); + + expect(targetRects, hasLength(1)); + expect(targetRects.last, containsPair('width', 0.0)); + + controller.selection = const TextSelection( + baseOffset: 4, + extentOffset: 7, + ); + await tester.pumpAndSettle(); + + expect(targetRects, hasLength(2)); + expect(targetRects.last['width'], greaterThan(0.0)); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('can be rebuilt', (WidgetTester tester) async { + final TextEditingController controller = TextEditingController( + text: 'one two three', + ); + late StateSetter setState; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter localSetState) { + setState = localSetState; + return TextField( + controller: controller, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + editableTextState: editableTextState, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + ), + ); + + await tester.tap(find.byType(TextField)); + final EditableTextState state = tester.state(find.byType(EditableText)); + expect(state.showToolbar(), true); + await tester.pump(); + + setState(() {}); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('can handle multiple instances', (WidgetTester tester) async { + final TextEditingController controller1 = TextEditingController( + text: 'one two three', + ); + final TextEditingController controller2 = TextEditingController( + text: 'four five six', + ); + final GlobalKey field1Key = GlobalKey(); + final GlobalKey field2Key = GlobalKey(); + final GlobalKey menu1Key = GlobalKey(); + final GlobalKey menu2Key = GlobalKey(); + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: Center( + child: Column( + children: [ + TextField( + key: field1Key, + controller: controller1, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + key: menu1Key, + editableTextState: editableTextState, + ); + }, + ), + TextField( + key: field2Key, + controller: controller2, + contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) { + return SystemContextMenu.editableText( + key: menu2Key, + editableTextState: editableTextState, + ); + }, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + + expect(find.byType(SystemContextMenu), findsNothing); + + await tester.tap(find.byKey(field1Key)); + final EditableTextState state1 = tester.state( + find.descendant( + of: find.byKey(field1Key), + matching: find.byType(EditableText), + ), + ); + expect(state1.showToolbar(), true); + await tester.pump(); + expect(find.byKey(menu1Key), findsOneWidget); + expect(find.byKey(menu2Key), findsNothing); + + // In a real app, this message is sent by iOS when the user taps anywhere + // outside of the system context menu. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'method': 'ContextMenu.onDismissSystemContextMenu', + }); + await binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/platform', + messageBytes, + (ByteData? data) {}, + ); + await tester.pump(); + expect(find.byType(SystemContextMenu), findsNothing); + + await tester.tap(find.byKey(field2Key)); + final EditableTextState state2 = tester.state( + find.descendant( + of: find.byKey(field2Key), + matching: find.byType(EditableText), + ), + ); + expect(state2.showToolbar(), true); + await tester.pump(); + expect(find.byKey(menu1Key), findsNothing); + expect(find.byKey(menu2Key), findsOneWidget); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('asserts when built with no text input connection', (WidgetTester tester) async { + SystemContextMenu? systemContextMenu; + late StateSetter setState; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter localSetState) { + setState = localSetState; + return Column( + children: [ + const TextField(), + if (systemContextMenu != null) + systemContextMenu!, + ], + ); + }, + ), + ), + ), + ); + }, + ), + ); + + // No SystemContextMenu yet, so no assertion error. + expect(tester.takeException(), isNull); + + // Add the SystemContextMenu and receive an assertion since there is no + // active text input connection. + setState(() { + final EditableTextState state = tester.state(find.byType(EditableText)); + systemContextMenu = SystemContextMenu.editableText( + editableTextState: state, + ); + }); + + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + dynamic exception; + FlutterError.onError = (FlutterErrorDetails details) { + exception ??= details.exception; + }; + addTearDown(() { + FlutterError.onError = oldHandler; + }); + + await tester.pump(); + expect(exception, isAssertionError); + expect(exception.toString(), contains('only be shown for an active text input connection')); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + + testWidgets('does not assert when built with an active text input connection', (WidgetTester tester) async { + SystemContextMenu? systemContextMenu; + late StateSetter setState; + await tester.pumpWidget( + Builder( + builder: (BuildContext context) { + final MediaQueryData mediaQueryData = MediaQuery.of(context); + return MediaQuery( + data: mediaQueryData.copyWith( + supportsShowingSystemContextMenu: true, + ), + child: MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter localSetState) { + setState = localSetState; + return Column( + children: [ + const TextField(), + if (systemContextMenu != null) + systemContextMenu!, + ], + ); + }, + ), + ), + ), + ); + }, + ), + ); + + // No SystemContextMenu yet, so no assertion error. + expect(tester.takeException(), isNull); + + // Tap the field to open a text input connection. + await tester.tap(find.byType(TextField)); + await tester.pump(); + + // Add the SystemContextMenu and expect no error. + setState(() { + final EditableTextState state = tester.state(find.byType(EditableText)); + systemContextMenu = SystemContextMenu.editableText( + editableTextState: state, + ); + }); + + final FlutterExceptionHandler? oldHandler = FlutterError.onError; + dynamic exception; + FlutterError.onError = (FlutterErrorDetails details) { + exception ??= details.exception; + }; + addTearDown(() { + FlutterError.onError = oldHandler; + }); + + await tester.pump(); + expect(exception, isNull); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); +} diff --git a/packages/flutter_test/lib/src/window.dart b/packages/flutter_test/lib/src/window.dart index e94d6de46e0b..b108c0588e94 100644 --- a/packages/flutter_test/lib/src/window.dart +++ b/packages/flutter_test/lib/src/window.dart @@ -307,6 +307,18 @@ class TestPlatformDispatcher implements PlatformDispatcher { _nativeSpellCheckServiceDefinedTestValue = null; } + @override + bool get supportsShowingSystemContextMenu => _supportsShowingSystemContextMenu ?? _platformDispatcher.supportsShowingSystemContextMenu; + bool? _supportsShowingSystemContextMenu; + set supportsShowingSystemContextMenu(bool value) { // ignore: avoid_setters_without_getters + _supportsShowingSystemContextMenu = value; + } + + /// Resets [supportsShowingSystemContextMenu] to the default value. + void resetSupportsShowingSystemContextMenu() { + _supportsShowingSystemContextMenu = null; + } + @override bool get brieflyShowPassword => _brieflyShowPasswordTestValue ?? _platformDispatcher.brieflyShowPassword; bool? _brieflyShowPasswordTestValue; @@ -458,6 +470,7 @@ class TestPlatformDispatcher implements PlatformDispatcher { clearTextScaleFactorTestValue(); clearNativeSpellCheckServiceDefined(); resetBrieflyShowPassword(); + resetSupportsShowingSystemContextMenu(); resetInitialLifecycleState(); resetSystemFontFamily(); }