From c19548728c9be3ecc91e6fefb35bc14929109d60 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 23 Jan 2023 12:00:05 -0800 Subject: [PATCH] Add maintainVisibleContentPosition support on Android (#35049) Summary: This adds support for `maintainVisibleContentPosition` on Android. The implementation is heavily inspired from iOS, it works by finding the first visible view and its frame before views are update, then adjusting the scroll position once the views are updated. Most of the logic is abstracted away in MaintainVisibleScrollPositionHelper to be used in both vertical and horizontal scrollview implementations. Note that this only works for the old architecture, I have a follow up ready to add fabric support. ## Changelog [Android] [Added] - Add maintainVisibleContentPosition support on Android Pull Request resolved: https://github.com/facebook/react-native/pull/35049 Test Plan: Test in RN tester example on Android https://user-images.githubusercontent.com/2677334/197319855-d81ced33-a80b-495f-a688-4106fc699f3c.mov Reviewed By: ryancat Differential Revision: D40642469 Pulled By: skinsshark fbshipit-source-id: d60f3e2d0613d21af5f150ca0d099beeac6feb91 --- Libraries/Components/ScrollView/ScrollView.js | 1 - .../MaintainVisibleScrollPositionHelper.java | 181 ++++++++++++++++++ .../scroll/ReactHorizontalScrollView.java | 67 ++++++- .../ReactHorizontalScrollViewManager.java | 10 + .../react/views/scroll/ReactScrollView.java | 35 +++- .../views/scroll/ReactScrollViewHelper.java | 4 + .../views/scroll/ReactScrollViewManager.java | 10 + .../examples/ScrollView/ScrollViewExample.js | 22 ++- 8 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index d4582314d74..096874e7f2c 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 00000000000..6ce4d8b23d2 --- /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 3c962f03565..5f734256571 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.uimanager.events.NativeGestureUtil; 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 @@ import java.util.List; /** 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 class ReactHorizontalScrollView extends HorizontalScrollView I18nUtil.getInstance().isRTL(context) ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); + + setOnHierarchyChangeListener(this); } public boolean getScrollEnabled() { @@ -243,6 +251,20 @@ public class ReactHorizontalScrollView extends HorizontalScrollView 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 @@ public class ReactHorizontalScrollView extends HorizontalScrollView if (mRemoveClippedSubviews) { updateClippingRect(); } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.start(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.stop(); + } } @Override @@ -714,6 +747,18 @@ public class ReactHorizontalScrollView extends HorizontalScrollView 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 @@ public class ReactHorizontalScrollView extends HorizontalScrollView } } + @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 aada6a7c407..f681465620f 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 class ReactHorizontalScrollViewManager extends ViewGroupManager 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 b574cb501b0..81d2382b212 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 class ReactScrollViewHelper { /** 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 1a6ada453c7..e6464ab6ef2 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 class ReactScrollViewManager extends ViewGroupManager 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 33f467a4923..ec3b0d7a461 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)}