diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts index b6e538ddc5a1be..bd9f15396e756f 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.d.ts @@ -586,6 +586,15 @@ export interface ScrollViewPropsAndroid { * Causes the scrollbars not to turn transparent when they are not in use. The default value is false. */ persistentScrollbar?: boolean | undefined; + + /** + * When false, the ScrollView will not automatically scroll to a focused child when + * the child requests focus. This can be useful when you want to control the scroll + * position programmatically. The default value is true. + * + * @platform android + */ + scrollsChildToFocus?: boolean | undefined; } export interface ScrollViewProps diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index d247ac706d2b63..09242371d07bc9 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -388,6 +388,14 @@ export type ScrollViewPropsAndroid = Readonly<{ * @platform android */ fadingEdgeLength?: ?number | {start: number, end: number}, + /** + * When false, the ScrollView will not automatically scroll to a focused child when + * the child requests focus. This can be useful when you want to control the scroll + * position programmatically. The default value is true. + * + * @platform android + */ + scrollsChildToFocus?: ?boolean, }>; type StickyHeaderComponentType = component( diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js index 82f9c93baf348c..465131b71443f4 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js @@ -88,6 +88,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = }, pointerEvents: true, isInvertedVirtualizedList: true, + scrollsChildToFocus: true, }, } : { diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js index 05a2eb18690d26..d51593b088036b 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js @@ -69,6 +69,7 @@ export type ScrollViewNativeProps = Readonly<{ scrollPerfTag?: ?string, scrollToOverflowEnabled?: ?boolean, scrollsToTop?: ?boolean, + scrollsChildToFocus?: ?boolean, sendMomentumEvents?: ?boolean, showsHorizontalScrollIndicator?: ?boolean, showsVerticalScrollIndicator?: ?boolean, diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 072c5c832c06c4..b616d52bf3776f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -136,6 +136,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView private int mFadingEdgeLengthStart = 0; private int mFadingEdgeLengthEnd = 0; private boolean mEmittedOverScrollSinceScrollBegin = false; + private boolean mScrollsChildToFocus = true; public ReactHorizontalScrollView(Context context) { this(context, null); @@ -199,6 +200,7 @@ private void initView() { mMaintainVisibleContentPositionHelper = null; mFadingEdgeLengthStart = 0; mFadingEdgeLengthEnd = 0; + mScrollsChildToFocus = true; } /* package */ void recycleView() { @@ -324,6 +326,10 @@ public void setPagingEnabled(boolean pagingEnabled) { mPagingEnabled = pagingEnabled; } + public void setScrollsChildToFocus(boolean scrollsChildToFocus) { + mScrollsChildToFocus = scrollsChildToFocus; + } + public void setDecelerationRate(float decelerationRate) { getReactScrollViewScrollState().setDecelerationRate(decelerationRate); @@ -545,7 +551,7 @@ protected void onLayout(boolean changed, int l, int t, int r, int b) { */ @Override public void requestChildFocus(View child, View focused) { - if (focused != null && !mPagingEnabled) { + if (focused != null && !mPagingEnabled && mScrollsChildToFocus) { scrollToChild(focused); } requestChildFocusWithoutScroll(child, focused); @@ -560,6 +566,14 @@ protected void requestChildFocusWithoutScroll(View child, View focused) { super.requestChildFocus(child, focused); } + @Override + public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { + if (!mScrollsChildToFocus) { + return false; + } + return super.requestChildRectangleOnScreen(child, rectangle, immediate); + } + @Override public void addFocusables(ArrayList views, int direction, int focusableMode) { if (mPagingEnabled && !mPagedArrowScrolling) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt index 412500ccbf3772..f2bdae139bbe0c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt @@ -117,6 +117,11 @@ constructor(private val fpsListener: FpsListener? = null) : view.setDisableIntervalMomentum(disableIntervalMomentum) } + @ReactProp(name = "scrollsChildToFocus", defaultBoolean = true) + public fun setScrollsChildToFocus(view: ReactHorizontalScrollView, scrollsChildToFocus: Boolean) { + view.setScrollsChildToFocus(scrollsChildToFocus) + } + @ReactProp(name = "snapToInterval") public fun setSnapToInterval(view: ReactHorizontalScrollView, snapToInterval: Float) { // snapToInterval needs to be exposed as a float because of the Javascript interface. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index d1b1ff17ba26b7..6566b15a7a48b2 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -133,6 +133,7 @@ public class ReactScrollView extends ScrollView private int mFadingEdgeLengthStart; private int mFadingEdgeLengthEnd; private boolean mEmittedOverScrollSinceScrollBegin; + private boolean mScrollsChildToFocus; public ReactScrollView(Context context) { this(context, null); @@ -196,6 +197,7 @@ private void initView() { mFadingEdgeLengthStart = 0; mFadingEdgeLengthEnd = 0; mEmittedOverScrollSinceScrollBegin = false; + mScrollsChildToFocus = true; } /* package */ void recycleView() { @@ -298,6 +300,10 @@ public void setPagingEnabled(boolean pagingEnabled) { mPagingEnabled = pagingEnabled; } + public void setScrollsChildToFocus(boolean scrollsChildToFocus) { + mScrollsChildToFocus = scrollsChildToFocus; + } + public void setDecelerationRate(float decelerationRate) { getReactScrollViewScrollState().setDecelerationRate(decelerationRate); @@ -507,7 +513,7 @@ protected void onDetachedFromWindow() { */ @Override public void requestChildFocus(View child, View focused) { - if (focused != null) { + if (focused != null && mScrollsChildToFocus) { scrollToChild(focused); } requestChildFocusWithoutScroll(child, focused); @@ -522,6 +528,14 @@ protected void requestChildFocusWithoutScroll(View child, View focused) { super.requestChildFocus(child, focused); } + @Override + public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { + if (!mScrollsChildToFocus) { + return false; + } + return super.requestChildRectangleOnScreen(child, rectangle, immediate); + } + private int getScrollDelta(View descendent) { descendent.getDrawingRect(mTempRect); offsetDescendantRectToMyCoords(descendent, mTempRect); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt index cc715b64afe4af..0de168f067dd9a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt @@ -104,6 +104,11 @@ constructor(private val fpsListener: FpsListener? = null) : view.setDisableIntervalMomentum(disableIntervalMomentum) } + @ReactProp(name = "scrollsChildToFocus", defaultBoolean = true) + public fun setScrollsChildToFocus(view: ReactScrollView, scrollsChildToFocus: Boolean) { + view.setScrollsChildToFocus(scrollsChildToFocus) + } + @ReactProp(name = "snapToInterval") public fun setSnapToInterval(view: ReactScrollView, snapToInterval: Float) { // snapToInterval needs to be exposed as a float because of the Javascript interface. diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 8f175a5437bfd0..55a658f492d785 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -545,6 +545,14 @@ if (Platform.OS === 'ios') { return ; }, }); + examples.push({ + title: ' scrollsChildToFocus\n', + description: + 'When false, the ScrollView will not automatically scroll to a focused child. Useful for controlling scroll position programmatically.', + render(): React.Node { + return ; + }, + }); } exports.examples = examples; @@ -566,6 +574,45 @@ const AndroidScrollBarOptions = () => { ); }; +const ScrollsChildToFocusExample = () => { + const [scrollsChildToFocus, setScrollsChildToFocus] = useState(true); + return ( + + + Focus a TextInput below to see the scroll behavior. + + + + + + + + + + + + +