Skip to content

Commit 1255435

Browse files
authored
Native ios context menu (flutter#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.
1 parent 708fe97 commit 1255435

File tree

13 files changed

+1321
-43
lines changed

13 files changed

+1321
-43
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Flutter code sample for [SystemContextMenu].
8+
9+
void main() => runApp(const SystemContextMenuExampleApp());
10+
11+
class SystemContextMenuExampleApp extends StatelessWidget {
12+
const SystemContextMenuExampleApp({super.key});
13+
14+
@override
15+
Widget build(BuildContext context) {
16+
return MaterialApp(
17+
home: Scaffold(
18+
appBar: AppBar(
19+
title: const Text('SystemContextMenu Basic Example'),
20+
),
21+
body: Center(
22+
child: TextField(
23+
contextMenuBuilder: (BuildContext context, EditableTextState editableTextState) {
24+
// If supported, show the system context menu.
25+
if (SystemContextMenu.isSupported(context)) {
26+
return SystemContextMenu.editableText(
27+
editableTextState: editableTextState,
28+
);
29+
}
30+
// Otherwise, show the flutter-rendered context menu for the current
31+
// platform.
32+
return AdaptiveTextSelectionToolbar.editableText(
33+
editableTextState: editableTextState,
34+
);
35+
},
36+
),
37+
),
38+
),
39+
);
40+
}
41+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2014 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:flutter_api_samples/widgets/system_context_menu/system_context_menu.0.dart' as example;
8+
import 'package:flutter_test/flutter_test.dart';
9+
10+
void main() {
11+
testWidgets('only shows the system context menu on iOS when MediaQuery says it is supported', (WidgetTester tester) async {
12+
await tester.pumpWidget(
13+
Builder(
14+
builder: (BuildContext context) {
15+
final MediaQueryData mediaQueryData = MediaQuery.of(context);
16+
return MediaQuery(
17+
data: mediaQueryData.copyWith(
18+
// Faking this value, which is usually set to true only on
19+
// devices running iOS 16+.
20+
supportsShowingSystemContextMenu: defaultTargetPlatform == TargetPlatform.iOS,
21+
),
22+
child: const example.SystemContextMenuExampleApp(),
23+
);
24+
},
25+
),
26+
);
27+
28+
expect(find.byType(SystemContextMenu), findsNothing);
29+
30+
// Show the context menu.
31+
final Finder textFinder = find.byType(EditableText);
32+
await tester.longPress(textFinder);
33+
tester.state<EditableTextState>(textFinder).showToolbar();
34+
await tester.pumpAndSettle();
35+
36+
switch (defaultTargetPlatform) {
37+
case TargetPlatform.iOS:
38+
expect(find.byType(SystemContextMenu), findsOneWidget);
39+
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
40+
case TargetPlatform.android:
41+
case TargetPlatform.fuchsia:
42+
case TargetPlatform.linux:
43+
case TargetPlatform.macOS:
44+
case TargetPlatform.windows:
45+
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
46+
expect(find.byType(SystemContextMenu), findsNothing);
47+
}
48+
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
49+
50+
testWidgets('does not show the system context menu when not supported', (WidgetTester tester) async {
51+
await tester.pumpWidget(
52+
// By default, MediaQueryData.supportsShowingSystemContextMenu is false.
53+
const example.SystemContextMenuExampleApp(),
54+
);
55+
56+
expect(find.byType(SystemContextMenu), findsNothing);
57+
58+
// Show the context menu.
59+
final Finder textFinder = find.byType(EditableText);
60+
await tester.longPress(textFinder);
61+
tester.state<EditableTextState>(textFinder).showToolbar();
62+
await tester.pumpAndSettle();
63+
64+
expect(find.byType(AdaptiveTextSelectionToolbar), findsOneWidget);
65+
expect(find.byType(SystemContextMenu), findsNothing);
66+
}, variant: TargetPlatformVariant.all(), skip: kIsWeb); // [intended]
67+
}

packages/flutter/lib/src/services/binding.dart

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,15 +357,23 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
357357

358358
Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
359359
final String method = methodCall.method;
360-
assert(method == 'SystemChrome.systemUIChange' || method == 'System.requestAppExit');
361360
switch (method) {
361+
// Called when the system dismisses the system context menu, such as when
362+
// the user taps outside the menu. Not called when Flutter shows a new
363+
// system context menu while an old one is still visible.
364+
case 'ContextMenu.onDismissSystemContextMenu':
365+
for (final SystemContextMenuClient client in _systemContextMenuClients) {
366+
client.handleSystemHide();
367+
}
362368
case 'SystemChrome.systemUIChange':
363369
final List<dynamic> args = methodCall.arguments as List<dynamic>;
364370
if (_systemUiChangeCallback != null) {
365371
await _systemUiChangeCallback!(args[0] as bool);
366372
}
367373
case 'System.requestAppExit':
368374
return <String, dynamic>{'response': (await handleRequestAppExit()).name};
375+
default:
376+
throw AssertionError('Method "$method" not handled.');
369377
}
370378
}
371379

@@ -510,6 +518,19 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
510518
Future<void> initializationComplete() async {
511519
await SystemChannels.platform.invokeMethod('System.initializationComplete');
512520
}
521+
522+
final Set<SystemContextMenuClient> _systemContextMenuClients = <SystemContextMenuClient>{};
523+
524+
/// Registers a [SystemContextMenuClient] that will receive system context
525+
/// menu calls from the engine.
526+
static void registerSystemContextMenuClient(SystemContextMenuClient client) {
527+
instance._systemContextMenuClients.add(client);
528+
}
529+
530+
/// Unregisters a [SystemContextMenuClient] so that it is no longer called.
531+
static void unregisterSystemContextMenuClient(SystemContextMenuClient client) {
532+
instance._systemContextMenuClients.remove(client);
533+
}
513534
}
514535

515536
/// Signature for listening to changes in the [SystemUiMode].
@@ -588,3 +609,23 @@ class _DefaultBinaryMessenger extends BinaryMessenger {
588609
}
589610
}
590611
}
612+
613+
/// An interface to receive calls related to the system context menu from the
614+
/// engine.
615+
///
616+
/// Currently this is only supported on iOS 16+.
617+
///
618+
/// See also:
619+
/// * [SystemContextMenuController], which uses this to provide a fully
620+
/// featured way to control the system context menu.
621+
/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates
622+
/// whether the system context menu is supported.
623+
/// * [SystemContextMenu], which provides a widget interface for displaying the
624+
/// system context menu.
625+
mixin SystemContextMenuClient {
626+
/// Handles the system hiding a context menu.
627+
///
628+
/// This is called for all instances of [SystemContextMenuController], so it's
629+
/// not guaranteed that this instance was the one that was hidden.
630+
void handleSystemHide();
631+
}

packages/flutter/lib/src/services/text_input.dart

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart';
1717
import 'package:vector_math/vector_math_64.dart' show Matrix4;
1818

1919
import 'autofill.dart';
20+
import 'binding.dart';
2021
import 'clipboard.dart' show Clipboard;
2122
import 'keyboard_inserted_content.dart';
2223
import 'message_codec.dart';
@@ -1808,7 +1809,7 @@ class TextInput {
18081809

18091810
Future<dynamic> _handleTextInputInvocation(MethodCall methodCall) async {
18101811
final String method = methodCall.method;
1811-
switch (methodCall.method) {
1812+
switch (method) {
18121813
case 'TextInputClient.focusElement':
18131814
final List<dynamic> args = methodCall.arguments as List<dynamic>;
18141815
_scribbleClients[args[0]]?.onScribbleFocus(Offset((args[1] as num).toDouble(), (args[2] as num).toDouble()));
@@ -2403,3 +2404,178 @@ class _PlatformTextInputControl with TextInputControl {
24032404
);
24042405
}
24052406
}
2407+
2408+
/// Allows access to the system context menu.
2409+
///
2410+
/// The context menu is the menu that appears, for example, when doing text
2411+
/// selection. Flutter typically draws this menu itself, but this class deals
2412+
/// with the platform-rendered context menu.
2413+
///
2414+
/// Only one instance can be visible at a time. Calling [show] while the system
2415+
/// context menu is already visible will hide it and show it again at the new
2416+
/// [Rect]. An instance that is hidden is informed via [onSystemHide].
2417+
///
2418+
/// Currently this system context menu is bound to text input. The buttons that
2419+
/// are shown and the actions they perform are dependent on the currently
2420+
/// active [TextInputConnection]. Using this without an active
2421+
/// [TextInputConnection] is a noop.
2422+
///
2423+
/// Call [dispose] when no longer needed.
2424+
///
2425+
/// See also:
2426+
///
2427+
/// * [ContextMenuController], which controls Flutter-drawn context menus.
2428+
/// * [SystemContextMenu], which wraps this functionality in a widget.
2429+
/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates
2430+
/// whether the system context menu is supported.
2431+
class SystemContextMenuController with SystemContextMenuClient {
2432+
/// Creates an instance of [SystemContextMenuController].
2433+
///
2434+
/// Not shown until [show] is called.
2435+
SystemContextMenuController({
2436+
this.onSystemHide,
2437+
}) {
2438+
ServicesBinding.registerSystemContextMenuClient(this);
2439+
}
2440+
2441+
/// Called when the system has hidden the context menu.
2442+
///
2443+
/// For example, tapping outside of the context menu typically causes the
2444+
/// system to hide it directly. Flutter is made aware that the context menu is
2445+
/// no longer visible through this callback.
2446+
///
2447+
/// This is not called when [show]ing a new system context menu causes another
2448+
/// to be hidden.
2449+
final VoidCallback? onSystemHide;
2450+
2451+
static const MethodChannel _channel = SystemChannels.platform;
2452+
2453+
static SystemContextMenuController? _lastShown;
2454+
2455+
/// The target [Rect] that was last given to [show].
2456+
///
2457+
/// Null if [show] has not been called.
2458+
Rect? _lastTargetRect;
2459+
2460+
/// True when the instance most recently [show]n has been hidden by the
2461+
/// system.
2462+
bool _hiddenBySystem = false;
2463+
2464+
bool get _isVisible => this == _lastShown && !_hiddenBySystem;
2465+
2466+
/// After calling [dispose], this instance can no longer be used.
2467+
bool _isDisposed = false;
2468+
2469+
// Begin SystemContextMenuClient.
2470+
2471+
@override
2472+
void handleSystemHide() {
2473+
assert(!_isDisposed);
2474+
// If this instance wasn't being shown, then it wasn't the instance that was
2475+
// hidden.
2476+
if (!_isVisible) {
2477+
return;
2478+
}
2479+
if (_lastShown == this) {
2480+
_lastShown = null;
2481+
}
2482+
_hiddenBySystem = true;
2483+
onSystemHide?.call();
2484+
}
2485+
2486+
// End SystemContextMenuClient.
2487+
2488+
/// Shows the system context menu anchored on the given [Rect].
2489+
///
2490+
/// The [Rect] represents what the context menu is pointing to. For example,
2491+
/// for some text selection, this would be the selection [Rect].
2492+
///
2493+
/// There can only be one system context menu visible at a time. Calling this
2494+
/// while another system context menu is already visible will remove the old
2495+
/// menu before showing the new menu.
2496+
///
2497+
/// Currently this system context menu is bound to text input. The buttons
2498+
/// that are shown and the actions they perform are dependent on the
2499+
/// currently active [TextInputConnection]. Using this without an active
2500+
/// [TextInputConnection] will be a noop.
2501+
///
2502+
/// This is only supported on iOS 16.0 and later.
2503+
///
2504+
/// See also:
2505+
///
2506+
/// * [hideSystemContextMenu], which hides the menu shown by this method.
2507+
/// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
2508+
/// this method is supported on the current platform.
2509+
Future<void> show(Rect targetRect) {
2510+
assert(!_isDisposed);
2511+
assert(
2512+
TextInput._instance._currentConnection != null,
2513+
'Currently, the system context menu can only be shown for an active text input connection',
2514+
);
2515+
2516+
// Don't show the same thing that's already being shown.
2517+
if (_lastShown != null && _lastShown!._isVisible && _lastShown!._lastTargetRect == targetRect) {
2518+
return Future<void>.value();
2519+
}
2520+
2521+
assert(
2522+
_lastShown == null || _lastShown == this || !_lastShown!._isVisible,
2523+
'Attempted to show while another instance was still visible.',
2524+
);
2525+
2526+
_lastTargetRect = targetRect;
2527+
_lastShown = this;
2528+
_hiddenBySystem = false;
2529+
return _channel.invokeMethod<Map<String, dynamic>>(
2530+
'ContextMenu.showSystemContextMenu',
2531+
<String, dynamic>{
2532+
'targetRect': <String, double>{
2533+
'x': targetRect.left,
2534+
'y': targetRect.top,
2535+
'width': targetRect.width,
2536+
'height': targetRect.height,
2537+
},
2538+
},
2539+
);
2540+
}
2541+
2542+
/// Hides this system context menu.
2543+
///
2544+
/// If this hasn't been shown, or if another instance has hidden this menu,
2545+
/// does nothing.
2546+
///
2547+
/// Currently this is only supported on iOS 16.0 and later.
2548+
///
2549+
/// See also:
2550+
///
2551+
/// * [showSystemContextMenu], which shows the menu hidden by this method.
2552+
/// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
2553+
/// the system context menu is supported on the current platform.
2554+
Future<void> hide() async {
2555+
assert(!_isDisposed);
2556+
// This check prevents a given instance from accidentally hiding some other
2557+
// instance, since only one can be visible at a time.
2558+
if (this != _lastShown) {
2559+
return;
2560+
}
2561+
_lastShown = null;
2562+
// This may be called unnecessarily in the case where the user has already
2563+
// hidden the menu (for example by tapping the screen).
2564+
return _channel.invokeMethod<void>(
2565+
'ContextMenu.hideSystemContextMenu',
2566+
);
2567+
}
2568+
2569+
@override
2570+
String toString() {
2571+
return 'SystemContextMenuController(onSystemHide=$onSystemHide, _hiddenBySystem=$_hiddenBySystem, _isVisible=$_isVisible, _isDiposed=$_isDisposed)';
2572+
}
2573+
2574+
/// Used to release resources when this instance will never be used again.
2575+
void dispose() {
2576+
assert(!_isDisposed);
2577+
hide();
2578+
ServicesBinding.unregisterSystemContextMenuClient(this);
2579+
_isDisposed = true;
2580+
}
2581+
}

0 commit comments

Comments
 (0)