Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"]) {
Copy link
Contributor Author

@hellohuanlin hellohuanlin Apr 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinmc Wanna double check before landing it:

For non-text input, if developers setup contextMenuBuilder to use system menu, does it call this method? Does it crash or does it just NO-OP?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently it calls it, even if there is no active text input connection. The result I see is that nothing happens. I wonder if there's anything we can do to prevent that unnecessary call, let me look on the framework side...

To be clear, at least the only way to do this now is to directly use SystemContextMenuController. It's not currently possible to use SystemContextMenu without an EditableText.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a good way for the framework to know whether or not there is an active text input connection at that point (in SystemContextMenuController). Maybe should the ContextMenu.showSystemContextMenu call indicate an error in the case where it doesn't succeed in showing the menu?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm adding some docs to the framework PR about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there's a good way for the framework to know whether or not there is an active text input connection at that point (in SystemContextMenuController). Maybe should the ContextMenu.showSystemContextMenu call indicate an error in the case where it doesn't succeed in showing the menu?

I think I can add a warning on the engine side.

[self showSystemContextMenu:args];
result(nil);
} else if ([method isEqualToString:@"ContextMenu.hideSystemContextMenu"]) {
[self hideSystemContextMenu];
result(nil);
} else {
result(FlutterMethodNotImplemented);
}
}

- (void)showSystemContextMenu:(NSDictionary*)args {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible problem I just thought of is the lack of a hideSystemContextMenu. Normally this is fine because the system context menu will hide itself when the text input connection is closed or when the user taps outside of the menu. However, it could be a problem if the user rebuilds their text field with a new context menu.

Is it possible to add a hideSystemContextMenu method, and do you think it's useful?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be easy to add. Let me try!

Copy link
Contributor Author

@hellohuanlin hellohuanlin Feb 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just added ContextMenu.hideSystemContextMenu (no params). Feel free to play around.

Or maybe it doesn't matter since it's only related to development and not a release build.

If it's easy for you, I'm open to just fix it here. But if it's hard, we can also leave it, and I can create an issue to track that (no strong opinion)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I'll play around with the hot restart thing and see if I can fix it, otherwise we'll open an issue.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick turnaround on hideSystemContextMenu. I'll try it out and post back.

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.";
Copy link
Contributor Author

@hellohuanlin hellohuanlin Apr 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinmc added this log if fails to show edit menu (e.g. when calling from non-text input).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add this sentence if it's not too long?

"Ensure the system context menu is shown with an active text input connection."

That way it's more clear what someone needs to do to fix this, if it wasn't already obvious.

}
}
}

- (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];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So in future milestones the framework could pass UIMenuElements like this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the framework can send the items via showNativeEditMenu channel, and we can return it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom actions (e.g. send email) can be a bit more involved (e.g. the engine would need to call back to the framework).

}

- (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) {
Copy link
Contributor Author

@hellohuanlin hellohuanlin Apr 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinmc this is where I determine the menu is triggered by text input widgets vs non-text input widgets.

I can also check _textInputClient != 0, but I have a feeling that there could be an edge case where _textInputClient is set, but it is not yet in editing state. So I feel checking the first responder is safer.

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