[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].

<!-- Links -->
[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>
This commit is contained in:
Koji Wakamiya 2025-04-01 04:27:13 +09:00 committed by GitHub
parent 8e3fee85a1
commit 02bdfd2a6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 78 additions and 1 deletions

View File

@ -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]]) {

View File

@ -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];