Fix issues related to keyboard inset (#37719)
* fix keyboard inset not collapsing when expected
* fix some formatting
* fix issue with rotating with undocked and split keyboard
* fix formatting
* fix behavior on slide over view
* fix formatting
* refactor to make logic more clear
* move enum to header file, remove unneeded parameters, syntax fixes, remove rotation logic
* ignore notification if app state is not active, change way it checks if keyboard intersects with screen to accomodate for repeating decimals, format
* fix leaking unit test
* use viewIfLoaded and update tests to fix mocking
* change ignore logic related to application state to be more specific
* add more comments
* add more comments
* change function name to be more clear, add warning log if view is not loaded, update a comment
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
index 94553aa..fec7983 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm
@@ -22,11 +22,11 @@
- (void)testClipboardHasCorrectStrings {
[UIPasteboard generalPasteboard].string = nil;
- FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
+ FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
FlutterPlatformPlugin* plugin =
- [[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
+ [[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
XCTestExpectation* setStringExpectation = [self expectationWithDescription:@"setString"];
FlutterResult resultSet = ^(id result) {
@@ -61,11 +61,11 @@
- (void)testClipboardSetDataToNullDoNotCrash {
[UIPasteboard generalPasteboard].string = nil;
- FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
+ FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
FlutterPlatformPlugin* plugin =
- [[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
+ [[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
XCTestExpectation* setStringExpectation = [self expectationWithDescription:@"setData"];
FlutterResult resultSet = ^(id result) {
@@ -88,18 +88,18 @@
}
- (void)testPopSystemNavigator {
- FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
+ FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
[engine runWithEntrypoint:nil];
FlutterViewController* flutterViewController =
[[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
- UINavigationController* navigationController =
- [[UINavigationController alloc] initWithRootViewController:flutterViewController];
- UITabBarController* tabBarController = [[UITabBarController alloc] init];
+ UINavigationController* navigationController = [[[UINavigationController alloc]
+ initWithRootViewController:flutterViewController] autorelease];
+ UITabBarController* tabBarController = [[[UITabBarController alloc] init] autorelease];
tabBarController.viewControllers = @[ navigationController ];
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
FlutterPlatformPlugin* plugin =
- [[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
+ [[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
id navigationControllerMock = OCMPartialMock(navigationController);
OCMStub([navigationControllerMock popViewControllerAnimated:YES]);
@@ -113,16 +113,19 @@
[plugin handleMethodCall:methodCallSet result:resultSet];
[self waitForExpectationsWithTimeout:1 handler:nil];
OCMVerify([navigationControllerMock popViewControllerAnimated:YES]);
+
+ [flutterViewController deregisterNotifications];
+ [flutterViewController release];
}
- (void)testWhetherDeviceHasLiveTextInputInvokeCorrectly {
- FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
+ FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
XCTestExpectation* invokeExpectation =
[self expectationWithDescription:@"isLiveTextInputAvailableInvoke"];
FlutterPlatformPlugin* plugin =
- [[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
+ [[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
FlutterPlatformPlugin* mockPlugin = OCMPartialMock(plugin);
FlutterMethodCall* methodCall =
[FlutterMethodCall methodCallWithMethodName:@"LiveText.isLiveTextInputAvailable"
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
index 37b7c1d..7a80c93 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm
@@ -65,6 +65,7 @@
*/
@property(nonatomic, assign) double targetViewInsetBottom;
@property(nonatomic, retain) VSyncClient* keyboardAnimationVSyncClient;
+@property(nonatomic, assign) BOOL isKeyboardInOrTransitioningFromBackground;
/// VSyncClient for touch events delivery frame rate correction.
///
@@ -316,6 +317,11 @@
object:nil];
[center addObserver:self
+ selector:@selector(keyboardWillShowNotification:)
+ name:UIKeyboardWillShowNotification
+ object:nil];
+
+ [center addObserver:self
selector:@selector(keyboardWillBeHidden:)
name:UIKeyboardWillHideNotification
object:nil];
@@ -588,6 +594,16 @@
return _keyboardAnimationView.get();
}
+- (UIScreen*)mainScreenIfViewLoaded {
+ if (@available(iOS 13.0, *)) {
+ if (self.viewIfLoaded == nil) {
+ FML_LOG(WARNING) << "Trying to access the view before it is loaded.";
+ }
+ return self.viewIfLoaded.window.windowScene.screen;
+ }
+ return UIScreen.mainScreen;
+}
+
- (BOOL)loadDefaultSplashScreenView {
NSString* launchscreenName =
[[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"];
@@ -873,6 +889,7 @@
- (void)applicationBecameActive:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationBecameActive");
+ self.isKeyboardInOrTransitioningFromBackground = NO;
if (_viewportMetrics.physical_width) {
[self surfaceUpdated:YES];
}
@@ -891,6 +908,7 @@
- (void)applicationDidEnterBackground:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationDidEnterBackground");
+ self.isKeyboardInOrTransitioningFromBackground = YES;
[self surfaceUpdated:NO];
[self goToApplicationLifecycle:@"AppLifecycleState.paused"];
}
@@ -1272,65 +1290,207 @@
#pragma mark - Keyboard events
+- (void)keyboardWillShowNotification:(NSNotification*)notification {
+ // Immediately prior to a docked keyboard being shown or when a keyboard goes from
+ // undocked/floating to docked, this notification is triggered. This notification also happens
+ // when Minimized/Expanded Shortcuts bar is dropped after dragging (the keyboard's end frame will
+ // be CGRectZero).
+ [self handleKeyboardNotification:notification];
+}
+
- (void)keyboardWillChangeFrame:(NSNotification*)notification {
- NSDictionary* info = [notification userInfo];
-
- // Ignore keyboard notifications related to other apps.
- id isLocal = info[UIKeyboardIsLocalUserInfoKey];
- if (isLocal && ![isLocal boolValue]) {
- return;
- }
-
- // 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.
- self.targetViewInsetBottom = bottom * scale;
- } else {
- self.targetViewInsetBottom = 0;
- }
- [self startKeyBoardAnimation:duration];
+ // Immediately prior to a change in keyboard frame, this notification is triggered.
+ // Sometimes when the keyboard is being hidden or undocked, this notification's keyboard's end
+ // frame is not yet entirely out of screen, which is why we also use
+ // UIKeyboardWillHideNotification.
+ [self handleKeyboardNotification:notification];
}
- (void)keyboardWillBeHidden:(NSNotification*)notification {
- NSDictionary* info = [notification userInfo];
+ // When keyboard is hidden or undocked, this notification will be triggered.
+ // This notification might not occur when the keyboard is changed from docked to floating, which
+ // is why we also use UIKeyboardWillChangeFrameNotification.
+ [self handleKeyboardNotification:notification];
+}
- // Ignore keyboard notifications related to other apps.
+- (void)handleKeyboardNotification:(NSNotification*)notification {
+ // See https:://flutter.dev/go/ios-keyboard-calculating-inset for more details
+ // on why notifications are used and how things are calculated.
+ if ([self shouldIgnoreKeyboardNotification:notification]) {
+ return;
+ }
+
+ NSDictionary* info = notification.userInfo;
+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
+ FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:notification];
+ CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode];
+
+ // Avoid double triggering startKeyBoardAnimation.
+ if (self.targetViewInsetBottom == calculatedInset) {
+ return;
+ }
+
+ self.targetViewInsetBottom = calculatedInset;
+ NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
+ [self startKeyBoardAnimation:duration];
+}
+
+- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification {
+ // Don't ignore UIKeyboardWillHideNotification notifications.
+ // Even if the notification is triggered in the background or by a different app/view controller,
+ // we want to always handle this notification to avoid inaccurate inset when in a mulitasking mode
+ // or when switching between apps.
+ if (notification.name == UIKeyboardWillHideNotification) {
+ return NO;
+ }
+
+ // Ignore notification when keyboard's dimensions and position are all zeroes for
+ // UIKeyboardWillChangeFrameNotification. This happens when keyboard is dragged. Do not ignore if
+ // the notification is UIKeyboardWillShowNotification, as CGRectZero for that notfication only
+ // occurs when Minimized/Expanded Shortcuts Bar is dropped after dragging, which we later use to
+ // categorize it as floating.
+ NSDictionary* info = notification.userInfo;
+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
+ if (notification.name == UIKeyboardWillChangeFrameNotification &&
+ CGRectEqualToRect(keyboardFrame, CGRectZero)) {
+ return YES;
+ }
+
+ // When keyboard's height or width is set to 0, don't ignore. This does not happen
+ // often but can happen sometimes when switching between multitasking modes.
+ if (CGRectIsEmpty(keyboardFrame)) {
+ return NO;
+ }
+
+ // Ignore keyboard notifications related to other apps or view controllers.
+ if ([self isKeyboardNotificationForDifferentView:notification]) {
+ return YES;
+ }
+
+ if (@available(iOS 13.0, *)) {
+ // noop
+ } else {
+ // If OS version is less than 13, ignore notification if the app is in the background
+ // or is transitioning from the background. In older versions, when switching between
+ // apps with the keyboard open in the secondary app, notifications are sent when
+ // the app is in the background/transitioning from background as if they belong
+ // to the app and as if the keyboard is showing even though it is not.
+ if (self.isKeyboardInOrTransitioningFromBackground) {
+ return YES;
+ }
+ }
+
+ return NO;
+}
+
+- (BOOL)isKeyboardNotificationForDifferentView:(NSNotification*)notification {
+ NSDictionary* info = notification.userInfo;
+ // Keyboard notifications related to other apps.
+ // If the UIKeyboardIsLocalUserInfoKey key doesn't exist (this should not happen after iOS 8),
+ // proceed as if it was local so that the notification is not ignored.
id isLocal = info[UIKeyboardIsLocalUserInfoKey];
if (isLocal && ![isLocal boolValue]) {
- return;
+ return YES;
}
-
- // Ignore keyboard notifications if engine’s viewController is not current viewController.
+ // Engine’s viewController is not current viewController.
if ([_engine.get() viewController] != self) {
- return;
+ return YES;
+ }
+ return NO;
+}
+
+- (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification {
+ // There are multiple types of keyboard: docked, undocked, split, split docked,
+ // floating, expanded shortcuts bar, minimized shortcuts bar. This function will categorize
+ // the keyboard as one of the following modes: docked, floating, or hidden.
+ // Docked mode includes docked, split docked, expanded shortcuts bar (when opening via click),
+ // and minimized shortcuts bar (when opened via click).
+ // Floating includes undocked, split, floating, expanded shortcuts bar (when dragged and dropped),
+ // and minimized shortcuts bar (when dragged and dropped).
+ NSDictionary* info = notification.userInfo;
+ CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
+
+ if (notification.name == UIKeyboardWillHideNotification) {
+ return FlutterKeyboardModeHidden;
}
- if (self.targetViewInsetBottom != 0) {
- // Ensure the keyboard will be dismissed. Just like the keyboardWillChangeFrame,
- // keyboardWillBeHidden is also in an animation block in iOS sdk, so we don't need to set the
- // animation curve. Related issue: https://github.com/flutter/flutter/issues/99951
- self.targetViewInsetBottom = 0;
- NSTimeInterval duration =
- [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
- [self startKeyBoardAnimation:duration];
+ // If keyboard's dimensions and position are all zeroes, that means it's a Minimized/Expanded
+ // Shortcuts Bar that has been dropped after dragging, which we categorize as floating.
+ if (CGRectEqualToRect(keyboardFrame, CGRectZero)) {
+ return FlutterKeyboardModeFloating;
}
+ // If keyboard's width or height are 0, it's hidden.
+ if (CGRectIsEmpty(keyboardFrame)) {
+ return FlutterKeyboardModeHidden;
+ }
+
+ CGRect screenRect = [self mainScreenIfViewLoaded].bounds;
+ CGRect adjustedKeyboardFrame = keyboardFrame;
+ adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect
+ keyboardFrame:keyboardFrame];
+
+ // If the keyboard is partially or fully showing within the screen, it's either docked or
+ // floating. Sometimes with custom keyboard extensions, the keyboard's position may be off by a
+ // small decimal amount (which is why CGRectIntersectRect can't be used). Round to compare.
+ CGRect intersection = CGRectIntersection(adjustedKeyboardFrame, screenRect);
+ CGFloat intersectionHeight = CGRectGetHeight(intersection);
+ CGFloat intersectionWidth = CGRectGetWidth(intersection);
+ if (round(intersectionHeight) > 0 && intersectionWidth > 0) {
+ // If the keyboard is above the bottom of the screen, it's floating.
+ CGFloat screenHeight = CGRectGetHeight(screenRect);
+ CGFloat adjustedKeyboardBottom = CGRectGetMaxY(adjustedKeyboardFrame);
+ if (round(adjustedKeyboardBottom) < screenHeight) {
+ return FlutterKeyboardModeFloating;
+ }
+ return FlutterKeyboardModeDocked;
+ }
+ return FlutterKeyboardModeHidden;
+}
+
+- (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame {
+ // In Slide Over mode, the keyboard's frame does not include the space
+ // below the app, even though the keyboard may be at the bottom of the screen.
+ // To handle, shift the Y origin by the amount of space below the app.
+ if (self.viewIfLoaded.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad &&
+ self.viewIfLoaded.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact &&
+ self.viewIfLoaded.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular) {
+ CGFloat screenHeight = CGRectGetHeight(screenRect);
+ CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame);
+
+ // Stage Manager mode will also meet the above parameters, but it does not handle
+ // the keyboard positioning the same way, so skip if keyboard is at bottom of page.
+ if (screenHeight == keyboardBottom) {
+ return 0;
+ }
+ CGRect viewRectRelativeToScreen =
+ [self.viewIfLoaded convertRect:self.viewIfLoaded.frame
+ toCoordinateSpace:[self mainScreenIfViewLoaded].coordinateSpace];
+ CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen);
+ CGFloat offset = screenHeight - viewBottom;
+ if (offset > 0) {
+ return offset;
+ }
+ }
+ return 0;
+}
+
+- (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(NSInteger)keyboardMode {
+ // Only docked keyboards will have an inset.
+ if (keyboardMode == FlutterKeyboardModeDocked) {
+ // Calculate how much of the keyboard intersects with the view.
+ CGRect viewRectRelativeToScreen =
+ [self.viewIfLoaded convertRect:self.viewIfLoaded.frame
+ toCoordinateSpace:[self mainScreenIfViewLoaded].coordinateSpace];
+ CGRect intersection = CGRectIntersection(keyboardFrame, viewRectRelativeToScreen);
+ CGFloat portionOfKeyboardInView = CGRectGetHeight(intersection);
+
+ // 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.
+ CGFloat scale = [self mainScreenIfViewLoaded].scale;
+ return portionOfKeyboardInView * scale;
+ }
+ return 0;
}
- (void)startKeyBoardAnimation:(NSTimeInterval)duration {
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm
index 5cbd2aa..5d3c1c9 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm
@@ -113,6 +113,7 @@
@interface FlutterViewController (Tests)
@property(nonatomic, assign) double targetViewInsetBottom;
+@property(nonatomic, assign) BOOL isKeyboardInOrTransitioningFromBackground;
- (void)createTouchRateCorrectionVSyncClientIfNeeded;
- (void)surfaceUpdated:(BOOL)appeared;
@@ -124,8 +125,12 @@
- (void)onUserSettingsChanged:(NSNotification*)notification;
- (void)applicationWillTerminate:(NSNotification*)notification;
- (void)goToApplicationLifecycle:(nonnull NSString*)state;
-- (void)keyboardWillChangeFrame:(NSNotification*)notification;
-- (void)keyboardWillBeHidden:(NSNotification*)notification;
+- (UIScreen*)mainScreenIfViewLoaded;
+- (void)handleKeyboardNotification:(NSNotification*)notification;
+- (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(int)keyboardMode;
+- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification;
+- (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification;
+- (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame;
- (void)startKeyBoardAnimation:(NSTimeInterval)duration;
- (void)setupKeyboardAnimationVsyncClient;
- (void)ensureViewportMetricsIsCorrect;
@@ -185,33 +190,324 @@
OCMVerify([viewControllerMock setupKeyboardAnimationVsyncClient]);
}
-- (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation {
+- (void)testShouldIgnoreKeyboardNotification {
+ FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
+ [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
+ FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
+ nibName:nil
+ bundle:nil];
+ FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
+ OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
+
+ CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width;
+ CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height;
+ CGRect emptyKeyboard = CGRectZero;
+ CGRect zeroHeightKeyboard = CGRectMake(0, 0, screenWidth, 0);
+ CGRect validKeyboardEndFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320);
+ BOOL isLocal = NO;
+
+ // Hide notification, valid keyboard
+ NSNotification* notification =
+ [NSNotification notificationWithName:UIKeyboardWillHideNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ BOOL shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == NO);
+
+ // All zero keyboard
+ isLocal = YES;
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(emptyKeyboard),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == YES);
+
+ // Zero height keyboard
+ isLocal = NO;
+ notification =
+ [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(zeroHeightKeyboard),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == NO);
+
+ // Valid keyboard, triggered from another app
+ isLocal = NO;
+ notification =
+ [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == YES);
+
+ // Valid keyboard
+ isLocal = YES;
+ notification =
+ [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == NO);
+
+ if (@available(iOS 13.0, *)) {
+ // noop
+ } else {
+ // Valid keyboard, keyboard is in background
+ OCMStub([viewControllerMock isKeyboardInOrTransitioningFromBackground]).andReturn(YES);
+
+ isLocal = YES;
+ notification =
+ [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification];
+ XCTAssertTrue(shouldIgnore == YES);
+ }
+}
+
+- (void)testCalculateKeyboardAttachMode {
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]
- }];
+ FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
+ CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width;
+ CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height;
+
+ OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
+
+ // hide notification
+ CGRect keyboardFrame = CGRectZero;
+ NSNotification* notification =
+ [NSNotification notificationWithName:UIKeyboardWillHideNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ FlutterKeyboardMode keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden);
+
+ // all zeros
+ keyboardFrame = CGRectZero;
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeFloating);
+
+ // 0 height
+ keyboardFrame = CGRectMake(0, 0, screenWidth, 0);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden);
+
+ // floating
+ keyboardFrame = CGRectMake(0, 0, 320, 320);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeFloating);
+
+ // undocked
+ keyboardFrame = CGRectMake(0, 0, screenWidth, 320);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeFloating);
+
+ // docked
+ keyboardFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeDocked);
+
+ // docked - rounded values
+ CGFloat longDecimalHeight = 320.666666666666666;
+ keyboardFrame = CGRectMake(0, screenHeight - longDecimalHeight, screenWidth, longDecimalHeight);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeDocked);
+
+ // hidden - rounded values
+ keyboardFrame = CGRectMake(0, screenHeight - .0000001, screenWidth, longDecimalHeight);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden);
+
+ // hidden
+ keyboardFrame = CGRectMake(0, screenHeight, screenWidth, 320);
+ notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(YES)
+ }];
+ keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification];
+ XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden);
+}
+
+- (void)testCalculateMultitaskingAdjustment {
+ FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
+ [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
+ FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
+ nibName:nil
+ bundle:nil];
+ FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
+ OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
+
+ CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width;
+ CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height;
+ CGRect screenRect = UIScreen.mainScreen.bounds;
+ CGRect viewOrigFrame = CGRectMake(0, 0, 320, screenHeight - 40);
+ CGRect convertedViewFrame = CGRectMake(20, 20, 320, screenHeight - 40);
+ CGRect keyboardFrame = CGRectMake(20, screenHeight - 320, screenWidth, 300);
+
+ id mockView = OCMClassMock([UIView class]);
+ OCMStub([mockView frame]).andReturn(viewOrigFrame);
+ OCMStub([mockView convertRect:viewOrigFrame toCoordinateSpace:[OCMArg any]])
+ .andReturn(convertedViewFrame);
+ id mockTraitCollection = OCMClassMock([UITraitCollection class]);
+ OCMStub([mockTraitCollection userInterfaceIdiom]).andReturn(UIUserInterfaceIdiomPad);
+ OCMStub([mockTraitCollection horizontalSizeClass]).andReturn(UIUserInterfaceSizeClassCompact);
+ OCMStub([mockTraitCollection verticalSizeClass]).andReturn(UIUserInterfaceSizeClassRegular);
+ OCMStub([mockView traitCollection]).andReturn(mockTraitCollection);
+ OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView);
+
+ CGFloat adjustment = [viewControllerMock calculateMultitaskingAdjustment:screenRect
+ keyboardFrame:keyboardFrame];
+ XCTAssertTrue(adjustment == 20);
+}
+
+- (void)testCalculateKeyboardInset {
+ FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
+ [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
+ FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
+ nibName:nil
+ bundle:nil];
+ FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
+ OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
+
+ CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width;
+ CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height;
+ CGRect viewOrigFrame = CGRectMake(0, 0, 320, screenHeight - 40);
+ CGRect convertedViewFrame = CGRectMake(20, 20, 320, screenHeight - 40);
+ CGRect keyboardFrame = CGRectMake(20, screenHeight - 320, screenWidth, 300);
+
+ id mockView = OCMClassMock([UIView class]);
+ OCMStub([mockView frame]).andReturn(viewOrigFrame);
+ OCMStub([mockView convertRect:viewOrigFrame toCoordinateSpace:[OCMArg any]])
+ .andReturn(convertedViewFrame);
+ OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView);
+
+ CGFloat inset = [viewControllerMock calculateKeyboardInset:keyboardFrame
+ keyboardMode:FlutterKeyboardModeDocked];
+ XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale);
+}
+
+- (void)testHandleKeyboardNotification {
+ FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
+ [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
+ FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
+ nibName:nil
+ bundle:nil];
+ // keyboard is empty
+ CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width;
+ CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height;
+ CGRect keyboardFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320);
+ CGRect viewFrame = UIScreen.mainScreen.bounds;
+ BOOL isLocal = YES;
+ NSNotification* notification =
+ [NSNotification notificationWithName:UIKeyboardWillShowNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @0.25,
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
+ FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
+ OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen);
+ id mockView = OCMClassMock([UIView class]);
+ OCMStub([mockView frame]).andReturn(viewFrame);
+ OCMStub([mockView convertRect:viewFrame toCoordinateSpace:[OCMArg any]]).andReturn(viewFrame);
+ OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView);
+ viewControllerMock.targetViewInsetBottom = 0;
XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"];
OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()])
.ignoringNonObjectArgs()
.andDo(^(NSInvocation* invocation) {
[expectation fulfill];
});
- id viewControllerMock = OCMPartialMock(viewController);
- [viewControllerMock keyboardWillChangeFrame:notification];
+
+ [viewControllerMock handleKeyboardNotification:notification];
+ XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 320 * UIScreen.mainScreen.scale);
OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]);
[self waitForExpectationsWithTimeout:5.0 handler:nil];
}
@@ -224,19 +520,19 @@
bundle:nil];
FlutterViewController* viewControllerMock = OCMPartialMock(viewController);
- CGRect keyboardFrame = CGRectMake(0, 0, 0, 0);
+ CGRect keyboardFrame = CGRectZero;
BOOL isLocal = YES;
- NSNotification* fakeNotification = [NSNotification
- notificationWithName:@""
- object:nil
- userInfo:@{
- @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame],
- @"UIKeyboardAnimationDurationUserInfoKey" : @(0.25),
- @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
- }];
+ NSNotification* fakeNotification =
+ [NSNotification notificationWithName:UIKeyboardWillHideNotification
+ object:nil
+ userInfo:@{
+ @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame),
+ @"UIKeyboardAnimationDurationUserInfoKey" : @(0.25),
+ @"UIKeyboardIsLocalUserInfoKey" : @(isLocal)
+ }];
viewControllerMock.targetViewInsetBottom = 10;
- [viewControllerMock keyboardWillBeHidden:fakeNotification];
+ [viewControllerMock handleKeyboardNotification:fakeNotification];
XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0);
}
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h
index 1b4af46..664b4c2 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h
+++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h
@@ -27,6 +27,12 @@
FLUTTER_DARWIN_EXPORT
extern NSNotificationName const FlutterViewControllerShowHomeIndicator;
+typedef NS_ENUM(NSInteger, FlutterKeyboardMode) {
+ FlutterKeyboardModeHidden = 0,
+ FlutterKeyboardModeDocked = 1,
+ FlutterKeyboardModeFloating = 2,
+};
+
@interface FlutterViewController () <FlutterViewResponder>
@property(class, nonatomic, readonly) BOOL accessibilityIsOnOffSwitchLabelsEnabled;