Skip to content

Commit c069ae3

Browse files
roryabrahamjanicduplessis
authored andcommitted
Add maintainVisibleContentPosition prop for android scroll view
1 parent 950ea91 commit c069ae3

File tree

8 files changed

+322
-11
lines changed

8 files changed

+322
-11
lines changed

Libraries/Components/ScrollView/ScrollView.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,6 @@ type IOSProps = $ReadOnly<{|
279279
* visibility. Occlusion, transforms, and other complexity won't be taken into account as to
280280
* whether content is "visible" or not.
281281
*
282-
* @platform ios
283282
*/
284283
maintainVisibleContentPosition?: ?$ReadOnly<{|
285284
minIndexForVisible: number,
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.scroll;
9+
10+
import android.graphics.Rect;
11+
import android.view.View;
12+
import android.view.ViewGroup;
13+
14+
import androidx.annotation.Nullable;
15+
16+
import com.facebook.infer.annotation.Assertions;
17+
import com.facebook.react.bridge.ReactContext;
18+
import com.facebook.react.bridge.ReadableMap;
19+
import com.facebook.react.bridge.UIManager;
20+
import com.facebook.react.bridge.UIManagerListener;
21+
import com.facebook.react.bridge.UiThreadUtil;
22+
import com.facebook.react.uimanager.UIManagerHelper;
23+
import com.facebook.react.uimanager.common.ViewUtil;
24+
import com.facebook.react.views.view.ReactViewGroup;
25+
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll;
26+
27+
import java.lang.ref.WeakReference;
28+
29+
/**
30+
* Manage state for the maintainVisibleContentPosition prop.
31+
*
32+
* This uses UIManager to listen to updates and capture position of items before and after layout.
33+
*/
34+
public class MaintainVisibleScrollPositionHelper<ScrollViewT extends ViewGroup & HasSmoothScroll> implements UIManagerListener {
35+
private final ScrollViewT mScrollView;
36+
private final boolean mHorizontal;
37+
private @Nullable Config mConfig;
38+
private @Nullable WeakReference<View> mFirstVisibleView = null;
39+
private @Nullable Rect mPrevFirstVisibleFrame = null;
40+
private boolean mListening = false;
41+
42+
public static class Config {
43+
public final int minIndexForVisible;
44+
public final @Nullable Integer autoScrollToTopThreshold;
45+
46+
Config(int minIndexForVisible, @Nullable Integer autoScrollToTopThreshold) {
47+
this.minIndexForVisible = minIndexForVisible;
48+
this.autoScrollToTopThreshold = autoScrollToTopThreshold;
49+
}
50+
51+
static Config fromReadableMap(ReadableMap value) {
52+
int minIndexForVisible = value.getInt("minIndexForVisible");
53+
Integer autoScrollToTopThreshold =
54+
value.hasKey("autoscrollToTopThreshold")
55+
? value.getInt("autoscrollToTopThreshold")
56+
: null;
57+
return new Config(minIndexForVisible, autoScrollToTopThreshold);
58+
}
59+
}
60+
61+
public MaintainVisibleScrollPositionHelper(ScrollViewT scrollView, boolean horizontal) {
62+
mScrollView = scrollView;
63+
mHorizontal = horizontal;
64+
}
65+
66+
public void setConfig(@Nullable Config config) {
67+
mConfig = config;
68+
}
69+
70+
/**
71+
* Start listening to view hierarchy updates. Should be called when this is created.
72+
*/
73+
public void start() {
74+
if (mListening) {
75+
return;
76+
}
77+
mListening = true;
78+
getUIManagerModule().addUIManagerEventListener(this);
79+
}
80+
81+
/**
82+
* Stop listening to view hierarchy updates. Should be called before this is destroyed.
83+
*/
84+
public void stop() {
85+
if (!mListening) {
86+
return;
87+
}
88+
mListening = false;
89+
getUIManagerModule().removeUIManagerEventListener(this);
90+
}
91+
92+
/**
93+
* Update the scroll position of the managed ScrollView. This should be called after layout
94+
* has been updated.
95+
*/
96+
public void updateScrollPosition() {
97+
if (mConfig == null
98+
|| mFirstVisibleView == null
99+
|| mPrevFirstVisibleFrame == null) {
100+
return;
101+
}
102+
103+
View firstVisibleView = mFirstVisibleView.get();
104+
Rect newFrame = new Rect();
105+
firstVisibleView.getHitRect(newFrame);
106+
107+
if (mHorizontal) {
108+
int deltaX = newFrame.left - mPrevFirstVisibleFrame.left;
109+
if (deltaX != 0) {
110+
int scrollX = mScrollView.getScrollX();
111+
mScrollView.scrollTo(scrollX + deltaX, mScrollView.getScrollY());
112+
mPrevFirstVisibleFrame = newFrame;
113+
if (mConfig.autoScrollToTopThreshold != null && scrollX <= mConfig.autoScrollToTopThreshold) {
114+
mScrollView.reactSmoothScrollTo(0, mScrollView.getScrollY());
115+
}
116+
}
117+
} else {
118+
int deltaY = newFrame.top - mPrevFirstVisibleFrame.top;
119+
if (deltaY != 0) {
120+
int scrollY = mScrollView.getScrollY();
121+
mScrollView.scrollTo(mScrollView.getScrollX(), scrollY + deltaY);
122+
mPrevFirstVisibleFrame = newFrame;
123+
if (mConfig.autoScrollToTopThreshold != null && scrollY <= mConfig.autoScrollToTopThreshold) {
124+
mScrollView.reactSmoothScrollTo(mScrollView.getScrollX(), 0);
125+
}
126+
}
127+
}
128+
}
129+
130+
private @Nullable ReactViewGroup getContentView() {
131+
return (ReactViewGroup) mScrollView.getChildAt(0);
132+
}
133+
134+
private UIManager getUIManagerModule() {
135+
return Assertions.assertNotNull(
136+
UIManagerHelper.getUIManager(
137+
(ReactContext) mScrollView.getContext(),
138+
ViewUtil.getUIManagerType(mScrollView.getId())));
139+
}
140+
141+
private void computeTargetView() {
142+
if (mConfig == null) {
143+
return;
144+
}
145+
ReactViewGroup contentView = getContentView();
146+
if (contentView == null) {
147+
return;
148+
}
149+
150+
int currentScroll = mHorizontal ? mScrollView.getScrollX() : mScrollView.getScrollY();
151+
for (int i = mConfig.minIndexForVisible; i < contentView.getChildCount(); i++) {
152+
View child = contentView.getChildAt(i);
153+
float position = mHorizontal ? child.getX() : child.getY();
154+
if (position > currentScroll || i == contentView.getChildCount() - 1) {
155+
mFirstVisibleView = new WeakReference<>(child);
156+
Rect frame = new Rect();
157+
child.getHitRect(frame);
158+
mPrevFirstVisibleFrame = frame;
159+
break;
160+
}
161+
}
162+
}
163+
164+
// UIManagerListener
165+
166+
@Override
167+
public void willDispatchViewUpdates(final UIManager uiManager) {
168+
UiThreadUtil.runOnUiThread(
169+
new Runnable() {
170+
@Override
171+
public void run() {
172+
computeTargetView();
173+
}
174+
});
175+
}
176+
177+
@Override
178+
public void didDispatchMountItems(UIManager uiManager) {
179+
// noop
180+
}
181+
182+
@Override
183+
public void didScheduleMountItems(UIManager uiManager) {
184+
// noop
185+
}
186+
}

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator;
4646
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle;
4747
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState;
48+
import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll;
4849
import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState;
4950
import com.facebook.react.views.view.ReactViewBackgroundManager;
5051
import java.lang.reflect.Field;
@@ -54,11 +55,14 @@
5455
/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */
5556
public class ReactHorizontalScrollView extends HorizontalScrollView
5657
implements ReactClippingViewGroup,
58+
ViewGroup.OnHierarchyChangeListener,
59+
View.OnLayoutChangeListener,
5760
FabricViewStateManager.HasFabricViewStateManager,
5861
ReactOverflowViewWithInset,
5962
HasScrollState,
6063
HasFlingAnimator,
61-
HasScrollEventThrottle {
64+
HasScrollEventThrottle,
65+
HasSmoothScroll {
6266

6367
private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG;
6468
private static String TAG = ReactHorizontalScrollView.class.getSimpleName();
@@ -107,6 +111,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
107111
private PointerEvents mPointerEvents = PointerEvents.AUTO;
108112
private long mLastScrollDispatchTime = 0;
109113
private int mScrollEventThrottle = 0;
114+
private @Nullable View mContentView;
115+
private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper = null;
110116

111117
private final Rect mTempRect = new Rect();
112118

@@ -127,6 +133,8 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe
127133
I18nUtil.getInstance().isRTL(context)
128134
? ViewCompat.LAYOUT_DIRECTION_RTL
129135
: ViewCompat.LAYOUT_DIRECTION_LTR);
136+
137+
setOnHierarchyChangeListener(this);
130138
}
131139

132140
public boolean getScrollEnabled() {
@@ -243,6 +251,19 @@ public void setOverflow(String overflow) {
243251
invalidate();
244252
}
245253

254+
public void setMaintainVisibleContentPosition(@Nullable MaintainVisibleScrollPositionHelper.Config config) {
255+
if (config != null && mMaintainVisibleContentPositionHelper == null) {
256+
mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, true);
257+
mMaintainVisibleContentPositionHelper.start();
258+
} else if (config == null && mMaintainVisibleContentPositionHelper != null) {
259+
mMaintainVisibleContentPositionHelper.stop();
260+
mMaintainVisibleContentPositionHelper = null;
261+
}
262+
if (mMaintainVisibleContentPositionHelper != null) {
263+
mMaintainVisibleContentPositionHelper.setConfig(config);
264+
}
265+
}
266+
246267
@Override
247268
public @Nullable String getOverflow() {
248269
return mOverflow;
@@ -635,6 +656,17 @@ protected void onAttachedToWindow() {
635656
if (mRemoveClippedSubviews) {
636657
updateClippingRect();
637658
}
659+
if (mMaintainVisibleContentPositionHelper != null) {
660+
mMaintainVisibleContentPositionHelper.start();
661+
}
662+
}
663+
664+
@Override
665+
protected void onDetachedFromWindow() {
666+
super.onDetachedFromWindow();
667+
if (mMaintainVisibleContentPositionHelper != null) {
668+
mMaintainVisibleContentPositionHelper.stop();
669+
}
638670
}
639671

640672
@Override
@@ -714,6 +746,18 @@ protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolea
714746
super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
715747
}
716748

749+
@Override
750+
public void onChildViewAdded(View parent, View child) {
751+
mContentView = child;
752+
mContentView.addOnLayoutChangeListener(this);
753+
}
754+
755+
@Override
756+
public void onChildViewRemoved(View parent, View child) {
757+
mContentView.removeOnLayoutChangeListener(this);
758+
mContentView = null;
759+
}
760+
717761
private void enableFpsListener() {
718762
if (isScrollPerfLoggingEnabled()) {
719763
Assertions.assertNotNull(mFpsListener);
@@ -1237,6 +1281,26 @@ private void setPendingContentOffsets(int x, int y) {
12371281
}
12381282
}
12391283

1284+
@Override
1285+
public void onLayoutChange(
1286+
View v,
1287+
int left,
1288+
int top,
1289+
int right,
1290+
int bottom,
1291+
int oldLeft,
1292+
int oldTop,
1293+
int oldRight,
1294+
int oldBottom) {
1295+
if (mContentView == null) {
1296+
return;
1297+
}
1298+
1299+
if (mMaintainVisibleContentPositionHelper != null) {
1300+
mMaintainVisibleContentPositionHelper.updateScrollPosition();
1301+
}
1302+
}
1303+
12401304
@Override
12411305
public FabricViewStateManager getFabricViewStateManager() {
12421306
return mFabricViewStateManager;

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,16 @@ public void setContentOffset(ReactHorizontalScrollView view, ReadableMap value)
328328
}
329329
}
330330

331+
@ReactProp(name = "maintainVisibleContentPosition")
332+
public void setMaintainVisibleContentPosition(ReactHorizontalScrollView view, ReadableMap value) {
333+
if (value != null) {
334+
view.setMaintainVisibleContentPosition(
335+
MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value));
336+
} else {
337+
view.setMaintainVisibleContentPosition(null);
338+
}
339+
}
340+
331341
@ReactProp(name = ViewProps.POINTER_EVENTS)
332342
public void setPointerEvents(ReactHorizontalScrollView view, @Nullable String pointerEventsStr) {
333343
view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr));

0 commit comments

Comments
 (0)