diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 8d73e00a609..aab9f20baf2 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -266,33 +266,6 @@ type IOSProps = $ReadOnly<{| * @platform ios */ canCancelContentTouches?: ?boolean, - /** - * When set, the scroll view will adjust the scroll position so that the first child that is - * currently visible and at or beyond `minIndexForVisible` will not change position. This is - * useful for lists that are loading content in both directions, e.g. a chat thread, where new - * messages coming in might otherwise cause the scroll position to jump. A value of 0 is common, - * but other values such as 1 can be used to skip loading spinners or other content that should - * not maintain position. - * - * The optional `autoscrollToTopThreshold` can be used to make the content automatically scroll - * to the top after making the adjustment if the user was within the threshold of the top before - * the adjustment was made. This is also useful for chat-like applications where you want to see - * new messages scroll into place, but not if the user has scrolled up a ways and it would be - * disruptive to scroll a bunch. - * - * Caveat 1: Reordering elements in the scrollview with this enabled will probably cause - * jumpiness and jank. It can be fixed, but there are currently no plans to do so. For now, - * don't re-order the content of any ScrollViews or Lists that use this feature. - * - * Caveat 2: This simply uses `contentOffset` and `frame.origin` in native code to compute - * visibility. Occlusion, transforms, and other complexity won't be taken into account as to - * whether content is "visible" or not. - * - */ - maintainVisibleContentPosition?: ?$ReadOnly<{| - minIndexForVisible: number, - autoscrollToTopThreshold?: ?number, - |}>, /** * The maximum allowed zoom scale. The default value is 1.0. * @platform ios @@ -505,6 +478,33 @@ export type Props = $ReadOnly<{| * - `true`, deprecated, use 'always' instead */ keyboardShouldPersistTaps?: ?('always' | 'never' | 'handled' | true | false), + /** + * When set, the scroll view will adjust the scroll position so that the first child that is + * partially or fully visible and at or beyond `minIndexForVisible` will not change position. + * This is useful for lists that are loading content in both directions, e.g. a chat thread, + * where new messages coming in might otherwise cause the scroll position to jump. A value of 0 + * is common, but other values such as 1 can be used to skip loading spinners or other content + * that should not maintain position. + * + * The optional `autoscrollToTopThreshold` can be used to make the content automatically scroll + * to the top after making the adjustment if the user was within the threshold of the top before + * the adjustment was made. This is also useful for chat-like applications where you want to see + * new messages scroll into place, but not if the user has scrolled up a ways and it would be + * disruptive to scroll a bunch. + * + * Caveat 1: Reordering elements in the scrollview with this enabled will probably cause + * jumpiness and jank. It can be fixed, but there are currently no plans to do so. For now, + * don't re-order the content of any ScrollViews or Lists that use this feature. + * + * Caveat 2: This simply uses `contentOffset` and `frame.origin` in native code to compute + * visibility. Occlusion, transforms, and other complexity won't be taken into account as to + * whether content is "visible" or not. + * + */ + maintainVisibleContentPosition?: ?$ReadOnly<{| + minIndexForVisible: number, + autoscrollToTopThreshold?: ?number, + |}>, /** * Called when the momentum scroll starts (scroll which occurs as the ScrollView glides to a stop). */ diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js index 69ebcef0d56..50d95a9de87 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js @@ -46,6 +46,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = }, decelerationRate: true, disableIntervalMomentum: true, + maintainVisibleContentPosition: true, pagingEnabled: true, scrollEnabled: true, showsVerticalScrollIndicator: true, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 90a9a134dce..265ad7a922b 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -752,13 +752,13 @@ static void RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrol BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; int minIdx = props.maintainVisibleContentPosition.value().minIndexForVisible; for (NSUInteger ii = minIdx; ii < _contentView.subviews.count; ++ii) { - // Find the first entirely visible view. + // Find the first view that is partially or fully visible. UIView *subview = _contentView.subviews[ii]; BOOL hasNewView = NO; if (horizontal) { - hasNewView = subview.frame.origin.x > _scrollView.contentOffset.x; + hasNewView = subview.frame.origin.x + subview.frame.size.width > _scrollView.contentOffset.x; } else { - hasNewView = subview.frame.origin.y > _scrollView.contentOffset.y; + hasNewView = subview.frame.origin.y + subview.frame.size.height > _scrollView.contentOffset.y; } if (hasNewView || ii == _contentView.subviews.count - 1) { _prevFirstVisibleFrame = subview.frame; diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index 1aead514ac3..7dd55cf3348 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -939,19 +939,19 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidScrollToTop, onScrollToTop) 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 + // Find the first partially or fully 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; + hasNewView = subview.frame.origin.x + subview.frame.size.width > 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; + hasNewView = subview.frame.origin.y + subview.frame.size.height > y; } if (hasNewView || ii == self->_contentView.subviews.count - 1) { self->_prevFirstVisibleFrame = subview.frame; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java index 7839dcd8006..20ab0d71a47 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java @@ -34,6 +34,7 @@ import java.lang.ref.WeakReference; @Nullsafe(Nullsafe.Mode.LOCAL) class MaintainVisibleScrollPositionHelper implements UIManagerListener { + private final ScrollViewT mScrollView; private final boolean mHorizontal; private @Nullable Config mConfig; @@ -42,6 +43,7 @@ class MaintainVisibleScrollPositionHelper currentScroll || i == contentView.getChildCount() - 1) { mFirstVisibleView = new WeakReference<>(child); Rect frame = new Rect();