Skip to content

Commit

Permalink
[ios_edit_menu]add native edit menu (flutter#50095)
Browse files Browse the repository at this point in the history
Support native edit menu on the engine side. 

Design doc: https://docs.google.com/document/d/16-8kn58h_oD902e7vPSh6W20aHRBJKyNOdSe5rbAe_g/edit?resourcekey=0-gVdJ3fbOybV70ZKeHU7fkQ&tab=t.0

*List which issues are fixed by this PR. You must list at least one issue.*

flutter/flutter#103163

*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*

[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
  • Loading branch information
hellohuanlin authored Apr 22, 2024
1 parent aeb987b commit f8e373d
Show file tree
Hide file tree
Showing 14 changed files with 262 additions and 3 deletions.
16 changes: 16 additions & 0 deletions lib/ui/platform_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions lib/ui/window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
2 changes: 2 additions & 0 deletions lib/web_ui/lib/platform_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ abstract class PlatformDispatcher {

bool get nativeSpellCheckServiceDefined => false;

bool get supportsShowingSystemContextMenu => false;

bool get brieflyShowPassword => true;

VoidCallback? get onTextScaleFactorChanged;
Expand Down
3 changes: 3 additions & 0 deletions lib/web_ui/lib/src/engine/window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions lib/web_ui/lib/window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ abstract class SingletonFlutterWindow extends FlutterView {

bool get nativeSpellCheckServiceDefined;

bool get supportsShowingSystemContextMenu;

bool get brieflyShowPassword;

bool get alwaysUse24HourFormat;
Expand Down
6 changes: 6 additions & 0 deletions shell/platform/darwin/ios/framework/Source/FlutterEngine.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,36 @@ - (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 {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin];
BOOL shownEditMenu = [textInputPlugin showEditMenu:args];
if (!shownEditMenu) {
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.";
}
}
}

- (void)hideSystemContextMenu {
if (@available(iOS 16.0, *)) {
FlutterTextInputPlugin* textInputPlugin = [_engine.get() textInputPlugin];
[textInputPlugin hideEditMenu];
}
}

- (void)showShareViewController:(NSString*)content {
UIViewController* engineViewController = [_engine.get() viewController];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ typedef NS_ENUM(NSInteger, FlutterScribbleInteractionStatus) {
*/
- (void)setUpIndirectScribbleInteraction:(id<FlutterViewResponder>)viewResponder;
- (void)resetViewResponder;
- (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0));
- (void)hideEditMenu API_AVAILABLE(ios(16.0));

@end

Expand Down Expand Up @@ -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 <UITextInput, UIScribbleInteractionDelegate>
@interface FlutterTextInputView
: UIView <UITextInput, UIScribbleInteractionDelegate, UIEditMenuInteractionDelegate>

// UITextInput
@property(nonatomic, readonly) NSMutableString* text;
Expand Down Expand Up @@ -158,6 +161,8 @@ FLUTTER_DARWIN_EXPORT
@property(nonatomic, weak) id<FlutterViewResponder> viewResponder;
@property(nonatomic) FlutterScribbleFocusStatus scribbleFocusStatus;
@property(nonatomic, strong) NSArray<FlutterTextSelectionRect*>* selectionRects;

@property(nonatomic, strong) UIEditMenuInteraction* editMenuInteraction API_AVAILABLE(ios(16.0));
- (void)resetScribbleInteractionStatusIfEnding;
- (BOOL)isScribbleAvailable;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<UIMenuElement*>*)suggestedActions API_AVAILABLE(ios(16.0)) {
return [UIMenu menuWithChildren:suggestedActions];
}

- (void)editMenuInteraction:(UIEditMenuInteraction*)interaction
willDismissMenuForConfiguration:(UIEditMenuConfiguration*)configuration
animator:(id<UIEditMenuInteractionAnimating>)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];
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -2511,6 +2549,23 @@ - (void)takeKeyboardScreenshotAndDisplay {
_keyboardViewContainer.frame = _keyboardRect;
}

- (BOOL)showEditMenu:(NSDictionary*)args API_AVAILABLE(ios(16.0)) {
if (!self.activeView.isFirstResponder) {
return NO;
}
NSDictionary<NSString*, NSNumber*>* 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 {
[self.activeView hideEditMenu];
}

- (void)setEditableSizeAndTransform:(NSDictionary*)dictionary {
NSArray* transform = dictionary[@"transform"];
[_activeView setEditableTransform:transform];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2847,6 +2847,117 @@ - (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_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 =
[[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);

OCMStub([mockInputView isFirstResponder]).andReturn(YES);

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<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(0), @"y" : @(0), @"width" : @(0), @"height" : @(0)};

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 =
[[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);
OCMStub([mockInputView isFirstResponder]).andReturn(YES);

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<NSString*, NSNumber*>* encodedTargetRect =
@{@"x" : @(100), @"y" : @(200), @"width" : @(300), @"height" : @(400)};

BOOL shownEditMenu = [myInputPlugin showEditMenu:@{@"targetRect" : encodedTargetRect}];
XCTAssertTrue(shownEditMenu, @"Should show edit menu with correct configuration.");
[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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2134,7 +2134,8 @@ - (void)onUserSettingsChanged:(NSNotification*)notification {
@"alwaysUse24HourFormat" : @([self isAlwaysUse24HourFormat]),
@"platformBrightness" : [self brightnessMode],
@"platformContrast" : [self contrastMode],
@"nativeSpellCheckServiceDefined" : @true
@"nativeSpellCheckServiceDefined" : @true,
@"supportsShowingSystemContextMenu" : @([self supportsShowingSystemContextMenu])
}];
}

Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit f8e373d

Please sign in to comment.