[iOS] Support keyboard animation on iOS (flutter/engine#29281)

This commit is contained in:
WenJingRui 2021-11-09 02:03:20 +08:00 committed by GitHub
parent 856be97c04
commit bb7e8ec6c2
2 changed files with 145 additions and 7 deletions

View File

@ -69,6 +69,12 @@ typedef struct MouseState {
@property(nonatomic, assign) BOOL isHomeIndicatorHidden;
@property(nonatomic, assign) BOOL isPresentingViewControllerAnimating;
/**
* Keyboard animation properties
*/
@property(nonatomic, assign) double targetViewInsetBottom;
@property(nonatomic, strong) CADisplayLink* displayLink;
/**
* Creates and registers plugins used by this view controller.
*/
@ -113,6 +119,7 @@ typedef enum UIAccessibilityContrast : NSInteger {
fml::scoped_nsobject<UIScrollView> _scrollView;
fml::scoped_nsobject<UIPointerInteraction> _pointerInteraction API_AVAILABLE(ios(13.4));
fml::scoped_nsobject<UIPanGestureRecognizer> _panGestureRecognizer API_AVAILABLE(ios(13.4));
fml::scoped_nsobject<UIView> _keyboardAnimationView;
MouseState _mouseState;
}
@ -542,6 +549,10 @@ static void sendFakeTouchEvent(FlutterEngine* engine,
return _splashScreenView.get();
}
- (UIView*)keyboardAnimationView {
return _keyboardAnimationView.get();
}
- (BOOL)loadDefaultSplashScreenView {
NSString* launchscreenName =
[[[NSBundle mainBundle] infoDictionary] objectForKey:@"UILaunchStoryboardName"];
@ -719,6 +730,8 @@ static void sendFakeTouchEvent(FlutterEngine* engine,
- (void)viewDidDisappear:(BOOL)animated {
TRACE_EVENT0("flutter", "viewDidDisappear");
if ([_engine.get() viewController] == self) {
[self invalidateDisplayLink];
[self ensureViewportMetricsIsCorrect];
[self surfaceUpdated:NO];
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"];
[self flushOngoingTouches];
@ -1104,29 +1117,115 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch)
}
}
// Ignore keyboard notifications if engines viewController is not current viewController.
if ([_engine.get() viewController] != self) {
return;
}
CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
CGRect screenRect = [[UIScreen mainScreen] bounds];
// Get the animation duration
NSTimeInterval duration =
[[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
// Considering the iPad's split keyboard, Flutter needs to check if the keyboard frame is present
// in the screen to see if the keyboard is visible.
if (CGRectIntersectsRect(keyboardFrame, screenRect)) {
CGFloat bottom = CGRectGetHeight(keyboardFrame);
CGFloat scale = [UIScreen mainScreen].scale;
// The keyboard is treated as an inset since we want to effectively reduce the window size by
// the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
// bottom padding.
_viewportMetrics.physical_view_inset_bottom = bottom * scale;
self.targetViewInsetBottom = bottom * scale;
} else {
_viewportMetrics.physical_view_inset_bottom = 0;
self.targetViewInsetBottom = 0;
}
[self updateViewportMetrics];
[self startKeyBoardAnimation:duration];
}
- (void)keyboardWillBeHidden:(NSNotification*)notification {
_viewportMetrics.physical_view_inset_bottom = 0;
[self updateViewportMetrics];
// When keyboard hide, the keyboardWillChangeFrame function will be called to update viewport
// metrics. So do not call [self updateViewportMetrics] here again.
}
- (void)startKeyBoardAnimation:(NSTimeInterval)duration {
// If current physical_view_inset_bottom == targetViewInsetBottom,do nothing.
if (_viewportMetrics.physical_view_inset_bottom == self.targetViewInsetBottom) {
return;
}
// When call this method first time,
// initialize the keyboardAnimationView to get animation interpolation during animation.
if ([self keyboardAnimationView] == nil) {
UIView* keyboardAnimationView = [[UIView alloc] init];
[keyboardAnimationView setHidden:YES];
_keyboardAnimationView.reset(keyboardAnimationView);
}
if ([self keyboardAnimationView].superview == nil) {
[self.view addSubview:[self keyboardAnimationView]];
}
// Remove running animation when start another animation.
// After calling this line,the old display link will invalidate.
[[self keyboardAnimationView].layer removeAllAnimations];
// Set animation begin value.
[self keyboardAnimationView].frame =
CGRectMake(0, _viewportMetrics.physical_view_inset_bottom, 0, 0);
// Invalidate old display link if the old animation is not complete
[self invalidateDisplayLink];
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink)];
[self.displayLink addToRunLoop:NSRunLoop.currentRunLoop forMode:NSRunLoopCommonModes];
__block CADisplayLink* currentDisplayLink = self.displayLink;
[UIView animateWithDuration:duration
animations:^{
// Set end value.
[self keyboardAnimationView].frame = CGRectMake(0, self.targetViewInsetBottom, 0, 0);
}
completion:^(BOOL finished) {
if (self.displayLink == currentDisplayLink) {
[self invalidateDisplayLink];
}
if (finished) {
[self removeKeyboardAnimationView];
[self ensureViewportMetricsIsCorrect];
}
}];
}
- (void)invalidateDisplayLink {
[self.displayLink invalidate];
}
- (void)removeKeyboardAnimationView {
if ([self keyboardAnimationView].superview != nil) {
[[self keyboardAnimationView] removeFromSuperview];
}
}
- (void)ensureViewportMetricsIsCorrect {
if (_viewportMetrics.physical_view_inset_bottom != self.targetViewInsetBottom) {
// Make sure the `physical_view_inset_bottom` is the target value.
_viewportMetrics.physical_view_inset_bottom = self.targetViewInsetBottom;
[self updateViewportMetrics];
}
}
- (void)onDisplayLink {
if ([self keyboardAnimationView].superview == nil) {
// Ensure the keyboardAnimationView is in view hierarchy when animation running.
[self.view addSubview:[self keyboardAnimationView]];
}
if ([self keyboardAnimationView].layer.presentationLayer) {
CGFloat value = [self keyboardAnimationView].layer.presentationLayer.frame.origin.y;
_viewportMetrics.physical_view_inset_bottom = value;
[self updateViewportMetrics];
}
}
- (void)handlePressEvent:(FlutterUIPressProxy*)press

View File

@ -124,6 +124,10 @@ typedef enum UIAccessibilityContrast : NSInteger {
- (void)scrollEvent:(UIPanGestureRecognizer*)recognizer;
- (void)updateViewportMetrics;
- (void)onUserSettingsChanged:(NSNotification*)notification;
- (void)keyboardWillChangeFrame:(NSNotification*)notification;
- (void)startKeyBoardAnimation:(NSTimeInterval)duration;
- (void)ensureViewportMetricsIsCorrect;
- (void)invalidateDisplayLink;
@end
@interface FlutterViewControllerTest : XCTestCase
@ -151,6 +155,41 @@ typedef enum UIAccessibilityContrast : NSInteger {
self.messageSent = nil;
}
- (void)testkeyboardWillChangeFrameWillStartKeyboardAnimation {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
CGFloat width = UIScreen.mainScreen.bounds.size.width;
CGRect keyboardFrame = CGRectMake(0, 100, width, 400);
BOOL isLocal = YES;
NSNotification* notification = [NSNotification
notificationWithName:@""
object:nil
userInfo:@{
@"UIKeyboardFrameEndUserInfoKey" : [NSValue valueWithCGRect:keyboardFrame],
@"UIKeyboardAnimationDurationUserInfoKey" : [NSNumber numberWithDouble:0.25],
@"UIKeyboardIsLocalUserInfoKey" : [NSNumber numberWithBool:isLocal]
}];
id viewControllerMock = OCMPartialMock(viewController);
[viewControllerMock keyboardWillChangeFrame:notification];
OCMVerify([viewControllerMock startKeyBoardAnimation:0.25]);
}
- (void)testEnsureViewportMetricsWillInvokeAndDisplayLinkWillInvalidateInViewDidDisappear {
FlutterEngine* mockEngine = OCMPartialMock([[FlutterEngine alloc] init]);
[mockEngine createShell:@"" libraryURI:@"" initialRoute:nil];
FlutterViewController* viewController = [[FlutterViewController alloc] initWithEngine:mockEngine
nibName:nil
bundle:nil];
id viewControllerMock = OCMPartialMock(viewController);
[viewControllerMock viewDidDisappear:YES];
OCMVerify([viewControllerMock ensureViewportMetricsIsCorrect]);
OCMVerify([viewControllerMock invalidateDisplayLink]);
}
- (void)testViewDidDisappearDoesntPauseEngineWhenNotTheViewController {
id lifecycleChannel = OCMClassMock([FlutterBasicMessageChannel class]);
FlutterEnginePartialMock* mockEngine = [[FlutterEnginePartialMock alloc] init];