Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 1519063

Browse files
committed
[platform_view]add focus support for platform view
1 parent f91ccb4 commit 1519063

7 files changed

+145
-0
lines changed

shell/platform/darwin/ios/framework/Source/FlutterEngine.mm

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,37 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView
986986
arguments:@[ @(client) ]];
987987
}
988988

989+
- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView {
990+
// Platform view's first responder detection logic
991+
//
992+
// All text input widgets (e.g. EditableText) are backed by a dummy UITextInput view
993+
// in the text input plugin. When this dummy UITextInput view resigns first responder,
994+
// check if any platform view becomes first responder. If any platform view becomes
995+
// first responder, send a "viewFocused" channel message to inform the framework to un-focus
996+
// the previously focused text input.
997+
//
998+
// Caveat:
999+
// 1. This detection logic does not cover the scenario when a platform view becomes
1000+
// first responder without any flutter text input resigning its first responder status
1001+
// (e.g. user tapping on platform view first). For now it works fine because there can only be
1002+
// one first responder in iOS, so we do not need to keep platform view's first responder status
1003+
// in the text input plugin (which is different from Android implementation).
1004+
//
1005+
// 2. This detection logic assumes that all text input widgets are backed by a dummy
1006+
// UITextInput view in the text input plugin, which may not hold true in the future.
1007+
1008+
// Have to check in the next run loop, because iOS requests the previous first responder to
1009+
// resign before requesting the next view to become first responder.
1010+
dispatch_async(dispatch_get_main_queue(), ^(void) {
1011+
long platform_view_id = self.platformViewsController->findFirstResponderPlatformViewId();
1012+
if (platform_view_id == -1) {
1013+
return;
1014+
}
1015+
1016+
[_platformViewsChannel.get() invokeMethod:@"viewFocused" arguments:@(platform_view_id)];
1017+
});
1018+
}
1019+
9891020
#pragma mark - Undo Manager Delegate
9901021

9911022
- (void)flutterUndoManagerPlugin:(FlutterUndoManagerPlugin*)undoManagerPlugin

shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@
1919
#import "flutter/shell/platform/darwin/ios/ios_surface.h"
2020
#import "flutter/shell/platform/darwin/ios/ios_surface_gl.h"
2121

22+
@implementation UIView (FirstResponder)
23+
- (BOOL)hasFirstResponderInViewHierarchySubtree {
24+
if (self.isFirstResponder) {
25+
return YES;
26+
}
27+
for (UIView* subview in self.subviews) {
28+
if (subview.hasFirstResponderInViewHierarchySubtree) {
29+
return YES;
30+
}
31+
}
32+
return NO;
33+
}
34+
@end
35+
2236
namespace flutter {
2337

2438
std::shared_ptr<FlutterPlatformViewLayer> FlutterPlatformViewLayerPool::GetLayer(
@@ -328,6 +342,15 @@
328342
return [touch_interceptors_[view_id].get() embeddedView];
329343
}
330344

345+
long FlutterPlatformViewsController::findFirstResponderPlatformViewId() {
346+
for (auto const& [id, root_view] : root_views_) {
347+
if ([(UIView*)root_view.get() hasFirstResponderInViewHierarchySubtree]) {
348+
return id;
349+
}
350+
}
351+
return -1;
352+
}
353+
331354
std::vector<SkCanvas*> FlutterPlatformViewsController::GetCurrentCanvases() {
332355
std::vector<SkCanvas*> canvases;
333356
for (size_t i = 0; i < composition_order_.size(); i++) {

shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1105,4 +1105,36 @@ - (int)alphaOfPoint:(CGPoint)point onView:(UIView*)view {
11051105
return pixel[3];
11061106
}
11071107

1108+
- (void)testHasFirstResponderInViewHierarchySubtree_viewItselfBecomesFirstResponder {
1109+
// For view to become the first responder, it must be a descendant of a UIWindow
1110+
UIWindow* window = [[UIWindow alloc] init];
1111+
UITextField* textField = [[UITextField alloc] init];
1112+
[window addSubview:textField];
1113+
1114+
[textField becomeFirstResponder];
1115+
XCTAssertTrue(textField.isFirstResponder);
1116+
XCTAssertTrue(textField.hasFirstResponderInViewHierarchySubtree);
1117+
[textField resignFirstResponder];
1118+
XCTAssertFalse(textField.isFirstResponder);
1119+
XCTAssertFalse(textField.hasFirstResponderInViewHierarchySubtree);
1120+
}
1121+
1122+
- (void)testHasFirstResponderInViewHierarchySubtree_descendantViewBecomesFirstResponder {
1123+
// For view to become the first responder, it must be a descendant of a UIWindow
1124+
UIWindow* window = [[UIWindow alloc] init];
1125+
UIView* view = [[UIView alloc] init];
1126+
UIView* childView = [[UIView alloc] init];
1127+
UITextField* textField = [[UITextField alloc] init];
1128+
[window addSubview:view];
1129+
[view addSubview:childView];
1130+
[childView addSubview:textField];
1131+
1132+
[textField becomeFirstResponder];
1133+
XCTAssertTrue(textField.isFirstResponder);
1134+
XCTAssertTrue(view.hasFirstResponderInViewHierarchySubtree);
1135+
[textField resignFirstResponder];
1136+
XCTAssertFalse(textField.isFirstResponder);
1137+
XCTAssertFalse(view.hasFirstResponderInViewHierarchySubtree);
1138+
}
1139+
11081140
@end

shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ class FlutterPlatformViewsController {
177177

178178
void OnMethodCall(FlutterMethodCall* call, FlutterResult& result);
179179

180+
// Returns the platform view id if the platform view (or any of its descendant view) is the first
181+
// responder. Returns -1 if no such platform view is found.
182+
long findFirstResponderPlatformViewId();
183+
180184
private:
181185
static const size_t kMaxLayerAllocations = 2;
182186

@@ -329,4 +333,9 @@ class FlutterPlatformViewsController {
329333
- (UIView*)embeddedView;
330334
@end
331335

336+
@interface UIView (FirstResponder)
337+
// Returns YES if a view or any of its descendant view is the first responder. Returns NO otherwise.
338+
@property(nonatomic, readonly) BOOL hasFirstResponderInViewHierarchySubtree;
339+
@end
340+
332341
#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERPLATFORMVIEWS_INTERNAL_H_

shell/platform/darwin/ios/framework/Source/FlutterTextInputDelegate.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ typedef NS_ENUM(NSInteger, FlutterFloatingCursorDragState) {
5959
insertTextPlaceholderWithSize:(CGSize)size
6060
withClient:(int)client;
6161
- (void)flutterTextInputView:(FlutterTextInputView*)textInputView removeTextPlaceholder:(int)client;
62+
- (void)flutterTextInputViewDidResignFirstResponder:(FlutterTextInputView*)textInputView;
6263

6364
@end
6465

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
static NSString* const kShowMethod = @"TextInput.show";
4040
static NSString* const kHideMethod = @"TextInput.hide";
4141
static NSString* const kSetClientMethod = @"TextInput.setClient";
42+
static NSString* const kSetPlatformViewClientMethod = @"TextInput.setPlatformViewClient";
4243
static NSString* const kSetEditingStateMethod = @"TextInput.setEditingState";
4344
static NSString* const kClearClientMethod = @"TextInput.clearClient";
4445
static NSString* const kSetEditableSizeAndTransformMethod =
@@ -1075,6 +1076,14 @@ - (BOOL)canBecomeFirstResponder {
10751076
return _textInputClient != 0;
10761077
}
10771078

1079+
- (BOOL)resignFirstResponder {
1080+
BOOL success = [super resignFirstResponder];
1081+
if (success) {
1082+
[self.textInputDelegate flutterTextInputViewDidResignFirstResponder:self];
1083+
}
1084+
return success;
1085+
}
1086+
10781087
- (BOOL)canPerformAction:(SEL)action withSender:(id)sender {
10791088
// When scribble is available, the FlutterTextInputView will display the native toolbar unless
10801089
// these text editing actions are disabled.
@@ -2071,6 +2080,9 @@ - (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
20712080
} else if ([method isEqualToString:kSetClientMethod]) {
20722081
[self setTextInputClient:[args[0] intValue] withConfiguration:args[1]];
20732082
result(nil);
2083+
} else if ([method isEqualToString:kSetPlatformViewClientMethod]) {
2084+
[self setPlatformViewTextInputClient:[args[@"platformViewId"] longValue]];
2085+
result(nil);
20742086
} else if ([method isEqualToString:kSetEditingStateMethod]) {
20752087
[self setTextInputEditingState:args];
20762088
result(nil);
@@ -2187,6 +2199,16 @@ - (void)triggerAutofillSave:(BOOL)saveEntries {
21872199
[self addToInputParentViewIfNeeded:_activeView];
21882200
}
21892201

2202+
- (void)setPlatformViewTextInputClient:(long)platformViewID {
2203+
// No need to track the platformViewID for now (unlike in Android), because in iOS there can
2204+
// only be one single first responder. When a platform view becomes first responder, hide
2205+
// this dummy text input view (`_activeView`) for the previously focused widget.
2206+
[self removeEnableFlutterTextInputViewAccessibilityTimer];
2207+
_activeView.accessibilityEnabled = NO;
2208+
[_activeView removeFromSuperview];
2209+
[_inputHider removeFromSuperview];
2210+
}
2211+
21902212
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
21912213
[self resetAllClientIds];
21922214
// Hide all input views from autofill, only make those in the new configuration visible

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1866,4 +1866,31 @@ - (void)testFlutterTextInputPluginHostViewNotNil {
18661866
XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
18671867
}
18681868

1869+
- (void)testSetPlatformViewClient {
1870+
FlutterViewController* flutterViewController = [FlutterViewController new];
1871+
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
1872+
myInputPlugin.viewController = flutterViewController;
1873+
1874+
__weak UIView* activeView;
1875+
@autoreleasepool {
1876+
FlutterMethodCall* setClientCall = [FlutterMethodCall
1877+
methodCallWithMethodName:@"TextInput.setClient"
1878+
arguments:@[
1879+
[NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
1880+
]];
1881+
[myInputPlugin handleMethodCall:setClientCall
1882+
result:^(id _Nullable result){
1883+
}];
1884+
activeView = myInputPlugin.textInputView;
1885+
XCTAssertNotNil(activeView.superview, @"activeView must be added to the view hierarchy.");
1886+
FlutterMethodCall* setPlatformViewClientCall = [FlutterMethodCall
1887+
methodCallWithMethodName:@"TextInput.setPlatformViewClient"
1888+
arguments:@{@"platformViewId" : [NSNumber numberWithLong:456]}];
1889+
[myInputPlugin handleMethodCall:setPlatformViewClientCall
1890+
result:^(id _Nullable result){
1891+
}];
1892+
XCTAssertNil(activeView.superview, @"activeView must be removed from view hierarchy.");
1893+
}
1894+
}
1895+
18691896
@end

0 commit comments

Comments
 (0)