diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 696781ffbe3def..e4985f640ff849 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -171,6 +171,12 @@ type IOSProps = $ReadOnly<{| * @platform ios */ automaticallyAdjustContentInsets?: ?boolean, + /** + * Controls whether the ScrollView should automatically adjust it's contentInset + * and scrollViewInsets when the Keyboard changes it's size. The default value is false. + * @platform ios + */ + automaticallyAdjustKeyboardInsets?: ?boolean, /** * Controls whether iOS should automatically adjust the scroll indicator * insets. The default value is true. Available on iOS 13 and later. diff --git a/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js b/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index 24e0c70f442787..3037cce7797b07 100644 --- a/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -21,6 +21,7 @@ export type ScrollViewNativeProps = $ReadOnly<{ alwaysBounceHorizontal?: ?boolean, alwaysBounceVertical?: ?boolean, automaticallyAdjustContentInsets?: ?boolean, + automaticallyAdjustKeyboardInsets?: ?boolean, automaticallyAdjustsScrollIndicatorInsets?: ?boolean, bounces?: ?boolean, bouncesZoom?: ?boolean, diff --git a/Libraries/Components/ScrollView/ScrollViewViewConfig.js b/Libraries/Components/ScrollView/ScrollViewViewConfig.js index 5b276d54fa0fe0..47abbe194f4806 100644 --- a/Libraries/Components/ScrollView/ScrollViewViewConfig.js +++ b/Libraries/Components/ScrollView/ScrollViewViewConfig.js @@ -24,6 +24,7 @@ const ScrollViewViewConfig = { alwaysBounceHorizontal: true, alwaysBounceVertical: true, automaticallyAdjustContentInsets: true, + automaticallyAdjustKeyboardInsets: true, automaticallyAdjustsScrollIndicatorInsets: true, bounces: true, bouncesZoom: true, diff --git a/React/Views/ScrollView/RCTScrollView.h b/React/Views/ScrollView/RCTScrollView.h index 765d3b3378a5a6..f6d36719e2bb14 100644 --- a/React/Views/ScrollView/RCTScrollView.h +++ b/React/Views/ScrollView/RCTScrollView.h @@ -35,6 +35,7 @@ @property (nonatomic, assign) UIEdgeInsets contentInset; @property (nonatomic, assign) BOOL automaticallyAdjustContentInsets; +@property (nonatomic, assign) BOOL automaticallyAdjustKeyboardInsets; @property (nonatomic, assign) BOOL DEPRECATED_sendUpdatedChildFrames; @property (nonatomic, assign) NSTimeInterval scrollEventThrottle; @property (nonatomic, assign) BOOL centerContent; diff --git a/React/Views/ScrollView/RCTScrollView.m b/React/Views/ScrollView/RCTScrollView.m index eb8f90e1cd9351..59bdaa77714a49 100644 --- a/React/Views/ScrollView/RCTScrollView.m +++ b/React/Views/ScrollView/RCTScrollView.m @@ -273,11 +273,78 @@ @implementation RCTScrollView { NSHashTable *_scrollListeners; } +- (void)registerKeyboardListener +{ + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(keyboardWillChangeFrame:) + name:UIKeyboardWillChangeFrameNotification + object:nil]; +} + +- (void)unregisterKeyboardListener +{ + [[NSNotificationCenter defaultCenter] removeObserver:self + name:UIKeyboardWillChangeFrameNotification + object:nil]; +} + +static inline UIViewAnimationOptions animationOptionsWithCurve(UIViewAnimationCurve curve) +{ + // UIViewAnimationCurve #7 is used for keyboard and therefore private - so we can't use switch/case here. + // source: https://stackoverflow.com/a/7327374/5281431 + RCTAssert(UIViewAnimationCurveLinear << 16 == UIViewAnimationOptionCurveLinear, @"Unexpected implementation of UIViewAnimationCurve"); + return curve << 16; +} + +- (void)keyboardWillChangeFrame:(NSNotification*)notification +{ + if (![self automaticallyAdjustKeyboardInsets]) { + return; + } + if ([self isHorizontal:_scrollView]) { + return; + } + + double duration = [notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; + UIViewAnimationCurve curve = (UIViewAnimationCurve)[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] unsignedIntegerValue]; + CGRect beginFrame = [notification.userInfo[UIKeyboardFrameBeginUserInfoKey] CGRectValue]; + CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; + + CGPoint absoluteViewOrigin = [self convertPoint:self.bounds.origin toView:nil]; + CGFloat scrollViewLowerY = self.inverted ? absoluteViewOrigin.y : absoluteViewOrigin.y + self.bounds.size.height; + + UIEdgeInsets newEdgeInsets = _scrollView.contentInset; + CGFloat inset = MAX(scrollViewLowerY - endFrame.origin.y, 0); + if (self.inverted) { + newEdgeInsets.top = MAX(inset, _contentInset.top); + } else { + newEdgeInsets.bottom = MAX(inset, _contentInset.bottom); + } + + CGPoint newContentOffset = _scrollView.contentOffset; + CGFloat contentDiff = endFrame.origin.y - beginFrame.origin.y; + if (self.inverted) { + newContentOffset.y += contentDiff; + } else { + newContentOffset.y -= contentDiff; + } + + [UIView animateWithDuration:duration + delay:0.0 + options:animationOptionsWithCurve(curve) + animations:^{ + self->_scrollView.contentInset = newEdgeInsets; + self->_scrollView.scrollIndicatorInsets = newEdgeInsets; + [self scrollToOffset:newContentOffset animated:NO]; + } completion:nil]; +} + - (instancetype)initWithEventDispatcher:(id)eventDispatcher { RCTAssertParam(eventDispatcher); if ((self = [super initWithFrame:CGRectZero])) { + [self registerKeyboardListener]; _eventDispatcher = eventDispatcher; _scrollView = [[RCTCustomScrollView alloc] initWithFrame:CGRectZero]; @@ -398,6 +465,7 @@ - (void)dealloc { _scrollView.delegate = nil; [_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self]; + [self unregisterKeyboardListener]; } - (void)layoutSubviews @@ -834,6 +902,7 @@ - (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContent - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager { RCTAssertUIManagerQueue(); + [manager prependUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary *viewRegistry) { BOOL horz = [self isHorizontal:self->_scrollView]; @@ -842,9 +911,17 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager // Find the first entirely visible view. This must be done after we update the content offset // or it will tend to grab rows that were made visible by the shift in position UIView *subview = self->_contentView.subviews[ii]; - if ((horz ? subview.frame.origin.x >= self->_scrollView.contentOffset.x - : subview.frame.origin.y >= self->_scrollView.contentOffset.y) || - ii == self->_contentView.subviews.count - 1) { + BOOL hasNewView = NO; + if (horz) { + CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left; + CGFloat x = self->_scrollView.contentOffset.x + leftInset; + hasNewView = subview.frame.origin.x > x; + } else { + CGFloat bottomInset = self.inverted ? self->_scrollView.contentInset.top : self->_scrollView.contentInset.bottom; + CGFloat y = self->_scrollView.contentOffset.y + bottomInset; + hasNewView = subview.frame.origin.y > y; + } + if (hasNewView || ii == self->_contentView.subviews.count - 1) { self->_prevFirstVisibleFrame = subview.frame; self->_firstVisibleView = subview; break; @@ -860,12 +937,14 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager if ([self isHorizontal:self->_scrollView]) { CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x; if (ABS(deltaX) > 0.1) { + CGFloat leftInset = self.inverted ? self->_scrollView.contentInset.right : self->_scrollView.contentInset.left; + CGFloat x = self->_scrollView.contentOffset.x + leftInset; self->_scrollView.contentOffset = CGPointMake(self->_scrollView.contentOffset.x + deltaX, self->_scrollView.contentOffset.y); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (self->_scrollView.contentOffset.x - deltaX <= [autoscrollThreshold integerValue]) { - [self scrollToOffset:CGPointMake(0, self->_scrollView.contentOffset.y) animated:YES]; + if (x - deltaX <= [autoscrollThreshold integerValue]) { + [self scrollToOffset:CGPointMake(-leftInset, self->_scrollView.contentOffset.y) animated:YES]; } } } @@ -873,12 +952,14 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager CGRect newFrame = self->_firstVisibleView.frame; CGFloat deltaY = newFrame.origin.y - self->_prevFirstVisibleFrame.origin.y; if (ABS(deltaY) > 0.1) { + CGFloat bottomInset = self.inverted ? self->_scrollView.contentInset.top : self->_scrollView.contentInset.bottom; + CGFloat y = self->_scrollView.contentOffset.y + bottomInset; self->_scrollView.contentOffset = CGPointMake(self->_scrollView.contentOffset.x, self->_scrollView.contentOffset.y + deltaY); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (self->_scrollView.contentOffset.y - deltaY <= [autoscrollThreshold integerValue]) { - [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, 0) animated:YES]; + if (y - deltaY <= [autoscrollThreshold integerValue]) { + [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, -bottomInset) animated:YES]; } } } diff --git a/React/Views/ScrollView/RCTScrollViewManager.m b/React/Views/ScrollView/RCTScrollViewManager.m index bd5573f6a55808..c9aee3a1647628 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -66,6 +66,7 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(centerContent, BOOL) RCT_EXPORT_VIEW_PROPERTY(maintainVisibleContentPosition, NSDictionary) RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets, BOOL) +RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustKeyboardInsets, BOOL) RCT_EXPORT_VIEW_PROPERTY(decelerationRate, CGFloat) RCT_EXPORT_VIEW_PROPERTY(directionalLockEnabled, BOOL) RCT_EXPORT_VIEW_PROPERTY(indicatorStyle, UIScrollViewIndicatorStyle)