From 081a81296fbf8f765e85f4d97162f9b7db598b51 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Thu, 17 Nov 2022 10:30:29 -0600 Subject: [PATCH 01/15] fix keyboard inset not collapsing when expected --- .../framework/Source/FlutterViewController.mm | 131 +++++++--- .../Source/FlutterViewControllerTest.mm | 227 +++++++++++++++++- 2 files changed, 327 insertions(+), 31 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 2716ba252ef5a..776719c23a7bf 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -315,6 +315,11 @@ - (void)setupNotificationCenterObservers { name:UIKeyboardWillChangeFrameNotification object:nil]; + [center addObserver:self + selector:@selector(keyboardWillShowNotification:) + name:UIKeyboardWillShowNotification + object:nil]; + [center addObserver:self selector:@selector(keyboardWillBeHidden:) name:UIKeyboardWillHideNotification @@ -1272,7 +1277,54 @@ - (void)updateViewportPadding { #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 + + NSDictionary* info = [notification userInfo]; + CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; + bool isEmpty = CGRectIsEmpty(keyboardFrame); + + // If keyboard is empty, bypass check if it's from another app + if (isEmpty == false) { + // 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 screenRect = [[UIScreen mainScreen] bounds]; + CGFloat calculatedInset = 0; + + // If keyboard is within the screen and it's not empty, calculate and set the inset. + // If keyboard is not within the screen (it's usually below), set inset to 0. + // If keyboard frame is empty (usually because it was dragged and dropped), set inset to 0. + if (CGRectIntersectsRect(keyboardFrame, screenRect) && !isEmpty) { + calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + } + + // avoid double triggering startKeyBoardAnimation + if (self.targetViewInsetBottom == calculatedInset) { + return; + } + + self.targetViewInsetBottom = calculatedInset; + NSTimeInterval duration = + [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + [self startKeyBoardAnimation:duration]; +} + - (void)keyboardWillChangeFrame:(NSNotification*)notification { + // Immediately prior to a change in keyboard frame, this notification is triggered. + // There are some cases where UIKeyboardWillShowNotification & UIKeyboardWillHideNotification + // do not act as expected and this is used to catch those cases. + NSDictionary* info = [notification userInfo]; // Ignore keyboard notifications related to other apps. @@ -1287,45 +1339,70 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { } CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; + + // Ignore notification when keyboard has zero width/height + // This happens when keyboard is dragged + if (CGRectIsEmpty(keyboardFrame)) { + return; + } + CGRect screenRect = [[UIScreen mainScreen] bounds]; + CGFloat screenHeight = CGRectGetHeight(screenRect); + CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); + + // If the keyboard is partially or fully showing at the bottom of the screen, + // calculate and set the inset. + // When the keyboard goes from docked to the floating small keyboard, it sometimes + // does not send a UIKeyboardWillHideNotification notification as expected. + // To handle, if the keyboard is above the bottom of the screen, set the inset to 0. + // If keyboard is not within the screen (it's usually below), set inset to 0. + CGFloat calculatedInset = 0; + if (keyboardBottom >= screenHeight && CGRectIntersectsRect(keyboardFrame, screenRect)) { + calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + } + + // avoid double triggering startKeyBoardAnimation + if (self.targetViewInsetBottom == calculatedInset) { + return; + } - // Get the animation duration + self.targetViewInsetBottom = calculatedInset; 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]; } -- (void)keyboardWillBeHidden:(NSNotification*)notification { - NSDictionary* info = [notification userInfo]; +- (CGFloat)calculateKeyboardInset:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame { + // Sometimes when rotating orientation, the keyboard height will be higher than it really is. + // So calculate how much of the keyboard is showing using position. + CGFloat screenHeight = CGRectGetHeight(screenRect); + CGFloat keyboardTop = CGRectGetMinY(keyboardFrame); + CGFloat portionOfKeyboardShowing = screenHeight - keyboardTop; - // Ignore keyboard notifications related to other apps. - id isLocal = info[UIKeyboardIsLocalUserInfoKey]; - if (isLocal && ![isLocal boolValue]) { - return; + // Double check inset does not exceed the height of the keyboard. + // This can sometimes happen with Slide Over view when there's a safe area padding. + CGFloat keyboardHeight = CGRectGetHeight(keyboardFrame); + if (portionOfKeyboardShowing > keyboardHeight) { + portionOfKeyboardShowing = keyboardHeight; } - // Ignore keyboard notifications if engine’s viewController is not current viewController. - if ([_engine.get() viewController] != self) { - return; - } + // 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 = [UIScreen mainScreen].scale; + CGFloat calculatedInset = portionOfKeyboardShowing * scale; + return calculatedInset; +} + +- (void)keyboardWillBeHidden:(NSNotification*)notification { + // When keyboard is hidden or undocked, this notification will be triggered 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 + // Ensure the keyboard will be dismissed. Just like keyboardWillShowNotification + // and 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 + NSDictionary* info = [notification userInfo]; self.targetViewInsetBottom = 0; NSTimeInterval duration = [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 7ed4c0592e0d3..410a81be43155 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -124,8 +124,10 @@ - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; - (void)goToApplicationLifecycle:(nonnull NSString*)state; +- (void)keyboardWillShowNotification:(NSNotification*)notification; - (void)keyboardWillChangeFrame:(NSNotification*)notification; - (void)keyboardWillBeHidden:(NSNotification*)notification; +- (CGFloat)calculateKeyboardInset:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame; - (void)startKeyBoardAnimation:(NSTimeInterval)duration; - (void)setupKeyboardAnimationVsyncClient; - (void)ensureViewportMetricsIsCorrect; @@ -185,15 +187,43 @@ - (void)testStartKeyboardAnimationWillInvokeSetupKeyboardAnimationVsyncClient { OCMVerify([viewControllerMock setupKeyboardAnimationVsyncClient]); } -- (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation { +- (void)testKeyboardWillShowNotificationEmptyKeyboard { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; + // keyboard is empty + CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); + 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); + viewControllerMock.targetViewInsetBottom = 10; + + [viewControllerMock keyboardWillShowNotification:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); +} - CGFloat width = UIScreen.mainScreen.bounds.size.width; - CGRect keyboardFrame = CGRectMake(0, 100, width, 400); +- (void)testKeyboardWillShowNotificationKeyboardNotInScreen { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is hidden below the screen bounds + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400; + CGRect keyboardFrame = CGRectMake(0, screenHeight, screenWidth, keyboardHeight); BOOL isLocal = YES; NSNotification* notification = [NSNotification notificationWithName:@"" @@ -204,18 +234,207 @@ - (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation { @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + viewControllerMock.targetViewInsetBottom = 400; + + [viewControllerMock keyboardWillShowNotification:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); +} + +- (void)testKeyboardWillShowNotificationKeyboardInScreen { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is docked at bottom of screen + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400; + CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight, screenWidth, keyboardHeight); + BOOL isLocal = YES; + NSNotification* notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) .ignoringNonObjectArgs() .andDo(^(NSInvocation* invocation) { [expectation fulfill]; }); - id viewControllerMock = OCMPartialMock(viewController); + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + viewControllerMock.targetViewInsetBottom = 0; + CGFloat expectedInset = keyboardHeight * UIScreen.mainScreen.scale; + + [viewControllerMock keyboardWillShowNotification:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == expectedInset); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; +} + +- (void)testKeyboardWillChangeFrameEmptyKeyboard { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is empty + CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); + 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); + viewControllerMock.targetViewInsetBottom = 10; + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 10); +} + +- (void)testKeyboardWillChangeFrameKeyboardInScreenAtBottom { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is docked at bottom of screen + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400; + CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight, screenWidth, keyboardHeight); + BOOL isLocal = YES; + NSNotification* notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; + OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) + .ignoringNonObjectArgs() + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + viewControllerMock.targetViewInsetBottom = 0; + CGFloat expectedInset = keyboardHeight * UIScreen.mainScreen.scale; + + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == expectedInset); OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); [self waitForExpectationsWithTimeout:5.0 handler:nil]; } +- (void)testKeyboardWillChangeFrameKeyboardAboveBottom { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is not docked, floating above bottom of screen + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400; + CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight - 10, screenWidth, keyboardHeight); + 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); + viewControllerMock.targetViewInsetBottom = 400; + + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); +} + +- (void)testKeyboardWillChangeFrameKeyboardBelowScreen { + FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); + [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; + FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine + nibName:nil + bundle:nil]; + + // keyboard is hidden below the screen bounds + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400; + CGRect keyboardFrame = CGRectMake(0, screenHeight, screenWidth, keyboardHeight); + 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); + viewControllerMock.targetViewInsetBottom = 400; + + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); +} + +- (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); + + CGRect screenRect = CGRectMake(0, 0, 810, 1080); + CGRect keyboardFrame = CGRectMake(0, 760, 810, 320); + + CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + + // 1080 - 760 = 320 + XCTAssertTrue(inset == 320 * UIScreen.mainScreen.scale); +} + +- (void)testCalculateKeyboardInsetExceedsKeyboardHeight { + 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); + + CGRect screenRect = CGRectMake(0, 0, 810, 1080); + CGRect keyboardFrame = CGRectMake(0, 760, 810, 300); + + CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + + // 1080 - 760 = 320, which is greater than 300, so should be 300 + XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale); +} + - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; From 8501ff2913cd3a5864b7b15ed8eabcaf77689282 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Thu, 17 Nov 2022 10:43:51 -0600 Subject: [PATCH 02/15] fix some formatting --- .../ios/framework/Source/FlutterViewControllerTest.mm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 410a81be43155..3c94ffb2105ec 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -352,7 +352,8 @@ - (void)testKeyboardWillChangeFrameKeyboardAboveBottom { CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; CGFloat keyboardHeight = 400; - CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight - 10, screenWidth, keyboardHeight); + CGRect keyboardFrame = + CGRectMake(0, screenHeight - keyboardHeight - 10, screenWidth, keyboardHeight); BOOL isLocal = YES; NSNotification* notification = [NSNotification notificationWithName:@"" @@ -411,7 +412,8 @@ - (void)testCalculateKeyboardInset { CGRect screenRect = CGRectMake(0, 0, 810, 1080); CGRect keyboardFrame = CGRectMake(0, 760, 810, 320); - CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect + keyboardFrame:keyboardFrame]; // 1080 - 760 = 320 XCTAssertTrue(inset == 320 * UIScreen.mainScreen.scale); @@ -429,7 +431,8 @@ - (void)testCalculateKeyboardInsetExceedsKeyboardHeight { CGRect screenRect = CGRectMake(0, 0, 810, 1080); CGRect keyboardFrame = CGRectMake(0, 760, 810, 300); - CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; + CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect + keyboardFrame:keyboardFrame]; // 1080 - 760 = 320, which is greater than 300, so should be 300 XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale); From 0c0cc731cd223e70f6396772525a6400920d7fda Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Thu, 17 Nov 2022 14:37:30 -0600 Subject: [PATCH 03/15] fix issue with rotating with undocked and split keyboard --- .../framework/Source/FlutterViewController.mm | 11 ++++++- .../Source/FlutterViewControllerTest.mm | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 776719c23a7bf..c1ad0a78cd480 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1348,7 +1348,15 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { CGRect screenRect = [[UIScreen mainScreen] bounds]; CGFloat screenHeight = CGRectGetHeight(screenRect); - CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); + CGRect keyboardBeginFrame = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue]; + CGFloat keyboardBeginWidth = CGRectGetWidth(keyboardBeginFrame); + + // Ignore notification when keyboard is in process of being rotated. + // When the keyboard's width at the beginning of the animation equals the screen's + // current height, we can assume the keyboard was rotated. + if (screenHeight == keyboardBeginWidth) { + return; + } // If the keyboard is partially or fully showing at the bottom of the screen, // calculate and set the inset. @@ -1357,6 +1365,7 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { // To handle, if the keyboard is above the bottom of the screen, set the inset to 0. // If keyboard is not within the screen (it's usually below), set inset to 0. CGFloat calculatedInset = 0; + CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); if (keyboardBottom >= screenHeight && CGRectIntersectsRect(keyboardFrame, screenRect)) { calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 3c94ffb2105ec..b6b5e34f443ea 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -304,6 +304,35 @@ - (void)testKeyboardWillChangeFrameEmptyKeyboard { XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 10); } +- (void)testKeyboardWillChangeFrameRotating { + 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 keyboardBeginFrame = CGRectMake(0, screenWidth - 320, screenHeight, 320); + CGRect keyboardEndFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); + BOOL isLocal = YES; + NSNotification* notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameBeginUserInfoKey" : [NSValue valueWithCGRect:keyboardBeginFrame], + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardEndFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + viewControllerMock.targetViewInsetBottom = 10; + + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 10); +} + - (void)testKeyboardWillChangeFrameKeyboardInScreenAtBottom { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; From 078308cc590c2f1351847b06a654fe140c50ca10 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Thu, 17 Nov 2022 16:56:35 -0600 Subject: [PATCH 04/15] fix formatting --- .../darwin/ios/framework/Source/FlutterViewControllerTest.mm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index b6b5e34f443ea..1e8ecd08641c7 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -321,7 +321,8 @@ - (void)testKeyboardWillChangeFrameRotating { notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameBeginUserInfoKey" : [NSValue valueWithCGRect:keyboardBeginFrame], + @"UIKeyboardFrameBeginUserInfoKey" : + [NSValue valueWithCGRect:keyboardBeginFrame], @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardEndFrame], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] From 6f16d3d333693259a0b6a5e1fa5d917c46c84523 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Fri, 18 Nov 2022 09:58:02 -0600 Subject: [PATCH 05/15] fix behavior on slide over view --- .../framework/Source/FlutterViewController.mm | 36 ++++++++++---- .../Source/FlutterViewControllerTest.mm | 48 +++++++++++++------ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index c1ad0a78cd480..e40a340e4e1e2 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1300,11 +1300,24 @@ - (void)keyboardWillShowNotification:(NSNotification*)notification { } CGRect screenRect = [[UIScreen mainScreen] bounds]; - CGFloat calculatedInset = 0; + + // In Slide Over view, the keyboard's dimensions/position 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. + CGFloat screenHeight = CGRectGetHeight(screenRect); + CGFloat screenWidth = CGRectGetWidth(screenRect); + CGFloat appHeight = CGRectGetHeight(self.view.window.frame); + CGFloat appWidth = CGRectGetWidth(self.view.window.frame); + if (self.view.safeAreaInsets.bottom > 0 && appWidth < screenWidth) { + // In Slide Over view, the app is vertically centered with space above and below, + // which is why we divide by 2 to get the space below. + keyboardFrame.origin.y += (screenHeight - appHeight) / 2; + } // If keyboard is within the screen and it's not empty, calculate and set the inset. // If keyboard is not within the screen (it's usually below), set inset to 0. // If keyboard frame is empty (usually because it was dragged and dropped), set inset to 0. + CGFloat calculatedInset = 0; if (CGRectIntersectsRect(keyboardFrame, screenRect) && !isEmpty) { calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; } @@ -1341,7 +1354,7 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; // Ignore notification when keyboard has zero width/height - // This happens when keyboard is dragged + // This happens when keyboard is dragged. if (CGRectIsEmpty(keyboardFrame)) { return; } @@ -1358,6 +1371,18 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { return; } + // In Slide Over view, the keyboard's dimensions/position 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. + CGFloat screenWidth = CGRectGetWidth(screenRect); + CGFloat appHeight = CGRectGetHeight(self.view.window.frame); + CGFloat appWidth = CGRectGetWidth(self.view.window.frame); + if (self.view.safeAreaInsets.bottom > 0 && appWidth < screenWidth) { + // In Slide Over view, the app is vertically centered with space above and below, + // which is why we divide by 2 to get the space below. + keyboardFrame.origin.y += (screenHeight - appHeight) / 2; + } + // If the keyboard is partially or fully showing at the bottom of the screen, // calculate and set the inset. // When the keyboard goes from docked to the floating small keyboard, it sometimes @@ -1388,13 +1413,6 @@ - (CGFloat)calculateKeyboardInset:(CGRect)screenRect keyboardFrame:(CGRect)keybo CGFloat keyboardTop = CGRectGetMinY(keyboardFrame); CGFloat portionOfKeyboardShowing = screenHeight - keyboardTop; - // Double check inset does not exceed the height of the keyboard. - // This can sometimes happen with Slide Over view when there's a safe area padding. - CGFloat keyboardHeight = CGRectGetHeight(keyboardFrame); - if (portionOfKeyboardShowing > keyboardHeight) { - portionOfKeyboardShowing = keyboardHeight; - } - // 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. diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 1e8ecd08641c7..134e3de849f11 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -212,7 +212,7 @@ - (void)testKeyboardWillShowNotificationEmptyKeyboard { OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); } -- (void)testKeyboardWillShowNotificationKeyboardNotInScreen { +- (void)testKeyboardWillShowNotificationKeyboarBelowScreen { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine @@ -311,7 +311,7 @@ - (void)testKeyboardWillChangeFrameRotating { nibName:nil bundle:nil]; - // keyboard is empty + // keyboard has just been rotated CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; CGRect keyboardBeginFrame = CGRectMake(0, screenWidth - 320, screenHeight, 320); @@ -430,26 +430,44 @@ - (void)testKeyboardWillChangeFrameKeyboardBelowScreen { OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); } -- (void)testCalculateKeyboardInset { +- (void)testKeyboardWillChangeFrameKeyboardSplitViewBelowScreen { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; + // keyboard is hidden below the screen bounds, but is offset by split view padding + CGFloat padding = 25; + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGFloat keyboardHeight = 400 - padding; + CGRect keyboardFrame = CGRectMake(0, screenHeight - padding, screenWidth, keyboardHeight); + CGRect appFrame = CGRectMake(20, 0, screenWidth / 2, screenHeight - padding * 2); + BOOL isLocal = YES; + NSNotification* notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + UIView* mockView = OCMClassMock([UIView class]); + UIWindow* mockWindow = OCMClassMock([UIWindow class]); + OCMStub([mockWindow frame]).andReturn(appFrame); + OCMStub([mockView window]).andReturn(mockWindow); + OCMStub([mockView safeAreaInsets]).andReturn(UIEdgeInsetsMake(20,20,20,20)); FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + viewControllerMock.targetViewInsetBottom = 400; + viewControllerMock.view = mockView; - CGRect screenRect = CGRectMake(0, 0, 810, 1080); - CGRect keyboardFrame = CGRectMake(0, 760, 810, 320); - - CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect - keyboardFrame:keyboardFrame]; - - // 1080 - 760 = 320 - XCTAssertTrue(inset == 320 * UIScreen.mainScreen.scale); + [viewControllerMock keyboardWillChangeFrame:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); } -- (void)testCalculateKeyboardInsetExceedsKeyboardHeight { +- (void)testCalculateKeyboardInset { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine @@ -459,13 +477,13 @@ - (void)testCalculateKeyboardInsetExceedsKeyboardHeight { FlutterViewController* viewControllerMock = OCMPartialMock(viewController); CGRect screenRect = CGRectMake(0, 0, 810, 1080); - CGRect keyboardFrame = CGRectMake(0, 760, 810, 300); + CGRect keyboardFrame = CGRectMake(0, 760, 810, 320); CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; - // 1080 - 760 = 320, which is greater than 300, so should be 300 - XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale); + // 1080 - 760 = 320 + XCTAssertTrue(inset == 320 * UIScreen.mainScreen.scale); } - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { From 79705b4f924f27279d549be4fead2b3a1b192fdb Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Fri, 18 Nov 2022 10:19:06 -0600 Subject: [PATCH 06/15] fix formatting --- .../darwin/ios/framework/Source/FlutterViewControllerTest.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 134e3de849f11..7418f99ed8b06 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -457,7 +457,7 @@ - (void)testKeyboardWillChangeFrameKeyboardSplitViewBelowScreen { UIWindow* mockWindow = OCMClassMock([UIWindow class]); OCMStub([mockWindow frame]).andReturn(appFrame); OCMStub([mockView window]).andReturn(mockWindow); - OCMStub([mockView safeAreaInsets]).andReturn(UIEdgeInsetsMake(20,20,20,20)); + OCMStub([mockView safeAreaInsets]).andReturn(UIEdgeInsetsMake(20, 20, 20, 20)); FlutterViewController* viewControllerMock = OCMPartialMock(viewController); viewControllerMock.targetViewInsetBottom = 400; viewControllerMock.view = mockView; From 62704283a85d447747df1af6a410a4aad1ff9e61 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Thu, 24 Nov 2022 00:04:46 -0600 Subject: [PATCH 07/15] refactor to make logic more clear --- .../framework/Source/FlutterViewController.mm | 293 ++++++++----- .../Source/FlutterViewControllerTest.mm | 413 +++++++++--------- 2 files changed, 384 insertions(+), 322 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index e40a340e4e1e2..ed557c60616ed 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -593,6 +593,13 @@ - (UIView*)keyboardAnimationView { return _keyboardAnimationView.get(); } +- (UIScreen*)getMainScreen { + if (@available(iOS 13.0, *)) { + return self.view.window.windowScene.screen; + } + return UIScreen.mainScreen; +} + - (BOOL)loadDefaultSplashScreenView { NSString* launchscreenName = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"]; @@ -1277,52 +1284,46 @@ - (void)updateViewportPadding { #pragma mark - Keyboard events +typedef NS_ENUM(NSInteger, FlutterKeyboardMode) { + FlutterKeyboardModeHidden = 0, + FlutterKeyboardModeDocked = 1, + FlutterKeyboardModeFloating = 2, +}; + - (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 - - NSDictionary* info = [notification userInfo]; - CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; - bool isEmpty = CGRectIsEmpty(keyboardFrame); - - // If keyboard is empty, bypass check if it's from another app - if (isEmpty == false) { - // Ignore keyboard notifications related to other apps. - id isLocal = info[UIKeyboardIsLocalUserInfoKey]; - if (isLocal && ![isLocal boolValue]) { - return; - } + // undocked/floating to docked, this notification is triggered. + [self handleKeyboardNotification:notification notificationName:UIKeyboardWillShowNotification]; +} - // Ignore keyboard notifications if engine’s viewController is not current viewController. - if ([_engine.get() viewController] != self) { - return; - } - } +- (void)keyboardWillChangeFrame:(NSNotification*)notification { + // Immediately prior to a change in keyboard frame, this notification is triggered. + // There are some cases where UIKeyboardWillShowNotification & UIKeyboardWillHideNotification + // do not act as expected and this is used to catch those cases. + [self handleKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; +} - CGRect screenRect = [[UIScreen mainScreen] bounds]; +- (void)keyboardWillBeHidden:(NSNotification*)notification { + // When keyboard is hidden or undocked, this notification will be triggered. + [self handleKeyboardNotification:notification notificationName:UIKeyboardWillHideNotification]; +} - // In Slide Over view, the keyboard's dimensions/position 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. - CGFloat screenHeight = CGRectGetHeight(screenRect); - CGFloat screenWidth = CGRectGetWidth(screenRect); - CGFloat appHeight = CGRectGetHeight(self.view.window.frame); - CGFloat appWidth = CGRectGetWidth(self.view.window.frame); - if (self.view.safeAreaInsets.bottom > 0 && appWidth < screenWidth) { - // In Slide Over view, the app is vertically centered with space above and below, - // which is why we divide by 2 to get the space below. - keyboardFrame.origin.y += (screenHeight - appHeight) / 2; +- (void)handleKeyboardNotification:(NSNotification*)notification + notificationName:(NSNotificationName)notificationName { + BOOL ignoreNotification = [self shouldIgnoreKeyboardNotification:notification + notificationName:notificationName]; + if (ignoreNotification) { + return; } - // If keyboard is within the screen and it's not empty, calculate and set the inset. - // If keyboard is not within the screen (it's usually below), set inset to 0. - // If keyboard frame is empty (usually because it was dragged and dropped), set inset to 0. - CGFloat calculatedInset = 0; - if (CGRectIntersectsRect(keyboardFrame, screenRect) && !isEmpty) { - calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; - } + NSDictionary* info = notification.userInfo; + CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; + FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:keyboardFrame + notificationName:notificationName]; + CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode]; - // avoid double triggering startKeyBoardAnimation + // Avoid double triggering startKeyBoardAnimation. if (self.targetViewInsetBottom == calculatedInset) { return; } @@ -1333,108 +1334,170 @@ - (void)keyboardWillShowNotification:(NSNotification*)notification { [self startKeyBoardAnimation:duration]; } -- (void)keyboardWillChangeFrame:(NSNotification*)notification { - // Immediately prior to a change in keyboard frame, this notification is triggered. - // There are some cases where UIKeyboardWillShowNotification & UIKeyboardWillHideNotification - // do not act as expected and this is used to catch those cases. +- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification + notificationName:(NSNotificationName)notificationName { + BOOL isKeyboardNotificationForThisView = [self isKeyboardNotificationForThisView:notification]; + BOOL isKeyboardRotated = [self isKeyboardRotated:notification]; - NSDictionary* info = [notification userInfo]; + // Don't ignore UIKeyboardWillHideNotification notifications. + if (notificationName == UIKeyboardWillHideNotification) { + // Skip hide notification when rotation in progress unless triggered by another app. + if (isKeyboardRotated && isKeyboardNotificationForThisView) { + return YES; + } + return NO; + } + + // Ignore notification when keyboard's dimensions and position are all zeroes, + // for UIKeyboardWillChangeFrameNotification. This happens when keyboard is dragged. + NSDictionary* info = notification.userInfo; + CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; + if (notificationName == UIKeyboardWillChangeFrameNotification && + CGRectEqualToRect(keyboardFrame, CGRectZero)) { + return YES; + } + + // Don't ignore other times a keyboard's height or width is 0. + if (CGRectIsEmpty(keyboardFrame)) { + return NO; + } // Ignore keyboard notifications related to other apps. - id isLocal = info[UIKeyboardIsLocalUserInfoKey]; - if (isLocal && ![isLocal boolValue]) { - return; + if (!isKeyboardNotificationForThisView) { + return YES; } - // Ignore keyboard notifications if engine’s viewController is not current viewController. - if ([_engine.get() viewController] != self) { - return; + // Ignore notification when keyboard is in process of being rotated. + if (isKeyboardRotated) { + return YES; } - CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; + return NO; +} - // Ignore notification when keyboard has zero width/height - // This happens when keyboard is dragged. - if (CGRectIsEmpty(keyboardFrame)) { - return; +- (BOOL)isKeyboardNotificationForThisView:(NSNotification*)notification { + NSDictionary* info = notification.userInfo; + // Keyboard notifications related to other apps. + id isLocal = info[UIKeyboardIsLocalUserInfoKey]; + if (isLocal && ![isLocal boolValue]) { + return NO; } + // Engine’s viewController is not current viewController. + if ([_engine.get() viewController] != self) { + return NO; + } + return YES; +} - CGRect screenRect = [[UIScreen mainScreen] bounds]; +- (BOOL)isKeyboardRotated:(NSNotification*)notification { + // When the keyboard's width at the beginning of the animation equals the screen's + // current height, we can assume the keyboard was rotated. + NSDictionary* info = notification.userInfo; + CGRect screenRect = [self getMainScreen].bounds; CGFloat screenHeight = CGRectGetHeight(screenRect); + CGRect keyboardEndFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; CGRect keyboardBeginFrame = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue]; CGFloat keyboardBeginWidth = CGRectGetWidth(keyboardBeginFrame); - - // Ignore notification when keyboard is in process of being rotated. - // When the keyboard's width at the beginning of the animation equals the screen's - // current height, we can assume the keyboard was rotated. + if (CGRectEqualToRect(keyboardEndFrame, CGRectZero)) { + return NO; + } if (screenHeight == keyboardBeginWidth) { - return; + return YES; } + return NO; +} - // In Slide Over view, the keyboard's dimensions/position 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. - CGFloat screenWidth = CGRectGetWidth(screenRect); - CGFloat appHeight = CGRectGetHeight(self.view.window.frame); - CGFloat appWidth = CGRectGetWidth(self.view.window.frame); - if (self.view.safeAreaInsets.bottom > 0 && appWidth < screenWidth) { - // In Slide Over view, the app is vertically centered with space above and below, - // which is why we divide by 2 to get the space below. - keyboardFrame.origin.y += (screenHeight - appHeight) / 2; - } - - // If the keyboard is partially or fully showing at the bottom of the screen, - // calculate and set the inset. - // When the keyboard goes from docked to the floating small keyboard, it sometimes - // does not send a UIKeyboardWillHideNotification notification as expected. - // To handle, if the keyboard is above the bottom of the screen, set the inset to 0. - // If keyboard is not within the screen (it's usually below), set inset to 0. - CGFloat calculatedInset = 0; - CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); - if (keyboardBottom >= screenHeight && CGRectIntersectsRect(keyboardFrame, screenRect)) { - calculatedInset = [self calculateKeyboardInset:screenRect keyboardFrame:keyboardFrame]; - } - - // avoid double triggering startKeyBoardAnimation - if (self.targetViewInsetBottom == calculatedInset) { - return; +- (FlutterKeyboardMode)calculateKeyboardAttachMode:(CGRect)keyboardFrame + notificationName:(NSNotificationName)notificationName { + // There are multiple types of keyboard: docked, undocked, split, split docked, + // floating, predictive-only, minimized. This function will categorize + // the keyboard as one of the following modes: docked, floating, or hidden. + // Docked mode includes docked, split docked, predictive-only (when opening via click), + // and minimized (when opened via click). + // Floating includes undocked, split, floating, predictive-only (when dragged and dropped), + // and minimized (when dragged and dropped). + if (notificationName == UIKeyboardWillHideNotification) { + return FlutterKeyboardModeHidden; } - self.targetViewInsetBottom = calculatedInset; - NSTimeInterval duration = - [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; - [self startKeyBoardAnimation:duration]; -} + // If keyboard's dimensions and position are all zeroes, + // that means it's been dragged and therefore 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 getMainScreen].bounds; + CGFloat keyboardWidth = CGRectGetWidth(keyboardFrame); + CGFloat screenWidth = CGRectGetWidth(screenRect); + + // If keyboard is not full width, it's floating. + if (keyboardWidth != screenWidth) { + return FlutterKeyboardModeFloating; + } -- (CGFloat)calculateKeyboardInset:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame { - // Sometimes when rotating orientation, the keyboard height will be higher than it really is. - // So calculate how much of the keyboard is showing using position. CGFloat screenHeight = CGRectGetHeight(screenRect); - CGFloat keyboardTop = CGRectGetMinY(keyboardFrame); - CGFloat portionOfKeyboardShowing = screenHeight - keyboardTop; + CGRect adjustedKeyboardFrame = keyboardFrame; + adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect + keyboardFrame:keyboardFrame]; + CGFloat adjustedKeyboardBottom = CGRectGetMaxY(adjustedKeyboardFrame); - // 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 = [UIScreen mainScreen].scale; - CGFloat calculatedInset = portionOfKeyboardShowing * scale; + // If the keyboard is above the bottom of the screen, it's floating. + if (adjustedKeyboardBottom < screenHeight) { + return FlutterKeyboardModeFloating; + } + // If the keyboard is partially or fully showing at the bottom of the screen, it's docked. + if (CGRectIntersectsRect(adjustedKeyboardFrame, screenRect)) { + return FlutterKeyboardModeDocked; + } + return FlutterKeyboardModeHidden; +} - return calculatedInset; +- (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.view.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad && + self.view.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact && + self.view.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 viewFrameInScreen = [self.view convertRect:self.view.frame + toCoordinateSpace:[self getMainScreen].coordinateSpace]; + CGFloat viewBottom = CGRectGetMaxY(viewFrameInScreen); + CGFloat offset = screenHeight - viewBottom; + if (offset > 0) { + return offset; + } + } + return 0; } -- (void)keyboardWillBeHidden:(NSNotification*)notification { - // When keyboard is hidden or undocked, this notification will be triggered - if (self.targetViewInsetBottom != 0) { - // Ensure the keyboard will be dismissed. Just like keyboardWillShowNotification - // and 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 - NSDictionary* info = [notification userInfo]; - self.targetViewInsetBottom = 0; - NSTimeInterval duration = - [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; - [self startKeyBoardAnimation:duration]; +- (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 viewFrameInScreen = [self.view convertRect:self.view.frame + toCoordinateSpace:[self getMainScreen].coordinateSpace]; + CGRect intersection = CGRectIntersection(keyboardFrame, viewFrameInScreen); + 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 getMainScreen].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 7418f99ed8b06..03c2baf6e3d3b 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -124,10 +124,16 @@ - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; - (void)goToApplicationLifecycle:(nonnull NSString*)state; +- (UIScreen*)getMainScreen; - (void)keyboardWillShowNotification:(NSNotification*)notification; - (void)keyboardWillChangeFrame:(NSNotification*)notification; - (void)keyboardWillBeHidden:(NSNotification*)notification; -- (CGFloat)calculateKeyboardInset:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame; +- (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(int)keyboardMode; +- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification + notificationName:(NSNotificationName)notificationName; +- (NSInteger)calculateKeyboardAttachMode:(CGRect)keyboardFrame + notificationName:(NSNotificationName)notificationName; +- (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame; - (void)startKeyBoardAnimation:(NSTimeInterval)duration; - (void)setupKeyboardAnimationVsyncClient; - (void)ensureViewportMetricsIsCorrect; @@ -187,263 +193,269 @@ - (void)testStartKeyboardAnimationWillInvokeSetupKeyboardAnimationVsyncClient { OCMVerify([viewControllerMock setupKeyboardAnimationVsyncClient]); } -- (void)testKeyboardWillShowNotificationEmptyKeyboard { +- (void)testShouldIgnoreKeyboardNotification { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; - // keyboard is empty - CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); - BOOL isLocal = YES; + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; + CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; + CGRect emptyKeyboard = CGRectMake(0, 0, 0, 0); + CGRect zeroHeightKeyboard = CGRectMake(0, 0, screenWidth, 0); + CGRect rotatedKeyboardBeginFrame = CGRectMake(0, screenWidth - 320, screenHeight, 320); + CGRect validKeyboardEndFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); + BOOL isLocal = NO; + + // Hide notification, valid keyboard NSNotification* notification = [NSNotification notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardFrameEndUserInfoKey" : + [NSValue valueWithCGRect:validKeyboardEndFrame], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 10; - - [viewControllerMock keyboardWillShowNotification:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); -} - -- (void)testKeyboardWillShowNotificationKeyboarBelowScreen { - FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); - [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; - FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine - nibName:nil - bundle:nil]; - - // keyboard is hidden below the screen bounds - CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; - CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400; - CGRect keyboardFrame = CGRectMake(0, screenHeight, screenWidth, keyboardHeight); - BOOL isLocal = YES; - NSNotification* notification = [NSNotification + BOOL shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillHideNotification]; + XCTAssertTrue(shouldIgnore == NO); + + // Hide notification, keyboard rotated + isLocal = YES; + notification = [NSNotification notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardFrameBeginUserInfoKey" : + [NSValue valueWithCGRect:rotatedKeyboardBeginFrame], + @"UIKeyboardFrameEndUserInfoKey" : + [NSValue valueWithCGRect:validKeyboardEndFrame], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 400; - - [viewControllerMock keyboardWillShowNotification:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); -} - -- (void)testKeyboardWillShowNotificationKeyboardInScreen { - FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); - [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; - FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine - nibName:nil - bundle:nil]; - - // keyboard is docked at bottom of screen - CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; - CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400; - CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight, screenWidth, keyboardHeight); - BOOL isLocal = YES; - NSNotification* notification = [NSNotification + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillHideNotification]; + XCTAssertTrue(shouldIgnore == YES); + + // All zero keyboard + isLocal = YES; + notification = [NSNotification notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:emptyKeyboard], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; - OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) - .ignoringNonObjectArgs() - .andDo(^(NSInvocation* invocation) { - [expectation fulfill]; - }); - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 0; - CGFloat expectedInset = keyboardHeight * UIScreen.mainScreen.scale; - - [viewControllerMock keyboardWillShowNotification:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == expectedInset); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); - [self waitForExpectationsWithTimeout:5.0 handler:nil]; -} - -- (void)testKeyboardWillChangeFrameEmptyKeyboard { - FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); - [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; - FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine - nibName:nil - bundle:nil]; - - // keyboard is empty - CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); - BOOL isLocal = YES; - NSNotification* notification = [NSNotification + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(shouldIgnore == YES); + + // Zero height keyboard + isLocal = NO; + notification = [NSNotification notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], + @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:zeroHeightKeyboard], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 10; - - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 10); -} - -- (void)testKeyboardWillChangeFrameRotating { - FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); - [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; - FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine - nibName:nil - bundle:nil]; - - // keyboard has just been rotated - CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; - CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGRect keyboardBeginFrame = CGRectMake(0, screenWidth - 320, screenHeight, 320); - CGRect keyboardEndFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); - BOOL isLocal = YES; - NSNotification* notification = [NSNotification + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(shouldIgnore == NO); + + // Valid keyboard, triggered from another app + isLocal = NO; + notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : + [NSValue valueWithCGRect:validKeyboardEndFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(shouldIgnore == YES); + + // Valid keyboard, keyboard rotated + isLocal = YES; + notification = [NSNotification notificationWithName:@"" object:nil userInfo:@{ @"UIKeyboardFrameBeginUserInfoKey" : - [NSValue valueWithCGRect:keyboardBeginFrame], - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardEndFrame], + [NSValue valueWithCGRect:rotatedKeyboardBeginFrame], + @"UIKeyboardFrameEndUserInfoKey" : + [NSValue valueWithCGRect:validKeyboardEndFrame], @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 10; - - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 10); + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(shouldIgnore == YES); + + // Valid keyboard, keyboard rotated + isLocal = YES; + notification = [NSNotification + notificationWithName:@"" + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : + [NSValue valueWithCGRect:validKeyboardEndFrame], + @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], + @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + }]; + shouldIgnore = + [viewControllerMock shouldIgnoreKeyboardNotification:notification + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(shouldIgnore == NO); } -- (void)testKeyboardWillChangeFrameKeyboardInScreenAtBottom { +- (void)testCalculateKeyboardAttachMode { FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]); [mockEngine createShell:@"" libraryURI:@"" initialRoute:nil]; FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; - // keyboard is docked at bottom of screen + FlutterViewController* viewControllerMock = OCMPartialMock(viewController); + CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400; - CGRect keyboardFrame = CGRectMake(0, screenHeight - keyboardHeight, screenWidth, keyboardHeight); - BOOL isLocal = YES; - NSNotification* notification = [NSNotification - notificationWithName:@"" - object:nil - userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] - }]; - XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; - OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) - .ignoringNonObjectArgs() - .andDo(^(NSInvocation* invocation) { - [expectation fulfill]; - }); - FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 0; - CGFloat expectedInset = keyboardHeight * UIScreen.mainScreen.scale; - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == expectedInset); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); - [self waitForExpectationsWithTimeout:5.0 handler:nil]; -} + OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); -- (void)testKeyboardWillChangeFrameKeyboardAboveBottom { + // hide notification + CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); + NSNumber keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillHideNotification]; + XCTAssertTrue(keyboardMode == 0); + + // all zeros + keyboardFrame = CGRectMake(0, 0, 0, 0); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 2); + + // 0 height + keyboardFrame = CGRectMake(0, 0, screenWidth, 0); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 0); + + // floating + keyboardFrame = CGRectMake(0, 0, 320, 320); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 2); + + // floating + keyboardFrame = CGRectMake(0, 0, screenWidth, 320); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 2); + + // docked + keyboardFrame = CGRectMake(0, screenHeight-320, screenWidth, 320); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 1); + + // hidden + keyboardFrame = CGRectMake(0, screenHeight, screenWidth, 320); + keyboardMode = + [viewControllerMock calculateKeyboardAttachMode:keyboardFrame + notificationName:UIKeyboardWillChangeFrameNotification]; + XCTAssertTrue(keyboardMode == 0); +} + +- (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 getMainScreen]).andReturn(UIScreen.mainScreen); - // keyboard is not docked, floating above bottom of screen CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400; - CGRect keyboardFrame = - CGRectMake(0, screenHeight - keyboardHeight - 10, screenWidth, keyboardHeight); - 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); - viewControllerMock.targetViewInsetBottom = 400; + 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); + viewControllerMock.view = mockView; - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); + CGFloat adjustment = [viewControllerMock calculateMultitaskingAdjustment:screenRect + keyboardFrame:keyboardFrame]; + XCTAssertTrue(adjustment == 20); } -- (void)testKeyboardWillChangeFrameKeyboardBelowScreen { +- (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 getMainScreen]).andReturn(UIScreen.mainScreen); - // keyboard is hidden below the screen bounds CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400; - CGRect keyboardFrame = CGRectMake(0, screenHeight, screenWidth, keyboardHeight); - 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); - viewControllerMock.targetViewInsetBottom = 400; + 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); + viewControllerMock.view = mockView; - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); - OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); + CGFloat inset = [viewControllerMock calculateKeyboardInset:keyboardFrame keyboardMode:1]; + XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale); } -- (void)testKeyboardWillChangeFrameKeyboardSplitViewBelowScreen { +- (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 hidden below the screen bounds, but is offset by split view padding - CGFloat padding = 25; + // keyboard is empty CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGFloat keyboardHeight = 400 - padding; - CGRect keyboardFrame = CGRectMake(0, screenHeight - padding, screenWidth, keyboardHeight); - CGRect appFrame = CGRectMake(20, 0, screenWidth / 2, screenHeight - padding * 2); + CGRect keyboardFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); + CGRect viewFrame = UIScreen.mainScreen.bounds; BOOL isLocal = YES; NSNotification* notification = [NSNotification notificationWithName:@"" @@ -453,37 +465,24 @@ - (void)testKeyboardWillChangeFrameKeyboardSplitViewBelowScreen { @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] }]; - UIView* mockView = OCMClassMock([UIView class]); - UIWindow* mockWindow = OCMClassMock([UIWindow class]); - OCMStub([mockWindow frame]).andReturn(appFrame); - OCMStub([mockView window]).andReturn(mockWindow); - OCMStub([mockView safeAreaInsets]).andReturn(UIEdgeInsetsMake(20, 20, 20, 20)); FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - viewControllerMock.targetViewInsetBottom = 400; + OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + id mockView = OCMClassMock([UIView class]); + OCMStub([mockView frame]).andReturn(viewFrame); + OCMStub([mockView convertRect:viewFrame toCoordinateSpace:[OCMArg any]]).andReturn(viewFrame); viewControllerMock.view = mockView; + viewControllerMock.targetViewInsetBottom = 0; + XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; + OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) + .ignoringNonObjectArgs() + .andDo(^(NSInvocation* invocation) { + [expectation fulfill]; + }); - [viewControllerMock keyboardWillChangeFrame:notification]; - XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); + [viewControllerMock keyboardWillShowNotification:notification]; + XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 320 * UIScreen.mainScreen.scale); OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); -} - -- (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); - - CGRect screenRect = CGRectMake(0, 0, 810, 1080); - CGRect keyboardFrame = CGRectMake(0, 760, 810, 320); - - CGFloat inset = [viewControllerMock calculateKeyboardInset:screenRect - keyboardFrame:keyboardFrame]; - - // 1080 - 760 = 320 - XCTAssertTrue(inset == 320 * UIScreen.mainScreen.scale); + [self waitForExpectationsWithTimeout:5.0 handler:nil]; } - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { From 084b22865cb6c1f522a2df02c88483bd3cbdd054 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Fri, 2 Dec 2022 12:23:41 -0600 Subject: [PATCH 08/15] move enum to header file, remove unneeded parameters, syntax fixes, remove rotation logic --- .../framework/Source/FlutterViewController.mm | 102 +++----- .../Source/FlutterViewControllerTest.mm | 242 +++++++++--------- .../Source/FlutterViewController_Internal.h | 6 + 3 files changed, 157 insertions(+), 193 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index ed557c60616ed..47f0d34cbf08a 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -593,7 +593,7 @@ - (UIView*)keyboardAnimationView { return _keyboardAnimationView.get(); } -- (UIScreen*)getMainScreen { +- (UIScreen*)mainScreen { if (@available(iOS 13.0, *)) { return self.view.window.windowScene.screen; } @@ -1284,43 +1284,30 @@ - (void)updateViewportPadding { #pragma mark - Keyboard events -typedef NS_ENUM(NSInteger, FlutterKeyboardMode) { - FlutterKeyboardModeHidden = 0, - FlutterKeyboardModeDocked = 1, - FlutterKeyboardModeFloating = 2, -}; - - (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. - [self handleKeyboardNotification:notification notificationName:UIKeyboardWillShowNotification]; + [self handleKeyboardNotification:notification]; } - (void)keyboardWillChangeFrame:(NSNotification*)notification { // Immediately prior to a change in keyboard frame, this notification is triggered. - // There are some cases where UIKeyboardWillShowNotification & UIKeyboardWillHideNotification - // do not act as expected and this is used to catch those cases. - [self handleKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; + [self handleKeyboardNotification:notification]; } - (void)keyboardWillBeHidden:(NSNotification*)notification { // When keyboard is hidden or undocked, this notification will be triggered. - [self handleKeyboardNotification:notification notificationName:UIKeyboardWillHideNotification]; + [self handleKeyboardNotification:notification]; } -- (void)handleKeyboardNotification:(NSNotification*)notification - notificationName:(NSNotificationName)notificationName { - BOOL ignoreNotification = [self shouldIgnoreKeyboardNotification:notification - notificationName:notificationName]; - if (ignoreNotification) { +- (void)handleKeyboardNotification:(NSNotification*)notification { + if ([self shouldIgnoreKeyboardNotification:notification]) { return; } NSDictionary* info = notification.userInfo; - CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; - FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:keyboardFrame - notificationName:notificationName]; + CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:notification]; CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode]; // Avoid double triggering startKeyBoardAnimation. @@ -1329,30 +1316,22 @@ - (void)handleKeyboardNotification:(NSNotification*)notification } self.targetViewInsetBottom = calculatedInset; - NSTimeInterval duration = - [[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; [self startKeyBoardAnimation:duration]; } -- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification - notificationName:(NSNotificationName)notificationName { +- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { BOOL isKeyboardNotificationForThisView = [self isKeyboardNotificationForThisView:notification]; - BOOL isKeyboardRotated = [self isKeyboardRotated:notification]; - // Don't ignore UIKeyboardWillHideNotification notifications. - if (notificationName == UIKeyboardWillHideNotification) { - // Skip hide notification when rotation in progress unless triggered by another app. - if (isKeyboardRotated && isKeyboardNotificationForThisView) { - return YES; - } + 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. NSDictionary* info = notification.userInfo; - CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; - if (notificationName == UIKeyboardWillChangeFrameNotification && + CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + if (notification.name == UIKeyboardWillChangeFrameNotification && CGRectEqualToRect(keyboardFrame, CGRectZero)) { return YES; } @@ -1367,11 +1346,6 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification return YES; } - // Ignore notification when keyboard is in process of being rotated. - if (isKeyboardRotated) { - return YES; - } - return NO; } @@ -1389,34 +1363,18 @@ - (BOOL)isKeyboardNotificationForThisView:(NSNotification*)notification { return YES; } -- (BOOL)isKeyboardRotated:(NSNotification*)notification { - // When the keyboard's width at the beginning of the animation equals the screen's - // current height, we can assume the keyboard was rotated. - NSDictionary* info = notification.userInfo; - CGRect screenRect = [self getMainScreen].bounds; - CGFloat screenHeight = CGRectGetHeight(screenRect); - CGRect keyboardEndFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue]; - CGRect keyboardBeginFrame = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue]; - CGFloat keyboardBeginWidth = CGRectGetWidth(keyboardBeginFrame); - if (CGRectEqualToRect(keyboardEndFrame, CGRectZero)) { - return NO; - } - if (screenHeight == keyboardBeginWidth) { - return YES; - } - return NO; -} - -- (FlutterKeyboardMode)calculateKeyboardAttachMode:(CGRect)keyboardFrame - notificationName:(NSNotificationName)notificationName { +- (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification { // There are multiple types of keyboard: docked, undocked, split, split docked, - // floating, predictive-only, minimized. This function will categorize + // 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, predictive-only (when opening via click), - // and minimized (when opened via click). - // Floating includes undocked, split, floating, predictive-only (when dragged and dropped), - // and minimized (when dragged and dropped). - if (notificationName == UIKeyboardWillHideNotification) { + // 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; } @@ -1471,9 +1429,9 @@ - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGR if (screenHeight == keyboardBottom) { return 0; } - CGRect viewFrameInScreen = [self.view convertRect:self.view.frame - toCoordinateSpace:[self getMainScreen].coordinateSpace]; - CGFloat viewBottom = CGRectGetMaxY(viewFrameInScreen); + CGRect viewRectRelativeToScreen = [self.view convertRect:self.view.frame + toCoordinateSpace:[self mainScreen].coordinateSpace]; + CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen); CGFloat offset = screenHeight - viewBottom; if (offset > 0) { return offset; @@ -1486,15 +1444,15 @@ - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(NSInteger) // Only docked keyboards will have an inset. if (keyboardMode == FlutterKeyboardModeDocked) { // Calculate how much of the keyboard intersects with the view. - CGRect viewFrameInScreen = [self.view convertRect:self.view.frame - toCoordinateSpace:[self getMainScreen].coordinateSpace]; - CGRect intersection = CGRectIntersection(keyboardFrame, viewFrameInScreen); + CGRect viewRectRelativeToScreen = [self.view convertRect:self.view.frame + toCoordinateSpace:[self mainScreen].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 getMainScreen].scale; + CGFloat scale = [self mainScreen].scale; return portionOfKeyboardInView * scale; } return 0; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 03c2baf6e3d3b..2aea12adf8bb5 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -124,15 +124,13 @@ - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; - (void)goToApplicationLifecycle:(nonnull NSString*)state; -- (UIScreen*)getMainScreen; +- (UIScreen*)mainScreen; - (void)keyboardWillShowNotification:(NSNotification*)notification; - (void)keyboardWillChangeFrame:(NSNotification*)notification; - (void)keyboardWillBeHidden:(NSNotification*)notification; - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(int)keyboardMode; -- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification - notificationName:(NSNotificationName)notificationName; -- (NSInteger)calculateKeyboardAttachMode:(CGRect)keyboardFrame - notificationName:(NSNotificationName)notificationName; +- (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification; +- (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification; - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame; - (void)startKeyBoardAnimation:(NSTimeInterval)duration; - (void)setupKeyboardAnimationVsyncClient; @@ -200,127 +198,80 @@ - (void)testShouldIgnoreKeyboardNotification { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - CGRect emptyKeyboard = CGRectMake(0, 0, 0, 0); + CGRect emptyKeyboard = CGRectZero; CGRect zeroHeightKeyboard = CGRectMake(0, 0, screenWidth, 0); - CGRect rotatedKeyboardBeginFrame = CGRectMake(0, screenWidth - 320, screenHeight, 320); CGRect validKeyboardEndFrame = CGRectMake(0, screenHeight - 320, screenWidth, 320); BOOL isLocal = NO; // Hide notification, valid keyboard NSNotification* notification = [NSNotification - notificationWithName:@"" + notificationWithName:UIKeyboardWillHideNotification object:nil userInfo:@{ @"UIKeyboardFrameEndUserInfoKey" : - [NSValue valueWithCGRect:validKeyboardEndFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @(validKeyboardEndFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; - BOOL shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillHideNotification]; + BOOL shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == NO); - // Hide notification, keyboard rotated - isLocal = YES; - notification = [NSNotification - notificationWithName:@"" - object:nil - userInfo:@{ - @"UIKeyboardFrameBeginUserInfoKey" : - [NSValue valueWithCGRect:rotatedKeyboardBeginFrame], - @"UIKeyboardFrameEndUserInfoKey" : - [NSValue valueWithCGRect:validKeyboardEndFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] - }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillHideNotification]; - XCTAssertTrue(shouldIgnore == YES); - // All zero keyboard isLocal = YES; notification = [NSNotification - notificationWithName:@"" + notificationWithName:UIKeyboardWillChangeFrameNotification object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:emptyKeyboard], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @"UIKeyboardFrameEndUserInfoKey" : @(emptyKeyboard), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; + shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == YES); // Zero height keyboard isLocal = NO; notification = [NSNotification - notificationWithName:@"" + notificationWithName:UIKeyboardWillChangeFrameNotification object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:zeroHeightKeyboard], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @"UIKeyboardFrameEndUserInfoKey" : @(zeroHeightKeyboard), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; + shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == NO); // Valid keyboard, triggered from another app isLocal = NO; notification = [NSNotification - notificationWithName:@"" + notificationWithName:UIKeyboardWillChangeFrameNotification object:nil userInfo:@{ @"UIKeyboardFrameEndUserInfoKey" : - [NSValue valueWithCGRect:validKeyboardEndFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] - }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(shouldIgnore == YES); - - // Valid keyboard, keyboard rotated - isLocal = YES; - notification = [NSNotification - notificationWithName:@"" - object:nil - userInfo:@{ - @"UIKeyboardFrameBeginUserInfoKey" : - [NSValue valueWithCGRect:rotatedKeyboardBeginFrame], - @"UIKeyboardFrameEndUserInfoKey" : - [NSValue valueWithCGRect:validKeyboardEndFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @(validKeyboardEndFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; + shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == YES); - // Valid keyboard, keyboard rotated + // Valid keyboard isLocal = YES; notification = [NSNotification - notificationWithName:@"" + notificationWithName:UIKeyboardWillChangeFrameNotification object:nil userInfo:@{ @"UIKeyboardFrameEndUserInfoKey" : - [NSValue valueWithCGRect:validKeyboardEndFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @(validKeyboardEndFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; - shouldIgnore = - [viewControllerMock shouldIgnoreKeyboardNotification:notification - notificationName:UIKeyboardWillChangeFrameNotification]; + shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == NO); } @@ -336,56 +287,105 @@ - (void)testCalculateKeyboardAttachMode { CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); // hide notification - CGRect keyboardFrame = CGRectMake(0, 0, 0, 0); - NSNumber keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillHideNotification]; - XCTAssertTrue(keyboardMode == 0); + 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 = CGRectMake(0, 0, 0, 0); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 2); + 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); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 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); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 2); + notification = [NSNotification + notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : + @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(YES) + }]; + keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification]; + XCTAssertTrue(keyboardMode == FlutterKeyboardModeFloating); - // floating + // undocked keyboardFrame = CGRectMake(0, 0, screenWidth, 320); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 2); + 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); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 1); + 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); // hidden keyboardFrame = CGRectMake(0, screenHeight, screenWidth, 320); - keyboardMode = - [viewControllerMock calculateKeyboardAttachMode:keyboardFrame - notificationName:UIKeyboardWillChangeFrameNotification]; - XCTAssertTrue(keyboardMode == 0); + notification = [NSNotification + notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : + @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(YES) + }]; + keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification]; + XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden); } - (void)testCalculateMultitaskingAdjustment { @@ -395,7 +395,7 @@ - (void)testCalculateMultitaskingAdjustment { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -427,7 +427,7 @@ - (void)testCalculateKeyboardInset { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -461,12 +461,12 @@ - (void)testHandleKeyboardNotification { notificationWithName:@"" object:nil userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame], - @"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25], - @"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal] + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock getMainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); id mockView = OCMClassMock([UIView class]); OCMStub([mockView frame]).andReturn(viewFrame); OCMStub([mockView convertRect:viewFrame toCoordinateSpace:[OCMArg any]]).andReturn(viewFrame); @@ -493,13 +493,13 @@ - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { 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], + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), @"UIKeyboardAnimationDurationUserInfoKey" : @(0.25), @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h b/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h index 1b4af46a68b36..664b4c282b9f8 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 @@ extern NSNotificationName const FlutterViewControllerHideHomeIndicator; FLUTTER_DARWIN_EXPORT extern NSNotificationName const FlutterViewControllerShowHomeIndicator; +typedef NS_ENUM(NSInteger, FlutterKeyboardMode) { + FlutterKeyboardModeHidden = 0, + FlutterKeyboardModeDocked = 1, + FlutterKeyboardModeFloating = 2, +}; + @interface FlutterViewController () @property(class, nonatomic, readonly) BOOL accessibilityIsOnOffSwitchLabelsEnabled; From cbece735424a6e4ee11fb6e57a7162ae88aae04d Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Fri, 2 Dec 2022 15:53:22 -0600 Subject: [PATCH 09/15] ignore notification if app state is not active, change way it checks if keyboard intersects with screen to accomodate for repeating decimals, format --- .../framework/Source/FlutterViewController.mm | 55 ++-- .../Source/FlutterViewControllerTest.mm | 278 ++++++++++-------- 2 files changed, 186 insertions(+), 147 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 47f0d34cbf08a..b4f997b5843ba 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1297,10 +1297,12 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification { - (void)keyboardWillBeHidden:(NSNotification*)notification { // When keyboard is hidden or undocked, this notification will be triggered. - [self handleKeyboardNotification:notification]; + [self handleKeyboardNotification:notification]; } - (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; } @@ -1321,7 +1323,6 @@ - (void)handleKeyboardNotification:(NSNotification*)notification { } - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { - BOOL isKeyboardNotificationForThisView = [self isKeyboardNotificationForThisView:notification]; // Don't ignore UIKeyboardWillHideNotification notifications. if (notification.name == UIKeyboardWillHideNotification) { return NO; @@ -1336,31 +1337,38 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { return YES; } - // Don't ignore other times a keyboard's height or width is 0. + // When keyboard's height/width is set to 0 by other app, + // do not ignore so that the inset will be set to 0. if (CGRectIsEmpty(keyboardFrame)) { return NO; } // Ignore keyboard notifications related to other apps. - if (!isKeyboardNotificationForThisView) { + if ([self isKeyboardNotificationForDifferentView:notification]) { + return YES; + } + + // Ignore notification if the app is not active (meaning it's running in the + // background, interrupted, or the app is transitioning to or from the background). + if (UIApplication.sharedApplication.applicationState != UIApplicationStateActive) { return YES; } return NO; } -- (BOOL)isKeyboardNotificationForThisView:(NSNotification*)notification { +- (BOOL)isKeyboardNotificationForDifferentView:(NSNotification*)notification { NSDictionary* info = notification.userInfo; // Keyboard notifications related to other apps. id isLocal = info[UIKeyboardIsLocalUserInfoKey]; if (isLocal && ![isLocal boolValue]) { - return NO; + return YES; } // Engine’s viewController is not current viewController. if ([_engine.get() viewController] != self) { - return NO; + return YES; } - return YES; + return NO; } - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification { @@ -1388,27 +1396,24 @@ - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification return FlutterKeyboardModeHidden; } - CGRect screenRect = [self getMainScreen].bounds; - CGFloat keyboardWidth = CGRectGetWidth(keyboardFrame); - CGFloat screenWidth = CGRectGetWidth(screenRect); - - // If keyboard is not full width, it's floating. - if (keyboardWidth != screenWidth) { - return FlutterKeyboardModeFloating; - } - - CGFloat screenHeight = CGRectGetHeight(screenRect); + CGRect screenRect = [self mainScreen].bounds; CGRect adjustedKeyboardFrame = keyboardFrame; adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect keyboardFrame:keyboardFrame]; - CGFloat adjustedKeyboardBottom = CGRectGetMaxY(adjustedKeyboardFrame); - // If the keyboard is above the bottom of the screen, it's floating. - if (adjustedKeyboardBottom < screenHeight) { - return FlutterKeyboardModeFloating; - } - // If the keyboard is partially or fully showing at the bottom of the screen, it's docked. - if (CGRectIntersectsRect(adjustedKeyboardFrame, screenRect)) { + // 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. 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; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 2aea12adf8bb5..b946b450d5d28 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -197,6 +197,9 @@ - (void)testShouldIgnoreKeyboardNotification { FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; + id mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; + UIApplicationState applicationState = UIApplicationStateActive; + [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); @@ -208,71 +211,86 @@ - (void)testShouldIgnoreKeyboardNotification { BOOL isLocal = NO; // Hide notification, valid keyboard - NSNotification* notification = [NSNotification - notificationWithName:UIKeyboardWillHideNotification - object:nil - userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : - @(validKeyboardEndFrame), - @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, - @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + notification = + [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) + }]; shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == NO); + + // Valid keyboard, state is not active + [mockApplication stopMocking]; + mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; + applicationState = UIApplicationStateBackground; + [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; + isLocal = YES; + notification = + [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(validKeyboardEndFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) + }]; + shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; + XCTAssertTrue(shouldIgnore == YES); + + [mockApplication stopMocking]; } - (void)testCalculateKeyboardAttachMode { @@ -291,99 +309,111 @@ - (void)testCalculateKeyboardAttachMode { // hide notification CGRect keyboardFrame = CGRectZero; - NSNotification* notification = [NSNotification - notificationWithName:UIKeyboardWillHideNotification - object:nil - userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : - @(keyboardFrame), - @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, - @"UIKeyboardIsLocalUserInfoKey" : @(YES) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + 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) - }]; + notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(YES) + }]; keyboardMode = [viewControllerMock calculateKeyboardAttachMode:notification]; XCTAssertTrue(keyboardMode == FlutterKeyboardModeHidden); } @@ -451,20 +481,23 @@ - (void)testHandleKeyboardNotification { FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; + id mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; + UIApplicationState applicationState = UIApplicationStateActive; + [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; // 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:@"" - object:nil - userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), - @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, - @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) - }]; + NSNotification* notification = + [NSNotification notificationWithName:UIKeyboardWillShowNotification + object:nil + userInfo:@{ + @"UIKeyboardFrameEndUserInfoKey" : @(keyboardFrame), + @"UIKeyboardAnimationDurationUserInfoKey" : @0.25, + @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) + }]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); id mockView = OCMClassMock([UIView class]); @@ -483,6 +516,7 @@ - (void)testHandleKeyboardNotification { XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 320 * UIScreen.mainScreen.scale); OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); [self waitForExpectationsWithTimeout:5.0 handler:nil]; + [mockApplication stopMocking]; } - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { @@ -495,14 +529,14 @@ - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { FlutterViewController* viewControllerMock = OCMPartialMock(viewController); CGRect keyboardFrame = CGRectZero; BOOL isLocal = YES; - NSNotification* fakeNotification = [NSNotification - notificationWithName:@"" - object:nil - userInfo:@{ - @"UIKeyboardFrameEndUserInfoKey" : @(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]; From 39313a7739605ddadb8654f6ace90b111a9cd97a Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Tue, 6 Dec 2022 17:45:45 -0600 Subject: [PATCH 10/15] fix leaking unit test --- .../Source/FlutterPlatformPluginTest.mm | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm index 94553aaf1fe34..fec7983d7a8c2 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformPluginTest.mm @@ -22,11 +22,11 @@ @implementation FlutterPlatformPluginTest - (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> _weakFactory = std::make_unique>(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)testClipboardHasCorrectStrings { - (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> _weakFactory = std::make_unique>(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)testClipboardSetDataToNullDoNotCrash { } - (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> _weakFactory = std::make_unique>(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 @@ - (void)testPopSystemNavigator { [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> _weakFactory = std::make_unique>(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" From 602c7038a4b7accfab68a1d8d54265cce172c6d0 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Tue, 6 Dec 2022 19:57:29 -0600 Subject: [PATCH 11/15] use viewIfLoaded and update tests to fix mocking --- .../framework/Source/FlutterViewController.mm | 18 +++++---- .../Source/FlutterViewControllerTest.mm | 37 +++++++++---------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index b4f997b5843ba..e7412a5a02e22 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -595,7 +595,7 @@ - (UIView*)keyboardAnimationView { - (UIScreen*)mainScreen { if (@available(iOS 13.0, *)) { - return self.view.window.windowScene.screen; + return self.viewIfLoaded.window.windowScene.screen; } return UIScreen.mainScreen; } @@ -1423,9 +1423,9 @@ - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGR // 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.view.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad && - self.view.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact && - self.view.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular) { + if (self.viewIfLoaded.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad && + self.viewIfLoaded.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact && + self.viewIfLoaded.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassRegular) { CGFloat screenHeight = CGRectGetHeight(screenRect); CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame); @@ -1434,8 +1434,9 @@ - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGR if (screenHeight == keyboardBottom) { return 0; } - CGRect viewRectRelativeToScreen = [self.view convertRect:self.view.frame - toCoordinateSpace:[self mainScreen].coordinateSpace]; + CGRect viewRectRelativeToScreen = + [self.viewIfLoaded convertRect:self.viewIfLoaded.frame + toCoordinateSpace:[self mainScreen].coordinateSpace]; CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen); CGFloat offset = screenHeight - viewBottom; if (offset > 0) { @@ -1449,8 +1450,9 @@ - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(NSInteger) // Only docked keyboards will have an inset. if (keyboardMode == FlutterKeyboardModeDocked) { // Calculate how much of the keyboard intersects with the view. - CGRect viewRectRelativeToScreen = [self.view convertRect:self.view.frame - toCoordinateSpace:[self mainScreen].coordinateSpace]; + CGRect viewRectRelativeToScreen = + [self.viewIfLoaded convertRect:self.viewIfLoaded.frame + toCoordinateSpace:[self mainScreen].coordinateSpace]; CGRect intersection = CGRectIntersection(keyboardFrame, viewRectRelativeToScreen); CGFloat portionOfKeyboardInView = CGRectGetHeight(intersection); diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index b946b450d5d28..8b47eeb5f1b76 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -125,9 +125,7 @@ - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; - (void)goToApplicationLifecycle:(nonnull NSString*)state; - (UIScreen*)mainScreen; -- (void)keyboardWillShowNotification:(NSNotification*)notification; -- (void)keyboardWillChangeFrame:(NSNotification*)notification; -- (void)keyboardWillBeHidden:(NSNotification*)notification; +- (void)handleKeyboardNotification:(NSNotification*)notification; - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(int)keyboardMode; - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification; - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification; @@ -197,9 +195,9 @@ - (void)testShouldIgnoreKeyboardNotification { FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; - id mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; - UIApplicationState applicationState = UIApplicationStateActive; - [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateActive); FlutterViewController* viewControllerMock = OCMPartialMock(viewController); OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); @@ -275,9 +273,9 @@ - (void)testShouldIgnoreKeyboardNotification { // Valid keyboard, state is not active [mockApplication stopMocking]; - mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; - applicationState = UIApplicationStateBackground; - [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; + mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateBackground); isLocal = YES; notification = [NSNotification notificationWithName:UIKeyboardWillChangeFrameNotification @@ -289,8 +287,6 @@ - (void)testShouldIgnoreKeyboardNotification { }]; shouldIgnore = [viewControllerMock shouldIgnoreKeyboardNotification:notification]; XCTAssertTrue(shouldIgnore == YES); - - [mockApplication stopMocking]; } - (void)testCalculateKeyboardAttachMode { @@ -443,7 +439,7 @@ - (void)testCalculateMultitaskingAdjustment { OCMStub([mockTraitCollection horizontalSizeClass]).andReturn(UIUserInterfaceSizeClassCompact); OCMStub([mockTraitCollection verticalSizeClass]).andReturn(UIUserInterfaceSizeClassRegular); OCMStub([mockView traitCollection]).andReturn(mockTraitCollection); - viewControllerMock.view = mockView; + OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView); CGFloat adjustment = [viewControllerMock calculateMultitaskingAdjustment:screenRect keyboardFrame:keyboardFrame]; @@ -469,9 +465,10 @@ - (void)testCalculateKeyboardInset { OCMStub([mockView frame]).andReturn(viewOrigFrame); OCMStub([mockView convertRect:viewOrigFrame toCoordinateSpace:[OCMArg any]]) .andReturn(convertedViewFrame); - viewControllerMock.view = mockView; + OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView); - CGFloat inset = [viewControllerMock calculateKeyboardInset:keyboardFrame keyboardMode:1]; + CGFloat inset = [viewControllerMock calculateKeyboardInset:keyboardFrame + keyboardMode:FlutterKeyboardModeDocked]; XCTAssertTrue(inset == 300 * UIScreen.mainScreen.scale); } @@ -481,9 +478,9 @@ - (void)testHandleKeyboardNotification { FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine nibName:nil bundle:nil]; - id mockApplication = [OCMockObject partialMockForObject:[UIApplication sharedApplication]]; - UIApplicationState applicationState = UIApplicationStateActive; - [[[mockApplication stub] andReturnValue:OCMOCK_VALUE(applicationState)] applicationState]; + id mockApplication = OCMClassMock([UIApplication class]); + OCMStub([mockApplication sharedApplication]).andReturn(mockApplication); + OCMStub([mockApplication applicationState]).andReturn(UIApplicationStateActive); // keyboard is empty CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -503,7 +500,7 @@ - (void)testHandleKeyboardNotification { id mockView = OCMClassMock([UIView class]); OCMStub([mockView frame]).andReturn(viewFrame); OCMStub([mockView convertRect:viewFrame toCoordinateSpace:[OCMArg any]]).andReturn(viewFrame); - viewControllerMock.view = mockView; + OCMStub([viewControllerMock viewIfLoaded]).andReturn(mockView); viewControllerMock.targetViewInsetBottom = 0; XCTestExpectation* expectation = [self expectationWithDescription:@"update viewport"]; OCMStub([mockEngine updateViewportMetrics:flutter::ViewportMetrics()]) @@ -512,7 +509,7 @@ - (void)testHandleKeyboardNotification { [expectation fulfill]; }); - [viewControllerMock keyboardWillShowNotification:notification]; + [viewControllerMock handleKeyboardNotification:notification]; XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 320 * UIScreen.mainScreen.scale); OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]); [self waitForExpectationsWithTimeout:5.0 handler:nil]; @@ -539,7 +536,7 @@ - (void)testEnsureBottomInsetIsZeroWhenKeyboardDismissed { }]; viewControllerMock.targetViewInsetBottom = 10; - [viewControllerMock keyboardWillBeHidden:fakeNotification]; + [viewControllerMock handleKeyboardNotification:fakeNotification]; XCTAssertTrue(viewControllerMock.targetViewInsetBottom == 0); } From ea788a185241f5bff4d8c0869ca6cc319e3084ac Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Fri, 9 Dec 2022 14:12:12 -0600 Subject: [PATCH 12/15] change ignore logic related to application state to be more specific --- .../framework/Source/FlutterViewController.mm | 18 ++++++-- .../Source/FlutterViewControllerTest.mm | 42 +++++++++---------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index e7412a5a02e22..f5f49d7c6d255 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -65,6 +65,7 @@ @interface FlutterViewController () Date: Mon, 12 Dec 2022 10:43:00 -0600 Subject: [PATCH 13/15] add more comments --- .../ios/framework/Source/FlutterViewController.mm | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index f5f49d7c6d255..1f3effd7f4f90 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1327,6 +1327,9 @@ - (void)handleKeyboardNotification:(NSNotification*)notification { - (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; } @@ -1340,13 +1343,13 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { return YES; } - // When keyboard's height/width is set to 0 by other app, - // do not ignore so that the inset will be set to 0. + // 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. + // Ignore keyboard notifications related to other apps or view controllers. if ([self isKeyboardNotificationForDifferentView:notification]) { return YES; } @@ -1359,7 +1362,7 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { // 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 == YES) { + if (self.isKeyboardInOrTransitioningFromBackground) { return YES; } } @@ -1413,7 +1416,7 @@ - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification // 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. Round to compare. + // 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); From 010e86b8dce4c6a5f4328733904af8bef1e06c46 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Tue, 13 Dec 2022 12:08:47 -0600 Subject: [PATCH 14/15] add more comments --- .../framework/Source/FlutterViewController.mm | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 1f3effd7f4f90..dcc29a51d8c9e 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -1289,17 +1289,24 @@ - (void)updateViewportPadding { - (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. + // 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 { // 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 { // 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]; } @@ -1327,15 +1334,18 @@ - (void)handleKeyboardNotification:(NSNotification*)notification { - (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. + // 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. + // 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 && @@ -1373,6 +1383,8 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { - (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), proceed as if + // it was local so that the notification is not ignored. id isLocal = info[UIKeyboardIsLocalUserInfoKey]; if (isLocal && ![isLocal boolValue]) { return YES; @@ -1399,8 +1411,8 @@ - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification return FlutterKeyboardModeHidden; } - // If keyboard's dimensions and position are all zeroes, - // that means it's been dragged and therefore floating. + // 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; } From aa6276e10ad6ded0beb653b624b47693b3b83b32 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth Date: Tue, 13 Dec 2022 13:41:16 -0600 Subject: [PATCH 15/15] change function name to be more clear, add warning log if view is not loaded, update a comment --- .../framework/Source/FlutterViewController.mm | 17 ++++++++++------- .../Source/FlutterViewControllerTest.mm | 12 ++++++------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index dcc29a51d8c9e..79ea9c1ddbe27 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -594,8 +594,11 @@ - (UIView*)keyboardAnimationView { return _keyboardAnimationView.get(); } -- (UIScreen*)mainScreen { +- (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; @@ -1383,8 +1386,8 @@ - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification { - (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), proceed as if - // it was local so that the notification is not ignored. + // 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 YES; @@ -1421,7 +1424,7 @@ - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification return FlutterKeyboardModeHidden; } - CGRect screenRect = [self mainScreen].bounds; + CGRect screenRect = [self mainScreenIfViewLoaded].bounds; CGRect adjustedKeyboardFrame = keyboardFrame; adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect keyboardFrame:keyboardFrame]; @@ -1461,7 +1464,7 @@ - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGR } CGRect viewRectRelativeToScreen = [self.viewIfLoaded convertRect:self.viewIfLoaded.frame - toCoordinateSpace:[self mainScreen].coordinateSpace]; + toCoordinateSpace:[self mainScreenIfViewLoaded].coordinateSpace]; CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen); CGFloat offset = screenHeight - viewBottom; if (offset > 0) { @@ -1477,14 +1480,14 @@ - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(NSInteger) // Calculate how much of the keyboard intersects with the view. CGRect viewRectRelativeToScreen = [self.viewIfLoaded convertRect:self.viewIfLoaded.frame - toCoordinateSpace:[self mainScreen].coordinateSpace]; + 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 mainScreen].scale; + CGFloat scale = [self mainScreenIfViewLoaded].scale; return portionOfKeyboardInView * scale; } return 0; diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 652d9f12adc5c..60d14412e0803 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -125,7 +125,7 @@ - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; - (void)goToApplicationLifecycle:(nonnull NSString*)state; -- (UIScreen*)mainScreen; +- (UIScreen*)mainScreenIfViewLoaded; - (void)handleKeyboardNotification:(NSNotification*)notification; - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame keyboardMode:(int)keyboardMode; - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification; @@ -197,7 +197,7 @@ - (void)testShouldIgnoreKeyboardNotification { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -301,7 +301,7 @@ - (void)testCalculateKeyboardAttachMode { CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; - OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen); // hide notification CGRect keyboardFrame = CGRectZero; @@ -421,7 +421,7 @@ - (void)testCalculateMultitaskingAdjustment { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -453,7 +453,7 @@ - (void)testCalculateKeyboardInset { nibName:nil bundle:nil]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); + OCMStub([viewControllerMock mainScreenIfViewLoaded]).andReturn(UIScreen.mainScreen); CGFloat screenWidth = UIScreen.mainScreen.bounds.size.width; CGFloat screenHeight = UIScreen.mainScreen.bounds.size.height; @@ -493,7 +493,7 @@ - (void)testHandleKeyboardNotification { @"UIKeyboardIsLocalUserInfoKey" : @(isLocal) }]; FlutterViewController* viewControllerMock = OCMPartialMock(viewController); - OCMStub([viewControllerMock mainScreen]).andReturn(UIScreen.mainScreen); + 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);