Use SystemContextMenu by default on iOS (#165354)

With this change, widgets based on EditableText will show the system
context menu by default on iOS. Anyone with a custom contextMenuBuilder
will not be affected and will have to opt-in to using SystemContextMenu.
Also, this does not affect SelectionArea, which can't receive paste.

Fixes https://github.com/flutter/flutter/issues/163067
This commit is contained in:
Justin McCandless 2025-03-24 09:53:04 -07:00 committed by GitHub
parent f236af4975
commit 8128f08603
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 374 additions and 6 deletions

View File

@ -808,6 +808,9 @@ class CupertinoTextField extends StatefulWidget {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return CupertinoAdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -2,6 +2,7 @@
// 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/services.dart';
import 'package:flutter/widgets.dart';
@ -271,6 +272,9 @@ class CupertinoTextFormFieldRow extends FormField<String> {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return CupertinoAdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -9,6 +9,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
@ -1536,6 +1537,9 @@ class SearchBar extends StatefulWidget {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -447,6 +447,9 @@ class SelectableText extends StatefulWidget {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -849,6 +849,9 @@ class TextField extends StatefulWidget {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -4,6 +4,7 @@
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
@ -326,6 +327,9 @@ class TextFormField extends FormField<String> {
BuildContext context,
EditableTextState editableTextState,
) {
if (defaultTargetPlatform == TargetPlatform.iOS && SystemContextMenu.isSupported(context)) {
return SystemContextMenu.editableText(editableTextState: editableTextState);
}
return AdaptiveTextSelectionToolbar.editableText(editableTextState: editableTextState);
}

View File

@ -25,8 +25,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/clipboard_utils.dart';
import '../widgets/editable_text_utils.dart'
show OverflowWidgetTextEditingController, isContextMenuProvidedByPlatform;
import '../widgets/editable_text_utils.dart';
import '../widgets/live_text_utils.dart';
import '../widgets/semantics_tester.dart';
import '../widgets/text_selection_toolbar_utils.dart';
@ -238,10 +237,6 @@ void main() {
return endpoints[0].point;
}
// Web has a less threshold for downstream/upstream text position.
Offset textOffsetToPosition(WidgetTester tester, int offset) =>
textOffsetToBottomLeftPosition(tester, offset) + const Offset(kIsWeb ? 1 : 0, -2);
setUp(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
@ -8840,6 +8835,39 @@ void main() {
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
final TextEditingController controller = TextEditingController(text: 'one two three');
addTearDown(controller.dispose);
await tester.pumpWidget(CupertinoApp(home: CupertinoTextField(controller: controller)));
// No context menu shown.
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
group('magnifier', () {
@ -9880,3 +9908,23 @@ void main() {
variant: TargetPlatformVariant.all(),
);
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -3,10 +3,13 @@
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/src/services/spell_check.dart';
import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart';
void main() {
testWidgets('Passes textAlign to underlying CupertinoTextField', (WidgetTester tester) async {
const TextAlign alignment = TextAlign.center;
@ -518,4 +521,59 @@ void main() {
expect(stateKey.currentState!.value, 'initialValue');
expect(value, 'initialValue');
});
group('context menu', () {
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
final TextEditingController controller = TextEditingController(text: 'one two three');
addTearDown(controller.dispose);
await tester.pumpWidget(CupertinoApp(home: CupertinoTextField(controller: controller)));
// No context menu shown.
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -3687,6 +3687,41 @@ void main() {
expect(find.byType(Placeholder), findsOneWidget);
});
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
final TextEditingController controller = TextEditingController(text: 'one two three');
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(home: Material(child: TextField(controller: controller))),
);
// No context menu shown.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
testWidgets('SearchAnchor does not dispose external SearchController', (
@ -4107,3 +4142,23 @@ Material getSearchViewMaterial(WidgetTester tester) {
find.descendant(of: findViewContent(), matching: find.byType(Material)).first,
);
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -16147,6 +16147,43 @@ void main() {
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
);
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
await tester.pumpWidget(
MaterialApp(
home: Material(
child: TextField(controller: _textEditingController(text: 'one two three')),
),
),
);
// No context menu shown.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
group('magnifier builder', () {
@ -17668,3 +17705,23 @@ TextEditingController _textEditingController({String text = ''}) {
addTearDown(result.dispose);
return result;
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -1651,4 +1651,61 @@ void main() {
expect(find.text('**validation error**'), findsOneWidget);
});
group('context menu', () {
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
final TextEditingController controller = TextEditingController(text: 'one two three');
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(home: Material(child: TextField(controller: controller))),
);
// No context menu shown.
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -5567,4 +5567,61 @@ void main() {
expect(tester.takeException(), isNull);
});
group('context menu', () {
testWidgets(
'iOS uses the system context menu by default if supported',
(WidgetTester tester) async {
TestWidgetsFlutterBinding.instance.platformDispatcher.supportsShowingSystemContextMenu =
true;
_updateMediaQueryFromView(tester);
addTearDown(() {
TestWidgetsFlutterBinding.instance.platformDispatcher
.resetSupportsShowingSystemContextMenu();
_updateMediaQueryFromView(tester);
});
final TextEditingController controller = TextEditingController(text: 'one two three');
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(home: Material(child: TextField(controller: controller))),
);
// No context menu shown.
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsNothing);
// Double tap to select the first word and show the menu.
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(const Duration(milliseconds: 50));
await tester.tapAt(textOffsetToPosition(tester, 1));
await tester.pump(SelectionOverlay.fadeDuration);
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
expect(find.byType(SystemContextMenu), findsOneWidget);
},
skip: kIsWeb, // [intended] on web the browser handles the context menu.
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
});
}
// Trigger MediaQuery to update itself based on the View, which is not
// recreated between tests. This is necessary when changing something on
// TestPlatformDispatcher and expecting it to be picked up by MediaQuery.
// TODO(justinmc): This hack can be removed if
// https://github.com/flutter/flutter/issues/165519 is fixed.
void _updateMediaQueryFromView(WidgetTester tester) {
expect(find.byType(MediaQuery), findsOneWidget);
final WidgetsBindingObserver widgetsBindingObserver =
tester.state(
find.ancestor(
of: find.byType(MediaQuery),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_MediaQueryFromView',
),
),
)
as WidgetsBindingObserver;
widgetsBindingObserver.didChangeMetrics();
}

View File

@ -83,6 +83,21 @@ void main() {
);
});
testWidgets('TestPlatformDispatcher can fake supportsShowingSystemContextMenu', (
WidgetTester tester,
) async {
verifyPropertyFaked<bool>(
tester: tester,
realValue: PlatformDispatcher.instance.supportsShowingSystemContextMenu,
fakeValue: !PlatformDispatcher.instance.supportsShowingSystemContextMenu,
propertyRetriever:
() => WidgetsBinding.instance.platformDispatcher.supportsShowingSystemContextMenu,
propertyFaker: (TestWidgetsFlutterBinding binding, bool fakeValue) {
binding.platformDispatcher.supportsShowingSystemContextMenu = fakeValue;
},
);
});
testWidgets('TestPlatformDispatcher can fake brieflyShowPassword', (WidgetTester tester) async {
verifyPropertyFaked<bool>(
tester: tester,