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