Reland Native ios context menu (#143002) (#148238)

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.
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 0000000..b648c11
--- /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 0000000..f8052e5
--- /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<EditableTextState>(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<EditableTextState>(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 1482510..b513f18 100644
--- a/packages/flutter/lib/src/services/binding.dart
+++ b/packages/flutter/lib/src/services/binding.dart
@@ -357,8 +357,14 @@
 
   Future<dynamic> _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<dynamic> args = methodCall.arguments as List<dynamic>;
         if (_systemUiChangeCallback != null) {
@@ -366,6 +372,8 @@
         }
       case 'System.requestAppExit':
         return <String, dynamic>{'response': (await handleRequestAppExit()).name};
+      default:
+        throw AssertionError('Method "$method" not handled.');
     }
   }
 
@@ -510,6 +518,19 @@
   Future<void> initializationComplete() async {
     await SystemChannels.platform.invokeMethod('System.initializationComplete');
   }
+
+  final Set<SystemContextMenuClient> _systemContextMenuClients = <SystemContextMenuClient>{};
+
+  /// 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 @@
     }
   }
 }
+
+/// 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 f46a753..14242ea 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: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 @@
 
   Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
     final String method = methodCall.method;
-    switch (methodCall.method) {
+    switch (method) {
       case 'TextInputClient.focusElement':
         final List<dynamic> args = methodCall.arguments as List<dynamic>;
         _scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble()));
@@ -2403,3 +2404,178 @@
     );
   }
 }
+
+/// 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:
+  ///
+  ///  * [hide], which hides the menu shown by this method.
+  ///  * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
+  ///    this method is supported on the current platform.
+  Future<void> 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<void>.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<Map<String, dynamic>>(
+      'ContextMenu.showSystemContextMenu',
+      <String, dynamic>{
+        'targetRect': <String, double>{
+          '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:
+  ///
+  ///  * [show], which shows the menu hidden by this method.
+  ///  * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
+  ///    the system context menu is supported on the current platform.
+  Future<void> 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<void>(
+      '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 f5b549e..0692306 100644
--- a/packages/flutter/lib/src/widgets/editable_text.dart
+++ b/packages/flutter/lib/src/widgets/editable_text.dart
@@ -2786,7 +2786,12 @@
 
   /// 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 @@
     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 @@
       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 @@
       );
     }
 
-    final _GlyphHeights glyphHeights = _getGlyphHeights();
+    final (startGlyphHeight: double startGlyphHeight, endGlyphHeight: double endGlyphHeight) = getGlyphHeights();
     final TextSelection selection = textEditingValue.selection;
     final List<TextSelectionPoint> 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 @@
   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 58a2bcf..b382137 100644
--- a/packages/flutter/lib/src/widgets/media_query.dart
+++ b/packages/flutter/lib/src/widgets/media_query.dart
@@ -74,6 +74,8 @@
   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 @@
     this.navigationMode = NavigationMode.traditional,
     this.gestureSettings = const DeviceGestureSettings(touchSlop: kTouchSlop),
     this.displayFeatures = const <ui.DisplayFeature>[],
+    this.supportsShowingSystemContextMenu = false,
   }) : _textScaleFactor = textScaleFactor,
        _textScaler = textScaler,
        assert(
@@ -250,7 +253,8 @@
       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,19 @@
   ///  [dart:ui.DisplayFeatureType.hinge]).
   final List<ui.DisplayFeature> 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:
+  ///
+  ///  * [SystemContextMenuController] and [SystemContextMenu], 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 +615,7 @@
     NavigationMode? navigationMode,
     DeviceGestureSettings? gestureSettings,
     List<ui.DisplayFeature>? displayFeatures,
+    bool? supportsShowingSystemContextMenu,
   }) {
     assert(textScaleFactor == null || textScaler == null);
     if (textScaleFactor != null) {
@@ -622,6 +640,7 @@
       navigationMode: navigationMode ?? this.navigationMode,
       gestureSettings: gestureSettings ?? this.gestureSettings,
       displayFeatures: displayFeatures ?? this.displayFeatures,
+      supportsShowingSystemContextMenu: supportsShowingSystemContextMenu ?? this.supportsShowingSystemContextMenu,
     );
   }
 
@@ -814,7 +833,8 @@
         && other.boldText == boldText
         && other.navigationMode == navigationMode
         && other.gestureSettings == gestureSettings
-        && listEquals(other.displayFeatures, displayFeatures);
+        && listEquals(other.displayFeatures, displayFeatures)
+        && other.supportsShowingSystemContextMenu == supportsShowingSystemContextMenu;
   }
 
   @override
@@ -836,6 +856,7 @@
     navigationMode,
     gestureSettings,
     Object.hashAll(displayFeatures),
+    supportsShowingSystemContextMenu,
   );
 
   @override
@@ -859,6 +880,7 @@
       'navigationMode: ${navigationMode.name}',
       'gestureSettings: $gestureSettings',
       'displayFeatures: $displayFeatures',
+      'supportsShowingSystemContextMenu: $supportsShowingSystemContextMenu',
     ];
     return '${objectRuntimeType(this, 'MediaQueryData')}(${properties.join(', ')})';
   }
@@ -1631,6 +1653,26 @@
   /// {@macro flutter.widgets.media_query.MediaQuery.dontUseMaybeOf}
   static List<ui.DisplayFeature>? 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 +1705,7 @@
       _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 0000000..05fd4b4
--- /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<SystemContextMenu> createState() => _SystemContextMenuState();
+}
+
+class _SystemContextMenuState extends State<SystemContextMenu> {
+  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 616a13a..f6072b2 100644
--- a/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
+++ b/packages/flutter/lib/src/widgets/text_selection_toolbar_anchors.dart
@@ -30,30 +30,17 @@
     required double endGlyphHeight,
     required List<TextSelectionPoint> selectionEndpoints,
   }) {
-    final Rect editingRegion = Rect.fromPoints(
-      renderBox.localToGlobal(Offset.zero),
-      renderBox.localToGlobal(renderBox.size.bottomRight(Offset.zero)),
+    final Rect selectionRect = getSelectionRect(
+      renderBox,
+      startGlyphHeight,
+      endGlyphHeight,
+      selectionEndpoints,
     );
-
-    if (editingRegion.left.isNaN || editingRegion.top.isNaN
-      || editingRegion.right.isNaN || editingRegion.bottom.isNaN) {
+    if (selectionRect == Rect.zero) {
       return const TextSelectionToolbarAnchors(primaryAnchor: 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,
-    );
-
+    final Rect editingRegion = _getEditingRegion(renderBox);
     return TextSelectionToolbarAnchors(
       primaryAnchor: Offset(
         selectionRect.left + selectionRect.width / 2,
@@ -66,6 +53,44 @@
     );
   }
 
+  /// 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<TextSelectionPoint> selectionEndpoints,
+  ) {
+    final Rect editingRegion = _getEditingRegion(renderBox);
+
+    if (editingRegion.left.isNaN || editingRegion.top.isNaN
+      || editingRegion.right.isNaN || editingRegion.bottom.isNaN) {
+      return Rect.zero;
+    }
+
+    final bool isMultiline = selectionEndpoints.last.point.dy - selectionEndpoints.first.point.dy >
+        endGlyphHeight / 2;
+
+    return 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,
+    );
+  }
+
   /// The location that the toolbar should attempt to position itself at.
   ///
   /// If the toolbar doesn't fit at this location, use [secondaryAnchor] if it
diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart
index 41eb3e9..c53eed2 100644
--- a/packages/flutter/lib/widgets.dart
+++ b/packages/flutter/lib/widgets.dart
@@ -141,6 +141,7 @@
 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 0000000..38fe480
--- /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<Map<String, double>> targetRects = <Map<String, double>>[];
+    int hideCount = 0;
+    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+      .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
+        switch (methodCall.method) {
+          case 'ContextMenu.showSystemContextMenu':
+            final Map<String, dynamic> arguments = methodCall.arguments as Map<String, dynamic>;
+            final Map<String, dynamic> untypedTargetRect = arguments['targetRect'] as Map<String, dynamic>;
+            final Map<String, double> lastTargetRect = untypedTargetRect.map((String key, dynamic value) {
+              return MapEntry<String, double>(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<Map<String, double>> targetRects = <Map<String, double>>[];
+    int hideCount = 0;
+    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+      .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
+        switch (methodCall.method) {
+          case 'ContextMenu.showSystemContextMenu':
+            final Map<String, dynamic> arguments = methodCall.arguments as Map<String, dynamic>;
+            final Map<String, dynamic> untypedTargetRect = arguments['targetRect'] as Map<String, dynamic>;
+            final Map<String, double> lastTargetRect = untypedTargetRect.map((String key, dynamic value) {
+              return MapEntry<String, double>(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(<String, dynamic>{
+      '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<Map<String, double>> targetRects = <Map<String, double>>[];
+    int hideCount = 0;
+    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+      .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
+        switch (methodCall.method) {
+          case 'ContextMenu.showSystemContextMenu':
+            final Map<String, dynamic> arguments = methodCall.arguments as Map<String, dynamic>;
+            final Map<String, dynamic> untypedTargetRect = arguments['targetRect'] as Map<String, dynamic>;
+            final Map<String, double> lastTargetRect = untypedTargetRect.map((String key, dynamic value) {
+              return MapEntry<String, double>(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 e1644e5..3de6786 100644
--- a/packages/flutter/test/services/text_input_utils.dart
+++ b/packages/flutter/test/services/text_input_utils.dart
@@ -90,3 +90,81 @@
     latestMethodCall = 'onScribbleFocus';
   }
 }
+
+class FakeTextInputClient with TextInputClient {
+  FakeTextInputClient(this.currentTextEditingValue);
+
+  String latestMethodCall = '';
+  final List<String> performedSelectors = <String>[];
+  late Map<String, dynamic>? latestPrivateCommandData;
+
+  @override
+  TextEditingValue currentTextEditingValue;
+
+  @override
+  AutofillScope? get currentAutofillScope => null;
+
+  @override
+  void performAction(TextInputAction action) {
+    latestMethodCall = 'performAction';
+  }
+
+  @override
+  void performPrivateCommand(String action, Map<String, dynamic>? 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 0000000..a7a7c47
--- /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<EditableTextState>(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<EditableTextState>(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<Map<String, double>> targetRects = <Map<String, double>>[];
+    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+      .setMockMethodCallHandler(SystemChannels.platform, (MethodCall methodCall) async {
+        if (methodCall.method == 'ContextMenu.showSystemContextMenu') {
+          final Map<String, dynamic> arguments = methodCall.arguments as Map<String, dynamic>;
+          final Map<String, dynamic> untypedTargetRect = arguments['targetRect'] as Map<String, dynamic>;
+          final Map<String, double> lastTargetRect = untypedTargetRect.map((String key, dynamic value) {
+            return MapEntry<String, double>(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<EditableTextState>(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<EditableTextState>(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: <Widget>[
+                      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<EditableTextState>(
+      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(<String, dynamic>{
+      '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<EditableTextState>(
+      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: <Widget>[
+                        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<EditableTextState>(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: <Widget>[
+                        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<EditableTextState>(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 e94d6de..b108c05 100644
--- a/packages/flutter_test/lib/src/window.dart
+++ b/packages/flutter_test/lib/src/window.dart
@@ -308,6 +308,18 @@
   }
 
   @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;
   /// Hides the real [brieflyShowPassword] and reports the given
@@ -458,6 +470,7 @@
     clearTextScaleFactorTestValue();
     clearNativeSpellCheckServiceDefined();
     resetBrieflyShowPassword();
+    resetSupportsShowingSystemContextMenu();
     resetInitialLifecycleState();
     resetSystemFontFamily();
   }