Add focus support for platform view (flutter/engine#33093)

This commit is contained in:
hellohuanlin 2022-05-27 23:33:02 -07:00 committed by GitHub
parent 13731ca4e3
commit 5bbb2437e2
7 changed files with 144 additions and 4 deletions

View File

@ -991,6 +991,36 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS
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

View File

@ -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(
@ -328,6 +342,15 @@ UIView* FlutterPlatformViewsController::GetPlatformViewByID(int view_id) {
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++) {

View File

@ -1105,4 +1105,36 @@ fml::RefPtr<fml::TaskRunner> CreateNewThread(std::string name) {
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

View File

@ -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_

View File

@ -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

View File

@ -39,6 +39,7 @@ const CGRect kSpacePanBounds = {{-2500, -2500}, {5000, 5000}};
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 @@ static BOOL IsSelectionRectCloserToPoint(CGPoint point,
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 @@ static BOOL IsSelectionRectCloserToPoint(CGPoint point,
} 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 @@ static BOOL IsSelectionRectCloserToPoint(CGPoint point,
[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

View File

@ -88,7 +88,7 @@ FLUTTER_ASSERT_ARC
textInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
viewController = [FlutterViewController new];
viewController = [[FlutterViewController alloc] init];
textInputPlugin.viewController = viewController;
// Clear pasteboard between tests.
@ -167,7 +167,7 @@ FLUTTER_ASSERT_ARC
#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 @@ FLUTTER_ASSERT_ARC
}
- (void)testFlutterTextInputPluginRetainsFlutterTextInputView {
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterViewController* flutterViewController = [[FlutterViewController alloc] init];
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
myInputPlugin.viewController = flutterViewController;
@ -1858,7 +1858,7 @@ FLUTTER_ASSERT_ARC
}
- (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 @@ FLUTTER_ASSERT_ARC
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