Skip to content

Commit

Permalink
Native ios context menu (#143002)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
justinmc committed May 13, 2024
1 parent 708fe97 commit 1255435
Show file tree
Hide file tree
Showing 13 changed files with 1,321 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -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,
);
},
),
),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -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]
}
43 changes: 42 additions & 1 deletion packages/flutter/lib/src/services/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -357,15 +357,23 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {

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) {
await _systemUiChangeCallback!(args[0] as bool);
}
case 'System.requestAppExit':
return <String, dynamic>{'response': (await handleRequestAppExit()).name};
default:
throw AssertionError('Method "$method" not handled.');
}
}

Expand Down Expand Up @@ -510,6 +518,19 @@ mixin ServicesBinding on BindingBase, SchedulerBinding {
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].
Expand Down Expand Up @@ -588,3 +609,23 @@ class _DefaultBinaryMessenger extends BinaryMessenger {
}
}
}

/// An interface to receive calls related to the system context menu from the
/// engine.
///
/// Currently this is only supported on iOS 16+.
///
/// See also:
/// * [SystemContextMenuController], which uses this to provide a fully
/// featured way to control the system context menu.
/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates
/// whether the system context menu is supported.
/// * [SystemContextMenu], which provides a widget interface for displaying the
/// system context menu.
mixin SystemContextMenuClient {
/// Handles the system hiding a context menu.
///
/// This is called for all instances of [SystemContextMenuController], so it's
/// not guaranteed that this instance was the one that was hidden.
void handleSystemHide();
}
178 changes: 177 additions & 1 deletion packages/flutter/lib/src/services/text_input.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import 'package:flutter/foundation.dart';
import 'package:vector_math/vector_math_64.dart' show Matrix4;

import 'autofill.dart';
import 'binding.dart';
import 'clipboard.dart' show Clipboard;
import 'keyboard_inserted_content.dart';
import 'message_codec.dart';
Expand Down Expand Up @@ -1808,7 +1809,7 @@ class TextInput {

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()));
Expand Down Expand Up @@ -2403,3 +2404,178 @@ class _PlatformTextInputControl with TextInputControl {
);
}
}

/// Allows access to the system context menu.
///
/// The context menu is the menu that appears, for example, when doing text
/// selection. Flutter typically draws this menu itself, but this class deals
/// with the platform-rendered context menu.
///
/// Only one instance can be visible at a time. Calling [show] while the system
/// context menu is already visible will hide it and show it again at the new
/// [Rect]. An instance that is hidden is informed via [onSystemHide].
///
/// Currently this system context menu is bound to text input. The buttons that
/// are shown and the actions they perform are dependent on the currently
/// active [TextInputConnection]. Using this without an active
/// [TextInputConnection] is a noop.
///
/// Call [dispose] when no longer needed.
///
/// See also:
///
/// * [ContextMenuController], which controls Flutter-drawn context menus.
/// * [SystemContextMenu], which wraps this functionality in a widget.
/// * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates
/// whether the system context menu is supported.
class SystemContextMenuController with SystemContextMenuClient {
/// Creates an instance of [SystemContextMenuController].
///
/// Not shown until [show] is called.
SystemContextMenuController({
this.onSystemHide,
}) {
ServicesBinding.registerSystemContextMenuClient(this);
}

/// Called when the system has hidden the context menu.
///
/// For example, tapping outside of the context menu typically causes the
/// system to hide it directly. Flutter is made aware that the context menu is
/// no longer visible through this callback.
///
/// This is not called when [show]ing a new system context menu causes another
/// to be hidden.
final VoidCallback? onSystemHide;

static const MethodChannel _channel = SystemChannels.platform;

static SystemContextMenuController? _lastShown;

/// The target [Rect] that was last given to [show].
///
/// Null if [show] has not been called.
Rect? _lastTargetRect;

/// True when the instance most recently [show]n has been hidden by the
/// system.
bool _hiddenBySystem = false;

bool get _isVisible => this == _lastShown && !_hiddenBySystem;

/// After calling [dispose], this instance can no longer be used.
bool _isDisposed = false;

// Begin SystemContextMenuClient.

@override
void handleSystemHide() {
assert(!_isDisposed);
// If this instance wasn't being shown, then it wasn't the instance that was
// hidden.
if (!_isVisible) {
return;
}
if (_lastShown == this) {
_lastShown = null;
}
_hiddenBySystem = true;
onSystemHide?.call();
}

// End SystemContextMenuClient.

/// Shows the system context menu anchored on the given [Rect].
///
/// The [Rect] represents what the context menu is pointing to. For example,
/// for some text selection, this would be the selection [Rect].
///
/// There can only be one system context menu visible at a time. Calling this
/// while another system context menu is already visible will remove the old
/// menu before showing the new menu.
///
/// Currently this system context menu is bound to text input. The buttons
/// that are shown and the actions they perform are dependent on the
/// currently active [TextInputConnection]. Using this without an active
/// [TextInputConnection] will be a noop.
///
/// This is only supported on iOS 16.0 and later.
///
/// See also:
///
/// * [hideSystemContextMenu], which hides the menu shown by this method.
/// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
/// this method is supported on the current platform.
Future<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:
///
/// * [showSystemContextMenu], which shows the menu hidden by this method.
/// * [MediaQuery.supportsShowingSystemContextMenu], which indicates whether
/// the system context menu is supported on the current platform.
Future<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;
}
}

0 comments on commit 1255435

Please sign in to comment.