mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
[iOS] Support keyboard animation on iOS (flutter/engine#29281)
This commit is contained in:
parent
856be97c04
commit
bb7e8ec6c2
@ -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 engine’s viewController is not current viewController.
|
||||
if ([_engine.get() viewController] != self) {
|
||||
return;
|
||||
}
|
||||
|
||||
CGRect keyboardFrame = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
|
||||
CGRect screenRect = [[UIScreen mainScreen] bounds];
|
||||
|
||||
// Get the animation duration
|
||||
NSTimeInterval duration =
|
||||
[[info objectForKey:UIKeyboardAnimationDurationUserInfoKey] doubleValue];
|
||||
|
||||
// Considering the iPad's split keyboard, Flutter needs to check if the keyboard frame is present
|
||||
// in the screen to see if the keyboard is visible.
|
||||
if (CGRectIntersectsRect(keyboardFrame, screenRect)) {
|
||||
CGFloat bottom = CGRectGetHeight(keyboardFrame);
|
||||
CGFloat scale = [UIScreen mainScreen].scale;
|
||||
|
||||
// The keyboard is treated as an inset since we want to effectively reduce the window size by
|
||||
// the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
|
||||
// bottom padding.
|
||||
_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
|
||||
|
||||
@ -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];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user