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)}