Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 479bb73

Browse files
authored
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
1 parent 3f22e19 commit 479bb73

File tree

4 files changed

+540
-75
lines changed

4 files changed

+540
-75
lines changed

shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm

+14-11
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ @implementation FlutterPlatformPluginTest
2222

2323
- (void)testClipboardHasCorrectStrings {
2424
[UIPasteboard generalPasteboard].string = nil;
25-
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
25+
FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
2626
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
2727
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
2828
FlutterPlatformPlugin* plugin =
29-
[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
29+
[[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
3030

3131
XCTestExpectation* setStringExpectation = [self expectationWithDescription:@"setString"];
3232
FlutterResult resultSet = ^(id result) {
@@ -61,11 +61,11 @@ - (void)testClipboardHasCorrectStrings {
6161

6262
- (void)testClipboardSetDataToNullDoNotCrash {
6363
[UIPasteboard generalPasteboard].string = nil;
64-
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
64+
FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
6565
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
6666
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
6767
FlutterPlatformPlugin* plugin =
68-
[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
68+
[[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
6969

7070
XCTestExpectation* setStringExpectation = [self expectationWithDescription:@"setData"];
7171
FlutterResult resultSet = ^(id result) {
@@ -88,18 +88,18 @@ - (void)testClipboardSetDataToNullDoNotCrash {
8888
}
8989

9090
- (void)testPopSystemNavigator {
91-
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
91+
FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
9292
[engine runWithEntrypoint:nil];
9393
FlutterViewController* flutterViewController =
9494
[[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
95-
UINavigationController* navigationController =
96-
[[UINavigationController alloc] initWithRootViewController:flutterViewController];
97-
UITabBarController* tabBarController = [[UITabBarController alloc] init];
95+
UINavigationController* navigationController = [[[UINavigationController alloc]
96+
initWithRootViewController:flutterViewController] autorelease];
97+
UITabBarController* tabBarController = [[[UITabBarController alloc] init] autorelease];
9898
tabBarController.viewControllers = @[ navigationController ];
9999
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
100100
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
101101
FlutterPlatformPlugin* plugin =
102-
[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
102+
[[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
103103

104104
id navigationControllerMock = OCMPartialMock(navigationController);
105105
OCMStub([navigationControllerMock popViewControllerAnimated:YES]);
@@ -113,16 +113,19 @@ - (void)testPopSystemNavigator {
113113
[plugin handleMethodCall:methodCallSet result:resultSet];
114114
[self waitForExpectationsWithTimeout:1 handler:nil];
115115
OCMVerify([navigationControllerMock popViewControllerAnimated:YES]);
116+
117+
[flutterViewController deregisterNotifications];
118+
[flutterViewController release];
116119
}
117120

118121
- (void)testWhetherDeviceHasLiveTextInputInvokeCorrectly {
119-
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"test" project:nil];
122+
FlutterEngine* engine = [[[FlutterEngine alloc] initWithName:@"test" project:nil] autorelease];
120123
std::unique_ptr<fml::WeakPtrFactory<FlutterEngine>> _weakFactory =
121124
std::make_unique<fml::WeakPtrFactory<FlutterEngine>>(engine);
122125
XCTestExpectation* invokeExpectation =
123126
[self expectationWithDescription:@"isLiveTextInputAvailableInvoke"];
124127
FlutterPlatformPlugin* plugin =
125-
[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()];
128+
[[[FlutterPlatformPlugin alloc] initWithEngine:_weakFactory->GetWeakPtr()] autorelease];
126129
FlutterPlatformPlugin* mockPlugin = OCMPartialMock(plugin);
127130
FlutterMethodCall* methodCall =
128131
[FlutterMethodCall methodCallWithMethodName:@"LiveText.isLiveTextInputAvailable"

shell/platform/darwin/ios/framework/Source/FlutterViewController.mm

+198-38
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)