diff --git a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index e70a8a235ef98..cfc669c899b8d 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -986,6 +986,36 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView arguments:@[ @(client) ]]; } +- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView { + // Platform view's first responder detection logic: + // + // All text input widgets (e.g. EditableText) are backed by a dummy UITextInput view + // in the TextInputPlugin. When this dummy UITextInput view resigns first responder, + // check if any platform view becomes first responder. If any platform view becomes + // first responder, send a "viewFocused" channel message to inform the framework to un-focus + // the previously focused text input. + // + // Caveat: + // 1. This detection logic does not cover the scenario when a platform view becomes + // first responder without any flutter text input resigning its first responder status + // (e.g. user tapping on platform view first). For now it works fine because the TextInputPlugin + // does not track the focused platform view id (which is different from Android implementation). + // + // 2. This detection logic assumes that all text input widgets are backed by a dummy + // UITextInput view in the TextInputPlugin, which may not hold true in the future. + + // Have to check in the next run loop, because iOS requests the previous first responder to + // resign before requesting the next view to become first responder. + dispatch_async(dispatch_get_main_queue(), ^(void) { + long platform_view_id = self.platformViewsController->FindFirstResponderPlatformViewId(); + if (platform_view_id == -1) { + return; + } + + [_platformViewsChannel.get() invokeMethod:@"viewFocused" arguments:@(platform_view_id)]; + }); +} + #pragma mark - Undo Manager Delegate - (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm index a919ed8b75eee..707d0733f49d0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm @@ -19,6 +19,20 @@ #import "flutter/shell/platform/darwin/ios/ios_surface.h" #import "flutter/shell/platform/darwin/ios/ios_surface_gl.h" +@implementation UIView (FirstResponder) +- (BOOL)flt_hasFirstResponderInViewHierarchySubtree { + if (self.isFirstResponder) { + return YES; + } + for (UIView* subview in self.subviews) { + if (subview.flt_hasFirstResponderInViewHierarchySubtree) { + return YES; + } + } + return NO; +} +@end + namespace flutter { std::shared_ptr FlutterPlatformViewLayerPool::GetLayer( @@ -328,6 +342,15 @@ return [touch_interceptors_[view_id].get() embeddedView]; } +long FlutterPlatformViewsController::FindFirstResponderPlatformViewId() { + for (auto const& [id, root_view] : root_views_) { + if ((UIView*)(root_view.get()).flt_hasFirstResponderInViewHierarchySubtree) { + return id; + } + } + return -1; +} + std::vector FlutterPlatformViewsController::GetCurrentCanvases() { std::vector canvases; for (size_t i = 0; i < composition_order_.size(); i++) { diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index fba267419a116..3ac57a2bf9e04 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -1105,4 +1105,36 @@ - (int)alphaOfPoint:(CGPoint)point onView:(UIView*)view { return pixel[3]; } +- (void)testHasFirstResponderInViewHierarchySubtree_viewItselfBecomesFirstResponder { + // For view to become the first responder, it must be a descendant of a UIWindow + UIWindow* window = [[UIWindow alloc] init]; + UITextField* textField = [[UITextField alloc] init]; + [window addSubview:textField]; + + [textField becomeFirstResponder]; + XCTAssertTrue(textField.isFirstResponder); + XCTAssertTrue(textField.flt_hasFirstResponderInViewHierarchySubtree); + [textField resignFirstResponder]; + XCTAssertFalse(textField.isFirstResponder); + XCTAssertFalse(textField.flt_hasFirstResponderInViewHierarchySubtree); +} + +- (void)testHasFirstResponderInViewHierarchySubtree_descendantViewBecomesFirstResponder { + // For view to become the first responder, it must be a descendant of a UIWindow + UIWindow* window = [[UIWindow alloc] init]; + UIView* view = [[UIView alloc] init]; + UIView* childView = [[UIView alloc] init]; + UITextField* textField = [[UITextField alloc] init]; + [window addSubview:view]; + [view addSubview:childView]; + [childView addSubview:textField]; + + [textField becomeFirstResponder]; + XCTAssertTrue(textField.isFirstResponder); + XCTAssertTrue(view.flt_hasFirstResponderInViewHierarchySubtree); + [textField resignFirstResponder]; + XCTAssertFalse(textField.isFirstResponder); + XCTAssertFalse(view.flt_hasFirstResponderInViewHierarchySubtree); +} + @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h index 8d42bc879488d..8662e3b4f5c1d 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h @@ -177,6 +177,10 @@ class FlutterPlatformViewsController { void OnMethodCall(FlutterMethodCall* call, FlutterResult& result); + // Returns the platform view id if the platform view (or any of its descendant view) is the first + // responder. Returns -1 if no such platform view is found. + long FindFirstResponderPlatformViewId(); + private: static const size_t kMaxLayerAllocations = 2; @@ -329,4 +333,9 @@ class FlutterPlatformViewsController { - (UIView*)embeddedView; @end +@interface UIView (FirstResponder) +// Returns YES if a view or any of its descendant view is the first responder. Returns NO otherwise. +@property(nonatomic, readonly) BOOL flt_hasFirstResponderInViewHierarchySubtree; +@end + #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERPLATFORMVIEWS_INTERNAL_H_ diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h index 7b33539446d63..7ecda6a23bb93 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h @@ -59,6 +59,7 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) { insertTextPlaceholderWithSize:(CGSize)size withClient:(int)client; - (void)flutterTextInputView:(FlutterTextInputView*)textInputView removeTextPlaceholder:(int)client; +- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView; @end diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 799afd5bb7fbb..fd120998cd9c0 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -39,6 +39,7 @@ static NSString* const kShowMethod = @"TextInput.show"; static NSString* const kHideMethod = @"TextInput.hide"; static NSString* const kSetClientMethod = @"TextInput.setClient"; +static NSString* const kSetPlatformViewClientMethod = @"TextInput.setPlatformViewClient"; static NSString* const kSetEditingStateMethod = @"TextInput.setEditingState"; static NSString* const kClearClientMethod = @"TextInput.clearClient"; static NSString* const kSetEditableSizeAndTransformMethod = @@ -1075,6 +1076,14 @@ - (BOOL)canBecomeFirstResponder { return _textInputClient != 0; } +- (BOOL)resignFirstResponder { + BOOL success = [super resignFirstResponder]; + if (success) { + [self.textInputDelegate flutterTextInputViewDidResignFirstResponder:self]; + } + return success; +} + - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { // When scribble is available, the FlutterTextInputView will display the native toolbar unless // these text editing actions are disabled. @@ -2071,6 +2080,10 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { } else if ([method isEqualToString:kSetClientMethod]) { [self setTextInputClient:[args[0] intValue] withConfiguration:args[1]]; result(nil); + } else if ([method isEqualToString:kSetPlatformViewClientMethod]) { + // This method call has a `platformViewId` argument, but we do not need it for iOS for now. + [self setPlatformViewTextInputClient]; + result(nil); } else if ([method isEqualToString:kSetEditingStateMethod]) { [self setTextInputEditingState:args]; result(nil); @@ -2187,6 +2200,16 @@ - (void)triggerAutofillSave:(BOOL)saveEntries { [self addToInputParentViewIfNeeded:_activeView]; } +- (void)setPlatformViewTextInputClient { + // No need to track the platformViewID (unlike in Android). When a platform view + // becomes the first responder, simply hide this dummy text input view (`_activeView`) + // for the previously focused widget. + [self removeEnableFlutterTextInputViewAccessibilityTimer]; + _activeView.accessibilityEnabled = NO; + [_activeView removeFromSuperview]; + [_inputHider removeFromSuperview]; +} + - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration { [self resetAllClientIds]; // Hide all input views from autofill, only make those in the new configuration visible diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 065ae1bfaf339..bd656cb6cc3aa 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -88,7 +88,7 @@ - (void)setUp { textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; - viewController = [FlutterViewController new]; + viewController = [[FlutterViewController alloc] init]; textInputPlugin.viewController = viewController; // Clear pasteboard between tests. @@ -167,7 +167,7 @@ - (FlutterTextRange*)getLineRangeFromTokenizer:(id)tokeniz #pragma mark - Tests - (void)testNoDanglingEnginePointer { __weak FlutterTextInputPlugin* weakFlutterTextInputPlugin; - FlutterViewController* flutterViewController = [FlutterViewController new]; + FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; __weak FlutterEngine* weakFlutterEngine; FlutterTextInputView* currentView; @@ -1825,7 +1825,7 @@ - (void)testFlutterTokenizerCanParseLines { } - (void)testFlutterTextInputPluginRetainsFlutterTextInputView { - FlutterViewController* flutterViewController = [FlutterViewController new]; + FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; myInputPlugin.viewController = flutterViewController; @@ -1858,7 +1858,7 @@ - (void)testFlutterTextInputPluginHostViewNilCrash { } - (void)testFlutterTextInputPluginHostViewNotNil { - FlutterViewController* flutterViewController = [FlutterViewController new]; + FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; FlutterEngine* flutterEngine = [[FlutterEngine alloc] init]; [flutterEngine runWithEntrypoint:nil]; flutterEngine.viewController = flutterViewController; @@ -1866,4 +1866,26 @@ - (void)testFlutterTextInputPluginHostViewNotNil { XCTAssertNotNil([flutterEngine.textInputPlugin hostView]); } +- (void)testSetPlatformViewClient { + FlutterViewController* flutterViewController = [[FlutterViewController alloc] init]; + FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine]; + myInputPlugin.viewController = flutterViewController; + + FlutterMethodCall* setClientCall = [FlutterMethodCall + methodCallWithMethodName:@"TextInput.setClient" + arguments:@[ [NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy ]]; + [myInputPlugin handleMethodCall:setClientCall + result:^(id _Nullable result){ + }]; + UIView* activeView = myInputPlugin.textInputView; + XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy."); + FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall + methodCallWithMethodName:@"TextInput.setPlatformViewClient" + arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}]; + [myInputPlugin handleMethodCall:setPlatformViewClientCall + result:^(id _Nullable result){ + }]; + XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy."); +} + @end