diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts index 2e26db757500d6..000e64bac3fd78 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts @@ -457,6 +457,8 @@ export interface ScrollViewPropsIOS { | { autoscrollToTopThreshold?: number | null | undefined; minIndexForVisible: number; + viewOffset?: number | null | undefined; + viewPosition?: number | null | undefined; } | undefined; /** diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index dbe5bf1f218a68..3fc4a0cbb845f5 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -519,6 +519,8 @@ export type Props = $ReadOnly<{| maintainVisibleContentPosition?: ?$ReadOnly<{| minIndexForVisible: number, autoscrollToTopThreshold?: ?number, + viewOffset?: ?number, + viewPosition?: ?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/ScrollViewNativeComponentType.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index f5a9632ee11999..f1dfb4450eaf6a 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -46,6 +46,8 @@ export type ScrollViewNativeProps = $ReadOnly<{ maintainVisibleContentPosition?: ?$ReadOnly<{ minIndexForVisible: number, autoscrollToTopThreshold?: ?number, + viewOffset?: ?number, + viewPosition?: ?number, }>, maximumZoomScale?: ?number, minimumZoomScale?: ?number, diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index e083609f06e083..81b26d6f5a418a 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -2037,6 +2037,8 @@ exports[`public API should not change unintentionally Libraries/Components/Scrol maintainVisibleContentPosition?: ?$ReadOnly<{ minIndexForVisible: number, autoscrollToTopThreshold?: ?number, + viewOffset?: ?number, + viewPosition?: ?number, }>, maximumZoomScale?: ?number, minimumZoomScale?: ?number, 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 98cf04959323cd..e7f894e9bd26de 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -810,6 +810,8 @@ - (void)_adjustForMaintainVisibleContentPosition } std::optional autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold; + int viewOffset = props.maintainVisibleContentPosition.value().viewOffset; + float viewPosition = props.maintainVisibleContentPosition.value().viewPosition; BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; // TODO: detect and handle/ignore re-ordering if (horizontal) { @@ -821,7 +823,8 @@ - (void)_adjustForMaintainVisibleContentPosition if (autoscrollThreshold) { // If the offset WAS within the threshold of the start, animate to the start. if (x <= autoscrollThreshold.value()) { - [self scrollToOffset:CGPointMake(0, _scrollView.contentOffset.y) animated:YES]; + CGFloat offset = MAX(0, deltaX - self.frame.size.width) * viewPosition - viewOffset; + [self scrollToOffset:CGPointMake(offset, _scrollView.contentOffset.y) animated:YES]; } } } @@ -835,7 +838,8 @@ - (void)_adjustForMaintainVisibleContentPosition if (autoscrollThreshold) { // If the offset WAS within the threshold of the start, animate to the start. if (y <= autoscrollThreshold.value()) { - [self scrollToOffset:CGPointMake(_scrollView.contentOffset.x, 0) animated:YES]; + CGFloat offset = MAX(0, deltaY - self.frame.size.height) * viewPosition - viewOffset; + [self scrollToOffset:CGPointMake(_scrollView.contentOffset.x, offset) animated:YES]; } } } diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index 81bdc49c3ed8dc..103187da839629 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -965,6 +965,8 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager return; // The prop might have changed in the previous UIBlocks, so need to abort here. } NSNumber *autoscrollThreshold = self->_maintainVisibleContentPosition[@"autoscrollToTopThreshold"]; + NSNumber *viewOffset = self->_maintainVisibleContentPosition[@"viewOffset"]; + NSNumber *viewPosition = self->_maintainVisibleContentPosition[@"viewPosition"]; // TODO: detect and handle/ignore re-ordering if ([self isHorizontal:self->_scrollView]) { CGFloat deltaX = self->_firstVisibleView.frame.origin.x - self->_prevFirstVisibleFrame.origin.x; @@ -976,7 +978,8 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. if (x <= [autoscrollThreshold integerValue]) { - [self scrollToOffset:CGPointMake(-leftInset, self->_scrollView.contentOffset.y) animated:YES]; + CGFloat offset = MAX(0, deltaX - self.frame.size.width) * [viewPosition floatValue] - [viewOffset floatValue] - leftInset; + [self scrollToOffset:CGPointMake(offset, self->_scrollView.contentOffset.y) animated:YES]; } } } @@ -992,7 +995,8 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. if (y <= [autoscrollThreshold integerValue]) { - [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, -bottomInset) animated:YES]; + CGFloat offset = MAX(0, deltaY - self.frame.size.height) * [viewPosition floatValue] - [viewOffset floatValue] - bottomInset; + [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, offset) animated:YES]; } } } 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 26c9aebc8388ad..0fd51b98eada29 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 @@ -46,10 +46,14 @@ public static class Config { public final int minIndexForVisible; public final @Nullable Integer autoScrollToTopThreshold; + public final int viewOffset; + public final int viewPosition; - Config(int minIndexForVisible, @Nullable Integer autoScrollToTopThreshold) { + Config(int minIndexForVisible, @Nullable Integer autoScrollToTopThreshold, int viewOffset, int viewPosition) { this.minIndexForVisible = minIndexForVisible; this.autoScrollToTopThreshold = autoScrollToTopThreshold; + this.viewOffset = viewOffset; + this.viewPosition = viewPosition; } static Config fromReadableMap(ReadableMap value) { @@ -58,7 +62,15 @@ static Config fromReadableMap(ReadableMap value) { value.hasKey("autoscrollToTopThreshold") ? value.getInt("autoscrollToTopThreshold") : null; - return new Config(minIndexForVisible, autoScrollToTopThreshold); + int viewOffset = + value.hasKey("viewOffset") + ? value.getInt("viewOffset") + : 0; + int viewPosition = + value.hasKey("viewPosition") + ? value.getInt("viewPosition") + : 0; + return new Config(minIndexForVisible, autoScrollToTopThreshold, viewOffset, viewPosition); } } @@ -122,7 +134,8 @@ private void updateScrollPositionInternal() { mPrevFirstVisibleFrame = newFrame; if (mConfig.autoScrollToTopThreshold != null && scrollX <= mConfig.autoScrollToTopThreshold) { - mScrollView.reactSmoothScrollTo(0, mScrollView.getScrollY()); + int offset = Math.max(0, deltaX - mScrollView.getWidth()) * mConfig.viewPosition - mConfig.viewOffset; + mScrollView.reactSmoothScrollTo(offset, mScrollView.getScrollY()); } } } else { @@ -133,7 +146,8 @@ private void updateScrollPositionInternal() { mPrevFirstVisibleFrame = newFrame; if (mConfig.autoScrollToTopThreshold != null && scrollY <= mConfig.autoScrollToTopThreshold) { - mScrollView.reactSmoothScrollTo(mScrollView.getScrollX(), 0); + int offset = Math.max(0, deltaY - mScrollView.getHeight()) * mConfig.viewPosition - mConfig.viewOffset; + mScrollView.reactSmoothScrollTo(mScrollView.getScrollX(), offset); } } } diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/conversions.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/conversions.h index 4c2b581dcda879..1773aa6a901518 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/conversions.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/conversions.h @@ -117,6 +117,14 @@ inline void fromRawValue( autoscrollToTopThreshold->second, result.autoscrollToTopThreshold); } + auto viewOffset = map.find("viewOffset"); + if (viewOffset != map.end()) { + fromRawValue(context, viewOffset->second, result.viewOffset); + } + auto viewPosition = map.find("viewPosition"); + if (viewPosition != map.end()) { + fromRawValue(context, viewPosition->second, result.viewPosition); + } } inline std::string toString(const ScrollViewSnapToAlignment& value) { @@ -174,7 +182,9 @@ inline std::string toString( } return "{minIndexForVisible: " + toString(value.value().minIndexForVisible) + ", autoscrollToTopThreshold: " + - toString(value.value().autoscrollToTopThreshold) + "}"; + toString(value.value().autoscrollToTopThreshold) + + ", viewOffset: " + toString(value.value().viewOffset) + + ", viewPosition: " + toString(value.value().viewPosition) + "}"; } #endif diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/primitives.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/primitives.h index bb3f4f1a559556..ca40a79cfc876e 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/primitives.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/primitives.h @@ -29,10 +29,20 @@ class ScrollViewMaintainVisibleContentPosition final { public: int minIndexForVisible{0}; std::optional autoscrollToTopThreshold{}; + int viewOffset{0}; + float viewPosition{0}; bool operator==(const ScrollViewMaintainVisibleContentPosition& rhs) const { - return std::tie(this->minIndexForVisible, this->autoscrollToTopThreshold) == - std::tie(rhs.minIndexForVisible, rhs.autoscrollToTopThreshold); + return std::tie( + this->minIndexForVisible, + this->autoscrollToTopThreshold, + this->viewOffset, + this->viewPosition) == + std::tie( + rhs.minIndexForVisible, + rhs.autoscrollToTopThreshold, + rhs.viewOffset, + rhs.viewPosition); } bool operator!=(const ScrollViewMaintainVisibleContentPosition& rhs) const {