@@ -65,6 +65,7 @@ @interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegat
6565 */
6666@property (nonatomic , assign ) double targetViewInsetBottom;
6767@property (nonatomic , retain ) VSyncClient* keyboardAnimationVSyncClient;
68+ @property (nonatomic , assign ) BOOL isKeyboardInOrTransitioningFromBackground;
6869
6970// / VSyncClient for touch events delivery frame rate correction.
7071// /
@@ -315,6 +316,11 @@ - (void)setupNotificationCenterObservers {
315316 name: UIKeyboardWillChangeFrameNotification
316317 object: nil ];
317318
319+ [center addObserver: self
320+ selector: @selector (keyboardWillShowNotification: )
321+ name: UIKeyboardWillShowNotification
322+ object: nil ];
323+
318324 [center addObserver: self
319325 selector: @selector (keyboardWillBeHidden: )
320326 name: UIKeyboardWillHideNotification
@@ -588,6 +594,16 @@ - (UIView*)keyboardAnimationView {
588594 return _keyboardAnimationView.get ();
589595}
590596
597+ - (UIScreen*)mainScreenIfViewLoaded {
598+ if (@available (iOS 13.0 , *)) {
599+ if (self.viewIfLoaded == nil ) {
600+ FML_LOG (WARNING) << " Trying to access the view before it is loaded." ;
601+ }
602+ return self.viewIfLoaded .window .windowScene .screen ;
603+ }
604+ return UIScreen.mainScreen ;
605+ }
606+
591607- (BOOL )loadDefaultSplashScreenView {
592608 NSString * launchscreenName =
593609 [[[NSBundle mainBundle ] infoDictionary ] objectForKey: @" UILaunchStoryboardName" ];
@@ -873,6 +889,7 @@ - (void)dealloc {
873889
874890- (void )applicationBecameActive : (NSNotification *)notification {
875891 TRACE_EVENT0 (" flutter" , " applicationBecameActive" );
892+ self.isKeyboardInOrTransitioningFromBackground = NO ;
876893 if (_viewportMetrics.physical_width ) {
877894 [self surfaceUpdated: YES ];
878895 }
@@ -891,6 +908,7 @@ - (void)applicationWillTerminate:(NSNotification*)notification {
891908
892909- (void )applicationDidEnterBackground : (NSNotification *)notification {
893910 TRACE_EVENT0 (" flutter" , " applicationDidEnterBackground" );
911+ self.isKeyboardInOrTransitioningFromBackground = YES ;
894912 [self surfaceUpdated: NO ];
895913 [self goToApplicationLifecycle: @" AppLifecycleState.paused" ];
896914}
@@ -1272,65 +1290,207 @@ - (void)updateViewportPadding {
12721290
12731291#pragma mark - Keyboard events
12741292
1293+ - (void )keyboardWillShowNotification : (NSNotification *)notification {
1294+ // Immediately prior to a docked keyboard being shown or when a keyboard goes from
1295+ // undocked/floating to docked, this notification is triggered. This notification also happens
1296+ // when Minimized/Expanded Shortcuts bar is dropped after dragging (the keyboard's end frame will
1297+ // be CGRectZero).
1298+ [self handleKeyboardNotification: notification];
1299+ }
1300+
12751301- (void )keyboardWillChangeFrame : (NSNotification *)notification {
1276- NSDictionary * info = [notification userInfo ];
1302+ // Immediately prior to a change in keyboard frame, this notification is triggered.
1303+ // Sometimes when the keyboard is being hidden or undocked, this notification's keyboard's end
1304+ // frame is not yet entirely out of screen, which is why we also use
1305+ // UIKeyboardWillHideNotification.
1306+ [self handleKeyboardNotification: notification];
1307+ }
12771308
1278- // Ignore keyboard notifications related to other apps.
1279- id isLocal = info[UIKeyboardIsLocalUserInfoKey];
1280- if (isLocal && ![isLocal boolValue ]) {
1309+ - (void )keyboardWillBeHidden : (NSNotification *)notification {
1310+ // When keyboard is hidden or undocked, this notification will be triggered.
1311+ // This notification might not occur when the keyboard is changed from docked to floating, which
1312+ // is why we also use UIKeyboardWillChangeFrameNotification.
1313+ [self handleKeyboardNotification: notification];
1314+ }
1315+
1316+ - (void )handleKeyboardNotification : (NSNotification *)notification {
1317+ // See https:://flutter.dev/go/ios-keyboard-calculating-inset for more details
1318+ // on why notifications are used and how things are calculated.
1319+ if ([self shouldIgnoreKeyboardNotification: notification]) {
12811320 return ;
12821321 }
12831322
1284- // Ignore keyboard notifications if engine’s viewController is not current viewController.
1285- if ([_engine.get () viewController ] != self) {
1323+ NSDictionary * info = notification.userInfo ;
1324+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue ];
1325+ FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode: notification];
1326+ CGFloat calculatedInset = [self calculateKeyboardInset: keyboardFrame keyboardMode: keyboardMode];
1327+
1328+ // Avoid double triggering startKeyBoardAnimation.
1329+ if (self.targetViewInsetBottom == calculatedInset) {
12861330 return ;
12871331 }
12881332
1289- CGRect keyboardFrame = [[info objectForKey: UIKeyboardFrameEndUserInfoKey] CGRectValue ];
1290- CGRect screenRect = [[UIScreen mainScreen ] bounds ];
1333+ self.targetViewInsetBottom = calculatedInset;
1334+ NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue ];
1335+ [self startKeyBoardAnimation: duration];
1336+ }
1337+
1338+ - (BOOL )shouldIgnoreKeyboardNotification : (NSNotification *)notification {
1339+ // Don't ignore UIKeyboardWillHideNotification notifications.
1340+ // Even if the notification is triggered in the background or by a different app/view controller,
1341+ // we want to always handle this notification to avoid inaccurate inset when in a mulitasking mode
1342+ // or when switching between apps.
1343+ if (notification.name == UIKeyboardWillHideNotification) {
1344+ return NO ;
1345+ }
12911346
1292- // Get the animation duration
1293- NSTimeInterval duration =
1294- [[info objectForKey: UIKeyboardAnimationDurationUserInfoKey] doubleValue ];
1347+ // Ignore notification when keyboard's dimensions and position are all zeroes for
1348+ // UIKeyboardWillChangeFrameNotification. This happens when keyboard is dragged. Do not ignore if
1349+ // the notification is UIKeyboardWillShowNotification, as CGRectZero for that notfication only
1350+ // occurs when Minimized/Expanded Shortcuts Bar is dropped after dragging, which we later use to
1351+ // categorize it as floating.
1352+ NSDictionary * info = notification.userInfo ;
1353+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue ];
1354+ if (notification.name == UIKeyboardWillChangeFrameNotification &&
1355+ CGRectEqualToRect (keyboardFrame, CGRectZero)) {
1356+ return YES ;
1357+ }
12951358
1296- // Considering the iPad's split keyboard, Flutter needs to check if the keyboard frame is present
1297- // in the screen to see if the keyboard is visible.
1298- if (CGRectIntersectsRect (keyboardFrame, screenRect)) {
1299- CGFloat bottom = CGRectGetHeight (keyboardFrame);
1300- CGFloat scale = [UIScreen mainScreen ].scale ;
1301- // The keyboard is treated as an inset since we want to effectively reduce the window size by
1302- // the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
1303- // bottom padding.
1304- self.targetViewInsetBottom = bottom * scale;
1359+ // When keyboard's height or width is set to 0, don't ignore. This does not happen
1360+ // often but can happen sometimes when switching between multitasking modes.
1361+ if (CGRectIsEmpty (keyboardFrame)) {
1362+ return NO ;
1363+ }
1364+
1365+ // Ignore keyboard notifications related to other apps or view controllers.
1366+ if ([self isKeyboardNotificationForDifferentView: notification]) {
1367+ return YES ;
1368+ }
1369+
1370+ if (@available (iOS 13.0 , *)) {
1371+ // noop
13051372 } else {
1306- self.targetViewInsetBottom = 0 ;
1373+ // If OS version is less than 13, ignore notification if the app is in the background
1374+ // or is transitioning from the background. In older versions, when switching between
1375+ // apps with the keyboard open in the secondary app, notifications are sent when
1376+ // the app is in the background/transitioning from background as if they belong
1377+ // to the app and as if the keyboard is showing even though it is not.
1378+ if (self.isKeyboardInOrTransitioningFromBackground ) {
1379+ return YES ;
1380+ }
13071381 }
1308- [self startKeyBoardAnimation: duration];
1309- }
13101382
1311- - ( void ) keyboardWillBeHidden : ( NSNotification *) notification {
1312- NSDictionary * info = [notification userInfo ];
1383+ return NO ;
1384+ }
13131385
1314- // Ignore keyboard notifications related to other apps.
1386+ - (BOOL )isKeyboardNotificationForDifferentView : (NSNotification *)notification {
1387+ NSDictionary * info = notification.userInfo ;
1388+ // Keyboard notifications related to other apps.
1389+ // If the UIKeyboardIsLocalUserInfoKey key doesn't exist (this should not happen after iOS 8),
1390+ // proceed as if it was local so that the notification is not ignored.
13151391 id isLocal = info[UIKeyboardIsLocalUserInfoKey];
13161392 if (isLocal && ![isLocal boolValue ]) {
1317- return ;
1393+ return YES ;
13181394 }
1319-
1320- // Ignore keyboard notifications if engine’s viewController is not current viewController.
1395+ // Engine’s viewController is not current viewController.
13211396 if ([_engine.get () viewController ] != self) {
1322- return ;
1397+ return YES ;
13231398 }
1399+ return NO ;
1400+ }
13241401
1325- if (self.targetViewInsetBottom != 0 ) {
1326- // Ensure the keyboard will be dismissed. Just like the keyboardWillChangeFrame,
1327- // keyboardWillBeHidden is also in an animation block in iOS sdk, so we don't need to set the
1328- // animation curve. Related issue: https://github.com/flutter/flutter/issues/99951
1329- self.targetViewInsetBottom = 0 ;
1330- NSTimeInterval duration =
1331- [[info objectForKey: UIKeyboardAnimationDurationUserInfoKey] doubleValue ];
1332- [self startKeyBoardAnimation: duration];
1402+ - (FlutterKeyboardMode)calculateKeyboardAttachMode : (NSNotification *)notification {
1403+ // There are multiple types of keyboard: docked, undocked, split, split docked,
1404+ // floating, expanded shortcuts bar, minimized shortcuts bar. This function will categorize
1405+ // the keyboard as one of the following modes: docked, floating, or hidden.
1406+ // Docked mode includes docked, split docked, expanded shortcuts bar (when opening via click),
1407+ // and minimized shortcuts bar (when opened via click).
1408+ // Floating includes undocked, split, floating, expanded shortcuts bar (when dragged and dropped),
1409+ // and minimized shortcuts bar (when dragged and dropped).
1410+ NSDictionary * info = notification.userInfo ;
1411+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue ];
1412+
1413+ if (notification.name == UIKeyboardWillHideNotification) {
1414+ return FlutterKeyboardModeHidden;
1415+ }
1416+
1417+ // If keyboard's dimensions and position are all zeroes, that means it's a Minimized/Expanded
1418+ // Shortcuts Bar that has been dropped after dragging, which we categorize as floating.
1419+ if (CGRectEqualToRect (keyboardFrame, CGRectZero)) {
1420+ return FlutterKeyboardModeFloating;
1421+ }
1422+ // If keyboard's width or height are 0, it's hidden.
1423+ if (CGRectIsEmpty (keyboardFrame)) {
1424+ return FlutterKeyboardModeHidden;
1425+ }
1426+
1427+ CGRect screenRect = [self mainScreenIfViewLoaded ].bounds ;
1428+ CGRect adjustedKeyboardFrame = keyboardFrame;
1429+ adjustedKeyboardFrame.origin .y += [self calculateMultitaskingAdjustment: screenRect
1430+ keyboardFrame: keyboardFrame];
1431+
1432+ // If the keyboard is partially or fully showing within the screen, it's either docked or
1433+ // floating. Sometimes with custom keyboard extensions, the keyboard's position may be off by a
1434+ // small decimal amount (which is why CGRectIntersectRect can't be used). Round to compare.
1435+ CGRect intersection = CGRectIntersection (adjustedKeyboardFrame, screenRect);
1436+ CGFloat intersectionHeight = CGRectGetHeight (intersection);
1437+ CGFloat intersectionWidth = CGRectGetWidth (intersection);
1438+ if (round (intersectionHeight) > 0 && intersectionWidth > 0 ) {
1439+ // If the keyboard is above the bottom of the screen, it's floating.
1440+ CGFloat screenHeight = CGRectGetHeight (screenRect);
1441+ CGFloat adjustedKeyboardBottom = CGRectGetMaxY (adjustedKeyboardFrame);
1442+ if (round (adjustedKeyboardBottom) < screenHeight) {
1443+ return FlutterKeyboardModeFloating;
1444+ }
1445+ return FlutterKeyboardModeDocked;
1446+ }
1447+ return FlutterKeyboardModeHidden;
1448+ }
1449+
1450+ - (CGFloat)calculateMultitaskingAdjustment : (CGRect)screenRect keyboardFrame : (CGRect)keyboardFrame {
1451+ // In Slide Over mode, the keyboard's frame does not include the space
1452+ // below the app, even though the keyboard may be at the bottom of the screen.
1453+ // To handle, shift the Y origin by the amount of space below the app.
1454+ if (self.viewIfLoaded .traitCollection .userInterfaceIdiom == UIUserInterfaceIdiomPad &&
1455+ self.viewIfLoaded .traitCollection .horizontalSizeClass == UIUserInterfaceSizeClassCompact &&
1456+ self.viewIfLoaded .traitCollection .verticalSizeClass == UIUserInterfaceSizeClassRegular) {
1457+ CGFloat screenHeight = CGRectGetHeight (screenRect);
1458+ CGFloat keyboardBottom = CGRectGetMaxY (keyboardFrame);
1459+
1460+ // Stage Manager mode will also meet the above parameters, but it does not handle
1461+ // the keyboard positioning the same way, so skip if keyboard is at bottom of page.
1462+ if (screenHeight == keyboardBottom) {
1463+ return 0 ;
1464+ }
1465+ CGRect viewRectRelativeToScreen =
1466+ [self .viewIfLoaded convertRect: self .viewIfLoaded.frame
1467+ toCoordinateSpace: [self mainScreenIfViewLoaded ].coordinateSpace];
1468+ CGFloat viewBottom = CGRectGetMaxY (viewRectRelativeToScreen);
1469+ CGFloat offset = screenHeight - viewBottom;
1470+ if (offset > 0 ) {
1471+ return offset;
1472+ }
1473+ }
1474+ return 0 ;
1475+ }
1476+
1477+ - (CGFloat)calculateKeyboardInset : (CGRect)keyboardFrame keyboardMode : (NSInteger )keyboardMode {
1478+ // Only docked keyboards will have an inset.
1479+ if (keyboardMode == FlutterKeyboardModeDocked) {
1480+ // Calculate how much of the keyboard intersects with the view.
1481+ CGRect viewRectRelativeToScreen =
1482+ [self .viewIfLoaded convertRect: self .viewIfLoaded.frame
1483+ toCoordinateSpace: [self mainScreenIfViewLoaded ].coordinateSpace];
1484+ CGRect intersection = CGRectIntersection (keyboardFrame, viewRectRelativeToScreen);
1485+ CGFloat portionOfKeyboardInView = CGRectGetHeight (intersection);
1486+
1487+ // The keyboard is treated as an inset since we want to effectively reduce the window size by
1488+ // the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
1489+ // bottom padding.
1490+ CGFloat scale = [self mainScreenIfViewLoaded ].scale ;
1491+ return portionOfKeyboardInView * scale;
13331492 }
1493+ return 0 ;
13341494}
13351495
13361496- (void )startKeyBoardAnimation : (NSTimeInterval )duration {
0 commit comments