From 68dccebb48ecfceef4af90e4550c2daa1833dde1 Mon Sep 17 00:00:00 2001 From: Matt2D <55360120+Matt2D@users.noreply.github.com> Date: Thu, 10 Aug 2023 15:02:08 -0400 Subject: [PATCH] Flutter iOS Interactive Keyboard: Fixing Behavior Issue (flutter/engine#44586) This PR addresses an issue with the behavior of the keyboard. Originally the behavior of the keyboard was to see if the pointer was above or below the middle of the keyboards full size and then animate appropriately. However we found that the behavior is instead based on velocity. This PR adjust the code to match this behavior. 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 --- .../Source/FlutterTextInputPlugin.mm | 56 +++++++++++-------- .../Source/FlutterTextInputPluginTest.mm | 19 ++++++- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index a4fb7a04d82..0d6ecbe9b63 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -22,9 +22,12 @@ static const char kTextAffinityUpstream[] = "TextAffinity.upstream"; static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5; // A delay before reenabling the UIView areAnimationsEnabled to YES -// in order for becomeFirstResponder to receive the proper value +// in order for becomeFirstResponder to receive the proper value. static const NSTimeInterval kKeyboardAnimationDelaySeconds = 0.1; +// A time set for the screenshot to animate back to the assigned position. +static const NSTimeInterval kKeyboardAnimationTimeToCompleteion = 0.3; + // The "canonical" invalid CGRect, similar to CGRectNull, used to // indicate a CGRect involved in firstRectForRange calculation is // invalid. The specific value is chosen so that if firstRectForRange @@ -2234,6 +2237,8 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, @property(nonatomic, strong) UIView* keyboardView; @property(nonatomic, strong) UIView* cachedFirstResponder; @property(nonatomic, assign) CGRect keyboardRect; +@property(nonatomic, assign) CGFloat previousPointerYPosition; +@property(nonatomic, assign) CGFloat pointerYVelocity; @end @implementation FlutterTextInputPlugin { @@ -2340,28 +2345,32 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, } - (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]; + if (_keyboardView.superview != nil) { + // Done to avoid the issue of a pointer up done without a screenshot + // View must be loaded at this point. + UIScreen* screen = _viewController.flutterScreenIfViewLoaded; + CGFloat screenHeight = screen.bounds.size.height; + CGFloat keyboardHeight = _keyboardRect.size.height; + // Negative velocity indicates a downward movement + BOOL shouldDismissKeyboardBasedOnVelocity = _pointerYVelocity < 0; + [UIView animateWithDuration:kKeyboardAnimationTimeToCompleteion + animations:^{ + double keyboardDestination = + shouldDismissKeyboardBasedOnVelocity ? screenHeight : screenHeight - keyboardHeight; + _keyboardViewContainer.frame = CGRectMake( + 0, keyboardDestination, _viewController.flutterScreenIfViewLoaded.bounds.size.width, + _keyboardViewContainer.frame.size.height); } - }]; + completion:^(BOOL finished) { + if (shouldDismissKeyboardBasedOnVelocity) { + [self.textInputDelegate flutterTextInputView:self.activeView + didResignFirstResponderWithTextInputClient:self.activeView.textInputClient]; + [self dismissKeyboardScreenshot]; + } else { + [self showKeyboardAndRemoveScreenshot]; + } + }]; + } } - (void)dismissKeyboardScreenshot { @@ -2395,13 +2404,16 @@ static BOOL IsSelectionRectBoundaryCloserToPoint(CGPoint point, [self hideKeyboardWithoutAnimationAndAvoidCursorDismissUpdate]; } else { [self setKeyboardContainerHeight:pointerY]; + _pointerYVelocity = _previousPointerYPosition - pointerY; } } else { if (_keyboardView.superview != nil) { // Keeps keyboard at proper height. _keyboardViewContainer.frame = _keyboardRect; + _pointerYVelocity = _previousPointerYPosition - pointerY; } } + _previousPointerYPosition = pointerY; } - (void)setKeyboardContainerHeight:(CGFloat)pointerY { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index edc95d8b3b4..5a214281eb5 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2655,6 +2655,17 @@ FLUTTER_ASSERT_ARC } - (void)testInteractiveKeyboardDidResignFirstResponderDelegateisCalledAfterDismissedKeyboard { + NSSet* 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: @"didResignFirstResponder is called after screenshot keyboard dismissed."]; @@ -2687,7 +2698,7 @@ FLUTTER_ASSERT_ARC result:^(id _Nullable result){ }]; - [self waitForExpectations:@[ expectation ] timeout:1.0]; + [self waitForExpectations:@[ expectation ] timeout:2.0]; textInputPlugin.cachedFirstResponder = nil; } @@ -2833,6 +2844,12 @@ FLUTTER_ASSERT_ARC [textInputPlugin handleMethodCall:subsequentMoveCall result:^(id _Nullable result){ }]; + FlutterMethodCall* upwardVelocityMoveCall = + [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerMoveForInteractiveKeyboard" + arguments:@{@"pointerY" : @(500)}]; + [textInputPlugin handleMethodCall:upwardVelocityMoveCall + result:^(id _Nullable result){ + }]; FlutterMethodCall* pointerUpCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.onPointerUpForInteractiveKeyboard"