Skip to content

Commit

Permalink
Feature: ScrollView automaticallyAdjustKeyboardInsets (#31402)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mrousavy authored and facebook-github-bot committed Dec 6, 2021
1 parent bba5e6b commit 49a1460
Show file tree
Hide file tree
Showing 7 changed files with 117 additions and 21 deletions.
6 changes: 6 additions & 0 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const ScrollViewNativeComponent: HostComponent<Props> =
alwaysBounceHorizontal: true,
alwaysBounceVertical: true,
automaticallyAdjustContentInsets: true,
automaticallyAdjustKeyboardInsets: true,
automaticallyAdjustsScrollIndicatorInsets: true,
bounces: true,
bouncesZoom: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ScrollViewNativeProps = $ReadOnly<{
alwaysBounceHorizontal?: ?boolean,
alwaysBounceVertical?: ?boolean,
automaticallyAdjustContentInsets?: ?boolean,
automaticallyAdjustKeyboardInsets?: ?boolean,
automaticallyAdjustsScrollIndicatorInsets?: ?boolean,
bounces?: ?boolean,
bouncesZoom?: ?boolean,
Expand Down
1 change: 1 addition & 0 deletions Libraries/Components/ScrollView/ScrollViewViewConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ScrollViewViewConfig = {
alwaysBounceHorizontal: true,
alwaysBounceVertical: true,
automaticallyAdjustContentInsets: true,
automaticallyAdjustKeyboardInsets: true,
automaticallyAdjustsScrollIndicatorInsets: true,
bounces: true,
bouncesZoom: true,
Expand Down
1 change: 1 addition & 0 deletions React/Views/ScrollView/RCTScrollView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
127 changes: 106 additions & 21 deletions React/Views/ScrollView/RCTScrollView.m
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,80 @@ @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<RCTEventDispatcherProtocol>)eventDispatcher
{
RCTAssertParam(eventDispatcher);

if ((self = [super initWithFrame:CGRectZero])) {
[self _registerKeyboardListener];
_eventDispatcher = eventDispatcher;

_scrollView = [[RCTCustomScrollView alloc] initWithFrame:CGRectZero];
Expand Down Expand Up @@ -396,6 +465,7 @@ - (void)dealloc
{
_scrollView.delegate = nil;
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
[self _unregisterKeyboardListener];
}

- (void)layoutSubviews
Expand Down Expand Up @@ -832,23 +902,33 @@ - (void)setMaintainVisibleContentPosition:(NSDictionary *)maintainVisibleContent
- (void)uiManagerWillPerformMounting:(RCTUIManager *)manager
{
RCTAssertUIManagerQueue();
[manager
prependUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
BOOL horz = [self isHorizontal:self->_scrollView];
NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
// 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) {
self->_prevFirstVisibleFrame = subview.frame;
self->_firstVisibleView = subview;
break;
}
}
}];

[manager prependUIBlock:^(
__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
BOOL horz = [self isHorizontal:self->_scrollView];
NSUInteger minIdx = [self->_maintainVisibleContentPosition[@"minIndexForVisible"] integerValue];
for (NSUInteger ii = minIdx; ii < self->_contentView.subviews.count; ++ii) {
// 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];
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;
}
}
}];
[manager addUIBlock:^(__unused RCTUIManager *uiManager, __unused NSDictionary<NSNumber *, UIView *> *viewRegistry) {
if (self->_maintainVisibleContentPosition == nil) {
return; // The prop might have changed in the previous UIBlocks, so need to abort here.
Expand All @@ -858,25 +938,30 @@ - (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];
}
}
}
} else {
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];
}
}
}
Expand Down
1 change: 1 addition & 0 deletions React/Views/ScrollView/RCTScrollViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 49a1460

Please sign in to comment.