Flutter iOS Embedder
FlutterKeyboardInsetManager.mm
Go to the documentation of this file.
1 // Copyright 2013 The Flutter Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
6 
7 #include <memory>
8 
9 #include "flutter/fml/platform/darwin/platform_version.h"
10 #include "flutter/fml/time/time_delta.h"
11 #include "flutter/fml/time/time_point.h"
14 #import "flutter/shell/platform/embedder/embedder.h"
15 #import "flutter/third_party/spring_animation/spring_animation.h"
16 
18 
19 @property(nonatomic, weak) id<FlutterKeyboardInsetManagerDelegate> delegate;
20 @property(nonatomic, assign, readwrite) CGFloat targetViewInsetBottom;
21 @property(nonatomic, assign) CGFloat originalViewInsetBottom;
22 @property(nonatomic, strong) FlutterVSyncClient* keyboardAnimationVSyncClient;
23 @property(nonatomic, assign) BOOL keyboardAnimationIsShowing;
24 @property(nonatomic, assign) NSTimeInterval keyboardAnimationStartTime;
25 @property(nonatomic, strong) UIView* keyboardAnimationView;
26 @property(nonatomic, strong) SpringAnimation* keyboardSpringAnimation;
27 
28 @end
29 
30 @implementation FlutterKeyboardInsetManager
31 
32 - (instancetype)initWithDelegate:(id<FlutterKeyboardInsetManagerDelegate>)delegate {
33  self = [super init];
34  if (self) {
35  _delegate = delegate;
36  _targetViewInsetBottom = 0.0;
37  }
38  return self;
39 }
40 
41 - (void)handleKeyboardNotification:(NSNotification*)notification {
42  // See https://flutter.cn/go/ios-keyboard-calculating-inset for more details
43  // on why notifications are used and how things are calculated.
44  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
45  if (!delegate || [self shouldIgnoreKeyboardNotification:notification]) {
46  return;
47  }
48 
49  NSDictionary* info = notification.userInfo;
50  CGRect beginKeyboardFrame = [info[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
51  CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
52  FlutterKeyboardMode keyboardMode = [self calculateKeyboardAttachMode:notification];
53  CGFloat calculatedInset = [self calculateKeyboardInset:keyboardFrame keyboardMode:keyboardMode];
54  NSTimeInterval duration = [info[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
55 
56  // If the software keyboard is displayed before displaying the PasswordManager prompt,
57  // UIKeyboardWillHideNotification will occur immediately after UIKeyboardWillShowNotification.
58  // The duration of the animation will be 0.0, and the calculated inset will be 0.0.
59  // In this case, it is necessary to cancel the animation and hide the keyboard immediately.
60  // https://github.com/flutter/flutter/pull/164884
61  if (keyboardMode == FlutterKeyboardModeHidden && calculatedInset == 0.0 && duration == 0.0) {
62  [self hideKeyboardImmediately];
63  return;
64  }
65 
66  // Avoid double triggering startKeyBoardAnimation.
67  if (self.targetViewInsetBottom == calculatedInset) {
68  return;
69  }
70  self.targetViewInsetBottom = calculatedInset;
71 
72  // Flag for simultaneous compounding animation calls.
73  // This captures animation calls made while the keyboard animation is currently animating. If the
74  // new animation is in the same direction as the current animation, this flag lets the current
75  // animation continue with an updated targetViewInsetBottom instead of starting a new keyboard
76  // animation. This allows for smoother keyboard animation interpolation.
77  BOOL keyboardWillShow = beginKeyboardFrame.origin.y > keyboardFrame.origin.y;
78  BOOL keyboardAnimationIsCompounding =
79  self.keyboardAnimationIsShowing == keyboardWillShow && _keyboardAnimationVSyncClient != nil;
80 
81  // Mark keyboard as showing or hiding.
82  self.keyboardAnimationIsShowing = keyboardWillShow;
83 
84  if (!keyboardAnimationIsCompounding) {
85  [self startKeyBoardAnimation:duration];
86  } else if (self.keyboardSpringAnimation) {
87  self.keyboardSpringAnimation.toValue = self.targetViewInsetBottom;
88  }
89 }
90 
91 - (BOOL)shouldIgnoreKeyboardNotification:(NSNotification*)notification {
92  // Don't ignore UIKeyboardWillHideNotification notifications.
93  // Even if the notification is triggered in the background or by a different app/view controller,
94  // we want to always handle this notification to avoid inaccurate inset when in a mulitasking mode
95  // or when switching between apps.
96  if (notification.name == UIKeyboardWillHideNotification) {
97  return NO;
98  }
99 
100  // Ignore notification when keyboard's dimensions and position are all zeroes for
101  // UIKeyboardWillChangeFrameNotification. This happens when keyboard is dragged. Do not ignore if
102  // the notification is UIKeyboardWillShowNotification, as CGRectZero for that notfication only
103  // occurs when Minimized/Expanded Shortcuts Bar is dropped after dragging, which we later use to
104  // categorize it as floating.
105  NSDictionary* info = notification.userInfo;
106  CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
107  if (notification.name == UIKeyboardWillChangeFrameNotification &&
108  CGRectEqualToRect(keyboardFrame, CGRectZero)) {
109  return YES;
110  }
111 
112  // When keyboard's height or width is set to 0, don't ignore. This does not happen
113  // often but can happen sometimes when switching between multitasking modes.
114  if (CGRectIsEmpty(keyboardFrame)) {
115  return NO;
116  }
117 
118  // Ignore keyboard notifications related to other apps or view controllers.
119  if ([self isKeyboardNotificationForDifferentView:notification]) {
120  return YES;
121  }
122  return NO;
123 }
124 
125 - (BOOL)isKeyboardNotificationForDifferentView:(NSNotification*)notification {
126  NSDictionary* info = notification.userInfo;
127 
128  // Keyboard notifications related to other apps.
129  // If the UIKeyboardIsLocalUserInfoKey key doesn't exist (this should not happen after iOS 8),
130  // proceed as if it was local so that the notification is not ignored.
131  id isLocal = info[UIKeyboardIsLocalUserInfoKey];
132  if (isLocal && ![isLocal boolValue]) {
133  return YES;
134  }
135  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
136  return (id)delegate.engine.viewController != (id)delegate;
137 }
138 
139 - (FlutterKeyboardMode)calculateKeyboardAttachMode:(NSNotification*)notification {
140  // There are multiple types of keyboard: docked, undocked, split, split docked,
141  // floating, expanded shortcuts bar, minimized shortcuts bar. This function will categorize
142  // the keyboard as one of the following modes: docked, floating, or hidden.
143  // Docked mode includes docked, split docked, expanded shortcuts bar (when opening via click),
144  // and minimized shortcuts bar (when opened via click).
145  // Floating includes undocked, split, floating, expanded shortcuts bar (when dragged and dropped),
146  // and minimized shortcuts bar (when dragged and dropped).
147  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
148  if (!delegate) {
149  return FlutterKeyboardModeHidden;
150  }
151  NSDictionary* info = notification.userInfo;
152  CGRect keyboardFrame = [info[UIKeyboardFrameEndUserInfoKey] CGRectValue];
153 
154  if (notification.name == UIKeyboardWillHideNotification) {
155  return FlutterKeyboardModeHidden;
156  }
157 
158  // If keyboard's dimensions and position are all zeroes, that means it's a Minimized/Expanded
159  // Shortcuts Bar that has been dropped after dragging, which we categorize as floating.
160  if (CGRectEqualToRect(keyboardFrame, CGRectZero)) {
161  return FlutterKeyboardModeFloating;
162  }
163  // If keyboard's width or height are 0, it's hidden.
164  if (CGRectIsEmpty(keyboardFrame)) {
165  return FlutterKeyboardModeHidden;
166  }
167 
168  CGRect screenRect = delegate.flutterScreenIfViewLoaded.bounds;
169  CGRect adjustedKeyboardFrame = keyboardFrame;
170  adjustedKeyboardFrame.origin.y += [self calculateMultitaskingAdjustment:screenRect
171  keyboardFrame:keyboardFrame];
172 
173  // If the keyboard is partially or fully showing within the screen, it's either docked or
174  // floating. Sometimes with custom keyboard extensions, the keyboard's position may be off by a
175  // small decimal amount (which is why CGRectIntersectRect can't be used). Round to compare.
176  CGRect intersection = CGRectIntersection(adjustedKeyboardFrame, screenRect);
177  CGFloat intersectionHeight = CGRectGetHeight(intersection);
178  CGFloat intersectionWidth = CGRectGetWidth(intersection);
179  if (round(intersectionHeight) > 0 && intersectionWidth > 0) {
180  CGFloat screenHeight = CGRectGetHeight(screenRect);
181  CGFloat adjustedKeyboardBottom = CGRectGetMaxY(adjustedKeyboardFrame);
182  if (round(adjustedKeyboardBottom) < screenHeight) {
183  return FlutterKeyboardModeFloating;
184  }
185  return FlutterKeyboardModeDocked;
186  }
187  return FlutterKeyboardModeHidden;
188 }
189 
190 /**
191  * @brief Calculates the adjustment needed for multitasking modes like Slide Over on iPad.
192  *
193  * In Slide Over mode, the keyboard's frame does not include the space below the app,
194  * even though the keyboard may be at the bottom of the screen. This method calculates
195  * that offset so we can shift the y origin correctly.
196  */
197 - (CGFloat)calculateMultitaskingAdjustment:(CGRect)screenRect keyboardFrame:(CGRect)keyboardFrame {
198  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
199  if (!delegate.isViewLoaded) {
200  return 0;
201  }
202 
203  // In Slide Over mode, the keyboard's frame does not include the space
204  // below the app, even though the keyboard may be at the bottom of the screen.
205  // To handle, shift the Y origin by the amount of space below the app.
206  UIView* view = delegate.view;
207  if ([delegate isPadInSlideOverOrStageManagerMode]) {
208  CGFloat screenHeight = CGRectGetHeight(screenRect);
209  CGFloat keyboardBottom = CGRectGetMaxY(keyboardFrame);
210 
211  // Stage Manager mode will also meet the above parameters, but it does not handle
212  // the keyboard positioning the same way, so skip if keyboard is at bottom of page.
213  if (screenHeight == keyboardBottom) {
214  return 0;
215  }
216  CGRect viewRectRelativeToScreen = [delegate convertViewRectToScreen:view.bounds];
217  CGFloat viewBottom = CGRectGetMaxY(viewRectRelativeToScreen);
218  CGFloat offset = screenHeight - viewBottom;
219  if (offset > 0) {
220  return offset;
221  }
222  }
223  return 0;
224 }
225 
226 - (CGFloat)calculateKeyboardInset:(CGRect)keyboardFrame
227  keyboardMode:(FlutterKeyboardMode)keyboardMode {
228  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
229 
230  // Only docked keyboards will have an inset.
231  if (keyboardMode == FlutterKeyboardModeDocked) {
232  if (!delegate.isViewLoaded) {
233  return 0;
234  }
235  UIView* view = delegate.view;
236  CGRect viewRectRelativeToScreen = [delegate convertViewRectToScreen:view.bounds];
237  CGRect intersection = CGRectIntersection(keyboardFrame, viewRectRelativeToScreen);
238  CGFloat portionOfKeyboardInView = CGRectGetHeight(intersection);
239 
240  // The keyboard is treated as an inset since we want to effectively reduce the window size by
241  // the keyboard height. The Dart side will compute a value accounting for the keyboard-consuming
242  // bottom padding.
243  CGFloat scale = delegate.flutterScreenIfViewLoaded.scale;
244  return portionOfKeyboardInView * scale;
245  }
246  return 0;
247 }
248 
249 - (void)startKeyBoardAnimation:(NSTimeInterval)duration {
250  // If current physical_view_inset_bottom == targetViewInsetBottom, do nothing.
251  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
252  if (!delegate.isViewLoaded) {
253  return;
254  }
255  UIView* view = delegate.view;
256 
257  // When this method is called for the first time,
258  // initialize the keyboardAnimationView to get animation interpolation during animation.
259  if (!self.keyboardAnimationView) {
260  UIView* keyboardAnimationView = [[UIView alloc] init];
261  keyboardAnimationView.hidden = YES;
262  self.keyboardAnimationView = keyboardAnimationView;
263  }
264 
265  if (self.keyboardAnimationView.superview != view) {
266  [view addSubview:self.keyboardAnimationView];
267  }
268 
269  // Remove running animation when start another animation.
270  [self.keyboardAnimationView.layer removeAllAnimations];
271 
272  // Set animation begin value and DisplayLink tracking values.
273  CGFloat currentInset = delegate.physicalViewInsetBottom;
274  self.keyboardAnimationView.frame = CGRectMake(0, currentInset, 0, 0);
275  self.keyboardAnimationStartTime = CACurrentMediaTime();
276  self.originalViewInsetBottom = currentInset;
277 
278  // Invalidate old vsync client if old animation is not completed.
279  [self invalidateKeyboardAnimationVSyncClient];
280 
281  __weak FlutterKeyboardInsetManager* weakSelf = self;
282  [self setUpKeyboardAnimationVsyncClient:^(NSTimeInterval targetTime) {
283  [weakSelf handleKeyboardAnimationCallbackWithTargetTime:targetTime];
284  }];
285  FlutterVSyncClient* currentVsyncClient = _keyboardAnimationVSyncClient;
286 
287  [UIView animateWithDuration:duration
288  animations:^{
289  FlutterKeyboardInsetManager* strongSelf = weakSelf;
290  if (!strongSelf) {
291  return;
292  }
293 
294  // Set end value.
295  strongSelf.keyboardAnimationView.frame =
296  CGRectMake(0, strongSelf.targetViewInsetBottom, 0, 0);
297 
298  // Setup keyboard animation interpolation.
299  [strongSelf.keyboardAnimationView layoutIfNeeded];
300  CAAnimation* keyboardAnimation =
301  [strongSelf.keyboardAnimationView.layer animationForKey:@"position"];
302  [strongSelf setUpKeyboardSpringAnimationIfNeeded:keyboardAnimation];
303  }
304  completion:^(BOOL finished) {
305  FlutterKeyboardInsetManager* strongSelf = weakSelf;
306  if (strongSelf && strongSelf.keyboardAnimationVSyncClient == currentVsyncClient) {
307  // Indicates the vsync client captured by this block is the original one, which also
308  // indicates the animation has not been interrupted from its beginning. Moreover,
309  // indicates the animation is over and there is no more to execute.
310  [strongSelf invalidateKeyboardAnimationVSyncClient];
311  [strongSelf removeKeyboardAnimationView];
312  [strongSelf ensureViewportMetricsIsCorrect];
313  }
314  }];
315 }
316 
317 - (void)hideKeyboardImmediately {
318  [self invalidateKeyboardAnimationVSyncClient];
319  if (self.keyboardAnimationView) {
320  [self.keyboardAnimationView.layer removeAllAnimations];
321  [self removeKeyboardAnimationView];
322  self.keyboardAnimationView = nil;
323  }
324  if (self.keyboardSpringAnimation) {
325  self.keyboardSpringAnimation = nil;
326  }
327  self.targetViewInsetBottom = 0.0;
328  [self ensureViewportMetricsIsCorrect];
329 }
330 
331 - (void)setUpKeyboardSpringAnimationIfNeeded:(CAAnimation*)keyboardAnimation {
332  // If keyboard animation is null or not a spring animation, fallback to DisplayLink tracking.
333  if (keyboardAnimation == nil || ![keyboardAnimation isKindOfClass:[CASpringAnimation class]]) {
334  _keyboardSpringAnimation = nil;
335  return;
336  }
337 
338  // Set up keyboard spring animation details for spring curve animation calculation.
339  CASpringAnimation* keyboardCASpringAnimation = (CASpringAnimation*)keyboardAnimation;
340  _keyboardSpringAnimation =
341  [[SpringAnimation alloc] initWithStiffness:keyboardCASpringAnimation.stiffness
342  damping:keyboardCASpringAnimation.damping
343  mass:keyboardCASpringAnimation.mass
344  initialVelocity:keyboardCASpringAnimation.initialVelocity
345  fromValue:self.originalViewInsetBottom
346  toValue:self.targetViewInsetBottom];
347 }
348 
349 - (void)handleKeyboardAnimationCallbackWithTargetTime:(NSTimeInterval)targetTime {
350  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
351 
352  // If the view controller's view is not loaded, bail out.
353  if (!delegate.isViewLoaded) {
354  return;
355  }
356  // If the view for tracking keyboard animation is nil, means it is not
357  // created, bail out.
358  if (!self.keyboardAnimationView) {
359  return;
360  }
361  // If keyboardAnimationVSyncClient is nil, means the animation ends.
362  // And should bail out.
363  if (!self.keyboardAnimationVSyncClient) {
364  return;
365  }
366 
367  if (self.keyboardAnimationView.superview != delegate.view) {
368  // Ensure the keyboardAnimationView is in view hierarchy when animation running.
369  [delegate.view addSubview:self.keyboardAnimationView];
370  }
371 
372  CGFloat currentInset = 0;
373  if (!self.keyboardSpringAnimation) {
374  if (self.keyboardAnimationView.layer.presentationLayer) {
375  currentInset = self.keyboardAnimationView.layer.presentationLayer.frame.origin.y;
376  }
377  } else {
378  NSTimeInterval timeElapsed = targetTime - self.keyboardAnimationStartTime;
379  currentInset = [self.keyboardSpringAnimation curveFunction:timeElapsed];
380  }
381 
382  [delegate updateViewportMetricsWithInset:currentInset];
383 }
384 
385 - (void)setUpKeyboardAnimationVsyncClient:
386  (FlutterKeyboardAnimationCallback)keyboardAnimationCallback {
387  if (!keyboardAnimationCallback) {
388  return;
389  }
390  NSAssert(_keyboardAnimationVSyncClient == nil,
391  @"_keyboardAnimationVSyncClient must be nil when setting up.");
392 
393  // Make sure the new viewport metrics get sent after the begin frame event has processed.
394  FlutterKeyboardAnimationCallback animationCallback = [keyboardAnimationCallback copy];
395 
396  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
397  auto vsyncCallback = ^(CFTimeInterval startTime, CFTimeInterval targetTime) {
398  CFTimeInterval frameInterval = targetTime - startTime;
399  CFTimeInterval projectedTargetTime = targetTime + frameInterval;
400  dispatch_async(dispatch_get_main_queue(), ^(void) {
401  animationCallback(projectedTargetTime);
402  });
403  };
404  _keyboardAnimationVSyncClient = [[FlutterVSyncClient alloc]
405  initWithTaskRunner:delegate.engine.uiTaskRunner
406  isVariableRefreshRateEnabled:FlutterDisplayLinkManager.maxRefreshRateEnabledOnIPhone
407  maxRefreshRate:FlutterDisplayLinkManager.displayRefreshRate
408  callback:vsyncCallback];
409  _keyboardAnimationVSyncClient.allowPauseAfterVsync = NO;
410  [_keyboardAnimationVSyncClient await];
411 }
412 
413 - (void)invalidateKeyboardAnimationVSyncClient {
414  [_keyboardAnimationVSyncClient invalidate];
415  _keyboardAnimationVSyncClient = nil;
416 }
417 
418 - (void)removeKeyboardAnimationView {
419  if (self.keyboardAnimationView.superview != nil) {
420  [self.keyboardAnimationView removeFromSuperview];
421  }
422 }
423 
424 - (void)ensureViewportMetricsIsCorrect {
425  id<FlutterKeyboardInsetManagerDelegate> delegate = self.delegate;
426  [delegate updateViewportMetricsWithInset:self.targetViewInsetBottom];
427 }
428 
429 - (void)invalidate {
430  [self invalidateKeyboardAnimationVSyncClient];
431  [self removeKeyboardAnimationView];
432 }
433 
434 @end
void(^ FlutterKeyboardAnimationCallback)(NSTimeInterval)
FlutterViewController * viewController
Coordinates the animation of the bottom viewport inset in response to system keyboard visibility chan...
void invalidate()
Terminates any active animations and releases internal resources.
CGFloat targetViewInsetBottom
The physical pixel value of the bottom inset once the current animation reaches its final state.
void hideKeyboardImmediately()
Immediately stops any active keyboard animations and synchronizes the engine's viewport metrics with ...
Manages the calculations and animations for software keyboard insets.
FlutterEngine * engine()
Returns the engine associated with the delegate.