From 22b16ad21286516704e5500ecbacd6162fb803bd Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Fri, 26 Jan 2024 11:47:02 -0800 Subject: [PATCH 1/3] [ios_edit_menu]add basic menu with default actions use targetRect API to auto determine the arrow direction parse global rects add unit tests add checks for copy/cut/delete use platform channel instead of text input channel, and use camel case add supportsShowingSystemContextMenu flag format add hide menu functionality add onDismiss callback use platform channel instead, and rename method fix conflict rename ContextMenu.onDismissSystemContextMenu add comment --- lib/ui/platform_dispatcher.dart | 16 ++++ lib/ui/window.dart | 7 ++ lib/web_ui/lib/platform_dispatcher.dart | 2 + lib/web_ui/lib/src/engine/window.dart | 3 + lib/web_ui/lib/window.dart | 2 + .../ios/framework/Source/FlutterEngine.mm | 6 ++ .../framework/Source/FlutterPlatformPlugin.mm | 23 +++++ .../Source/FlutterTextInputDelegate.h | 2 + .../framework/Source/FlutterTextInputPlugin.h | 7 +- .../Source/FlutterTextInputPlugin.mm | 53 ++++++++++- .../Source/FlutterTextInputPluginTest.mm | 95 +++++++++++++++++++ .../framework/Source/FlutterViewController.mm | 11 ++- .../Source/FlutterViewControllerTest.mm | 14 +++ .../Source/FlutterViewController_Internal.h | 2 + 14 files changed, 240 insertions(+), 3 deletions(-) diff --git a/lib/ui/platform_dispatcher.dart b/lib/ui/platform_dispatcher.dart index 63d7211dccba2..4b07bdb711aec 100644 --- a/lib/ui/platform_dispatcher.dart +++ b/lib/ui/platform_dispatcher.dart @@ -1075,6 +1075,14 @@ class PlatformDispatcher { bool get nativeSpellCheckServiceDefined => _nativeSpellCheckServiceDefined; bool _nativeSpellCheckServiceDefined = false; + /// Whether showing system context menu is supported on the current platform. + /// + /// This option is used by [AdaptiveTextSelectionToolbar] to decide whether + /// to show system context menu, or to fallback to the default Flutter context + /// menu. + bool get supportsShowingSystemContextMenu => _supportsShowingSystemContextMenu; + bool _supportsShowingSystemContextMenu = false; + /// Whether briefly displaying the characters as you type in obscured text /// fields is enabled in system settings. /// @@ -1142,6 +1150,14 @@ class PlatformDispatcher { } else { _nativeSpellCheckServiceDefined = false; } + + final bool? supportsShowingSystemContextMenu = data['supportsShowingSystemContextMenu'] as bool?; + if (supportsShowingSystemContextMenu != null) { + _supportsShowingSystemContextMenu = supportsShowingSystemContextMenu; + } else { + _supportsShowingSystemContextMenu = false; + } + // This field is optional. final bool? brieflyShowPassword = data['brieflyShowPassword'] as bool?; if (brieflyShowPassword != null) { diff --git a/lib/ui/window.dart b/lib/ui/window.dart index c33a06812541a..6bb26674b6752 100644 --- a/lib/ui/window.dart +++ b/lib/ui/window.dart @@ -561,6 +561,13 @@ class SingletonFlutterWindow extends FlutterView { /// service is specified. bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined; + /// Whether the spell check service is supported on the current platform. + /// + /// This option is used by [EditableTextState] to define its + /// [SpellCheckConfiguration] when a default spell check service + /// is requested. + bool get supportsShowingSystemContextMenu => platformDispatcher.supportsShowingSystemContextMenu; + /// Whether briefly displaying the characters as you type in obscured text /// fields is enabled in system settings. /// diff --git a/lib/web_ui/lib/platform_dispatcher.dart b/lib/web_ui/lib/platform_dispatcher.dart index 9180ef6b22869..d6995d7ee24e0 100644 --- a/lib/web_ui/lib/platform_dispatcher.dart +++ b/lib/web_ui/lib/platform_dispatcher.dart @@ -124,6 +124,8 @@ abstract class PlatformDispatcher { bool get nativeSpellCheckServiceDefined => false; + bool get supportsShowingSystemContextMenu => false; + bool get brieflyShowPassword => true; VoidCallback? get onTextScaleFactorChanged; diff --git a/lib/web_ui/lib/src/engine/window.dart b/lib/web_ui/lib/src/engine/window.dart index 59c6f37cc4b28..79d888e785d73 100644 --- a/lib/web_ui/lib/src/engine/window.dart +++ b/lib/web_ui/lib/src/engine/window.dart @@ -390,6 +390,9 @@ final class EngineFlutterWindow extends EngineFlutterView implements ui.Singleto @override bool get nativeSpellCheckServiceDefined => platformDispatcher.nativeSpellCheckServiceDefined; + @override + bool get supportsShowingSystemContextMenu => platformDispatcher.supportsShowingSystemContextMenu; + @override bool get brieflyShowPassword => platformDispatcher.brieflyShowPassword; diff --git a/lib/web_ui/lib/window.dart b/lib/web_ui/lib/window.dart index 1f4e10aac287d..d42bbf7854395 100644 --- a/lib/web_ui/lib/window.dart +++ b/lib/web_ui/lib/window.dart @@ -46,6 +46,8 @@ abstract class SingletonFlutterWindow extends FlutterView { bool get nativeSpellCheckServiceDefined; + bool get supportsShowingSystemContextMenu; + bool get brieflyShowPassword; bool get alwaysUse24HourFormat; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index f0fa9c8be5688..e2f355dca4505 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -1080,6 +1080,12 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client), @(start), @(end) ]]; } +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + willDismissEditMenuWithTextInputClient:(int)client { + [_platformChannel.get() invokeMethod:@"ContextMenu.onDismissSystemContextMenu" + arguments:@[ @(client) ]]; +} + #pragma mark - FlutterViewEngineDelegate - (void)flutterTextInputView:(FlutterTextInputView*)textInputView showToolbar:(int)client { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm index 281ab64092ea8..f672b0cc5e0cd 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm @@ -149,11 +149,34 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([method isEqualToString:@"Share.invoke"]) { [self showShareViewController:args]; result(nil); + } else if ([method isEqualToString:@"ContextMenu.showSystemContextMenu"]) { + [self showSystemContextMenu:args]; + result(nil); + } else if ([method isEqualToString:@"ContextMenu.hideSystemContextMenu"]) { + [self hideSystemContextMenu]; + result(nil); } else { result(FlutterMethodNotImplemented); } } +- (void)showSystemContextMenu:(NSDictionary*)args { + // Right now only text inputs support system context menu. + // However, it's possible to support it for non-text inputs too in the future. + // See: https://github.com/flutter/flutter/issues/143033 + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin]; + [textInputPlugin showEditMenu:args]; + } +} + +- (void)hideSystemContextMenu { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin]; + [textInputPlugin hideEditMenu]; + } +} + - (void)showShareViewController:(NSString*)content { UIViewController* engineViewController = [_engine.get() viewController]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 2b5c76f77a7fb..0bf715a88022b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -65,6 +65,8 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { - (void)flutterTextInputView:(FlutterTextInputView*)textInputView removeTextPlaceholder:(int)client; - (void)flutterTextInputView:(FlutterTextInputView*)textInputView didResignFirstResponderWithTextInputClient:(int)client; +- (void)flutterTextInputView:(FlutterTextInputView*)textInputView + willDismissEditMenuWithTextInputClient:(int)client; @end #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTDELEGATE_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index dcf4f69010a4b..ee41da7a7534c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -60,6 +60,8 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { */ - (void)setUpIndirectScribbleInteraction:(id)viewResponder; - (void)resetViewResponder; +- (void)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)); +- (void)hideEditMenu API_AVAILABLE(ios(16.0)); @end @@ -128,7 +130,8 @@ API_AVAILABLE(ios(13.0)) @interface FlutterTextPlaceholder : UITextPlaceholder #if FLUTTER_RUNTIME_MODE == FLUTTER_RUNTIME_MODE_DEBUG FLUTTER_DARWIN_EXPORT #endif -@interface FlutterTextInputView : UIView +@interface FlutterTextInputView + : UIView // UITextInput @property(nonatomic, readonly) NSMutableString* text; @@ -158,6 +161,8 @@ FLUTTER_DARWIN_EXPORT @property(nonatomic, weak) id viewResponder; @property(nonatomic) FlutterScribbleFocusStatus scribbleFocusStatus; @property(nonatomic, strong) NSArray* selectionRects; + +@property(nonatomic, strong) UIEditMenuInteraction* editMenuInteraction API_AVAILABLE(ios(16.0)); - (void)resetScribbleInteractionStatusIfEnding; - (BOOL)isScribbleAvailable; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index ea36de564d408..be50587e041bd 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -794,6 +794,7 @@ @interface FlutterTextInputView () // This is cleared at the start of each keyboard interaction. (Enter a character, delete a character // etc) @property(nonatomic, copy) NSString* temporarilyDeletedComposedCharacter; +@property(nonatomic, assign) CGRect editMenuTargetRect; - (void)setEditableTransform:(NSArray*)matrix; @end @@ -859,9 +860,44 @@ - (instancetype)initWithOwner:(FlutterTextInputPlugin*)textInputPlugin { } } + if (@available(iOS 16.0, *)) { + _editMenuInteraction = [[UIEditMenuInteraction alloc] initWithDelegate:self]; + [self addInteraction:_editMenuInteraction]; + } + return self; } +- (UIMenu*)editMenuInteraction:(UIEditMenuInteraction*)interaction + menuForConfiguration:(UIEditMenuConfiguration*)configuration + suggestedActions:(NSArray*)suggestedActions API_AVAILABLE(ios(16.0)) { + return [UIMenu menuWithChildren:suggestedActions]; +} + +- (void)editMenuInteraction:(UIEditMenuInteraction*)interaction + willDismissMenuForConfiguration:(UIEditMenuConfiguration*)configuration + animator:(id)animator + API_AVAILABLE(ios(16.0)) { + [self.textInputDelegate flutterTextInputView:self + willDismissEditMenuWithTextInputClient:_textInputClient]; +} + +- (CGRect)editMenuInteraction:(UIEditMenuInteraction*)interaction + targetRectForConfiguration:(UIEditMenuConfiguration*)configuration API_AVAILABLE(ios(16.0)) { + return _editMenuTargetRect; +} + +- (void)showEditMenuWithTargetRect:(CGRect)targetRect API_AVAILABLE(ios(16.0)) { + _editMenuTargetRect = targetRect; + UIEditMenuConfiguration* config = + [UIEditMenuConfiguration configurationWithIdentifier:nil sourcePoint:CGPointZero]; + [self.editMenuInteraction presentEditMenuWithConfiguration:config]; +} + +- (void)hideEditMenu API_AVAILABLE(ios(16.0)) { + [self.editMenuInteraction dismissMenu]; +} + - (void)configureWithDictionary:(NSDictionary*)configuration { NSDictionary* inputType = configuration[kKeyboardType]; NSString* keyboardAppearance = configuration[kKeyboardAppearance]; @@ -1148,8 +1184,10 @@ - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { if (action == @selector(paste:)) { // Forbid pasting images, memojis, or other non-string content. return [UIPasteboard generalPasteboard].hasStrings; + } else if (action == @selector(copy:) || action == @selector(cut:) || + action == @selector(delete:)) { + return [self textInRange:_selectedTextRange].length > 0; } - return [super canPerformAction:action withSender:sender]; } @@ -2511,6 +2549,19 @@ - (void)takeKeyboardScreenshotAndDisplay { _keyboardViewContainer.frame = _keyboardRect; } +- (void)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) { + NSDictionary* encodedTargetRect = args[@"targetRect"]; + CGRect globalTargetRect = CGRectMake( + [encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue], + [encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]); + CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView]; + [self.activeView showEditMenuWithTargetRect:localTargetRect]; +} + +- (void)hideEditMenu { + [self.activeView hideEditMenu]; +} + - (void)setEditableSizeAndTransform:(NSDictionary*)dictionary { NSArray* transform = dictionary[@"transform"]; [_activeView setEditableTransform:transform]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 724def096d19c..70aac11e0cd15 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2847,6 +2847,101 @@ - (void)testSetPlatformViewClient { XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy."); } +- (void)testEditMenu_shouldSetupEditMenuDelegateCorrectly { + if (@available(iOS 16.0, *)) { + FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; + [UIApplication.sharedApplication.keyWindow addSubview:inputView]; + XCTAssertEqual(inputView.editMenuInteraction.delegate, inputView, + @"editMenuInteraction setup delegate correctly"); + } +} + +- (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + + FlutterTextInputView* myInputView = myInputPlugin.activeView; + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + // arguments are released once invocation is released. + [invocation retainArguments]; + UIEditMenuConfiguration* config; + [invocation getArgument:&config atIndex:2]; + XCTAssertEqual(config.preferredArrowDirection, UIEditMenuArrowDirectionAutomatic, + @"UIEditMenuConfiguration must use automatic arrow direction."); + XCTAssert(CGPointEqualToPoint(config.sourcePoint, CGPointZero), + @"UIEditMenuConfiguration must have the correct point."); + [expectation fulfill]; + }); + + NSDictionary* encodedTargetRect = + @{@"x" : @(0), @"y" : @(0), @"width" : @(0), @"height" : @(0)}; + + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + [self waitForExpectations:@[ expectation ] timeout:1.0]; + } +} +- (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + FlutterViewController* myViewController = [[FlutterViewController alloc] init]; + myInputPlugin.viewController = myViewController; + [myViewController loadView]; + + FlutterMethodCall* setClientCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ @(123), self.mutableTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + FlutterTextInputView* myInputView = myInputPlugin.activeView; + + FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + + XCTestExpectation* expectation = [[XCTestExpectation alloc] + initWithDescription:@"presentEditMenuWithConfiguration must be called."]; + + id mockInteraction = OCMClassMock([UIEditMenuInteraction class]); + OCMStub([mockInputView editMenuInteraction]).andReturn(mockInteraction); + OCMStub([mockInteraction presentEditMenuWithConfiguration:[OCMArg any]]) + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + + myInputView.frame = CGRectMake(10, 20, 30, 40); + NSDictionary* encodedTargetRect = + @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; + + [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + [self waitForExpectations:@[ expectation ] timeout:1.0]; + + CGRect targetRect = + [myInputView editMenuInteraction:mockInteraction + targetRectForConfiguration:OCMClassMock([UIEditMenuConfiguration class])]; + // the encoded target rect is in global coordinate space. + XCTAssert(CGRectEqualToRect(targetRect, CGRectMake(90, 180, 300, 400)), + @"targetRectForConfiguration must return the correct target rect."); + } +} + - (void)testInteractiveKeyboardAfterUserScrollWillResignFirstResponder { FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin]; [UIApplication.sharedApplication.keyWindow addSubview:inputView]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 4bf66d112e62b..b8911bed34e77 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -2134,7 +2134,8 @@ - (void)onUserSettingsChanged:(NSNotification*)notification { @"alwaysUse24HourFormat" : @([self isAlwaysUse24HourFormat]), @"platformBrightness" : [self brightnessMode], @"platformContrast" : [self contrastMode], - @"nativeSpellCheckServiceDefined" : @true + @"nativeSpellCheckServiceDefined" : @true, + @"supportsShowingSystemContextMenu" : @([self supportsShowingSystemContextMenu]) }]; } @@ -2196,6 +2197,14 @@ - (CGFloat)textScaleFactor { #endif } +- (BOOL)supportsShowingSystemContextMenu { + if (@available(iOS 16.0, *)) { + return YES; + } else { + return NO; + } +} + - (BOOL)isAlwaysUse24HourFormat { // iOS does not report its "24-Hour Time" user setting in the API. Instead, it applies // it automatically to NSDateFormatter when used with [NSLocale currentLocale]. It is diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index c0c66796dc63c..99bb2efc2a8d4 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -2204,4 +2204,18 @@ - (void)testFlutterViewControllerStartKeyboardAnimationWillCreateVsyncClientCorr XCTAssertNil(viewController.keyboardAnimationVSyncClient); } +- (void)testSupportsShowingSystemContextMenuForIOS16AndAbove { + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + BOOL supportsShowingSystemContextMenu = [viewController supportsShowingSystemContextMenu]; + if (@available(iOS 16.0, *)) { + XCTAssertTrue(supportsShowingSystemContextMenu); + } else { + XCTAssertFalse(supportsShowingSystemContextMenu); + } +} + @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h index ed99a3339b617..31e91eb022614 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h @@ -72,6 +72,8 @@ typedef void (^FlutterKeyboardAnimationCallback)(fml::TimePoint); - (void)deregisterNotifications; - (int32_t)accessibilityFlags; +- (BOOL)supportsShowingSystemContextMenu; + @end #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERVIEWCONTROLLER_INTERNAL_H_ From 43ef18fc43b40293a0ed51ff161badb35163fec2 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Fri, 19 Apr 2024 17:30:07 -0700 Subject: [PATCH 2/3] add warning if it's not from text-input --- .../framework/Source/FlutterPlatformPlugin.mm | 9 +++++---- .../framework/Source/FlutterTextInputPlugin.h | 2 +- .../Source/FlutterTextInputPlugin.mm | 6 +++++- .../Source/FlutterTextInputPluginTest.mm | 20 +++++++++++++++++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm index f672b0cc5e0cd..47c4eac58167d 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm @@ -161,12 +161,13 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } - (void)showSystemContextMenu:(NSDictionary*)args { - // Right now only text inputs support system context menu. - // However, it's possible to support it for non-text inputs too in the future. - // See: https://github.com/flutter/flutter/issues/143033 if (@available(iOS 16.0, *)) { FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin]; - [textInputPlugin showEditMenu:args]; + BOOL shownEditMenu = [textInputPlugin showEditMenu:args]; + if (!shownEditMenu) { + FML_LOG(ERROR) << "Only text input supports system context menu for now. See " + "https://github.com/flutter/flutter/issues/143033."; + } } } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index ee41da7a7534c..3f30494457300 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -60,7 +60,7 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) { */ - (void)setUpIndirectScribbleInteraction:(id)viewResponder; - (void)resetViewResponder; -- (void)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)); +- (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)); - (void)hideEditMenu API_AVAILABLE(ios(16.0)); @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index be50587e041bd..87be4ee9bf093 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -2549,13 +2549,17 @@ - (void)takeKeyboardScreenshotAndDisplay { _keyboardViewContainer.frame = _keyboardRect; } -- (void)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) { +- (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) { + if (!self.activeView.isFirstResponder) { + return NO; + } NSDictionary* encodedTargetRect = args[@"targetRect"]; CGRect globalTargetRect = CGRectMake( [encodedTargetRect[@"x"] doubleValue], [encodedTargetRect[@"y"] doubleValue], [encodedTargetRect[@"width"] doubleValue], [encodedTargetRect[@"height"] doubleValue]); CGRect localTargetRect = [self.hostView convertRect:globalTargetRect toView:self.activeView]; [self.activeView showEditMenuWithTargetRect:localTargetRect]; + return YES; } - (void)hideEditMenu { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 70aac11e0cd15..46bffc9228c35 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2856,6 +2856,15 @@ - (void)testEditMenu_shouldSetupEditMenuDelegateCorrectly { } } +- (void)testEditMenu_shouldNotPresentEditMenuIfNotFirstResponder { + if (@available(iOS 16.0, *)) { + FlutterTextInputPlugin* myInputPlugin = + [[FlutterTextInputPlugin alloc] initWithDelegate:OCMClassMock([FlutterEngine class])]; + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{}]; + XCTAssertFalse(shownEditMenu, @"Should not show edit menu if not first responder."); + } +} + - (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration { if (@available(iOS 16.0, *)) { FlutterTextInputPlugin* myInputPlugin = @@ -2873,6 +2882,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration { FlutterTextInputView* myInputView = myInputPlugin.activeView; FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); + XCTestExpectation* expectation = [[XCTestExpectation alloc] initWithDescription:@"presentEditMenuWithConfiguration must be called."]; @@ -2894,10 +2905,12 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorrectConfiguration { NSDictionary* encodedTargetRect = @{@"x" : @(0), @"y" : @(0), @"width" : @(0), @"height" : @(0)}; - [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); [self waitForExpectations:@[ expectation ] timeout:1.0]; } } + - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { if (@available(iOS 16.0, *)) { FlutterTextInputPlugin* myInputPlugin = @@ -2912,9 +2925,11 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { [myInputPlugin handleMethodCall:setClientCall result:^(id _Nullable result){ }]; + FlutterTextInputView* myInputView = myInputPlugin.activeView; FlutterTextInputView* mockInputView = OCMPartialMock(myInputView); + OCMStub([mockInputView isFirstResponder]).andReturn(YES); XCTestExpectation* expectation = [[XCTestExpectation alloc] initWithDescription:@"presentEditMenuWithConfiguration must be called."]; @@ -2930,7 +2945,8 @@ - (void)testEditMenu_shouldPresentEditMenuWithCorectTargetRect { NSDictionary* encodedTargetRect = @{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)}; - [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}]; + XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration."); [self waitForExpectations:@[ expectation ] timeout:1.0]; CGRect targetRect = From c73c2c76025755d02e431ffd5b774c4ba4154af4 Mon Sep 17 00:00:00 2001 From: Huan Lin Date: Mon, 22 Apr 2024 11:33:19 -0700 Subject: [PATCH 3/3] update wording --- .../darwin/ios/framework/Source/FlutterPlatformPlugin.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm index 47c4eac58167d..2cbf76d15e8af 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPlugin.mm @@ -165,7 +165,8 @@ - (void)showSystemContextMenu:(NSDictionary*)args { FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin]; BOOL shownEditMenu = [textInputPlugin showEditMenu:args]; if (!shownEditMenu) { - FML_LOG(ERROR) << "Only text input supports system context menu for now. See " + FML_LOG(ERROR) << "Only text input supports system context menu for now. Ensure the system " + "context menu is shown with an active text input connection. See " "https://github.com/flutter/flutter/issues/143033."; } }