Skip to content

Commit c195487

Browse files
janicduplessisfacebook-github-bot
authored andcommitted
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 <!-- Help reviewers and the release process by writing your own changelog entry. For an example, see: https://reactnative.dev/contributing/changelogs-in-pull-requests --> [Android] [Added] - Add maintainVisibleContentPosition support on Android Pull Request resolved: #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
1 parent 04cf92f commit c195487

File tree

8 files changed

+319
-11
lines changed

8 files changed

+319
-11
lines changed

Libraries/Components/ScrollView/ScrollView.js

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

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

Lines changed: 66 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;
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,20 @@ public void setOverflow(String overflow) {
243251
invalidate();
244252
}
245253

254+
public void setMaintainVisibleContentPosition(
255+
@Nullable MaintainVisibleScrollPositionHelper.Config config) {
256+
if (config != null && mMaintainVisibleContentPositionHelper == null) {
257+
mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, true);
258+
mMaintainVisibleContentPositionHelper.start();
259+
} else if (config == null && mMaintainVisibleContentPositionHelper != null) {
260+
mMaintainVisibleContentPositionHelper.stop();
261+
mMaintainVisibleContentPositionHelper = null;
262+
}
263+
if (mMaintainVisibleContentPositionHelper != null) {
264+
mMaintainVisibleContentPositionHelper.setConfig(config);
265+
}
266+
}
267+
246268
@Override
247269
public @Nullable String getOverflow() {
248270
return mOverflow;
@@ -635,6 +657,17 @@ protected void onAttachedToWindow() {
635657
if (mRemoveClippedSubviews) {
636658
updateClippingRect();
637659
}
660+
if (mMaintainVisibleContentPositionHelper != null) {
661+
mMaintainVisibleContentPositionHelper.start();
662+
}
663+
}
664+
665+
@Override
666+
protected void onDetachedFromWindow() {
667+
super.onDetachedFromWindow();
668+
if (mMaintainVisibleContentPositionHelper != null) {
669+
mMaintainVisibleContentPositionHelper.stop();
670+
}
638671
}
639672

640673
@Override
@@ -714,6 +747,18 @@ protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolea
714747
super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
715748
}
716749

750+
@Override
751+
public void onChildViewAdded(View parent, View child) {
752+
mContentView = child;
753+
mContentView.addOnLayoutChangeListener(this);
754+
}
755+
756+
@Override
757+
public void onChildViewRemoved(View parent, View child) {
758+
mContentView.removeOnLayoutChangeListener(this);
759+
mContentView = null;
760+
}
761+
717762
private void enableFpsListener() {
718763
if (isScrollPerfLoggingEnabled()) {
719764
Assertions.assertNotNull(mFpsListener);
@@ -1237,6 +1282,26 @@ private void setPendingContentOffsets(int x, int y) {
12371282
}
12381283
}
12391284

1285+
@Override
1286+
public void onLayoutChange(
1287+
View v,
1288+
int left,
1289+
int top,
1290+
int right,
1291+
int bottom,
1292+
int oldLeft,
1293+
int oldTop,
1294+
int oldRight,
1295+
int oldBottom) {
1296+
if (mContentView == null) {
1297+
return;
1298+
}
1299+
1300+
if (mMaintainVisibleContentPositionHelper != null) {
1301+
mMaintainVisibleContentPositionHelper.updateScrollPosition();
1302+
}
1303+
}
1304+
12401305
@Override
12411306
public FabricViewStateManager getFabricViewStateManager() {
12421307
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)