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

Add focus support for platform view #33093

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions shell/platform/darwin/ios/framework/Source/FlutterEngine.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlutterPlatformViewLayer> FlutterPlatformViewLayerPool::GetLayer(
Expand Down Expand Up @@ -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<SkCanvas*> FlutterPlatformViewsController::GetCurrentCanvases() {
std::vector<SkCanvas*> canvases;
for (size_t i = 0; i < composition_order_.size(); i++) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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_
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -167,7 +167,7 @@ - (FlutterTextRange*)getLineRangeFromTokenizer:(id<UITextInputTokenizer>)tokeniz
#pragma mark - Tests
- (void)testNoDanglingEnginePointer {
__weak FlutterTextInputPlugin* weakFlutterTextInputPlugin;
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
__weak FlutterEngine* weakFlutterEngine;

FlutterTextInputView* currentView;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1858,12 +1858,34 @@ - (void)testFlutterTextInputPluginHostViewNilCrash {
}

- (void)testFlutterTextInputPluginHostViewNotNil {
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
FlutterEngine* flutterEngine = [[FlutterEngine alloc] init];
[flutterEngine runWithEntrypoint:nil];
flutterEngine.viewController = flutterViewController;
XCTAssertNotNil(flutterEngine.textInputPlugin.viewController);
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