Skip to content

Commit a43ed2e

Browse files
authored
[iOS] Support keyboard animation on iOS (flutter#29281)
1 parent 3e3bca0 commit a43ed2e

File tree

2 files changed

+145
-7
lines changed

2 files changed

+145
-7
lines changed

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

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ @interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegat
6969
@property(nonatomic, assign) BOOL isHomeIndicatorHidden;
7070
@property(nonatomic, assign) BOOL isPresentingViewControllerAnimating;
7171

72+
/**
73+
* Keyboard animation properties
74+
*/
75+
@property(nonatomic, assign) double targetViewInsetBottom;
76+
@property(nonatomic, strong) CADisplayLink* displayLink;
77+
7278
/**
7379
* Creates and registers plugins used by this view controller.
7480
*/
@@ -113,6 +119,7 @@ @implementation FlutterViewController {
113119
fml::scoped_nsobject<UIScrollView> _scrollView;
114120
fml::scoped_nsobject<UIPointerInteraction> _pointerInteraction API_AVAILABLE(ios(13.4));
115121
fml::scoped_nsobject<UIPanGestureRecognizer> _panGestureRecognizer API_AVAILABLE(ios(13.4));
122+
fml::scoped_nsobject<UIView> _keyboardAnimationView;
116123
MouseState _mouseState;
117124
}
118125

@@ -542,6 +549,10 @@ - (UIView*)splashScreenView {
542549
return _splashScreenView.get();
543550
}
544551

552+
- (UIView*)keyboardAnimationView {
553+
return _keyboardAnimationView.get();
554+
}
555+
545556
- (BOOL)loadDefaultSplashScreenView {
546557
NSString* launchscreenName =
547558
[[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"];
@@ -719,6 +730,8 @@ - (void)viewWillDisappear:(BOOL)animated {
719730
- (void)viewDidDisappear:(BOOL)animated {
720731
TRACE_EVENT0("flutter", "viewDidDisappear");
721732
if ([_engine.get() viewController] == self) {
733+
[self invalidateDisplayLink];
734+
[self ensureViewportMetricsIsCorrect];
722735
[self surfaceUpdated:NO];
723736
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"];
724737
[self flushOngoingTouches];
@@ -1104,29 +1117,115 @@ - (void)keyboardWillChangeFrame:(NSNotification*)notification {
11041117
}
11051118
}
11061119

1120+
// Ignore keyboard notifications if engine’s viewController is not current viewController.
1121+
if ([_engine.get() viewController] != self) {
1122+
return;
1123+
}
1124+
11071125
CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
11081126
CGRect screenRect = [[UIScreen mainScreen] bounds];
11091127

1128+
// Get the animation duration
1129+
NSTimeInterval duration =
1130+
[[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
1131+
11101132
// Considering the iPad's split keyboard, Flutter needs to check if the keyboard frame is present
11111133
// in the screen to see if the keyboard is visible.
11121134
if (CGRectIntersectsRect(keyboardFrame, screenRect)) {
11131135
CGFloat bottom = CGRectGetHeight(keyboardFrame);
11141136
CGFloat scale = [UIScreen mainScreen].scale;
1115-
11161137
// The keyboard is treated as an inset since we want to effectively reduce the window size by
11171138
// the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
11181139
// bottom padding.
1119-
_viewportMetrics.physical_view_inset_bottom = bottom * scale;
1140+
self.targetViewInsetBottom = bottom * scale;
11201141
} else {
1121-
_viewportMetrics.physical_view_inset_bottom = 0;
1142+
self.targetViewInsetBottom = 0;
11221143
}
1123-
1124-
[self updateViewportMetrics];
1144+
[self startKeyBoardAnimation:duration];
11251145
}
11261146

11271147
- (void)keyboardWillBeHidden:(NSNotification*)notification {
1128-
_viewportMetrics.physical_view_inset_bottom = 0;
1129-
[self updateViewportMetrics];
1148+
// When keyboard hide, the keyboardWillChangeFrame function will be called to update viewport
1149+
// metrics. So do not call [self updateViewportMetrics] here again.
1150+
}
1151+
1152+
- (void)startKeyBoardAnimation:(NSTimeInterval)duration {
1153+
// If current physical_view_inset_bottom == targetViewInsetBottom,do nothing.
1154+
if (_viewportMetrics.physical_view_inset_bottom == self.targetViewInsetBottom) {
1155+
return;
1156+
}
1157+
1158+
// When call this method first time,
1159+
// initialize the keyboardAnimationView to get animation interpolation during animation.
1160+
if ([self keyboardAnimationView] == nil) {
1161+
UIView* keyboardAnimationView = [[UIView alloc] init];
1162+
[keyboardAnimationView setHidden:YES];
1163+
_keyboardAnimationView.reset(keyboardAnimationView);
1164+
}
1165+
1166+
if ([self keyboardAnimationView].superview == nil) {
1167+
[self.view addSubview:[self keyboardAnimationView]];
1168+
}
1169+
1170+
// Remove running animation when start another animation.
1171+
// After calling this line,the old display link will invalidate.
1172+
[[self keyboardAnimationView].layer removeAllAnimations];
1173+
1174+
// Set animation begin value.
1175+
[self keyboardAnimationView].frame =
1176+
CGRectMake(0, _viewportMetrics.physical_view_inset_bottom, 0, 0);
1177+
1178+
// Invalidate old display link if the old animation is not complete
1179+
[self invalidateDisplayLink];
1180+
1181+
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink)];
1182+
[self.displayLink addToRunLoop:NSRunLoop.currentRunLoop forMode:NSRunLoopCommonModes];
1183+
__block CADisplayLink* currentDisplayLink = self.displayLink;
1184+
1185+
[UIView animateWithDuration:duration
1186+
animations:^{
1187+
// Set end value.
1188+
[self keyboardAnimationView].frame = CGRectMake(0, self.targetViewInsetBottom, 0, 0);
1189+
}
1190+
completion:^(BOOL finished) {
1191+
if (self.displayLink == currentDisplayLink) {
1192+
[self invalidateDisplayLink];
1193+
}
1194+
if (finished) {
1195+
[self removeKeyboardAnimationView];
1196+
[self ensureViewportMetricsIsCorrect];
1197+
}
1198+
}];
1199+
}
1200+
1201+
- (void)invalidateDisplayLink {
1202+
[self.displayLink invalidate];
1203+
}
1204+
1205+
- (void)removeKeyboardAnimationView {
1206+
if ([self keyboardAnimationView].superview != nil) {
1207+
[[self keyboardAnimationView] removeFromSuperview];
1208+
}
1209+
}
1210+
1211+
- (void)ensureViewportMetricsIsCorrect {
1212+
if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) {
1213+
// Make sure the `physical_view_inset_bottom` is the target value.
1214+
_viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom;
1215+
[self updateViewportMetrics];
1216+
}
1217+
}
1218+
1219+
- (void)onDisplayLink {
1220+
if ([self keyboardAnimationView].superview == nil) {
1221+
// Ensure the keyboardAnimationView is in view hierarchy when animation running.
1222+
[self.view addSubview:[self keyboardAnimationView]];
1223+
}
1224+
if ([self keyboardAnimationView].layer.presentationLayer) {
1225+
CGFloat value = [self keyboardAnimationView].layer.presentationLayer.frame.origin.y;
1226+
_viewportMetrics.physical_view_inset_bottom = value;
1227+
[self updateViewportMetrics];
1228+
}
11301229
}
11311230

11321231
- (void)handlePressEvent:(FlutterUIPressProxy*)press

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ - (void)handlePressEvent:(FlutterUIPressProxy*)press
124124
- (void)scrollEvent:(UIPanGestureRecognizer*)recognizer;
125125
- (void)updateViewportMetrics;
126126
- (void)onUserSettingsChanged:(NSNotification*)notification;
127+
- (void)keyboardWillChangeFrame:(NSNotification*)notification;
128+
- (void)startKeyBoardAnimation:(NSTimeInterval)duration;
129+
- (void)ensureViewportMetricsIsCorrect;
130+
- (void)invalidateDisplayLink;
127131
@end
128132

129133
@interface FlutterViewControllerTest : XCTestCase
@@ -151,6 +155,41 @@ - (void)tearDown {
151155
self.messageSent = nil;
152156
}
153157

158+
- (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation {
159+
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
160+
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
161+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
162+
nibName:nil
163+
bundle:nil];
164+
165+
CGFloat width = UIScreen.mainScreen.bounds.size.width;
166+
CGRect keyboardFrame = CGRectMake(0, 100, width, 400);
167+
BOOL isLocal = YES;
168+
NSNotification* notification = [NSNotification
169+
notificationWithName:@""
170+
object:nil
171+
userInfo:@{
172+
@"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame],
173+
@"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25],
174+
@"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal]
175+
}];
176+
id viewControllerMock = OCMPartialMock(viewController);
177+
[viewControllerMock keyboardWillChangeFrame:notification];
178+
OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]);
179+
}
180+
181+
- (void)testEnsureViewportMetricsWillInvokeAndDisplayLinkWillInvalidateInViewDidDisappear {
182+
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
183+
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
184+
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
185+
nibName:nil
186+
bundle:nil];
187+
id viewControllerMock = OCMPartialMock(viewController);
188+
[viewControllerMock viewDidDisappear:YES];
189+
OCMVerify([viewControllerMock ensureViewportMetricsIsCorrect]);
190+
OCMVerify([viewControllerMock invalidateDisplayLink]);
191+
}
192+
154193
- (void)testViewDidDisappearDoesntPauseEngineWhenNotTheViewController {
155194
id lifecycleChannel = OCMClassMock([FlutterBasicMessageChannel class]);
156195
FlutterEnginePartialMock* mockEngine = [[FlutterEnginePartialMock alloc] init];

0 commit comments

Comments
 (0)