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