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
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.dev/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
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 }
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
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 ...
void(^ FlutterKeyboardAnimationCallback)(NSTimeInterval)
const uintptr_t id
int BOOL