diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js
index d4582314d74551..096874e7f2c0e1 100644
--- a/Libraries/Components/ScrollView/ScrollView.js
+++ b/Libraries/Components/ScrollView/ScrollView.js
@@ -288,7 +288,6 @@ type IOSProps = $ReadOnly<{|
* visibility. Occlusion, transforms, and other complexity won't be taken into account as to
* whether content is "visible" or not.
*
- * @platform ios
*/
maintainVisibleContentPosition?: ?$ReadOnly<{|
minIndexForVisible: number,
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java
new file mode 100644
index 00000000000000..6ce4d8b23d2bbf
--- /dev/null
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+package com.facebook.react.views.scroll;
+
+import android.graphics.Rect;
+import android.view.View;
+import android.view.ViewGroup;
+import androidx.annotation.Nullable;
+import com.facebook.infer.annotation.Assertions;
+import com.facebook.react.bridge.ReactContext;
+import com.facebook.react.bridge.ReadableMap;
+import com.facebook.react.bridge.UIManager;
+import com.facebook.react.bridge.UIManagerListener;
+import com.facebook.react.bridge.UiThreadUtil;
+import com.facebook.react.uimanager.UIManagerHelper;
+import com.facebook.react.uimanager.common.ViewUtil;
+import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll;
+import com.facebook.react.views.view.ReactViewGroup;
+import java.lang.ref.WeakReference;
+
+/**
+ * Manage state for the maintainVisibleContentPosition prop.
+ *
+ *
This uses UIManager to listen to updates and capture position of items before and after
+ * layout.
+ */
+public class MaintainVisibleScrollPositionHelper
+ implements UIManagerListener {
+ private final ScrollViewT mScrollView;
+ private final boolean mHorizontal;
+ private @Nullable Config mConfig;
+ private @Nullable WeakReference mFirstVisibleView = null;
+ private @Nullable Rect mPrevFirstVisibleFrame = null;
+ private boolean mListening = false;
+
+ public static class Config {
+ public final int minIndexForVisible;
+ public final @Nullable Integer autoScrollToTopThreshold;
+
+ Config(int minIndexForVisible, @Nullable Integer autoScrollToTopThreshold) {
+ this.minIndexForVisible = minIndexForVisible;
+ this.autoScrollToTopThreshold = autoScrollToTopThreshold;
+ }
+
+ static Config fromReadableMap(ReadableMap value) {
+ int minIndexForVisible = value.getInt("minIndexForVisible");
+ Integer autoScrollToTopThreshold =
+ value.hasKey("autoscrollToTopThreshold")
+ ? value.getInt("autoscrollToTopThreshold")
+ : null;
+ return new Config(minIndexForVisible, autoScrollToTopThreshold);
+ }
+ }
+
+ public MaintainVisibleScrollPositionHelper(ScrollViewT scrollView, boolean horizontal) {
+ mScrollView = scrollView;
+ mHorizontal = horizontal;
+ }
+
+ public void setConfig(@Nullable Config config) {
+ mConfig = config;
+ }
+
+ /** Start listening to view hierarchy updates. Should be called when this is created. */
+ public void start() {
+ if (mListening) {
+ return;
+ }
+ mListening = true;
+ getUIManagerModule().addUIManagerEventListener(this);
+ }
+
+ /** Stop listening to view hierarchy updates. Should be called before this is destroyed. */
+ public void stop() {
+ if (!mListening) {
+ return;
+ }
+ mListening = false;
+ getUIManagerModule().removeUIManagerEventListener(this);
+ }
+
+ /**
+ * Update the scroll position of the managed ScrollView. This should be called after layout has
+ * been updated.
+ */
+ public void updateScrollPosition() {
+ if (mConfig == null || mFirstVisibleView == null || mPrevFirstVisibleFrame == null) {
+ return;
+ }
+
+ View firstVisibleView = mFirstVisibleView.get();
+ Rect newFrame = new Rect();
+ firstVisibleView.getHitRect(newFrame);
+
+ if (mHorizontal) {
+ int deltaX = newFrame.left - mPrevFirstVisibleFrame.left;
+ if (deltaX != 0) {
+ int scrollX = mScrollView.getScrollX();
+ mScrollView.scrollTo(scrollX + deltaX, mScrollView.getScrollY());
+ mPrevFirstVisibleFrame = newFrame;
+ if (mConfig.autoScrollToTopThreshold != null
+ && scrollX <= mConfig.autoScrollToTopThreshold) {
+ mScrollView.reactSmoothScrollTo(0, mScrollView.getScrollY());
+ }
+ }
+ } else {
+ int deltaY = newFrame.top - mPrevFirstVisibleFrame.top;
+ if (deltaY != 0) {
+ int scrollY = mScrollView.getScrollY();
+ mScrollView.scrollTo(mScrollView.getScrollX(), scrollY + deltaY);
+ mPrevFirstVisibleFrame = newFrame;
+ if (mConfig.autoScrollToTopThreshold != null
+ && scrollY <= mConfig.autoScrollToTopThreshold) {
+ mScrollView.reactSmoothScrollTo(mScrollView.getScrollX(), 0);
+ }
+ }
+ }
+ }
+
+ private @Nullable ReactViewGroup getContentView() {
+ return (ReactViewGroup) mScrollView.getChildAt(0);
+ }
+
+ private UIManager getUIManagerModule() {
+ return Assertions.assertNotNull(
+ UIManagerHelper.getUIManager(
+ (ReactContext) mScrollView.getContext(),
+ ViewUtil.getUIManagerType(mScrollView.getId())));
+ }
+
+ private void computeTargetView() {
+ if (mConfig == null) {
+ return;
+ }
+ ReactViewGroup contentView = getContentView();
+ if (contentView == null) {
+ return;
+ }
+
+ int currentScroll = mHorizontal ? mScrollView.getScrollX() : mScrollView.getScrollY();
+ for (int i = mConfig.minIndexForVisible; i < contentView.getChildCount(); i++) {
+ View child = contentView.getChildAt(i);
+ float position = mHorizontal ? child.getX() : child.getY();
+ if (position > currentScroll || i == contentView.getChildCount() - 1) {
+ mFirstVisibleView = new WeakReference<>(child);
+ Rect frame = new Rect();
+ child.getHitRect(frame);
+ mPrevFirstVisibleFrame = frame;
+ break;
+ }
+ }
+ }
+
+ // UIManagerListener
+
+ @Override
+ public void willDispatchViewUpdates(final UIManager uiManager) {
+ UiThreadUtil.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ computeTargetView();
+ }
+ });
+ }
+
+ @Override
+ public void didDispatchMountItems(UIManager uiManager) {
+ // noop
+ }
+
+ @Override
+ public void didScheduleMountItems(UIManager uiManager) {
+ // noop
+ }
+}
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java
index 3c962f03565a12..5f734256571277 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java
@@ -45,6 +45,7 @@
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState;
+import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
@@ -54,11 +55,14 @@
/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
public class ReactHorizontalScrollView extends HorizontalScrollView
implements ReactClippingViewGroup,
+ ViewGroup.OnHierarchyChangeListener,
+ View.OnLayoutChangeListener,
FabricViewStateManager.HasFabricViewStateManager,
ReactOverflowViewWithInset,
HasScrollState,
HasFlingAnimator,
- HasScrollEventThrottle {
+ HasScrollEventThrottle,
+ HasSmoothScroll {
private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG;
private static String TAG = ReactHorizontalScrollView.class.getSimpleName();
@@ -107,6 +111,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
private PointerEvents mPointerEvents = PointerEvents.AUTO;
private long mLastScrollDispatchTime = 0;
private int mScrollEventThrottle = 0;
+ private @Nullable View mContentView;
+ private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper;
private final Rect mTempRect = new Rect();
@@ -127,6 +133,8 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe
I18nUtil.getInstance().isRTL(context)
? ViewCompat.LAYOUT_DIRECTION_RTL
: ViewCompat.LAYOUT_DIRECTION_LTR);
+
+ setOnHierarchyChangeListener(this);
}
public boolean getScrollEnabled() {
@@ -243,6 +251,20 @@ public void setOverflow(String overflow) {
invalidate();
}
+ public void setMaintainVisibleContentPosition(
+ @Nullable MaintainVisibleScrollPositionHelper.Config config) {
+ if (config != null && mMaintainVisibleContentPositionHelper == null) {
+ mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, true);
+ mMaintainVisibleContentPositionHelper.start();
+ } else if (config == null && mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.stop();
+ mMaintainVisibleContentPositionHelper = null;
+ }
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.setConfig(config);
+ }
+ }
+
@Override
public @Nullable String getOverflow() {
return mOverflow;
@@ -635,6 +657,17 @@ protected void onAttachedToWindow() {
if (mRemoveClippedSubviews) {
updateClippingRect();
}
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.start();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.stop();
+ }
}
@Override
@@ -714,6 +747,18 @@ protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolea
super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
}
+ @Override
+ public void onChildViewAdded(View parent, View child) {
+ mContentView = child;
+ mContentView.addOnLayoutChangeListener(this);
+ }
+
+ @Override
+ public void onChildViewRemoved(View parent, View child) {
+ mContentView.removeOnLayoutChangeListener(this);
+ mContentView = null;
+ }
+
private void enableFpsListener() {
if (isScrollPerfLoggingEnabled()) {
Assertions.assertNotNull(mFpsListener);
@@ -1237,6 +1282,26 @@ private void setPendingContentOffsets(int x, int y) {
}
}
+ @Override
+ public void onLayoutChange(
+ View v,
+ int left,
+ int top,
+ int right,
+ int bottom,
+ int oldLeft,
+ int oldTop,
+ int oldRight,
+ int oldBottom) {
+ if (mContentView == null) {
+ return;
+ }
+
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.updateScrollPosition();
+ }
+ }
+
@Override
public FabricViewStateManager getFabricViewStateManager() {
return mFabricViewStateManager;
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java
index aada6a7c4079b2..f681465620f8e4 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java
@@ -328,6 +328,16 @@ public void setContentOffset(ReactHorizontalScrollView view, ReadableMap value)
}
}
+ @ReactProp(name = "maintainVisibleContentPosition")
+ public void setMaintainVisibleContentPosition(ReactHorizontalScrollView view, ReadableMap value) {
+ if (value != null) {
+ view.setMaintainVisibleContentPosition(
+ MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value));
+ } else {
+ view.setMaintainVisibleContentPosition(null);
+ }
+ }
+
@ReactProp(name = ViewProps.POINTER_EVENTS)
public void setPointerEvents(ReactHorizontalScrollView view, @Nullable String pointerEventsStr) {
view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr));
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
index 16250d423d2eed..7a619882dfdd14 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
@@ -47,6 +47,7 @@
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle;
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState;
+import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll;
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState;
import com.facebook.react.views.view.ReactViewBackgroundManager;
import java.lang.reflect.Field;
@@ -67,7 +68,8 @@ public class ReactScrollView extends ScrollView
ReactOverflowViewWithInset,
HasScrollState,
HasFlingAnimator,
- HasScrollEventThrottle {
+ HasScrollEventThrottle,
+ HasSmoothScroll {
private static @Nullable Field sScrollerField;
private static boolean sTriedToGetScrollerField = false;
@@ -112,6 +114,8 @@ public class ReactScrollView extends ScrollView
private PointerEvents mPointerEvents = PointerEvents.AUTO;
private long mLastScrollDispatchTime = 0;
private int mScrollEventThrottle = 0;
+ private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper =
+ null;
public ReactScrollView(Context context) {
this(context, null);
@@ -243,6 +247,20 @@ public void setOverflow(String overflow) {
invalidate();
}
+ public void setMaintainVisibleContentPosition(
+ @Nullable MaintainVisibleScrollPositionHelper.Config config) {
+ if (config != null && mMaintainVisibleContentPositionHelper == null) {
+ mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, false);
+ mMaintainVisibleContentPositionHelper.start();
+ } else if (config == null && mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.stop();
+ mMaintainVisibleContentPositionHelper = null;
+ }
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.setConfig(config);
+ }
+ }
+
@Override
public @Nullable String getOverflow() {
return mOverflow;
@@ -293,6 +311,17 @@ protected void onAttachedToWindow() {
if (mRemoveClippedSubviews) {
updateClippingRect();
}
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.start();
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.stop();
+ }
}
/**
@@ -1091,6 +1120,10 @@ public void onLayoutChange(
return;
}
+ if (mMaintainVisibleContentPositionHelper != null) {
+ mMaintainVisibleContentPositionHelper.updateScrollPosition();
+ }
+
int currentScrollY = getScrollY();
int maxScrollY = getMaxScrollY();
if (currentScrollY > maxScrollY) {
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java
index b574cb501b047c..81d2382b212f23 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java
@@ -602,4 +602,8 @@ public interface HasScrollEventThrottle {
/** Get the scroll view dispatch time for throttling */
long getLastScrollDispatchTime();
}
+
+ public interface HasSmoothScroll {
+ void reactSmoothScrollTo(int x, int y);
+ }
}
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java
index 1a6ada453c7f18..e6464ab6ef2dec 100644
--- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java
@@ -323,6 +323,16 @@ public void setContentOffset(ReactScrollView view, ReadableMap value) {
view.setContentOffset(value);
}
+ @ReactProp(name = "maintainVisibleContentPosition")
+ public void setMaintainVisibleContentPosition(ReactScrollView view, ReadableMap value) {
+ if (value != null) {
+ view.setMaintainVisibleContentPosition(
+ MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value));
+ } else {
+ view.setMaintainVisibleContentPosition(null);
+ }
+ }
+
@Override
public Object updateState(
ReactScrollView view, ReactStylesDiffMap props, StateWrapper stateWrapper) {
diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js
index 33f467a492311b..ec3b0d7a461446 100644
--- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js
+++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js
@@ -35,6 +35,7 @@ class EnableDisableList extends React.Component<{}, {scrollEnabled: boolean}> {
{ITEMS.map(createItemRow)}
@@ -78,6 +79,7 @@ class AppendingList extends React.Component<
minIndexForVisible: 1,
autoscrollToTopThreshold: 10,
}}
+ nestedScrollEnabled
style={styles.scrollView}>
{this.state.items.map(item =>
React.cloneElement(item, {key: item.props.msg}),
@@ -169,7 +171,10 @@ class AppendingList extends React.Component<
function CenterContentList(): React.Node {
return (
-
+
This should be in center.
);
@@ -208,6 +213,7 @@ const examples = ([
_scrollView = scrollView;
}}
automaticallyAdjustContentInsets={false}
+ nestedScrollEnabled
onScroll={() => {
console.log('onScroll!');
}}
@@ -397,10 +403,7 @@ const examples = ([
return ;
},
},
-]: Array);
-
-if (Platform.OS === 'ios') {
- examples.push({
+ {
title: ' smooth bi-directional content loading\n',
description:
'The `maintainVisibleContentPosition` prop allows insertions to either end of the content ' +
@@ -408,7 +411,10 @@ if (Platform.OS === 'ios') {
render: function () {
return ;
},
- });
+ },
+]: Array);
+
+if (Platform.OS === 'ios') {
examples.push({
title: ' (centerContent = true)\n',
description:
@@ -491,6 +497,7 @@ const AndroidScrollBarOptions = () => {
{ITEMS.map(createItemRow)}
@@ -1219,8 +1226,7 @@ const BouncesExampleHorizontal = () => {
style={[styles.scrollView, {height: 200}]}
horizontal={true}
alwaysBounceHorizontal={bounce}
- contentOffset={{x: 100, y: 0}}
- nestedScrollEnabled>
+ contentOffset={{x: 100, y: 0}}>
{ITEMS.map(createItemRow)}