From 02bdfd2a6c75bf08bb86ec269453f40dace05e35 Mon Sep 17 00:00:00 2001 From: Koji Wakamiya Date: Tue, 1 Apr 2025 04:27:13 +0900 Subject: [PATCH] [Engine][iOS] Cancel animation when recieved `UIKeyboardWillHideNotification` with duration 0.0 (#164884) fix https://github.com/flutter/flutter/issues/112281 The event log output shows that `UIKeyboardWillHideNotification` occurs immediately after `UIKeyboardWillShowNotification`. However, the animation is not cancelled in response to `UIKeyboardWillHideNotification`. This PR adds animation cancellation processing in response to `UIKeyboardWillHideNotification` with duration 0.0. https://github.com/user-attachments/assets/df0dbc6a-504b-476e-97ce-30e7ff40835f test app: https://github.com/koji-1009/pm_behavior_test ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> --- .../framework/Source/FlutterViewController.mm | 27 +++++++++- .../Source/FlutterViewControllerTest.mm | 52 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 2556a5b0635..22cd3061a38 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1523,6 +1523,17 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:notification]; CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode]; + NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + + // If the software keyboard is displayed before displaying the PasswordManager prompt, + // UIKeyboardWillHideNotification will occur immediately after UIKeyboardWillShowNotification. + // The duration of the animation will be 0.0, and the calculated inset will be 0.0. + // In this case, it is necessary to cancel the animation and hide the keyboard immediately. + // https://github.com/flutter/flutter/pull/164884 + if (keyboardMode == FlutterKeyboardModeHidden && calculatedInset == 0.0 && duration == 0.0) { + [self hideKeyboardImmediately]; + return; + } // Avoid double triggering startKeyBoardAnimation. if (self.targetViewInsetBottom == calculatedInset) { @@ -1530,7 +1541,6 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } self.targetViewInsetBottom = calculatedInset; - NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; // Flag for simultaneous compounding animation calls. // This captures animation calls made while the keyboard animation is currently animating. If the @@ -1773,6 +1783,21 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) }]; } +- (void)hideKeyboardImmediately { + [self invalidateKeyboardAnimationVSyncClient]; + if (self.keyboardAnimationView) { + [self.keyboardAnimationView.layer removeAllAnimations]; + [self removeKeyboardAnimationView]; + self.keyboardAnimationView = nil; + } + if (self.keyboardSpringAnimation) { + self.keyboardSpringAnimation = nil; + } + // Reset targetViewInsetBottom to 0.0. + self.targetViewInsetBottom = 0.0; + [self ensureViewportMetricsIsCorrect]; +} + - (void)setUpKeyboardSpringAnimationIfNeeded:(CAAnimation*)keyboardAnimation { // If keyboard animation is null or not a spring animation, fallback to DisplayLink tracking. if (keyboardAnimation == nil || ![keyboardAnimation isKindOfClass:[CASpringAnimation class]]) { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 654716535f4..c98b3e2b824 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -142,6 +142,7 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification; - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame; - (void)startKeyBoardAnimation:(NSTimeInterval)duration; +- (void)hideKeyboardImmediately; - (UIView*)keyboardAnimationView; - (SpringAnimation*)keyboardSpringAnimation; - (void)setUpKeyboardSpringAnimationIfNeeded:(CAAnimation*)keyboardAnimation; @@ -779,6 +780,57 @@ extern NSNotificationName const FlutterViewControllerWillDealloc; XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); } +- (void)testStopKeyBoardAnimationWhenReceivedWillHideNotificationAfterWillShowNotification { + // see: https://github.com/flutter/flutter/issues/112281 + + FlutterEngine* engine = [[FlutterEngine alloc] init]; + [engine runWithEntrypoint:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + UIScreen* screen = [self setUpMockScreen]; + CGRect viewFrame = screen.bounds; + [self setUpMockView:viewControllerMock + screen:screen + viewFrame:viewFrame + convertedFrame:viewFrame]; + viewControllerMock.targetViewInsetBottom = 0; + + CGFloat screenHeight = screen.bounds.size.height; + CGFloat screenWidth = screen.bounds.size.height; + CGRect keyboardFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); + BOOL isLocal = YES; + + // Receive will show notification + NSNotification* fakeShowNotification = + [NSNotification notificationWithName:UIKeyboardWillShowNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) + }]; + [viewControllerMock handleKeyboardNotification:fakeShowNotification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 320 * screen.scale); + + // Receive will hide notification + NSNotification* fakeHideNotification = + [NSNotification notificationWithName:UIKeyboardWillHideNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @(0.0), + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) + }]; + [viewControllerMock handleKeyboardNotification:fakeHideNotification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + + // Check if the keyboard animation is stopped. + XCTAssertNil(viewControllerMock.keyboardAnimationView); + XCTAssertNil(viewControllerMock.keyboardSpringAnimation); +} + - (void)testEnsureViewportMetricsWillInvokeAndDisplayLinkWillInvalidateInViewDidDisappear { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];