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 12246c05e12..adc0115d37e 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 @@ -69,6 +69,12 @@ typedef struct MouseState { @property(nonatomic, assign) BOOL isHomeIndicatorHidden; @property(nonatomic, assign) BOOL isPresentingViewControllerAnimating; +/** + * Keyboard animation properties + */ +@property(nonatomic, assign) double targetViewInsetBottom; +@property(nonatomic, strong) CADisplayLink* displayLink; + /** * Creates and registers plugins used by this view controller. */ @@ -113,6 +119,7 @@ typedef enum UIAccessibilityContrast : NSInteger { fml::scoped_nsobject _scrollView; fml::scoped_nsobject _pointerInteraction API_AVAILABLE(ios(13.4)); fml::scoped_nsobject _panGestureRecognizer API_AVAILABLE(ios(13.4)); + fml::scoped_nsobject _keyboardAnimationView; MouseState _mouseState; } @@ -542,6 +549,10 @@ static void sendFakeTouchEvent(FlutterEngine* engine, return _splashScreenView.get(); } +- (UIView*)keyboardAnimationView { + return _keyboardAnimationView.get(); +} + - (BOOL)loadDefaultSplashScreenView { NSString* launchscreenName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"]; @@ -719,6 +730,8 @@ static void sendFakeTouchEvent(FlutterEngine* engine, - (void)viewDidDisappear:(BOOL)animated { TRACE_EVENT0("flutter", "viewDidDisappear"); if ([_engine.get() viewController] == self) { + [self invalidateDisplayLink]; + [self ensureViewportMetricsIsCorrect]; [self surfaceUpdated:NO]; [[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"]; [self flushOngoingTouches]; @@ -1104,29 +1117,115 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } } + // Ignore keyboard notifications if engine’s viewController is not current viewController. + if ([_engine.get() viewController] != self) { + return; + } + CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; CGRect screenRect = [[UIScreen mainScreen] bounds]; + // Get the animation duration + NSTimeInterval duration = + [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + // Considering the iPad's split keyboard, Flutter needs to check if the keyboard frame is present // in the screen to see if the keyboard is visible. if (CGRectIntersectsRect(keyboardFrame, screenRect)) { CGFloat bottom = CGRectGetHeight(keyboardFrame); CGFloat scale = [UIScreen mainScreen].scale; - // The keyboard is treated as an inset since we want to effectively reduce the window size by // the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming // bottom padding. - _viewportMetrics.physical_view_inset_bottom = bottom * scale; + self.targetViewInsetBottom = bottom * scale; } else { - _viewportMetrics.physical_view_inset_bottom = 0; + self.targetViewInsetBottom = 0; } - - [self updateViewportMetrics]; + [self startKeyBoardAnimation:duration]; } - (void)keyboardWillBeHidden:(NSNotification*)notification { - _viewportMetrics.physical_view_inset_bottom = 0; - [self updateViewportMetrics]; + // When keyboard hide, the keyboardWillChangeFrame function will be called to update viewport + // metrics. So do not call [self updateViewportMetrics] here again. +} + +- (void)startKeyBoardAnimation:(NSTimeInterval)duration { + // If current physical_view_inset_bottom == targetViewInsetBottom,do nothing. + if (_viewportMetrics.physical_view_inset_bottom == self.targetViewInsetBottom) { + return; + } + + // When call this method first time, + // initialize the keyboardAnimationView to get animation interpolation during animation. + if ([self keyboardAnimationView] == nil) { + UIView* keyboardAnimationView = [[UIView alloc] init]; + [keyboardAnimationView setHidden:YES]; + _keyboardAnimationView.reset(keyboardAnimationView); + } + + if ([self keyboardAnimationView].superview == nil) { + [self.view addSubview:[self keyboardAnimationView]]; + } + + // Remove running animation when start another animation. + // After calling this line,the old display link will invalidate. + [[self keyboardAnimationView].layer removeAllAnimations]; + + // Set animation begin value. + [self keyboardAnimationView].frame = + CGRectMake(0, _viewportMetrics.physical_view_inset_bottom, 0, 0); + + // Invalidate old display link if the old animation is not complete + [self invalidateDisplayLink]; + + self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink)]; + [self.displayLink addToRunLoop:NSRunLoop.currentRunLoop forMode:NSRunLoopCommonModes]; + __block CADisplayLink* currentDisplayLink = self.displayLink; + + [UIView animateWithDuration:duration + animations:^{ + // Set end value. + [self keyboardAnimationView].frame = CGRectMake(0, self.targetViewInsetBottom, 0, 0); + } + completion:^(BOOL finished) { + if (self.displayLink == currentDisplayLink) { + [self invalidateDisplayLink]; + } + if (finished) { + [self removeKeyboardAnimationView]; + [self ensureViewportMetricsIsCorrect]; + } + }]; +} + +- (void)invalidateDisplayLink { + [self.displayLink invalidate]; +} + +- (void)removeKeyboardAnimationView { + if ([self keyboardAnimationView].superview != nil) { + [[self keyboardAnimationView] removeFromSuperview]; + } +} + +- (void)ensureViewportMetricsIsCorrect { + if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) { + // Make sure the `physical_view_inset_bottom` is the target value. + _viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom; + [self updateViewportMetrics]; + } +} + +- (void)onDisplayLink { + if ([self keyboardAnimationView].superview == nil) { + // Ensure the keyboardAnimationView is in view hierarchy when animation running. + [self.view addSubview:[self keyboardAnimationView]]; + } + if ([self keyboardAnimationView].layer.presentationLayer) { + CGFloat value = [self keyboardAnimationView].layer.presentationLayer.frame.origin.y; + _viewportMetrics.physical_view_inset_bottom = value; + [self updateViewportMetrics]; + } } - (void)handlePressEvent:(FlutterUIPressProxy*)press 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 42ad5b0c289..84abb4bb13d 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 @@ -124,6 +124,10 @@ typedef enum UIAccessibilityContrast : NSInteger { - (void)scrollEvent:(UIPanGestureRecognizer*)recognizer; - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; +- (void)keyboardWillChangeFrame:(NSNotification*)notification; +- (void)startKeyBoardAnimation:(NSTimeInterval)duration; +- (void)ensureViewportMetricsIsCorrect; +- (void)invalidateDisplayLink; @end @interface FlutterViewControllerTest : XCTestCase @@ -151,6 +155,41 @@ typedef enum UIAccessibilityContrast : NSInteger { self.messageSent = nil; } +- (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + CGFloat width = UIScreen.mainScreen.bounds.size.width; + CGRect keyboardFrame = CGRectMake(0, 100, width, 400); + BOOL isLocal = YES; + NSNotification* notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + id viewControllerMock = OCMPartialMock(viewController); + [viewControllerMock keyboardWillChangeFrame:notification]; + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); +} + +- (void)testEnsureViewportMetricsWillInvokeAndDisplayLinkWillInvalidateInViewDidDisappear { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + id viewControllerMock = OCMPartialMock(viewController); + [viewControllerMock viewDidDisappear:YES]; + OCMVerify([viewControllerMock ensureViewportMetricsIsCorrect]); + OCMVerify([viewControllerMock invalidateDisplayLink]); +} + - (void)testViewDidDisappearDoesntPauseEngineWhenNotTheViewController { id lifecycleChannel = OCMClassMock([FlutterBasicMessageChannel class]); FlutterEnginePartialMock* mockEngine = [[FlutterEnginePartialMock alloc] init];