Skip to content

Commit 919f1eb

Browse files
knopphouhuayong
authored andcommitted
[macOS] Put FlutterTextInputPlugin in view hierarchy (flutter#33827)
1 parent 35c1cbc commit 919f1eb

11 files changed

+215
-234
lines changed

shell/platform/darwin/macos/framework/Source/AccessibilityBridgeMacDelegate.mm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
// first responder.
129129
FlutterTextField* native_text_field = (FlutterTextField*)focused;
130130
if (native_text_field == mac_platform_node_delegate->GetFocus()) {
131-
[native_text_field becomeFirstResponder];
131+
[native_text_field startEditing];
132132
}
133133
break;
134134
}
@@ -172,7 +172,7 @@
172172
(FlutterTextField*)mac_platform_node_delegate->GetNativeViewAccessible();
173173
id focused = mac_platform_node_delegate->GetFocus();
174174
if (!focused || native_text_field == focused) {
175-
[native_text_field becomeFirstResponder];
175+
[native_text_field startEditing];
176176
}
177177
break;
178178
}

shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,13 @@
3939
*/
4040
- (void)handleEvent:(nonnull NSEvent*)event;
4141

42+
/**
43+
* Returns yes if is event currently being redispatched.
44+
*
45+
* In some instances (i.e. emoji shortcut) the event may be redelivered by cocoa
46+
* as key equivalent to FlutterTextInput, in which case it shouldn't be
47+
* processed again.
48+
*/
49+
- (BOOL)isDispatchingKeyEvent:(nonnull NSEvent*)event;
50+
4251
@end

shell/platform/darwin/macos/framework/Source/FlutterKeyboardManager.mm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ @interface FlutterKeyboardManager ()
7070

7171
@property(nonatomic) NSMutableDictionary<NSNumber*, NSNumber*>* layoutMap;
7272

73+
@property(nonatomic, nullable) NSEvent* eventBeingDispatched;
74+
7375
/**
7476
* Add a primary responder, which asynchronously decides whether to handle an
7577
* event.
@@ -168,6 +170,10 @@ - (void)handleEvent:(nonnull NSEvent*)event {
168170
[self processNextEvent];
169171
}
170172

173+
- (BOOL)isDispatchingKeyEvent:(NSEvent*)event {
174+
return _eventBeingDispatched == event;
175+
}
176+
171177
#pragma mark - Private
172178

173179
- (void)processNextEvent {
@@ -230,6 +236,8 @@ - (void)dispatchTextEvent:(NSEvent*)event {
230236
if (nextResponder == nil) {
231237
return;
232238
}
239+
NSAssert(_eventBeingDispatched == nil, @"An event is already being dispached.");
240+
_eventBeingDispatched = event;
233241
switch (event.type) {
234242
case NSEventTypeKeyDown:
235243
if ([nextResponder respondsToSelector:@selector(keyDown:)]) {
@@ -249,6 +257,8 @@ - (void)dispatchTextEvent:(NSEvent*)event {
249257
default:
250258
NSAssert(false, @"Unexpected key event type (got %lu).", event.type);
251259
}
260+
NSAssert(_eventBeingDispatched != nil, @"_eventBeingDispatched was cleared unexpectedly.");
261+
_eventBeingDispatched = nil;
252262
}
253263

254264
- (void)buildLayout {

shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,8 @@
293293
EXPECT_EQ(NSEqualRects(native_text_field.frame, NSMakeRect(0, 600 - expectedFrameSize,
294294
expectedFrameSize, expectedFrameSize)),
295295
YES);
296-
// The text of TextInputPlugin only starts syncing editing state to the
297-
// native text field when it becomes the first responder.
298-
[native_text_field becomeFirstResponder];
296+
297+
[native_text_field startEditing];
299298
EXPECT_EQ([native_text_field.stringValue isEqualToString:@"textfield"], YES);
300299
}
301300

shell/platform/darwin/macos/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,16 @@ - (void)dealloc {
237237

238238
#pragma mark - Private
239239

240+
- (void)resignAndRemoveFromSuperview {
241+
if (self.superview != nil) {
242+
// With accessiblity enabled TextInputPlugin is inside _client, so take the
243+
// nextResponder from the _client.
244+
NSResponder* nextResponder = _client != nil ? _client.nextResponder : self.nextResponder;
245+
[self.window makeFirstResponder:nextResponder];
246+
[self removeFromSuperview];
247+
}
248+
}
249+
240250
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
241251
BOOL handled = YES;
242252
NSString* method = call.method;
@@ -262,12 +272,19 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
262272
_activeModel = std::make_unique<flutter::TextInputModel>();
263273
}
264274
} else if ([method isEqualToString:kShowMethod]) {
275+
// Ensure the plugin is in hierarchy. Only do this with accessibility disabled.
276+
// When accessibility is enabled cocoa will reparent the plugin inside
277+
// FlutterTextField in [FlutterTextField startEditing].
278+
if (_client == nil) {
279+
[_flutterViewController.view addSubview:self];
280+
}
281+
[self.window makeFirstResponder:self];
265282
_shown = TRUE;
266-
[_textInputContext activate];
267283
} else if ([method isEqualToString:kHideMethod]) {
284+
[self resignAndRemoveFromSuperview];
268285
_shown = FALSE;
269-
[_textInputContext deactivate];
270286
} else if ([method isEqualToString:kClearClientMethod]) {
287+
[self resignAndRemoveFromSuperview];
271288
// If there's an active mark region, commit it, end composing, and clear the IME's mark text.
272289
if (_activeModel && _activeModel->composing()) {
273290
_activeModel->CommitComposing();
@@ -362,7 +379,8 @@ - (void)setEditingState:(NSDictionary*)state {
362379
if (composing_range.collapsed() && wasComposing) {
363380
[_textInputContext discardMarkedText];
364381
}
365-
[_client becomeFirstResponder];
382+
[_client startEditing];
383+
366384
[self updateTextAndSelection];
367385
}
368386

@@ -464,12 +482,6 @@ - (BOOL)handleKeyEvent:(NSEvent*)event {
464482
return NO;
465483
}
466484

467-
// NSTextInputContext sometimes deactivates itself without calling
468-
// deactivate. One such example is when the composing region is deleted.
469-
// TODO(LongCatIsLooong): put FlutterTextInputPlugin in the view hierarchy and
470-
// request/resign first responder when needed. Activate/deactivate shouldn't
471-
// be called by the application.
472-
[_textInputContext activate];
473485
return [_textInputContext handleEvent:event];
474486
}
475487

@@ -485,7 +497,20 @@ - (void)keyUp:(NSEvent*)event {
485497
}
486498

487499
- (BOOL)performKeyEquivalent:(NSEvent*)event {
488-
return [self.flutterViewController performKeyEquivalent:event];
500+
if ([_flutterViewController isDispatchingKeyEvent:event]) {
501+
// When NSWindow is nextResponder, keyboard manager will send to it
502+
// unhandled events (through [NSWindow keyDown:]). If event has has both
503+
// control and cmd modifiers set (i.e. cmd+control+space - emoji picker)
504+
// NSWindow will then send this event as performKeyEquivalent: to first
505+
// responder, which is FlutterTextInputPlugin. If that's the case, the
506+
// plugin must not handle the event, otherwise the emoji picker would not
507+
// work (due to first responder returning YES from performKeyEquivalent:)
508+
// and there would be endless loop, because FlutterViewController will
509+
// send the event back to [keyboardManager handleEvent:].
510+
return NO;
511+
}
512+
[self.flutterViewController keyDown:event];
513+
return YES;
489514
}
490515

491516
- (void)flagsChanged:(NSEvent*)event {

shell/platform/darwin/macos/framework/Source/FlutterTextInputPluginTest.mm

Lines changed: 98 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -250,60 +250,6 @@ - (bool)testComposingRegionRemovedByFramework {
250250
return true;
251251
}
252252

253-
- (bool)testInputContextIsKeptActive {
254-
id engineMock = OCMClassMock([FlutterEngine class]);
255-
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
256-
nibName:@""
257-
bundle:nil];
258-
259-
FlutterTextInputPlugin* plugin =
260-
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
261-
262-
[plugin handleMethodCall:[FlutterMethodCall
263-
methodCallWithMethodName:@"TextInput.setClient"
264-
arguments:@[
265-
@(1), @{
266-
@"inputAction" : @"action",
267-
@"inputType" : @{@"name" : @"inputName"},
268-
}
269-
]]
270-
result:^(id){
271-
}];
272-
273-
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
274-
arguments:@{
275-
@"text" : @"",
276-
@"selectionBase" : @(0),
277-
@"selectionExtent" : @(0),
278-
@"composingBase" : @(-1),
279-
@"composingExtent" : @(-1),
280-
}]
281-
result:^(id){
282-
}];
283-
284-
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
285-
arguments:@[]]
286-
result:^(id){
287-
}];
288-
289-
[plugin.inputContext deactivate];
290-
EXPECT_EQ(plugin.inputContext.isActive, NO);
291-
NSEvent* keyEvent = [NSEvent keyEventWithType:NSEventTypeKeyDown
292-
location:NSZeroPoint
293-
modifierFlags:0x100
294-
timestamp:0
295-
windowNumber:0
296-
context:nil
297-
characters:@""
298-
charactersIgnoringModifiers:@""
299-
isARepeat:NO
300-
keyCode:0x50];
301-
302-
[plugin handleKeyEvent:keyEvent];
303-
EXPECT_EQ(plugin.inputContext.isActive, YES);
304-
return true;
305-
}
306-
307253
- (bool)testClearClientDuringComposing {
308254
// Set up FlutterTextInputPlugin.
309255
id engineMock = OCMClassMock([FlutterEngine class]);
@@ -917,6 +863,63 @@ - (bool)testComposingWithDeltasWhenSelectionIsActive {
917863
return true;
918864
}
919865

866+
- (bool)testPerformKeyEquivalent {
867+
__block NSEvent* eventBeingDispatchedByKeyboardManager = nil;
868+
FlutterViewController* viewControllerMock = OCMClassMock([FlutterViewController class]);
869+
OCMStub([viewControllerMock isDispatchingKeyEvent:[OCMArg any]])
870+
.andDo(^(NSInvocation* invocation) {
871+
NSEvent* event;
872+
[invocation getArgument:(void*)&event atIndex:2];
873+
BOOL result = event == eventBeingDispatchedByKeyboardManager;
874+
[invocation setReturnValue:&result];
875+
});
876+
877+
NSEvent* event = [NSEvent keyEventWithType:NSEventTypeKeyDown
878+
location:NSZeroPoint
879+
modifierFlags:0x100
880+
timestamp:0
881+
windowNumber:0
882+
context:nil
883+
characters:@""
884+
charactersIgnoringModifiers:@""
885+
isARepeat:NO
886+
keyCode:0x50];
887+
888+
FlutterTextInputPlugin* plugin =
889+
[[FlutterTextInputPlugin alloc] initWithViewController:viewControllerMock];
890+
891+
OCMExpect([viewControllerMock keyDown:event]);
892+
893+
// Require that event is handled (returns YES)
894+
if (![plugin performKeyEquivalent:event]) {
895+
return false;
896+
};
897+
898+
@try {
899+
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
900+
[viewControllerMock keyDown:event]);
901+
} @catch (...) {
902+
return false;
903+
}
904+
905+
// performKeyEquivalent must not forward event if it is being
906+
// dispatched by keyboard manager
907+
eventBeingDispatchedByKeyboardManager = event;
908+
909+
OCMReject([viewControllerMock keyDown:event]);
910+
@try {
911+
// Require that event is not handled (returns NO) and not
912+
// forwarded to controller
913+
if ([plugin performKeyEquivalent:event]) {
914+
return false;
915+
};
916+
} @catch (...) {
917+
return false;
918+
}
919+
920+
return true;
921+
}
922+
920923
- (bool)testLocalTextAndSelectionUpdateAfterDelta {
921924
id engineMock = OCMClassMock([FlutterEngine class]);
922925
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
@@ -1005,10 +1008,6 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta {
10051008
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testComposingRegionRemovedByFramework]);
10061009
}
10071010

1008-
TEST(FlutterTextInputPluginTest, TestTextInputContextIsKeptAlive) {
1009-
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testInputContextIsKeptActive]);
1010-
}
1011-
10121011
TEST(FlutterTextInputPluginTest, TestClearClientDuringComposing) {
10131012
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
10141013
}
@@ -1037,6 +1036,10 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta {
10371036
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testLocalTextAndSelectionUpdateAfterDelta]);
10381037
}
10391038

1039+
TEST(FlutterTextInputPluginTest, TestPerformKeyEquivalent) {
1040+
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testPerformKeyEquivalent]);
1041+
}
1042+
10401043
TEST(FlutterTextInputPluginTest, CanWorkWithFlutterTextField) {
10411044
FlutterEngine* engine = CreateTestEngine();
10421045
NSString* fixtures = @(testing::GetFixturesPath());
@@ -1069,7 +1072,7 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta {
10691072
[[FlutterTextFieldMock alloc] initWithPlatformNode:&text_platform_node
10701073
fieldEditor:viewController.textInputPlugin];
10711074
[viewController.view addSubview:mockTextField];
1072-
[mockTextField becomeFirstResponder];
1075+
[mockTextField startEditing];
10731076

10741077
NSDictionary* arguments = @{
10751078
@"inputAction" : @"action",
@@ -1133,4 +1136,40 @@ - (bool)testLocalTextAndSelectionUpdateAfterDelta {
11331136
EXPECT_EQ([textField becomeFirstResponder], NO);
11341137
}
11351138

1139+
TEST(FlutterTextInputPluginTest, IsAddedAndRemovedFromViewHierarchy) {
1140+
FlutterEngine* engine = CreateTestEngine();
1141+
NSString* fixtures = @(testing::GetFixturesPath());
1142+
FlutterDartProject* project = [[FlutterDartProject alloc]
1143+
initWithAssetsPath:fixtures
1144+
ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]];
1145+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project];
1146+
[viewController loadView];
1147+
[engine setViewController:viewController];
1148+
1149+
NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
1150+
styleMask:NSBorderlessWindowMask
1151+
backing:NSBackingStoreBuffered
1152+
defer:NO];
1153+
window.contentView = viewController.view;
1154+
1155+
ASSERT_EQ(viewController.textInputPlugin.superview, nil);
1156+
ASSERT_FALSE(window.firstResponder == viewController.textInputPlugin);
1157+
1158+
[viewController.textInputPlugin
1159+
handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.show" arguments:@[]]
1160+
result:^(id){
1161+
}];
1162+
1163+
ASSERT_EQ(viewController.textInputPlugin.superview, viewController.view);
1164+
ASSERT_TRUE(window.firstResponder == viewController.textInputPlugin);
1165+
1166+
[viewController.textInputPlugin
1167+
handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" arguments:@[]]
1168+
result:^(id){
1169+
}];
1170+
1171+
ASSERT_EQ(viewController.textInputPlugin.superview, nil);
1172+
ASSERT_FALSE(window.firstResponder == viewController.textInputPlugin);
1173+
}
1174+
11361175
} // namespace flutter::testing

shell/platform/darwin/macos/framework/Source/FlutterTextInputSemanticsObject.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,10 @@ class FlutterTextPlatformNode : public ui::AXPlatformNodeBase {
8989
*/
9090
- (void)updateString:(NSString*)string withSelection:(NSRange)selection;
9191

92+
/**
93+
* Makes the field editor (plugin) current editor for this TextField, meaning
94+
* that the text field will start getting editing events.
95+
*/
96+
- (void)startEditing;
97+
9298
@end

0 commit comments

Comments
 (0)