Skip to content

Commit

Permalink
Add maintainVisibleContentPosition support on Android (#35049)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
janicduplessis authored and facebook-github-bot committed Jan 23, 2023
1 parent 04cf92f commit c195487
Show file tree
Hide file tree
Showing 8 changed files with 319 additions and 11 deletions.
1 change: 0 additions & 1 deletion Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>This uses UIManager to listen to updates and capture position of items before and after
* layout.
*/
public class MaintainVisibleScrollPositionHelper<ScrollViewT extends ViewGroup & HasSmoothScroll>
implements UIManagerListener {
private final ScrollViewT mScrollView;
private final boolean mHorizontal;
private @Nullable Config mConfig;
private @Nullable WeakReference<View> 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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();

Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading

0 comments on commit c195487

Please sign in to comment.