[macOS] Put FlutterTextInputPlugin in view hierarchy (flutter/engine#33827)

This commit is contained in:
Matej Knopp 2022-06-15 12:11:36 +02:00 committed by GitHub
parent c645b5f592
commit 651843b2da
11 changed files with 214 additions and 233 deletions

View File

@ -128,7 +128,7 @@ AccessibilityBridgeMacDelegate::MacOSEventsFromAXEvent(ui::AXEventGenerator::Eve
// first responder.
FlutterTextField* native_text_field = (FlutterTextField*)focused;
if (native_text_field == mac_platform_node_delegate->GetFocus()) {
[native_text_field becomeFirstResponder];
[native_text_field startEditing];
}
break;
}
@ -172,7 +172,7 @@ AccessibilityBridgeMacDelegate::MacOSEventsFromAXEvent(ui::AXEventGenerator::Eve
(FlutterTextField*)mac_platform_node_delegate->GetNativeViewAccessible();
id focused = mac_platform_node_delegate->GetFocus();
if (!focused || native_text_field == focused) {
[native_text_field becomeFirstResponder];
[native_text_field startEditing];
}
break;
}

View File

@ -39,4 +39,13 @@
*/
- (void)handleEvent:(nonnull NSEvent*)event;
/**
* Returns yes if is event currently being redispatched.
*
* In some instances (i.e. emoji shortcut) the event may be redelivered by cocoa
* as key equivalent to FlutterTextInput, in which case it shouldn't be
* processed again.
*/
- (BOOL)isDispatchingKeyEvent:(nonnull NSEvent*)event;
@end

View File

@ -70,6 +70,8 @@ typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
@property(nonatomic) NSMutableDictionary<NSNumber*, NSNumber*>* layoutMap;
@property(nonatomic, nullable) NSEvent* eventBeingDispatched;
/**
* Add a primary responder, which asynchronously decides whether to handle an
* event.
@ -168,6 +170,10 @@ typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
[self processNextEvent];
}
- (BOOL)isDispatchingKeyEvent:(NSEvent*)event {
return _eventBeingDispatched == event;
}
#pragma mark - Private
- (void)processNextEvent {
@ -230,6 +236,8 @@ typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
if (nextResponder == nil) {
return;
}
NSAssert(_eventBeingDispatched == nil, @"An event is already being dispached.");
_eventBeingDispatched = event;
switch (event.type) {
case NSEventTypeKeyDown:
if ([nextResponder respondsToSelector:@selector(keyDown:)]) {
@ -249,6 +257,8 @@ typedef _Nullable _NSResponderPtr (^NextResponderProvider)();
default:
NSAssert(false, @"Unexpected key event type (got %lu).", event.type);
}
NSAssert(_eventBeingDispatched != nil, @"_eventBeingDispatched was cleared unexpectedly.");
_eventBeingDispatched = nil;
}
- (void)buildLayout {

View File

@ -293,9 +293,8 @@ TEST(FlutterPlatformNodeDelegateMac, TextFieldUsesFlutterTextField) {
EXPECT_EQ(NSEqualRects(native_text_field.frame, NSMakeRect(0, 600 - expectedFrameSize,
expectedFrameSize, expectedFrameSize)),
YES);
// The text of TextInputPlugin only starts syncing editing state to the
// native text field when it becomes the first responder.
[native_text_field becomeFirstResponder];
[native_text_field startEditing];
EXPECT_EQ([native_text_field.stringValue isEqualToString:@"textfield"], YES);
}

View File

@ -237,6 +237,16 @@ static flutter::TextRange RangeFromBaseExtent(NSNumber* base,
#pragma mark - Private
- (void)resignAndRemoveFromSuperview {
if (self.superview != nil) {
// With accessiblity enabled TextInputPlugin is inside _client, so take the
// nextResponder from the _client.
NSResponder* nextResponder = _client != nil ? _client.nextResponder : self.nextResponder;
[self.window makeFirstResponder:nextResponder];
[self removeFromSuperview];
}
}
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
BOOL handled = YES;
NSString* method = call.method;
@ -262,12 +272,19 @@ static flutter::TextRange RangeFromBaseExtent(NSNumber* base,
_activeModel = std::make_unique<flutter::TextInputModel>();
}
} else if ([method isEqualToString:kShowMethod]) {
// Ensure the plugin is in hierarchy. Only do this with accessibility disabled.
// When accessibility is enabled cocoa will reparent the plugin inside
// FlutterTextField in [FlutterTextField startEditing].
if (_client == nil) {
[_flutterViewController.view addSubview:self];
}
[self.window makeFirstResponder:self];
_shown = TRUE;
[_textInputContext activate];
} else if ([method isEqualToString:kHideMethod]) {
[self resignAndRemoveFromSuperview];
_shown = FALSE;
[_textInputContext deactivate];
} else if ([method isEqualToString:kClearClientMethod]) {
[self resignAndRemoveFromSuperview];
// If there's an active mark region, commit it, end composing, and clear the IME's mark text.
if (_activeModel && _activeModel->composing()) {
_activeModel->CommitComposing();
@ -362,7 +379,8 @@ static flutter::TextRange RangeFromBaseExtent(NSNumber* base,
if (composing_range.collapsed() && wasComposing) {
[_textInputContext discardMarkedText];
}
[_client becomeFirstResponder];
[_client startEditing];
[self updateTextAndSelection];
}
@ -464,12 +482,6 @@ static flutter::TextRange RangeFromBaseExtent(NSNumber* base,
return NO;
}
// NSTextInputContext sometimes deactivates itself without calling
// deactivate. One such example is when the composing region is deleted.
// TODO(LongCatIsLooong): put FlutterTextInputPlugin in the view hierarchy and
// request/resign first responder when needed. Activate/deactivate shouldn't
// be called by the application.
[_textInputContext activate];
return [_textInputContext handleEvent:event];
}
@ -485,7 +497,20 @@ static flutter::TextRange RangeFromBaseExtent(NSNumber* base,
}
- (BOOL)performKeyEquivalent:(NSEvent*)event {
return [self.flutterViewController performKeyEquivalent:event];
if ([_flutterViewController isDispatchingKeyEvent:event]) {
// When NSWindow is nextResponder, keyboard manager will send to it
// unhandled events (through [NSWindow keyDown:]). If event has has both
// control and cmd modifiers set (i.e. cmd+control+space - emoji picker)
// NSWindow will then send this event as performKeyEquivalent: to first
// responder, which is FlutterTextInputPlugin. If that's the case, the
// plugin must not handle the event, otherwise the emoji picker would not
// work (due to first responder returning YES from performKeyEquivalent:)
// and there would be endless loop, because FlutterViewController will
// send the event back to [keyboardManager handleEvent:].
return NO;
}
[self.flutterViewController keyDown:event];
return YES;
}
- (void)flagsChanged:(NSEvent*)event {

View File

@ -250,60 +250,6 @@
return true;
}
- (bool)testInputContextIsKeptActive {
id engineMock = OCMClassMock([FlutterEngine class]);
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewController];
[plugin handleMethodCall:[FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
@(1), @{
@"inputAction" : @"action",
@"inputType" : @{@"name" : @"inputName"},
}
]]
result:^(id){
}];
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.setEditingState"
arguments:@{
@"text" : @"",
@"selectionBase" : @(0),
@"selectionExtent" : @(0),
@"composingBase" : @(-1),
@"composingExtent" : @(-1),
}]
result:^(id){
}];
[plugin handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
arguments:@[]]
result:^(id){
}];
[plugin.inputContext deactivate];
EXPECT_EQ(plugin.inputContext.isActive, NO);
NSEvent* keyEvent = [NSEvent keyEventWithType:NSEventTypeKeyDown
location:NSZeroPoint
modifierFlags:0x100
timestamp:0
windowNumber:0
context:nil
characters:@""
charactersIgnoringModifiers:@""
isARepeat:NO
keyCode:0x50];
[plugin handleKeyEvent:keyEvent];
EXPECT_EQ(plugin.inputContext.isActive, YES);
return true;
}
- (bool)testClearClientDuringComposing {
// Set up FlutterTextInputPlugin.
id engineMock = OCMClassMock([FlutterEngine class]);
@ -917,6 +863,63 @@
return true;
}
- (bool)testPerformKeyEquivalent {
__block NSEvent* eventBeingDispatchedByKeyboardManager = nil;
FlutterViewController* viewControllerMock = OCMClassMock([FlutterViewController class]);
OCMStub([viewControllerMock isDispatchingKeyEvent:[OCMArg any]])
.andDo(^(NSInvocation* invocation) {
NSEvent* event;
[invocation getArgument:(void*)&event atIndex:2];
BOOL result = event == eventBeingDispatchedByKeyboardManager;
[invocation setReturnValue:&result];
});
NSEvent* event = [NSEvent keyEventWithType:NSEventTypeKeyDown
location:NSZeroPoint
modifierFlags:0x100
timestamp:0
windowNumber:0
context:nil
characters:@""
charactersIgnoringModifiers:@""
isARepeat:NO
keyCode:0x50];
FlutterTextInputPlugin* plugin =
[[FlutterTextInputPlugin alloc] initWithViewController:viewControllerMock];
OCMExpect([viewControllerMock keyDown:event]);
// Require that event is handled (returns YES)
if (![plugin performKeyEquivalent:event]) {
return false;
};
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[viewControllerMock keyDown:event]);
} @catch (...) {
return false;
}
// performKeyEquivalent must not forward event if it is being
// dispatched by keyboard manager
eventBeingDispatchedByKeyboardManager = event;
OCMReject([viewControllerMock keyDown:event]);
@try {
// Require that event is not handled (returns NO) and not
// forwarded to controller
if ([plugin performKeyEquivalent:event]) {
return false;
};
} @catch (...) {
return false;
}
return true;
}
- (bool)testLocalTextAndSelectionUpdateAfterDelta {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
@ -1005,10 +1008,6 @@ TEST(FlutterTextInputPluginTest, TestComposingRegionRemovedByFramework) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testComposingRegionRemovedByFramework]);
}
TEST(FlutterTextInputPluginTest, TestTextInputContextIsKeptAlive) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testInputContextIsKeptActive]);
}
TEST(FlutterTextInputPluginTest, TestClearClientDuringComposing) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testClearClientDuringComposing]);
}
@ -1037,6 +1036,10 @@ TEST(FlutterTextInputPluginTest, TestLocalTextAndSelectionUpdateAfterDelta) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testLocalTextAndSelectionUpdateAfterDelta]);
}
TEST(FlutterTextInputPluginTest, TestPerformKeyEquivalent) {
ASSERT_TRUE([[FlutterInputPluginTestObjc alloc] testPerformKeyEquivalent]);
}
TEST(FlutterTextInputPluginTest, CanWorkWithFlutterTextField) {
FlutterEngine* engine = CreateTestEngine();
NSString* fixtures = @(testing::GetFixturesPath());
@ -1069,7 +1072,7 @@ TEST(FlutterTextInputPluginTest, CanWorkWithFlutterTextField) {
[[FlutterTextFieldMock alloc] initWithPlatformNode:&text_platform_node
fieldEditor:viewController.textInputPlugin];
[viewController.view addSubview:mockTextField];
[mockTextField becomeFirstResponder];
[mockTextField startEditing];
NSDictionary* arguments = @{
@"inputAction" : @"action",
@ -1133,4 +1136,40 @@ TEST(FlutterTextInputPluginTest, CanNotBecomeResponderIfNoViewController) {
EXPECT_EQ([textField becomeFirstResponder], NO);
}
TEST(FlutterTextInputPluginTest, IsAddedAndRemovedFromViewHierarchy) {
FlutterEngine* engine = CreateTestEngine();
NSString* fixtures = @(testing::GetFixturesPath());
FlutterDartProject* project = [[FlutterDartProject alloc]
initWithAssetsPath:fixtures
ICUDataPath:[fixtures stringByAppendingString:@"/icudtl.dat"]];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithProject:project];
[viewController loadView];
[engine setViewController:viewController];
NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 800, 600)
styleMask:NSBorderlessWindowMask
backing:NSBackingStoreBuffered
defer:NO];
window.contentView = viewController.view;
ASSERT_EQ(viewController.textInputPlugin.superview, nil);
ASSERT_FALSE(window.firstResponder == viewController.textInputPlugin);
[viewController.textInputPlugin
handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.show" arguments:@[]]
result:^(id){
}];
ASSERT_EQ(viewController.textInputPlugin.superview, viewController.view);
ASSERT_TRUE(window.firstResponder == viewController.textInputPlugin);
[viewController.textInputPlugin
handleMethodCall:[FlutterMethodCall methodCallWithMethodName:@"TextInput.hide" arguments:@[]]
result:^(id){
}];
ASSERT_EQ(viewController.textInputPlugin.superview, nil);
ASSERT_FALSE(window.firstResponder == viewController.textInputPlugin);
}
} // namespace flutter::testing

View File

@ -89,4 +89,10 @@ class FlutterTextPlatformNode : public ui::AXPlatformNodeBase {
*/
- (void)updateString:(NSString*)string withSelection:(NSRange)selection;
/**
* Makes the field editor (plugin) current editor for this TextField, meaning
* that the text field will start getting editing events.
*/
- (void)startEditing;
@end

View File

@ -103,50 +103,42 @@
_node->GetDelegate()->AccessibilityPerformAction(data);
}
#pragma mark - NSResponder
- (BOOL)becomeFirstResponder {
- (void)startEditing {
if (!_plugin) {
return NO;
return;
}
if (_plugin.client == self && [_plugin isFirstResponder]) {
// This text field is already the first responder.
return YES;
if (self.currentEditor == _plugin) {
return;
}
BOOL result = [super becomeFirstResponder];
if (result) {
_plugin.client = self;
// The default implementation of the becomeFirstResponder will change the
// text editing state. Need to manually set it back.
NSString* textValue = @(_node->GetStringAttribute(ax::mojom::StringAttribute::kValue).data());
int start = _node->GetIntAttribute(ax::mojom::IntAttribute::kTextSelStart);
int end = _node->GetIntAttribute(ax::mojom::IntAttribute::kTextSelEnd);
NSAssert((start >= 0 && end >= 0) || (start == -1 && end == -1), @"selection is invalid");
NSRange selection;
if (start >= 0 && end >= 0) {
selection = NSMakeRange(MIN(start, end), ABS(end - start));
} else {
// The native behavior is to place the cursor at the end of the string if
// there is no selection.
selection = NSMakeRange([self stringValue].length, 0);
}
[self updateString:textValue withSelection:selection];
}
return result;
}
// Selecting text seems to be the only way to make the field editor
// current editor.
[self selectText:self];
NSAssert(self.currentEditor == _plugin, @"Failed to set current editor");
- (BOOL)resignFirstResponder {
BOOL result = [super resignFirstResponder];
if (result && _plugin.client == self) {
_plugin.client = nil;
_plugin.client = self;
// Restore previous selection.
NSString* textValue = @(_node->GetStringAttribute(ax::mojom::StringAttribute::kValue).data());
int start = _node->GetIntAttribute(ax::mojom::IntAttribute::kTextSelStart);
int end = _node->GetIntAttribute(ax::mojom::IntAttribute::kTextSelEnd);
NSAssert((start >= 0 && end >= 0) || (start == -1 && end == -1), @"selection is invalid");
NSRange selection;
if (start >= 0 && end >= 0) {
selection = NSMakeRange(MIN(start, end), ABS(end - start));
} else {
// The native behavior is to place the cursor at the end of the string if
// there is no selection.
selection = NSMakeRange([self stringValue].length, 0);
}
return result;
[self updateString:textValue withSelection:selection];
}
#pragma mark - NSObject
- (void)dealloc {
[self resignFirstResponder];
if (_plugin.client == self) {
_plugin.client = nil;
}
}
@end

View File

@ -181,7 +181,7 @@ NSData* currentKeyboardLayoutData() {
* dispatched to various Flutter key responders, and whether the event is
* propagated to the next NSResponder.
*/
@property(nonatomic) FlutterKeyboardManager* keyboardManager;
@property(nonatomic, readonly, nonnull) FlutterKeyboardManager* keyboardManager;
@property(nonatomic) KeyboardLayoutNotifier keyboardLayoutNotifier;
@ -341,6 +341,10 @@ static void CommonInit(FlutterViewController* controller) {
return self;
}
- (BOOL)isDispatchingKeyEvent:(NSEvent*)event {
return [_keyboardManager isDispatchingKeyEvent:event];
}
- (void)loadView {
FlutterView* flutterView;
if ([FlutterRenderingBackend renderUsingMetal]) {
@ -431,10 +435,11 @@ static void CommonInit(FlutterViewController* controller) {
addLocalMonitorForEventsMatchingMask:NSEventMaskKeyUp
handler:^NSEvent*(NSEvent* event) {
// Intercept keyUp only for events triggered on the current
// view.
// view or textInputPlugin.
NSResponder* firstResponder = [[event window] firstResponder];
if (weakSelf.viewLoaded && weakSelf.flutterView &&
([[event window] firstResponder] ==
weakSelf.flutterView) &&
(firstResponder == weakSelf.flutterView ||
firstResponder == weakSelf.textInputPlugin) &&
([event modifierFlags] & NSEventModifierFlagCommand) &&
([event type] == NSEventTypeKeyUp)) {
[weakSelf keyUp:event];
@ -481,7 +486,6 @@ static void CommonInit(FlutterViewController* controller) {
// TODO(goderbauer): Seperate keyboard/textinput stuff into ViewController specific and Engine
// global parts. Move the global parts to FlutterEngine.
__weak FlutterViewController* weakSelf = self;
_textInputPlugin = [[FlutterTextInputPlugin alloc] initWithViewController:weakSelf];
_keyboardManager = [[FlutterKeyboardManager alloc] initWithViewDelegate:weakSelf];
}
@ -635,16 +639,11 @@ static void CommonInit(FlutterViewController* controller) {
- (void)onAccessibilityStatusChanged:(BOOL)enabled {
if (!enabled && self.viewLoaded && [_textInputPlugin isFirstResponder]) {
// The client (i.e. the FlutterTextField) of the textInputPlugin is a sibling
// of the FlutterView. macOS will pick the ancestor to be the next responder
// when the client is removed from the view hierarchy, which is the result of
// turning off semantics. This will cause the keyboard focus to stick at the
// NSWindow.
//
// Since the view controller creates the illustion that the FlutterTextField is
// below the FlutterView in accessibility (See FlutterViewWrapper), it has to
// manually pick the next responder.
[self.view.window makeFirstResponder:_flutterView];
// Normally TextInputPlugin, when editing, is child of FlutterViewWrapper.
// When accessiblity is enabled the TextInputPlugin gets added as an indirect
// child to FlutterTextField. When disabling the plugin needs to be reparented
// back.
[self.view addSubview:_textInputPlugin];
}
}
@ -740,27 +739,6 @@ static void CommonInit(FlutterViewController* controller) {
[_keyboardManager handleEvent:event];
}
- (BOOL)performKeyEquivalent:(NSEvent*)event {
[_keyboardManager handleEvent:event];
if (event.type == NSEventTypeKeyDown) {
// macOS only sends keydown for performKeyEquivalent, but the Flutter framework
// always expects a keyup for every keydown. Synthesizes a key up event so that
// the Flutter framework continues to work.
NSEvent* synthesizedUp = [NSEvent keyEventWithType:NSEventTypeKeyUp
location:event.locationInWindow
modifierFlags:event.modifierFlags
timestamp:event.timestamp
windowNumber:event.windowNumber
context:event.context
characters:event.characters
charactersIgnoringModifiers:event.charactersIgnoringModifiers
isARepeat:event.isARepeat
keyCode:event.keyCode];
[_keyboardManager handleEvent:synthesizedUp];
}
return YES;
}
- (void)flagsChanged:(NSEvent*)event {
[_keyboardManager handleEvent:event];
}

View File

@ -20,7 +20,6 @@
- (bool)testKeyEventsArePropagatedIfNotHandled;
- (bool)testKeyEventsAreNotPropagatedIfHandled;
- (bool)testFlagsChangedEventsArePropagatedIfNotHandled;
- (bool)testPerformKeyEquivalentSynthesizesKeyUp;
- (bool)testKeyboardIsRestartedOnEngineRestart;
- (bool)testTrackpadGesturesAreSentToFramework;
@ -72,7 +71,7 @@ TEST(FlutterViewController, HasViewThatHidesOtherViewsInAccessibility) {
EXPECT_EQ(accessibilityChildren[0], viewControllerMock.flutterView);
}
TEST(FlutterViewController, SetsFlutterViewFirstResponderWhenAccessibilityDisabled) {
TEST(FlutterViewController, ReparentsPluginWhenAccessibilityDisabled) {
FlutterEngine* engine = CreateTestEngine();
NSString* fixtures = @(testing::GetFixturesPath());
FlutterDartProject* project = [[FlutterDartProject alloc]
@ -87,14 +86,17 @@ TEST(FlutterViewController, SetsFlutterViewFirstResponderWhenAccessibilityDisabl
backing:NSBackingStoreBuffered
defer:NO];
window.contentView = viewController.view;
NSView* dummyView = [[NSView alloc] initWithFrame:CGRectZero];
[viewController.view addSubview:dummyView];
// Attaches FlutterTextInputPlugin to the view;
[viewController.view addSubview:viewController.textInputPlugin];
[dummyView addSubview:viewController.textInputPlugin];
// Makes sure the textInputPlugin can be the first responder.
EXPECT_TRUE([window makeFirstResponder:viewController.textInputPlugin]);
EXPECT_EQ([window firstResponder], viewController.textInputPlugin);
EXPECT_FALSE(viewController.textInputPlugin.superview == viewController.view);
[viewController onAccessibilityStatusChanged:NO];
// FlutterView becomes the first responder.
EXPECT_EQ([window firstResponder], viewController.flutterView);
// FlutterView becomes child of view controller
EXPECT_TRUE(viewController.textInputPlugin.superview == viewController.view);
}
TEST(FlutterViewController, CanSetMouseTrackingModeBeforeViewLoaded) {
@ -124,10 +126,6 @@ TEST(FlutterViewControllerTest, TestFlagsChangedEventsArePropagatedIfNotHandled)
[[FlutterViewControllerTestObjC alloc] testFlagsChangedEventsArePropagatedIfNotHandled]);
}
TEST(FlutterViewControllerTest, TestPerformKeyEquivalentSynthesizesKeyUp) {
ASSERT_TRUE([[FlutterViewControllerTestObjC alloc] testPerformKeyEquivalentSynthesizesKeyUp]);
}
TEST(FlutterViewControllerTest, TestKeyboardIsRestartedOnEngineRestart) {
ASSERT_TRUE([[FlutterViewControllerTestObjC alloc] testKeyboardIsRestartedOnEngineRestart]);
}
@ -341,86 +339,6 @@ TEST(FlutterViewControllerTest, TestTrackpadGesturesAreSentToFramework) {
return true;
}
- (bool)testPerformKeyEquivalentSynthesizesKeyUp {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));
OCMStub( // NOLINT(google-objc-avoid-throwing-exception)
[engineMock binaryMessenger])
.andReturn(binaryMessengerMock);
OCMStub([[engineMock ignoringNonObjectArgs] sendKeyEvent:FlutterKeyEvent {}
callback:nil
userData:nil])
.andCall([FlutterViewControllerTestObjC class],
@selector(respondFalseForSendEvent:callback:userData:));
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engineMock
nibName:@""
bundle:nil];
id responderMock = flutter::testing::mockResponder();
viewController.nextResponder = responderMock;
NSDictionary* expectedKeyDownEvent = @{
@"keymap" : @"macos",
@"type" : @"keydown",
@"keyCode" : @(65),
@"modifiers" : @(538968064),
@"characters" : @".",
@"charactersIgnoringModifiers" : @".",
};
NSData* encodedKeyDownEvent =
[[FlutterJSONMessageCodec sharedInstance] encode:expectedKeyDownEvent];
NSDictionary* expectedKeyUpEvent = @{
@"keymap" : @"macos",
@"type" : @"keyup",
@"keyCode" : @(65),
@"modifiers" : @(538968064),
@"characters" : @".",
@"charactersIgnoringModifiers" : @".",
};
NSData* encodedKeyUpEvent = [[FlutterJSONMessageCodec sharedInstance] encode:expectedKeyUpEvent];
CGEventRef cgEvent = CGEventCreateKeyboardEvent(NULL, 65, TRUE);
NSEvent* event = [NSEvent eventWithCGEvent:cgEvent];
OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/keyevent"
message:encodedKeyDownEvent
binaryReply:[OCMArg any]])
.andDo((^(NSInvocation* invocation) {
FlutterBinaryReply handler;
[invocation getArgument:&handler atIndex:4];
NSDictionary* reply = @{
@"handled" : @(true),
};
NSData* encodedReply = [[FlutterJSONMessageCodec sharedInstance] encode:reply];
handler(encodedReply);
}));
OCMExpect( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/keyevent"
message:encodedKeyUpEvent
binaryReply:[OCMArg any]])
.andDo((^(NSInvocation* invocation) {
FlutterBinaryReply handler;
[invocation getArgument:&handler atIndex:4];
NSDictionary* reply = @{
@"handled" : @(true),
};
NSData* encodedReply = [[FlutterJSONMessageCodec sharedInstance] encode:reply];
handler(encodedReply);
}));
[viewController viewWillAppear]; // Initializes the event channel.
[viewController performKeyEquivalent:event];
@try {
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/keyevent"
message:encodedKeyDownEvent
binaryReply:[OCMArg any]]);
OCMVerify( // NOLINT(google-objc-avoid-throwing-exception)
[binaryMessengerMock sendOnChannel:@"flutter/keyevent"
message:encodedKeyUpEvent
binaryReply:[OCMArg any]]);
} @catch (...) {
return false;
}
return true;
}
- (bool)testKeyboardIsRestartedOnEngineRestart {
id engineMock = OCMClassMock([FlutterEngine class]);
id binaryMessengerMock = OCMProtocolMock(@protocol(FlutterBinaryMessenger));

View File

@ -31,6 +31,11 @@
nibName:(nullable NSString*)nibName
bundle:(nullable NSBundle*)nibBundle NS_DESIGNATED_INITIALIZER;
/**
* Returns YES if provided event is being currently redispatched by keyboard manager.
*/
- (BOOL)isDispatchingKeyEvent:(nonnull NSEvent*)event;
@end
// Private methods made visible for testing