Flutter iOS Interactive Keyboard: Handle Pointer Up (#44457)
This PR address the movement aspect of the flutter interactive keyboard. It handles pointer up while a scroll view widget is visible, and the interactive behavior is chosen for keyboardDismissBehavior. This is a desired behavior of the keyboard that has not yet been implemented.
Design Document:
https://docs.google.com/document/d/1-T7_0mSkXzPaWxveeypIzzzAdyo-EEuP5V84161foL4/edit?pli=1
Issues Address:
https://github.com/flutter/flutter/issues/57609
[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
index 4101bd9..c263b37 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
@@ -48,6 +48,8 @@
static NSString* const kUpdateConfigMethod = @"TextInput.updateConfig";
static NSString* const kOnInteractiveKeyboardPointerMoveMethod =
@"TextInput.onPointerMoveForInteractiveKeyboard";
+static NSString* const kOnInteractiveKeyboardPointerUpMethod =
+ @"TextInput.onPointerUpForInteractiveKeyboard";
#pragma mark - TextInputConfiguration Field Names
static NSString* const kSecureTextEntry = @"obscureText";
@@ -762,6 +764,8 @@
@property(nonatomic, copy) NSString* autofillId;
@property(nonatomic, readonly) CATransform3D editableTransform;
@property(nonatomic, assign) CGRect markedRect;
+// Disables the cursor from dismissing when firstResponder is resigned
+@property(nonatomic, assign) BOOL preventCursorDismissWhenResignFirstResponder;
@property(nonatomic) BOOL isVisibleToAutofill;
@property(nonatomic, assign) BOOL accessibilityEnabled;
@property(nonatomic, assign) int textInputClient;
@@ -799,6 +803,7 @@
_textInputPlugin = textInputPlugin;
_textInputClient = 0;
_selectionAffinity = kTextAffinityUpstream;
+ _preventCursorDismissWhenResignFirstResponder = NO;
// UITextInput
_text = [[NSMutableString alloc] init];
@@ -1092,8 +1097,10 @@
- (BOOL)resignFirstResponder {
BOOL success = [super resignFirstResponder];
if (success) {
- [self.textInputDelegate flutterTextInputView:self
- didResignFirstResponderWithTextInputClient:_textInputClient];
+ if (!_preventCursorDismissWhenResignFirstResponder) {
+ [self.textInputDelegate flutterTextInputView:self
+ didResignFirstResponderWithTextInputClient:_textInputClient];
+ }
}
return success;
}
@@ -2319,22 +2326,64 @@
CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
[self handlePointerMove:pointerY];
result(nil);
+ } else if ([method isEqualToString:kOnInteractiveKeyboardPointerUpMethod]) {
+ CGFloat pointerY = (CGFloat)[args[@"pointerY"] doubleValue];
+ [self handlePointerUp:pointerY];
+ result(nil);
} else {
result(FlutterMethodNotImplemented);
}
}
+- (void)handlePointerUp:(CGFloat)pointerY {
+ // View must be loaded at this point.
+ UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
+ CGFloat screenHeight = screen.bounds.size.height;
+ CGFloat keyboardHeight = _keyboardRect.size.height;
+ BOOL shouldDismissKeyboard = (screenHeight - (keyboardHeight / 2)) < pointerY;
+ [UIView animateWithDuration:0.3f
+ animations:^{
+ double keyboardDestination =
+ shouldDismissKeyboard ? screenHeight : screenHeight - keyboardHeight;
+ _keyboardViewContainer.frame = CGRectMake(
+ 0, keyboardDestination, _viewController.flutterScreenIfViewLoaded.bounds.size.width,
+ _keyboardViewContainer.frame.size.height);
+ }
+ completion:^(BOOL finished) {
+ if (shouldDismissKeyboard) {
+ [self.textInputDelegate flutterTextInputView:self.activeView
+ didResignFirstResponderWithTextInputClient:self.activeView.textInputClient];
+ [self dismissKeyboardScreenshot];
+ } else {
+ [self showKeyboardAndRemoveScreenshot];
+ }
+ }];
+}
+
+- (void)dismissKeyboardScreenshot {
+ for (UIView* subView in _keyboardViewContainer.subviews) {
+ [subView removeFromSuperview];
+ }
+}
+
+- (void)showKeyboardAndRemoveScreenshot {
+ [UIView setAnimationsEnabled:NO];
+ [_cachedFirstResponder becomeFirstResponder];
+ [UIView setAnimationsEnabled:YES];
+ [self dismissKeyboardScreenshot];
+}
+
- (void)handlePointerMove:(CGFloat)pointerY {
// View must be loaded at this point.
UIScreen* screen = _viewController.flutterScreenIfViewLoaded;
- double screenHeight = screen.bounds.size.height;
- double keyboardHeight = _keyboardRect.size.height;
+ CGFloat screenHeight = screen.bounds.size.height;
+ CGFloat keyboardHeight = _keyboardRect.size.height;
if (screenHeight - keyboardHeight <= pointerY) {
// If the pointer is within the bounds of the keyboard.
if (_keyboardView.superview == nil) {
// If no screenshot has been taken.
[self takeKeyboardScreenshotAndDisplay];
- [self hideKeyboardWithoutAnimation];
+ [self hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate];
} else {
[self setKeyboardContainerHeight:pointerY];
}
@@ -2352,10 +2401,12 @@
_keyboardViewContainer.frame = frameRect;
}
-- (void)hideKeyboardWithoutAnimation {
+- (void)hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate {
[UIView setAnimationsEnabled:NO];
_cachedFirstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
+ _activeView.preventCursorDismissWhenResignFirstResponder = YES;
[_cachedFirstResponder resignFirstResponder];
+ _activeView.preventCursorDismissWhenResignFirstResponder = NO;
[UIView setAnimationsEnabled:YES];
}
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
index 50716fa..d341013 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm
@@ -12,6 +12,7 @@
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterEngine.h"
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h"
+#import "flutter/shell/platform/darwin/ios/framework/Source/UIViewController+FlutterScreenAndSceneIfLoaded.h"
FLUTTER_ASSERT_ARC
@@ -62,6 +63,7 @@
@property(nonatomic, assign) FlutterTextInputView* activeView;
@property(nonatomic, readonly) UIView* keyboardViewContainer;
@property(nonatomic, readonly) UIView* keyboardView;
+@property(nonatomic, assign) UIView* cachedFirstResponder;
@property(nonatomic, readonly) CGRect keyboardRect;
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
@@ -2449,6 +2451,7 @@
result:^(id _Nullable result){
}];
XCTAssertFalse(inputView.isFirstResponder);
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardAfterUserScrollToTopOfKeyboardWillTakeScreenshot {
@@ -2491,6 +2494,7 @@
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardScreenshotWillBeMovedDownAfterUserScroll {
@@ -2540,6 +2544,7 @@
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardScreenshotWillBeMovedToOrginalPositionAfterUserScroll {
@@ -2595,6 +2600,7 @@
for (UIView* subView in textInputPlugin.keyboardViewContainer.subviews) {
[subView removeFromSuperview];
}
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardFindFirstResponderRecursive {
@@ -2606,6 +2612,7 @@
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertEqualObjects(inputView, firstResponder);
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardFindFirstResponderRecursiveInMultipleSubviews {
@@ -2631,6 +2638,7 @@
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertEqualObjects(subFirstResponderInputView, firstResponder);
+ textInputPlugin.cachedFirstResponder = nil;
}
- (void)testInteractiveKeyboardFindFirstResponderIsNilRecursive {
@@ -2641,6 +2649,246 @@
UIView* firstResponder = UIApplication.sharedApplication.keyWindow.flutterFirstResponder;
XCTAssertNil(firstResponder);
+ textInputPlugin.cachedFirstResponder = nil;
+}
+
+- (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard {
+ XCTestExpectation* expectation = [[XCTestExpectation alloc]
+ initWithDescription:
+ @"didResignFirstResponder is called after screenshot keyboard dismissed."];
+ OCMStub([engine flutterTextInputView:[OCMArg any] didResignFirstResponderWithTextInputClient:0])
+ .andDo(^(NSInvocation* invocation) {
+ [expectation fulfill];
+ });
+ CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
+ [NSNotificationCenter.defaultCenter
+ postNotificationName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
+ FlutterMethodCall* initialMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(500)}];
+ [textInputPlugin handleMethodCall:initialMoveCall
+ result:^(id _Nullable result){
+ }];
+ FlutterMethodCall* subsequentMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:subsequentMoveCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* pointerUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:pointerUpCall
+ result:^(id _Nullable result){
+ }];
+
+ [self waitForExpectations:@[ expectation ] timeout:1.0];
+ textInputPlugin.cachedFirstResponder = nil;
+}
+
+- (void)testInteractiveKeyboardScreenshotDismissedAfterPointerLiftedAboveMiddleYOfKeyboard {
+ NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
+ XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
+ UIScene* scene = scenes.anyObject;
+ XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
+ UIWindowScene* windowScene = (UIWindowScene*)scene;
+ XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
+ UIWindow* window = windowScene.windows[0];
+ [window addSubview:viewController.view];
+
+ [viewController loadView];
+
+ CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
+ [NSNotificationCenter.defaultCenter
+ postNotificationName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
+ FlutterMethodCall* initialMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(500)}];
+ [textInputPlugin handleMethodCall:initialMoveCall
+ result:^(id _Nullable result){
+ }];
+ FlutterMethodCall* subsequentMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:subsequentMoveCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* subsequentMoveBackUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(0)}];
+ [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* pointerUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(0)}];
+ [textInputPlugin handleMethodCall:pointerUpCall
+ result:^(id _Nullable result){
+ }];
+ NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
+ return textInputPlugin.keyboardViewContainer.subviews.count == 0;
+ }];
+ XCTNSPredicateExpectation* expectation =
+ [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
+ [self waitForExpectations:@[ expectation ] timeout:10.0];
+ textInputPlugin.cachedFirstResponder = nil;
+}
+
+- (void)testInteractiveKeyboardKeyboardReappearsAfterPointerLiftedAboveMiddleYOfKeyboard {
+ NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
+ XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
+ UIScene* scene = scenes.anyObject;
+ XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
+ UIWindowScene* windowScene = (UIWindowScene*)scene;
+ XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
+ UIWindow* window = windowScene.windows[0];
+ [window addSubview:viewController.view];
+
+ [viewController loadView];
+
+ FlutterTextInputView* inputView = [[FlutterTextInputView alloc] initWithOwner:textInputPlugin];
+ [UIApplication.sharedApplication.keyWindow addSubview:inputView];
+
+ [inputView setTextInputClient:123];
+ [inputView reloadInputViews];
+ [inputView becomeFirstResponder];
+
+ CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
+ [NSNotificationCenter.defaultCenter
+ postNotificationName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
+ FlutterMethodCall* initialMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(500)}];
+ [textInputPlugin handleMethodCall:initialMoveCall
+ result:^(id _Nullable result){
+ }];
+ FlutterMethodCall* subsequentMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:subsequentMoveCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* subsequentMoveBackUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(0)}];
+ [textInputPlugin handleMethodCall:subsequentMoveBackUpCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* pointerUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(0)}];
+ [textInputPlugin handleMethodCall:pointerUpCall
+ result:^(id _Nullable result){
+ }];
+ NSPredicate* predicate = [NSPredicate predicateWithBlock:^BOOL(id item, NSDictionary* bindings) {
+ return textInputPlugin.cachedFirstResponder.isFirstResponder;
+ }];
+ XCTNSPredicateExpectation* expectation =
+ [[XCTNSPredicateExpectation alloc] initWithPredicate:predicate object:nil];
+ [self waitForExpectations:@[ expectation ] timeout:10.0];
+ textInputPlugin.cachedFirstResponder = nil;
+}
+
+- (void)testInteractiveKeyboardKeyboardAnimatesToOriginalPositionalOnPointerUp {
+ NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
+ XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
+ UIScene* scene = scenes.anyObject;
+ XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
+ UIWindowScene* windowScene = (UIWindowScene*)scene;
+ XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
+ UIWindow* window = windowScene.windows[0];
+ [window addSubview:viewController.view];
+
+ [viewController loadView];
+
+ XCTestExpectation* expectation =
+ [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
+ CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
+ [NSNotificationCenter.defaultCenter
+ postNotificationName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
+ FlutterMethodCall* initialMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(500)}];
+ [textInputPlugin handleMethodCall:initialMoveCall
+ result:^(id _Nullable result){
+ }];
+ FlutterMethodCall* subsequentMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:subsequentMoveCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* pointerUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(0)}];
+ [textInputPlugin
+ handleMethodCall:pointerUpCall
+ result:^(id _Nullable result) {
+ XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
+ viewController.flutterScreenIfViewLoaded.bounds.size.height -
+ keyboardFrame.origin.y);
+ [expectation fulfill];
+ }];
+ textInputPlugin.cachedFirstResponder = nil;
+}
+
+- (void)testInteractiveKeyboardKeyboardAnimatesToDismissalPositionalOnPointerUp {
+ NSSet<UIScene*>* scenes = UIApplication.sharedApplication.connectedScenes;
+ XCTAssertEqual(scenes.count, 1UL, @"There must only be 1 scene for test");
+ UIScene* scene = scenes.anyObject;
+ XCTAssert([scene isKindOfClass:[UIWindowScene class]], @"Must be a window scene for test");
+ UIWindowScene* windowScene = (UIWindowScene*)scene;
+ XCTAssert(windowScene.windows.count > 0, @"There must be at least 1 window for test");
+ UIWindow* window = windowScene.windows[0];
+ [window addSubview:viewController.view];
+
+ [viewController loadView];
+
+ XCTestExpectation* expectation =
+ [[XCTestExpectation alloc] initWithDescription:@"Keyboard animates to proper position."];
+ CGRect keyboardFrame = CGRectMake(0, 500, 500, 500);
+ [NSNotificationCenter.defaultCenter
+ postNotificationName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{UIKeyboardFrameEndUserInfoKey : @(keyboardFrame)}];
+ FlutterMethodCall* initialMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(500)}];
+ [textInputPlugin handleMethodCall:initialMoveCall
+ result:^(id _Nullable result){
+ }];
+ FlutterMethodCall* subsequentMoveCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin handleMethodCall:subsequentMoveCall
+ result:^(id _Nullable result){
+ }];
+
+ FlutterMethodCall* pointerUpCall =
+ [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"
+ arguments:@{@"pointerY" : @(1000)}];
+ [textInputPlugin
+ handleMethodCall:pointerUpCall
+ result:^(id _Nullable result) {
+ XCTAssertEqual(textInputPlugin.keyboardViewContainer.frame.origin.y,
+ viewController.flutterScreenIfViewLoaded.bounds.size.height);
+ [expectation fulfill];
+ }];
+ textInputPlugin.cachedFirstResponder = nil;
}
@end