Skip to content

Commit 49a1460

Browse files
mrousavyfacebook-github-bot
authored andcommitted
Feature: ScrollView automaticallyAdjustKeyboardInsets (#31402)
Summary: Retrying D30015799 (6e903b0) with a fix where ScrollViewNativeComponent was missing the automaticallyAdjustKeyboardInsets prop. ----- Original Summary Currently, ScrollViews provide the prop `keyboardDismissMode` which lets you choose `"interactive"`. However when the keyboard is shown, it will be rendered above the ScrollView, potentially blocking content. With the `automaticallyAdjustKeyboardInsets` prop the ScrollView will automatically adjust it's `contentInset`, `scrollIndicatorInsets` and `contentOffset` (scroll Y) props to push the content up so nothing gets blocked. * The animation curve and duration of the Keyboard is exactly matched. * The absolute position of the ScrollView is respected, so if the Keyboard only overlaps 10 pixels of the ScrollView, it will only get inset by 10 pixels. * By respecting the absolute position on screen, this automatically makes it fully compatible with phones with notches (custom safe areas) * By using the keyboard frame, this also works for different sized keyboards and even `<InputAccessoryView>`s * This also supports `maintainVisibleContentPosition` and `autoscrollToTopThreshold`. * I also fixed an issue with the `maintainVisibleContentPosition` (`autoscrollToTopThreshold`) prop(s), so they behave more reliably when `contentInset`s are applied. (This makes automatically scrolling to new items fully compatible with `automaticallyAdjustKeyboardInsets`) ## Changelog * [iOS] [Added] - ScrollView: `automaticallyAdjustKeyboardInsets` prop: Automatically animate `contentInset`, `scrollIndicatorInsets` and `contentOffset` (scroll Y) to avoid the Keyboard. (respecting absolute position on screen and safe-areas) * [iOS] [Fixed] - ScrollView: Respect `contentInset` when animating new items with `autoscrollToTopThreshold`, make `automaticallyAdjustKeyboardInsets` work with `autoscrollToTopThreshold` (includes vertical, vertical-inverted, horizontal and horizontal-inverted ScrollViews) Pull Request resolved: #31402 Test Plan: <table> <tr> <th>Before</th> <th>After</th> </tr> <tr> <td> https://user-images.githubusercontent.com/15199031/115708680-9700aa80-a370-11eb-8016-e75d81a92cd7.MP4 </td> <td> https://user-images.githubusercontent.com/15199031/115708699-9b2cc800-a370-11eb-976f-c4010cd96d55.MP4 </td> </table> ### "Why not just use `<KeyboardAvoidingView>`?" <table> <tr> <th>Before (with <code>&lt;KeyboardAvoidingView&gt;</code>)</th> <th>After (with <code>automaticallyAdjustKeyboardInsets</code>)</th> </tr> <tr> <td> https://user-images.githubusercontent.com/15199031/115708749-abdd3e00-a370-11eb-8e09-a27ffaef12b8.MP4 </td> <td> https://user-images.githubusercontent.com/15199031/115708777-b3044c00-a370-11eb-9b7a-e040ccb3ef8c.MP4 </td> </table> > Also notice how the `<KeyboardAvoidingView>` does not match the animation curve of the Keyboard ### Usage ```jsx export const ChatPage = ({ flatListProps, textInputProps }: Props): React.ReactElement => ( <> <FlatList {...flatListProps} keyboardDismissMode="interactive" automaticallyAdjustContentInsets={false} contentInsetAdjustmentBehavior="never" maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 100 }} automaticallyAdjustKeyboardInsets={true} /> <InputAccessoryView backgroundColor={colors.white}> <ChatInput {...textInputProps} /> </InputAccessoryView> </> ); ``` ## Related Issues * Fixes #31394 * Fixes #13073 Reviewed By: yungsters Differential Revision: D32578661 Pulled By: sota000 fbshipit-source-id: 45985e2844275fe96304eccfd1901907dc4f9279
1 parent bba5e6b commit 49a1460

7 files changed

+117
-21
lines changed

Libraries/Components/ScrollView/ScrollView.js

+6
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ type IOSProps = $ReadOnly<{|
171171
* @platform ios
172172
*/
173173
automaticallyAdjustContentInsets?: ?boolean,
174+
/**
175+
* Controls whether the ScrollView should automatically adjust it's contentInset
176+
* and scrollViewInsets when the Keyboard changes it's size. The default value is false.
177+
* @platform ios
178+
*/
179+
automaticallyAdjustKeyboardInsets?: ?boolean,
174180
/**
175181
* Controls whether iOS should automatically adjust the scroll indicator
176182
* insets. The default value is true. Available on iOS 13 and later.

Libraries/Components/ScrollView/ScrollViewNativeComponent.js

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const ScrollViewNativeComponent: HostComponent<Props> =
2525
alwaysBounceHorizontal: true,
2626
alwaysBounceVertical: true,
2727
automaticallyAdjustContentInsets: true,
28+
automaticallyAdjustKeyboardInsets: true,
2829
automaticallyAdjustsScrollIndicatorInsets: true,
2930
bounces: true,
3031
bouncesZoom: true,

Libraries/Components/ScrollView/ScrollViewNativeComponentType.js

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ScrollViewNativeProps = $ReadOnly<{
2121
alwaysBounceHorizontal?: ?boolean,
2222
alwaysBounceVertical?: ?boolean,
2323
automaticallyAdjustContentInsets?: ?boolean,
24+
automaticallyAdjustKeyboardInsets?: ?boolean,
2425
automaticallyAdjustsScrollIndicatorInsets?: ?boolean,
2526
bounces?: ?boolean,
2627
bouncesZoom?: ?boolean,

Libraries/Components/ScrollView/ScrollViewViewConfig.js

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const ScrollViewViewConfig = {
2424
alwaysBounceHorizontal: true,
2525
alwaysBounceVertical: true,
2626
automaticallyAdjustContentInsets: true,
27+
automaticallyAdjustKeyboardInsets: true,
2728
automaticallyAdjustsScrollIndicatorInsets: true,
2829
bounces: true,
2930
bouncesZoom: true,

React/Views/ScrollView/RCTScrollView.h

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
@property (nonatomic, assign) UIEdgeInsets contentInset;
3737
@property (nonatomic, assign) BOOL automaticallyAdjustContentInsets;
38+
@property (nonatomic, assign) BOOL automaticallyAdjustKeyboardInsets;
3839
@property (nonatomic, assign) BOOL DEPRECATED_sendUpdatedChildFrames;
3940
@property (nonatomic, assign) NSTimeInterval scrollEventThrottle;
4041
@property (nonatomic, assign) BOOL centerContent;

React/Views/ScrollView/RCTScrollView.m

+106-21
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,80 @@ @implementation RCTScrollView {
274274
NSHashTable *_scrollListeners;
275275
}
276276

277+
- (void)_registerKeyboardListener
278+
{
279+
[[NSNotificationCenter defaultCenter] addObserver:self
280+
selector:@selector(_keyboardWillChangeFrame:)
281+
name:UIKeyboardWillChangeFrameNotification
282+
object:nil];
283+
}
284+
285+
- (void)_unregisterKeyboardListener
286+
{
287+
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillChangeFrameNotification object:nil];
288+
}
289+
290+
static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCurve curve)
291+
{
292+
// UIViewAnimationCurve #7 is used for keyboard and therefore private - so we can't use switch/case here.
293+
// source: https://stackoverflow.com/a/7327374/5281431
294+
RCTAssert(
295+
UIViewAnimationCurveLinear << 16 == UIViewAnimationOptionCurveLinear,
296+
@"Unexpected implementation of UIViewAnimationCurve");
297+
return curve << 16;
298+
}
299+
300+
- (void)_keyboardWillChangeFrame:(NSNotification *)notification
301+
{
302+
if (![self automaticallyAdjustKeyboardInsets]) {
303+
return;
304+
}
305+
if ([self isHorizontal:_scrollView]) {
306+
return;
307+
}
308+
309+
double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue];
310+
UIViewAnimationCurve curve =
311+
(UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue];
312+
CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue];
313+
CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue];
314+
315+
CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil];
316+
CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height;
317+
318+
UIEdgeInsets newEdgeInsets = _scrollView.contentInset;
319+
CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0);
320+
if (self.inverted) {
321+
newEdgeInsets.top = MAX(inset, _contentInset.top);
322+
} else {
323+
newEdgeInsets.bottom = MAX(inset, _contentInset.bottom);
324+
}
325+
326+
CGPoint newContentOffset = _scrollView.contentOffset;
327+
CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y;
328+
if (self.inverted) {
329+
newContentOffset.y += contentDiff;
330+
} else {
331+
newContentOffset.y -= contentDiff;
332+
}
333+
334+
[UIView animateWithDuration:duration
335+
delay:0.0
336+
options:animationOptionsWithCurve(curve)
337+
animations:^{
338+
self->_scrollView.contentInset = newEdgeInsets;
339+
self->_scrollView.scrollIndicatorInsets = newEdgeInsets;
340+
[self scrollToOffset:newContentOffset animated:NO];
341+
}
342+
completion:nil];
343+
}
344+
277345
- (instancetype)initWithEventDispatcher:(id<RCTEventDispatcherProtocol>)eventDispatcher
278346
{
279347
RCTAssertParam(eventDispatcher);
280348

281349
if ((self = [super initWithFrame:CGRectZero])) {
350+
[self _registerKeyboardListener];
282351
_eventDispatcher = eventDispatcher;
283352

284353
_scrollView = [[RCTCustomScrollView alloc] initWithFrame:CGRectZero];
@@ -396,6 +465,7 @@ - (void)dealloc
396465
{
397466
_scrollView.delegate = nil;
398467
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
468+
[self _unregisterKeyboardListener];
399469
}
400470

401471
- (void)layoutSubviews
@@ -832,23 +902,33 @@ - (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContent
832902
- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
833903
{
834904
RCTAssertUIManagerQueue();
835-
[manager
836-
prependUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
837-
BOOL horz = [self isHorizontal:self->_scrollView];
838-
NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
839-
for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
840-
// Find the first entirely visible view. This must be done after we update the content offset
841-
// or it will tend to grab rows that were made visible by the shift in position
842-
UIView *subview = self->_contentView.subviews[ii];
843-
if ((horz ? subview.frame.origin.x >= self->_scrollView.contentOffset.x
844-
: subview.frame.origin.y >= self->_scrollView.contentOffset.y) ||
845-
ii == self->_contentView.subviews.count - 1) {
846-
self->_prevFirstVisibleFrame = subview.frame;
847-
self->_firstVisibleView = subview;
848-
break;
849-
}
850-
}
851-
}];
905+
906+
[manager prependUIBlock:^(
907+
__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
908+
BOOL horz = [self isHorizontal:self->_scrollView];
909+
NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
910+
for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
911+
// Find the first entirely visible view. This must be done after we update the content offset
912+
// or it will tend to grab rows that were made visible by the shift in position
913+
UIView *subview = self->_contentView.subviews[ii];
914+
BOOL hasNewView = NO;
915+
if (horz) {
916+
CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left;
917+
CGFloat x = self->_scrollView.contentOffset.x + leftInset;
918+
hasNewView = subview.frame.origin.x > x;
919+
} else {
920+
CGFloat bottomInset =
921+
self.inverted ? self->_scrollView.contentInset.top : self->_scrollView.contentInset.bottom;
922+
CGFloat y = self->_scrollView.contentOffset.y + bottomInset;
923+
hasNewView = subview.frame.origin.y > y;
924+
}
925+
if (hasNewView || ii == self->_contentView.subviews.count - 1) {
926+
self->_prevFirstVisibleFrame = subview.frame;
927+
self->_firstVisibleView = subview;
928+
break;
929+
}
930+
}
931+
}];
852932
[manager addUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
853933
if (self->_maintainVisibleContentPosition == nil) {
854934
return; // The prop might have changed in the previous UIBlocks, so need to abort here.
@@ -858,25 +938,30 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
858938
if ([self isHorizontal:self->_scrollView]) {
859939
CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x;
860940
if (ABS(deltaX) > 0.1) {
941+
CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left;
942+
CGFloat x = self->_scrollView.contentOffset.x + leftInset;
861943
self->_scrollView.contentOffset =
862944
CGPointMake(self->_scrollView.contentOffset.x + deltaX, self->_scrollView.contentOffset.y);
863945
if (autoscrollThreshold != nil) {
864946
// If the offset WAS within the threshold of the start, animate to the start.
865-
if (self->_scrollView.contentOffset.x - deltaX <= [autoscrollThreshold integerValue]) {
866-
[self scrollToOffset:CGPointMake(0, self->_scrollView.contentOffset.y) animated:YES];
947+
if (x - deltaX <= [autoscrollThreshold integerValue]) {
948+
[self scrollToOffset:CGPointMake(-leftInset, self->_scrollView.contentOffset.y) animated:YES];
867949
}
868950
}
869951
}
870952
} else {
871953
CGRect newFrame = self->_firstVisibleView.frame;
872954
CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y;
873955
if (ABS(deltaY) > 0.1) {
956+
CGFloat bottomInset =
957+
self.inverted ? self->_scrollView.contentInset.top : self->_scrollView.contentInset.bottom;
958+
CGFloat y = self->_scrollView.contentOffset.y + bottomInset;
874959
self->_scrollView.contentOffset =
875960
CGPointMake(self->_scrollView.contentOffset.x, self->_scrollView.contentOffset.y + deltaY);
876961
if (autoscrollThreshold != nil) {
877962
// If the offset WAS within the threshold of the start, animate to the start.
878-
if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) {
879-
[self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES];
963+
if (y - deltaY <= [autoscrollThreshold integerValue]) {
964+
[self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, -bottomInset) animated:YES];
880965
}
881966
}
882967
}

React/Views/ScrollView/RCTScrollViewManager.m

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ - (UIView *)view
6666
RCT_EXPORT_VIEW_PROPERTY(centerContent, BOOL)
6767
RCT_EXPORT_VIEW_PROPERTY(maintainVisibleContentPosition, NSDictionary)
6868
RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets, BOOL)
69+
RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustKeyboardInsets, BOOL)
6970
RCT_EXPORT_VIEW_PROPERTY(decelerationRate, CGFloat)
7071
RCT_EXPORT_VIEW_PROPERTY(directionalLockEnabled, BOOL)
7172
RCT_EXPORT_VIEW_PROPERTY(indicatorStyle, UIScrollViewIndicatorStyle)

0 commit comments

Comments
 (0)