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 a92a848c688b0d..38a3fdb4d39f68 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 @@ -13,13 +13,10 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; -import android.animation.Animator; -import android.animation.ObjectAnimator; -import android.animation.PropertyValuesHolder; -import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; @@ -37,19 +34,18 @@ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.common.ReactConstants; import com.facebook.react.common.build.ReactBuildConfig; import com.facebook.react.modules.i18nmanager.I18nUtil; import com.facebook.react.uimanager.FabricViewStateManager; import com.facebook.react.uimanager.MeasureSpecAssertions; -import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactClippingViewGroup; import com.facebook.react.uimanager.ReactClippingViewGroupHelper; import com.facebook.react.uimanager.ReactOverflowView; import com.facebook.react.uimanager.ViewProps; import com.facebook.react.uimanager.events.NativeGestureUtil; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; import java.lang.reflect.Field; import java.util.ArrayList; @@ -59,7 +55,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements ReactClippingViewGroup, FabricViewStateManager.HasFabricViewStateManager, - ReactOverflowView { + ReactOverflowView, + HasScrollState { private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG; private static String TAG = ReactHorizontalScrollView.class.getSimpleName(); @@ -68,10 +65,6 @@ public class ReactHorizontalScrollView extends HorizontalScrollView private static @Nullable Field sScrollerField; private static boolean sTriedToGetScrollerField = false; - private static final String CONTENT_OFFSET_LEFT = "contentOffsetLeft"; - private static final String CONTENT_OFFSET_TOP = "contentOffsetTop"; - private static final String SCROLL_AWAY_PADDING_TOP = "scrollAwayPaddingTop"; - private int mLayoutDirection; private int mScrollXAfterMeasure = NO_SCROLL_POSITION; @@ -107,13 +100,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView private int pendingContentOffsetX = UNSET_CONTENT_OFFSET; private int pendingContentOffsetY = UNSET_CONTENT_OFFSET; private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager(); - - private @Nullable ValueAnimator mScrollAnimator; - private int mFinalAnimatedPositionScrollX = 0; - private int mFinalAnimatedPositionScrollY = 0; - - private int mLastStateUpdateScrollX = -1; - private int mLastStateUpdateScrollY = -1; + private final ReactScrollViewScrollState mReactScrollViewScrollState; private final Rect mTempRect = new Rect(); @@ -144,10 +131,11 @@ public void onInitializeAccessibilityNodeInfo( }); mScroller = getOverScrollerFromParent(); - mLayoutDirection = - I18nUtil.getInstance().isRTL(context) - ? ViewCompat.LAYOUT_DIRECTION_RTL - : ViewCompat.LAYOUT_DIRECTION_LTR; + mReactScrollViewScrollState = + new ReactScrollViewScrollState( + I18nUtil.getInstance().isRTL(context) + ? ViewCompat.LAYOUT_DIRECTION_RTL + : ViewCompat.LAYOUT_DIRECTION_LTR); } @Nullable @@ -441,7 +429,7 @@ protected void onScrollChanged(int x, int y, int oldX, int oldY) { // when JS processes the scroll event, the C++ ShadowNode representation will have a // "more correct" scroll position. It will frequently be /incorrect/ but this decreases // the error as much as possible. - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(this); ReactScrollViewHelper.emitScrollEvent( this, @@ -525,7 +513,7 @@ public boolean onTouchEvent(MotionEvent ev) { mVelocityHelper.calculateVelocity(ev); int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_UP && mDragging) { - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(this); float velocityX = mVelocityHelper.getXVelocity(); float velocityY = mVelocityHelper.getYVelocity(); @@ -773,7 +761,7 @@ public void run() { mRunning = true; } else { // There has not been a scroll update since the last time this Runnable executed. - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(ReactHorizontalScrollView.this); // We keep checking for updates until the ScrollView has "stabilized" and hasn't // scrolled for N consecutive frames. This number is arbitrary: big enough to catch @@ -814,20 +802,6 @@ public void run() { this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); } - /** Get current X position or position after current animation finishes, if any. */ - private int getPostAnimationScrollX() { - return mScrollAnimator != null && mScrollAnimator.isRunning() - ? mFinalAnimatedPositionScrollX - : getScrollX(); - } - - /** Get current X position or position after current animation finishes, if any. */ - private int getPostAnimationScrollY() { - return mScrollAnimator != null && mScrollAnimator.isRunning() - ? mFinalAnimatedPositionScrollY - : getScrollY(); - } - private int predictFinalScrollPosition(int velocityX) { // ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // no way to customize the scroll duration. So, we create a temporary OverScroller @@ -838,9 +812,10 @@ private int predictFinalScrollPosition(int velocityX) { // predict where a fling would end up so we can scroll to the nearest snap offset int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this); + Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this); scroller.fling( - getPostAnimationScrollX(), // startX - getPostAnimationScrollY(), // startY + postAnimationScroll.x, // startX + postAnimationScroll.y, // startY velocityX, // velocityX 0, // velocityY 0, // minX @@ -864,7 +839,7 @@ private void smoothScrollAndSnap(int velocity) { } double interval = (double) getSnapInterval(); - double currentOffset = (double) (getPostAnimationScrollX()); + double currentOffset = (double) (ReactScrollViewHelper.getPostAnimationScroll(this).x); double targetOffset = (double) predictFinalScrollPosition(velocity); int previousPage = (int) Math.floor(currentOffset / interval); @@ -935,9 +910,10 @@ private void flingAndSnap(int velocityX) { int firstOffset = 0; int lastOffset = maximumOffset; int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this); + int layoutDirection = getReactScrollViewScrollState().getLayoutDirection(); // offsets are from the right edge in RTL layouts - if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { + if (layoutDirection == LAYOUT_DIRECTION_RTL) { targetOffset = maximumOffset - targetOffset; velocityX = -velocityX; } @@ -1014,7 +990,7 @@ private void flingAndSnap(int velocityX) { // if scrolling after the last snap offset and snapping to the // end of the list is disabled, then we allow free scrolling int currentOffset = getScrollX(); - if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { + if (layoutDirection == LAYOUT_DIRECTION_RTL) { currentOffset = maximumOffset - currentOffset; } if (!mSnapToEnd && targetOffset >= lastOffset) { @@ -1048,7 +1024,7 @@ private void flingAndSnap(int velocityX) { // Make sure the new offset isn't out of bounds targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset); - if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { + if (layoutDirection == LAYOUT_DIRECTION_RTL) { targetOffset = maximumOffset - targetOffset; velocityX = -velocityX; } @@ -1162,56 +1138,7 @@ public void setBorderStyle(@Nullable String style) { * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. */ public void reactSmoothScrollTo(int x, int y) { - if (DEBUG_MODE) { - FLog.i(TAG, "reactSmoothScrollTo[%d] x %d y %d", getId(), x, y); - } - - // `smoothScrollTo` contains some logic that, if called multiple times in a short amount of - // time, will treat all calls as part of the same animation and will not lengthen the duration - // of the animation. This means that, for example, if the user is scrolling rapidly, multiple - // pages could be considered part of one animation, causing some page animations to be animated - // very rapidly - looking like they're not animated at all. - if (mScrollAnimator != null) { - mScrollAnimator.cancel(); - } - - mFinalAnimatedPositionScrollX = x; - mFinalAnimatedPositionScrollY = y; - PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x); - PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y); - mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY); - mScrollAnimator.setDuration( - ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext())); - mScrollAnimator.addUpdateListener( - new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator valueAnimator) { - int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX"); - int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY"); - scrollTo(scrollValueX, scrollValueY); - } - }); - mScrollAnimator.addListener( - new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) {} - - @Override - public void onAnimationEnd(Animator animator) { - mFinalAnimatedPositionScrollX = -1; - mFinalAnimatedPositionScrollY = -1; - mScrollAnimator = null; - updateStateOnScroll(); - } - - @Override - public void onAnimationCancel(Animator animator) {} - - @Override - public void onAnimationRepeat(Animator animator) {} - }); - mScrollAnimator.start(); - updateStateOnScroll(x, y); + ReactScrollViewHelper.smoothScrollTo(this, x, y); setPendingContentOffsets(x, y); } @@ -1235,7 +1162,7 @@ public void scrollTo(int x, int y) { // to the last item in the list, but that item cannot be move to the start position of the view. final int actualX = getScrollX(); final int actualY = getScrollY(); - updateStateOnScroll(actualX, actualY); + ReactScrollViewHelper.updateStateOnScroll(this, actualX, actualY); setPendingContentOffsets(actualX, actualY); } @@ -1260,62 +1187,13 @@ private void setPendingContentOffsets(int x, int y) { } } - /** - * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. - */ - private void updateStateOnScroll(final int scrollX, final int scrollY) { - if (DEBUG_MODE) { - FLog.i(TAG, "updateStateOnScroll[%d] scrollX %d scrollY %d", getId(), scrollX, scrollY); - } - - // Dedupe events to reduce JNI traffic - if (scrollX == mLastStateUpdateScrollX && scrollY == mLastStateUpdateScrollY) { - return; - } - - mLastStateUpdateScrollX = scrollX; - mLastStateUpdateScrollY = scrollY; - - final int fabricScrollX; - if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { - // getScrollX returns offset from left even when layout direction is RTL. - // The following line calculates offset from right. - View child = getContentView(); - int contentWidth = child != null ? child.getWidth() : 0; - fabricScrollX = -(contentWidth - scrollX - getWidth()); - } else { - fabricScrollX = scrollX; - } - - if (DEBUG_MODE) { - FLog.i( - TAG, - "updateStateOnScroll[%d] scrollX %d scrollY %d fabricScrollX", - getId(), - scrollX, - scrollY, - fabricScrollX); - } - - mFabricViewStateManager.setState( - new FabricViewStateManager.StateUpdateCallback() { - @Override - public WritableMap getStateUpdate() { - WritableMap map = new WritableNativeMap(); - map.putDouble(CONTENT_OFFSET_LEFT, PixelUtil.toDIPFromPixel(fabricScrollX)); - map.putDouble(CONTENT_OFFSET_TOP, PixelUtil.toDIPFromPixel(scrollY)); - map.putDouble(SCROLL_AWAY_PADDING_TOP, 0); - return map; - } - }); - } - - private void updateStateOnScroll() { - updateStateOnScroll(getScrollX(), getScrollY()); - } - @Override public FabricViewStateManager getFabricViewStateManager() { return mFabricViewStateManager; } + + @Override + public ReactScrollViewScrollState getReactScrollViewScrollState() { + return mReactScrollViewScrollState; + } } 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 2abf2923a3e0a5..2305514a4dc8ce 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 @@ -13,13 +13,10 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; -import android.animation.Animator; -import android.animation.ObjectAnimator; -import android.animation.PropertyValuesHolder; -import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; +import android.graphics.Point; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; @@ -35,19 +32,16 @@ import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.react.R; -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.common.ReactConstants; import com.facebook.react.uimanager.FabricViewStateManager; import com.facebook.react.uimanager.MeasureSpecAssertions; -import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.ReactClippingViewGroup; import com.facebook.react.uimanager.ReactClippingViewGroupHelper; import com.facebook.react.uimanager.ReactOverflowView; import com.facebook.react.uimanager.ViewProps; -import com.facebook.react.uimanager.common.UIManagerType; -import com.facebook.react.uimanager.common.ViewUtil; import com.facebook.react.uimanager.events.NativeGestureUtil; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; import java.lang.reflect.Field; import java.util.List; @@ -64,13 +58,11 @@ public class ReactScrollView extends ScrollView ViewGroup.OnHierarchyChangeListener, View.OnLayoutChangeListener, FabricViewStateManager.HasFabricViewStateManager, - ReactOverflowView { + ReactOverflowView, + HasScrollState { private static @Nullable Field sScrollerField; private static boolean sTriedToGetScrollerField = false; - private static final String CONTENT_OFFSET_LEFT = "contentOffsetLeft"; - private static final String CONTENT_OFFSET_TOP = "contentOffsetTop"; - private static final String SCROLL_AWAY_PADDING_TOP = "scrollAwayPaddingTop"; private static final int UNSET_CONTENT_OFFSET = -1; @@ -104,15 +96,8 @@ public class ReactScrollView extends ScrollView private int pendingContentOffsetX = UNSET_CONTENT_OFFSET; private int pendingContentOffsetY = UNSET_CONTENT_OFFSET; private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager(); - - private @Nullable ValueAnimator mScrollAnimator; - private int mFinalAnimatedPositionScrollX; - private int mFinalAnimatedPositionScrollY; - - private int mScrollAwayPaddingTop = 0; - - private int mLastStateUpdateScrollX = -1; - private int mLastStateUpdateScrollY = -1; + private final ReactScrollViewScrollState mReactScrollViewScrollState = + new ReactScrollViewScrollState(ViewCompat.LAYOUT_DIRECTION_LTR); public ReactScrollView(Context context) { this(context, null); @@ -324,7 +309,7 @@ protected void onScrollChanged(int x, int y, int oldX, int oldY) { // when JS processes the scroll event, the C++ ShadowNode representation will have a // "more correct" scroll position. It will frequently be /incorrect/ but this decreases // the error as much as possible. - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(this); ReactScrollViewHelper.emitScrollEvent( this, @@ -366,7 +351,7 @@ public boolean onTouchEvent(MotionEvent ev) { mVelocityHelper.calculateVelocity(ev); int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_UP && mDragging) { - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(this); float velocityX = mVelocityHelper.getXVelocity(); float velocityY = mVelocityHelper.getYVelocity(); @@ -565,7 +550,7 @@ public void run() { mRunning = true; } else { // There has not been a scroll update since the last time this Runnable executed. - updateStateOnScroll(); + ReactScrollViewHelper.updateStateOnScroll(ReactScrollView.this); // We keep checking for updates until the ScrollView has "stabilized" and hasn't // scrolled for N consecutive frames. This number is arbitrary: big enough to catch @@ -606,20 +591,6 @@ public void run() { this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); } - /** Get current X position or position after current animation finishes, if any. */ - private int getPostAnimationScrollX() { - return mScrollAnimator != null && mScrollAnimator.isRunning() - ? mFinalAnimatedPositionScrollX - : getScrollX(); - } - - /** Get current X position or position after current animation finishes, if any. */ - private int getPostAnimationScrollY() { - return mScrollAnimator != null && mScrollAnimator.isRunning() - ? mFinalAnimatedPositionScrollY - : getScrollY(); - } - private int predictFinalScrollPosition(int velocityY) { // ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // no way to customize the scroll duration. So, we create a temporary OverScroller @@ -630,9 +601,10 @@ private int predictFinalScrollPosition(int velocityY) { // predict where a fling would end up so we can scroll to the nearest snap offset int maximumOffset = getMaxScrollY(); int height = getHeight() - getPaddingBottom() - getPaddingTop(); + Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this); scroller.fling( - getPostAnimationScrollX(), // startX - getPostAnimationScrollY(), // startY + postAnimationScroll.x, // startX + postAnimationScroll.y, // startY 0, // velocityX velocityY, // velocityY 0, // minX @@ -656,7 +628,7 @@ private View getContentView() { */ private void smoothScrollAndSnap(int velocity) { double interval = (double) getSnapInterval(); - double currentOffset = (double) getPostAnimationScrollY(); + double currentOffset = (double) (ReactScrollViewHelper.getPostAnimationScroll(this).y); double targetOffset = (double) predictFinalScrollPosition(velocity); int previousPage = (int) Math.floor(currentOffset / interval); @@ -945,52 +917,7 @@ public void onChildViewRemoved(View parent, View child) { * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. */ public void reactSmoothScrollTo(int x, int y) { - // `smoothScrollTo` contains some logic that, if called multiple times in a short amount of - // time, will treat all calls as part of the same animation and will not lengthen the duration - // of the animation. This means that, for example, if the user is scrolling rapidly, multiple - // pages could be considered part of one animation, causing some page animations to be animated - // very rapidly - looking like they're not animated at all. - if (mScrollAnimator != null) { - mScrollAnimator.cancel(); - } - - mFinalAnimatedPositionScrollX = x; - mFinalAnimatedPositionScrollY = y; - PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x); - PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y); - mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY); - mScrollAnimator.setDuration( - ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext())); - mScrollAnimator.addUpdateListener( - new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator valueAnimator) { - int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX"); - int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY"); - scrollTo(scrollValueX, scrollValueY); - } - }); - mScrollAnimator.addListener( - new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) {} - - @Override - public void onAnimationEnd(Animator animator) { - mFinalAnimatedPositionScrollX = -1; - mFinalAnimatedPositionScrollY = -1; - mScrollAnimator = null; - updateStateOnScroll(); - } - - @Override - public void onAnimationCancel(Animator animator) {} - - @Override - public void onAnimationRepeat(Animator animator) {} - }); - mScrollAnimator.start(); - updateStateOnScroll(x, y); + ReactScrollViewHelper.smoothScrollTo(this, x, y); setPendingContentOffsets(x, y); } @@ -1010,7 +937,7 @@ public void scrollTo(int x, int y) { // to the last item in the list, but that item cannot be move to the start position of the view. final int actualX = getScrollX(); final int actualY = getScrollY(); - updateStateOnScroll(actualX, actualY); + ReactScrollViewHelper.updateStateOnScroll(this, actualX, actualY); setPendingContentOffsets(actualX, actualY); } @@ -1119,61 +1046,18 @@ public void setScrollAwayTopPaddingEnabledUnstable(int topPadding) { setRemoveClippedSubviews(mRemoveClippedSubviews); } - /** - * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. - */ - private boolean updateStateOnScroll(final int scrollX, final int scrollY) { - if (ViewUtil.getUIManagerType(getId()) == UIManagerType.DEFAULT) { - return false; - } - - // Dedupe events to reduce JNI traffic - if (scrollX == mLastStateUpdateScrollX && scrollY == mLastStateUpdateScrollY) { - return false; - } - - mLastStateUpdateScrollX = scrollX; - mLastStateUpdateScrollY = scrollY; - - forceUpdateState(); - - return true; - } - - private boolean updateStateOnScroll() { - return updateStateOnScroll(getScrollX(), getScrollY()); - } - private void updateScrollAwayState(int scrollAwayPaddingTop) { - if (mScrollAwayPaddingTop == scrollAwayPaddingTop) { - return; - } - - mScrollAwayPaddingTop = scrollAwayPaddingTop; - - forceUpdateState(); - } - - private void forceUpdateState() { - final int scrollX = mLastStateUpdateScrollX; - final int scrollY = mLastStateUpdateScrollY; - final int scrollAwayPaddingTop = mScrollAwayPaddingTop; - - mFabricViewStateManager.setState( - new FabricViewStateManager.StateUpdateCallback() { - @Override - public WritableMap getStateUpdate() { - WritableMap map = new WritableNativeMap(); - map.putDouble(CONTENT_OFFSET_LEFT, PixelUtil.toDIPFromPixel(scrollX)); - map.putDouble(CONTENT_OFFSET_TOP, PixelUtil.toDIPFromPixel(scrollY)); - map.putDouble(SCROLL_AWAY_PADDING_TOP, PixelUtil.toDIPFromPixel(scrollAwayPaddingTop)); - return map; - } - }); + getReactScrollViewScrollState().setScrollAwayPaddingTop(scrollAwayPaddingTop); + ReactScrollViewHelper.forceUpdateState(this); } @Override public FabricViewStateManager getFabricViewStateManager() { return mFabricViewStateManager; } + + @Override + public ReactScrollViewScrollState getReactScrollViewScrollState() { + return mReactScrollViewScrollState; + } } 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 f383b2931260cd..7782d82283ef9f 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 @@ -7,20 +7,38 @@ package com.facebook.react.views.scroll; +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; import android.content.Context; +import android.graphics.Point; import android.view.View; import android.view.ViewGroup; import android.widget.OverScroller; import androidx.annotation.Nullable; +import com.facebook.common.logging.FLog; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeMap; +import com.facebook.react.common.build.ReactBuildConfig; +import com.facebook.react.uimanager.FabricViewStateManager; +import com.facebook.react.uimanager.PixelUtil; import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.common.UIManagerType; +import com.facebook.react.uimanager.common.ViewUtil; import java.util.Collections; import java.util.Set; import java.util.WeakHashMap; /** Helper class that deals with emitting Scroll Events. */ public class ReactScrollViewHelper { + private static String TAG = ReactHorizontalScrollView.class.getSimpleName(); + private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG; + private static final String CONTENT_OFFSET_LEFT = "contentOffsetLeft"; + private static final String CONTENT_OFFSET_TOP = "contentOffsetTop"; + private static final String SCROLL_AWAY_PADDING_TOP = "scrollAwayPaddingTop"; public static final long MOMENTUM_DELAY = 20; public static final String OVER_SCROLL_ALWAYS = "always"; @@ -32,6 +50,8 @@ public class ReactScrollViewHelper { public static final int SNAP_ALIGNMENT_CENTER = 2; public static final int SNAP_ALIGNMENT_END = 3; + private static @Nullable ValueAnimator sScrollAnimator; + public interface ScrollListener { void onScroll( ViewGroup scrollView, ScrollEventType scrollEventType, float xVelocity, float yVelocity); @@ -198,4 +218,210 @@ public static void addScrollListener(ScrollListener listener) { public static void removeScrollListener(ScrollListener listener) { sScrollListeners.remove(listener); } + + public static class ReactScrollViewScrollState { + private final int mLayoutDirection; + private final Point mFinalAnimatedPositionScroll = new Point(); + private int mScrollAwayPaddingTop = 0; + private final Point mLastStateUpdateScroll = new Point(-1, -1); + + public ReactScrollViewScrollState(final int layoutDirection) { + mLayoutDirection = layoutDirection; + } + + /** + * Get the layout direction. Can be either scrollView.LAYOUT_DIRECTION_RTL (1) or + * scrollView.LAYOUT_DIRECTION_LTR (0). If the value is -1, it means unknown layout. + */ + public int getLayoutDirection() { + return mLayoutDirection; + } + + /** Get the position after current animation is finished */ + public Point getFinalAnimatedPositionScroll() { + return mFinalAnimatedPositionScroll; + } + + /** Set the final scroll position after scrolling animation is finished */ + public ReactScrollViewScrollState setFinalAnimatedPositionScroll( + int finalAnimatedPositionScrollX, int finalAnimatedPositionScrollY) { + mFinalAnimatedPositionScroll.set(finalAnimatedPositionScrollX, finalAnimatedPositionScrollY); + return this; + } + + /** Get the Fabric state of last scroll position */ + public Point getLastStateUpdateScroll() { + return mLastStateUpdateScroll; + } + + /** Set the Fabric state of last scroll position */ + public ReactScrollViewScrollState setLastStateUpdateScroll( + int lastStateUpdateScrollX, int lastStateUpdateScrollY) { + mLastStateUpdateScroll.set(lastStateUpdateScrollX, lastStateUpdateScrollY); + return this; + } + + /** Get the padding on the top for nav bar */ + public int getScrollAwayPaddingTop() { + return mScrollAwayPaddingTop; + } + + /** Set the padding on the top for nav bar */ + public ReactScrollViewScrollState setScrollAwayPaddingTop(int scrollAwayPaddingTop) { + mScrollAwayPaddingTop = scrollAwayPaddingTop; + return this; + } + } + + public static < + T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + void smoothScrollTo(final T scrollView, final int x, final int y) { + if (DEBUG_MODE) { + FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.getId(), x, y); + } + + // `smoothScrollTo` contains some logic that, if called multiple times in a short amount of + // time, will treat all calls as part of the same animation and will not lengthen the duration + // of the animation. This means that, for example, if the user is scrolling rapidly, multiple + // pages could be considered part of one animation, causing some page animations to be animated + // very rapidly - looking like they're not animated at all. + if (sScrollAnimator != null) { + sScrollAnimator.cancel(); + } + + final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); + scrollState.setFinalAnimatedPositionScroll(x, y); + PropertyValuesHolder scrollX = + PropertyValuesHolder.ofInt("scrollX", scrollView.getScrollX(), x); + PropertyValuesHolder scrollY = + PropertyValuesHolder.ofInt("scrollY", scrollView.getScrollY(), y); + sScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY); + sScrollAnimator.setDuration(getDefaultScrollAnimationDuration(scrollView.getContext())); + sScrollAnimator.addUpdateListener( + new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX"); + int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY"); + scrollView.scrollTo(scrollValueX, scrollValueY); + } + }); + sScrollAnimator.addListener( + new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) {} + + @Override + public void onAnimationEnd(Animator animator) { + scrollState.setFinalAnimatedPositionScroll(-1, -1); + sScrollAnimator = null; + updateStateOnScroll(scrollView); + } + + @Override + public void onAnimationCancel(Animator animator) {} + + @Override + public void onAnimationRepeat(Animator animator) {} + }); + sScrollAnimator.start(); + updateStateOnScroll(scrollView, x, y); + } + + /** Get current (x, y) position or position after current animation finishes, if any. */ + public static < + T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + Point getPostAnimationScroll(final T scrollView) { + return sScrollAnimator != null && sScrollAnimator.isRunning() + ? scrollView.getReactScrollViewScrollState().getFinalAnimatedPositionScroll() + : new Point(scrollView.getScrollX(), scrollView.getScrollY()); + } + + public static < + T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + boolean updateStateOnScroll(final T scrollView) { + return updateStateOnScroll(scrollView, scrollView.getScrollX(), scrollView.getScrollY()); + } + + /** + * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. + */ + public static < + T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + boolean updateStateOnScroll(final T scrollView, final int scrollX, final int scrollY) { + if (DEBUG_MODE) { + FLog.i( + TAG, + "updateStateOnScroll[%d] scrollX %d scrollY %d", + scrollView.getId(), + scrollX, + scrollY); + } + + if (ViewUtil.getUIManagerType(scrollView.getId()) == UIManagerType.DEFAULT) { + return false; + } + + final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); + // Dedupe events to reduce JNI traffic + if (scrollState.getLastStateUpdateScroll().equals(scrollX, scrollY)) { + return false; + } + + scrollState.setLastStateUpdateScroll(scrollX, scrollY); + forceUpdateState(scrollView); + return true; + } + + public static < + T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + void forceUpdateState(final T scrollView) { + final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); + final int scrollAwayPaddingTop = scrollState.getScrollAwayPaddingTop(); + final Point scrollPos = scrollState.getLastStateUpdateScroll(); + final int scrollX = scrollPos.x; + final int scrollY = scrollPos.y; + final int fabricScrollX; + int layoutDirection = scrollState.getLayoutDirection(); + + if (layoutDirection == scrollView.LAYOUT_DIRECTION_RTL) { + // getScrollX returns offset from left even when layout direction is RTL. + // The following line calculates offset from right. + View child = scrollView.getChildAt(0); + int contentWidth = child != null ? child.getWidth() : 0; + fabricScrollX = -(contentWidth - scrollX - scrollView.getWidth()); + } else { + fabricScrollX = scrollX; + } + + if (DEBUG_MODE) { + FLog.i( + TAG, + "updateStateOnScroll[%d] scrollX %d scrollY %d fabricScrollX", + scrollView.getId(), + scrollX, + scrollY, + fabricScrollX); + } + + scrollView + .getFabricViewStateManager() + .setState( + new FabricViewStateManager.StateUpdateCallback() { + @Override + public WritableMap getStateUpdate() { + WritableMap map = new WritableNativeMap(); + map.putDouble(CONTENT_OFFSET_LEFT, PixelUtil.toDIPFromPixel(scrollX)); + map.putDouble(CONTENT_OFFSET_TOP, PixelUtil.toDIPFromPixel(scrollY)); + map.putDouble( + SCROLL_AWAY_PADDING_TOP, PixelUtil.toDIPFromPixel(scrollAwayPaddingTop)); + return map; + } + }); + } + + public interface HasScrollState { + /** Get the scroll state for the current ScrollView */ + ReactScrollViewScrollState getReactScrollViewScrollState(); + } }