Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
[platform_view]add focus support for platform view
  • Loading branch information
hellohuanlin committed May 27, 2022
commit a5dc884ec8b17f21b96fd376b48885653d50a37a
31 changes: 31 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,37 @@ - (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 text input plugin. 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 there can only be
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious:
Are we expecting an UIKit update that will introduce multiple first responders?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably rephrase it clearly - I am not expecting UIKit change. What I meant to say is that right now the text input plugin does not need to keep track of the platform view focus state, but in the future we may need to track it (not because of UIKit change, but for potential other unknown reasons)

// one first responder in iOS, so we do not need to keep platform view's first responder status
// in the text input plugin (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 text input plugin, 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)hasFirstResponderInViewHierarchySubtree {
if (self.isFirstResponder) {
return YES;
}
for (UIView* subview in self.subviews) {
if (subview.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() 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.hasFirstResponderInViewHierarchySubtree);
[textField resignFirstResponder];
XCTAssertFalse(textField.isFirstResponder);
XCTAssertFalse(textField.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.hasFirstResponderInViewHierarchySubtree);
[textField resignFirstResponder];
XCTAssertFalse(textField.isFirstResponder);
XCTAssertFalse(view.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 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,9 @@ - (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]) {
[self setPlatformViewTextInputClient:[args[@"platformViewId"] longValue]];
result(nil);
} else if ([method isEqualToString:kSetEditingStateMethod]) {
[self setTextInputEditingState:args];
result(nil);
Expand Down Expand Up @@ -2187,6 +2199,16 @@ - (void)triggerAutofillSave:(BOOL)saveEntries {
[self addToInputParentViewIfNeeded:_activeView];
}

- (void)setPlatformViewTextInputClient:(long)platformViewID {
// No need to track the platformViewID for now (unlike in Android), because in iOS there can
// only be one single first responder. When a platform view becomes first responder, hide
// this dummy text input view (`_activeView`) for the previously focused widget.
[self removeEnableFlutterTextInputViewAccessibilityTimer];
_activeView.accessibilityEnabled = NO;
[_activeView removeFromSuperview];
[_inputHider removeFromSuperview];
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fine to me.

cc @justinmc, do you mind take a quick look at this to see if it makes sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justinmc could you take a quick look? thanks

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im gonna land it first since i've tested it many times, and it's not used just yet (until the framework part is complete), but let me know if any thing comes up

- (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 @@ -1866,4 +1866,31 @@ - (void)testFlutterTextInputPluginHostViewNotNil {
XCTAssertNotNil([flutterEngine.textInputPlugin hostView]);
}

- (void)testSetPlatformViewClient {
FlutterViewController* flutterViewController = [FlutterViewController new];
FlutterTextInputPlugin* myInputPlugin = [[FlutterTextInputPlugin alloc] initWithDelegate:engine];
myInputPlugin.viewController = flutterViewController;

__weak UIView* activeView;
@autoreleasepool {
FlutterMethodCall* setClientCall = [FlutterMethodCall
methodCallWithMethodName:@"TextInput.setClient"
arguments:@[
[NSNumber numberWithInt:123], self.mutablePasswordTemplateCopy
]];
[myInputPlugin handleMethodCall:setClientCall
result:^(id _Nullable result){
}];
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