Feature: ScrollView `automaticallyAdjustKeyboardInsets` (#31402)
Summary:
Retrying D30015799 (6e903b07fa
) 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: https://github.com/facebook/react-native/pull/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><KeyboardAvoidingView></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 https://github.com/facebook/react-native/issues/31394
* Fixes https://github.com/facebook/react-native/issues/13073
Reviewed By: yungsters
Differential Revision: D32578661
Pulled By: sota000
fbshipit-source-id: 45985e2844275fe96304eccfd1901907dc4f9279
This commit is contained in:
Родитель
bba5e6b341
Коммит
49a1460a37
|
@ -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.
|
||||
|
|
|
@ -25,6 +25,7 @@ const ScrollViewNativeComponent: HostComponent<Props> =
|
|||
alwaysBounceHorizontal: true,
|
||||
alwaysBounceVertical: true,
|
||||
automaticallyAdjustContentInsets: true,
|
||||
automaticallyAdjustKeyboardInsets: true,
|
||||
automaticallyAdjustsScrollIndicatorInsets: true,
|
||||
bounces: true,
|
||||
bouncesZoom: true,
|
||||
|
|
|
@ -21,6 +21,7 @@ export type ScrollViewNativeProps = $ReadOnly<{
|
|||
alwaysBounceHorizontal?: ?boolean,
|
||||
alwaysBounceVertical?: ?boolean,
|
||||
automaticallyAdjustContentInsets?: ?boolean,
|
||||
automaticallyAdjustKeyboardInsets?: ?boolean,
|
||||
automaticallyAdjustsScrollIndicatorInsets?: ?boolean,
|
||||
bounces?: ?boolean,
|
||||
bouncesZoom?: ?boolean,
|
||||
|
|
|
@ -24,6 +24,7 @@ const ScrollViewViewConfig = {
|
|||
alwaysBounceHorizontal: true,
|
||||
alwaysBounceVertical: true,
|
||||
automaticallyAdjustContentInsets: true,
|
||||
automaticallyAdjustKeyboardInsets: true,
|
||||
automaticallyAdjustsScrollIndicatorInsets: true,
|
||||
bounces: true,
|
||||
bouncesZoom: true,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -274,11 +274,80 @@
|
|||
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];
|
||||
|
@ -396,6 +465,7 @@ static inline void RCTApplyTransformationAccordingLayoutDirection(
|
|||
{
|
||||
_scrollView.delegate = nil;
|
||||
[_eventDispatcher.bridge.uiManager.observerCoordinator removeObserver:self];
|
||||
[self _unregisterKeyboardListener];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
|
@ -832,23 +902,33 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
|
|||
- (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.
|
||||
|
@ -858,12 +938,14 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
|
|||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -871,12 +953,15 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop)
|
|||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ RCT_EXPORT_VIEW_PROPERTY(canCancelContentTouches, BOOL)
|
|||
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)
|
||||
|
|
Загрузка…
Ссылка в новой задаче