mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[macOS] Put FlutterTextInputPlugin in view hierarchy (flutter/engine#33827)
This commit is contained in:
parent
c645b5f592
commit
651843b2da
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user