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

Commit ce6c38e

Browse files
authored
Add focus support for platform view (#33093)
1 parent d9bed00 commit ce6c38e

7 files changed

+144
-4
lines changed

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,36 @@ - (void)flutterTextInputView:(FlutterTextInputView*)textInputView
991991
arguments:@[ @(client) ]];
992992
}
993993

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

9961026
- (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)flt_hasFirstResponderInViewHierarchySubtree {
24+
if (self.isFirstResponder) {
25+
return YES;
26+
}
27+
for (UIView* subview in self.subviews) {
28+
if (subview.flt_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()).flt_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.flt_hasFirstResponderInViewHierarchySubtree);
1117+
[textField resignFirstResponder];
1118+
XCTAssertFalse(textField.isFirstResponder);
1119+
XCTAssertFalse(textField.flt_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.flt_hasFirstResponderInViewHierarchySubtree);
1135+
[textField resignFirstResponder];
1136+
XCTAssertFalse(textField.isFirstResponder);
1137+
XCTAssertFalse(view.flt_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 flt_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: 23 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,10 @@ - (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+
// This method call has a `platformViewId` argument, but we do not need it for iOS for now.
2085+
[self setPlatformViewTextInputClient];
2086+
result(nil);
20742087
} else if ([method isEqualToString:kSetEditingStateMethod]) {
20752088
[self setTextInputEditingState:args];
20762089
result(nil);
@@ -2187,6 +2200,16 @@ - (void)triggerAutofillSave:(BOOL)saveEntries {
21872200
[self addToInputParentViewIfNeeded:_activeView];
21882201
}
21892202

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

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

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ - (void)setUp {
8888

8989
textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
9090

91-
viewController = [FlutterViewController new];
91+
viewController = [[FlutterViewController alloc] init];
9292
textInputPlugin.viewController = viewController;
9393

9494
// Clear pasteboard between tests.
@@ -167,7 +167,7 @@ - (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokeniz
167167
#pragma mark - Tests
168168
- (void)testNoDanglingEnginePointer {
169169
__weak FlutterTextInputPlugin* weakFlutterTextInputPlugin;
170-
FlutterViewController* flutterViewController = [FlutterViewController new];
170+
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
171171
__weak FlutterEngine* weakFlutterEngine;
172172

173173
FlutterTextInputView* currentView;
@@ -1825,7 +1825,7 @@ - (void)testFlutterTokenizerCanParseLines {
18251825
}
18261826

18271827
- (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
1828-
FlutterViewController* flutterViewController = [FlutterViewController new];
1828+
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
18291829
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
18301830
myInputPlugin.viewController = flutterViewController;
18311831

@@ -1858,12 +1858,34 @@ - (void)testFlutterTextInputPluginHostViewNilCrash {
18581858
}
18591859

18601860
- (void)testFlutterTextInputPluginHostViewNotNil {
1861-
FlutterViewController* flutterViewController = [FlutterViewController new];
1861+
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
18621862
FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
18631863
[flutterEngine runWithEntrypoint:nil];
18641864
flutterEngine.viewController = flutterViewController;
18651865
XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
18661866
XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
18671867
}
18681868

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

0 commit comments

Comments
 (0)