diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 12ef9ecd5f8..881ded1e9a2 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6648,8 +6648,9 @@ public class com/facebook/react/views/scroll/ReactScrollViewCommandHelper$Scroll public final field mAnimated Z } -public class com/facebook/react/views/scroll/ReactScrollViewHelper { +public final class com/facebook/react/views/scroll/ReactScrollViewHelper { public static final field AUTO Ljava/lang/String; + public static final field INSTANCE Lcom/facebook/react/views/scroll/ReactScrollViewHelper; public static final field MOMENTUM_DELAY J public static final field OVER_SCROLL_ALWAYS Ljava/lang/String; public static final field OVER_SCROLL_NEVER Ljava/lang/String; @@ -6657,26 +6658,25 @@ public class com/facebook/react/views/scroll/ReactScrollViewHelper { public static final field SNAP_ALIGNMENT_DISABLED I public static final field SNAP_ALIGNMENT_END I public static final field SNAP_ALIGNMENT_START I - public fun ()V - public static fun addScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V - public static fun emitLayoutEvent (Landroid/view/ViewGroup;)V - public static fun emitScrollBeginDragEvent (Landroid/view/ViewGroup;)V - public static fun emitScrollEndDragEvent (Landroid/view/ViewGroup;FF)V - public static fun emitScrollEvent (Landroid/view/ViewGroup;FF)V - public static fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V - public static fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V - public static fun forceUpdateState (Landroid/view/ViewGroup;)V - public static fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I - public static fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I - public static fun parseOverScrollMode (Ljava/lang/String;)I - public static fun parseSnapToAlignment (Ljava/lang/String;)I - public static fun predictFinalScrollPosition (Landroid/view/ViewGroup;IIII)Landroid/graphics/Point; - public static fun registerFlingAnimator (Landroid/view/ViewGroup;)V - public static fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V - public static fun smoothScrollTo (Landroid/view/ViewGroup;II)V - public static fun updateFabricScrollState (Landroid/view/ViewGroup;)V - public static fun updateFabricScrollState (Landroid/view/ViewGroup;II)V - public static fun updateStateOnScrollChanged (Landroid/view/ViewGroup;FF)V + public static final fun addScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V + public static final fun emitLayoutEvent (Landroid/view/ViewGroup;)V + public static final fun emitScrollBeginDragEvent (Landroid/view/ViewGroup;)V + public static final fun emitScrollEndDragEvent (Landroid/view/ViewGroup;FF)V + public static final fun emitScrollEvent (Landroid/view/ViewGroup;FF)V + public static final fun emitScrollMomentumBeginEvent (Landroid/view/ViewGroup;II)V + public static final fun emitScrollMomentumEndEvent (Landroid/view/ViewGroup;)V + public static final fun forceUpdateState (Landroid/view/ViewGroup;)V + public static final fun getDefaultScrollAnimationDuration (Landroid/content/Context;)I + public static final fun getNextFlingStartValue (Landroid/view/ViewGroup;III)I + public static final fun parseOverScrollMode (Ljava/lang/String;)I + public static final fun parseSnapToAlignment (Ljava/lang/String;)I + public static final fun predictFinalScrollPosition (Landroid/view/ViewGroup;IIII)Landroid/graphics/Point; + public final fun registerFlingAnimator (Landroid/view/ViewGroup;)V + public static final fun removeScrollListener (Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener;)V + public static final fun smoothScrollTo (Landroid/view/ViewGroup;II)V + public static final fun updateFabricScrollState (Landroid/view/ViewGroup;)V + public final fun updateFabricScrollState (Landroid/view/ViewGroup;II)V + public static final fun updateStateOnScrollChanged (Landroid/view/ViewGroup;FF)V } public abstract interface class com/facebook/react/views/scroll/ReactScrollViewHelper$HasFlingAnimator { @@ -6705,21 +6705,21 @@ public abstract interface class com/facebook/react/views/scroll/ReactScrollViewH public abstract fun getStateWrapper ()Lcom/facebook/react/uimanager/StateWrapper; } -public class com/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState { +public final class com/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState { public fun (I)V - public fun getDecelerationRate ()F - public fun getFinalAnimatedPositionScroll ()Landroid/graphics/Point; - public fun getIsCanceled ()Z - public fun getIsFinished ()Z - public fun getLastStateUpdateScroll ()Landroid/graphics/Point; - public fun getLayoutDirection ()I - public fun getScrollAwayPaddingTop ()I - public fun setDecelerationRate (F)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; - public fun setFinalAnimatedPositionScroll (II)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; - public fun setIsCanceled (Z)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; - public fun setIsFinished (Z)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; - public fun setLastStateUpdateScroll (II)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; - public fun setScrollAwayPaddingTop (I)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; + public final fun getDecelerationRate ()F + public final fun getFinalAnimatedPositionScroll ()Landroid/graphics/Point; + public final fun getLastStateUpdateScroll ()Landroid/graphics/Point; + public final fun getLayoutDirection ()I + public final fun getScrollAwayPaddingTop ()I + public final fun isCanceled ()Z + public final fun isFinished ()Z + public final fun setCanceled (Z)V + public final fun setDecelerationRate (F)V + public final fun setFinalAnimatedPositionScroll (II)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; + public final fun setFinished (Z)V + public final fun setLastStateUpdateScroll (II)Lcom/facebook/react/views/scroll/ReactScrollViewHelper$ReactScrollViewScrollState; + public final fun setScrollAwayPaddingTop (I)V } public abstract interface class com/facebook/react/views/scroll/ReactScrollViewHelper$ScrollListener { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java deleted file mode 100644 index 85ed97ec40f..00000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java +++ /dev/null @@ -1,603 +0,0 @@ -/* - * 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.animation.Animator; -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 androidx.core.view.ViewCompat; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.bridge.ReactContext; -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.uimanager.PixelUtil; -import com.facebook.react.uimanager.StateWrapper; -import com.facebook.react.uimanager.UIManagerHelper; -import com.facebook.react.uimanager.common.UIManagerType; -import com.facebook.react.uimanager.common.ViewUtil; -import com.facebook.react.uimanager.events.EventDispatcher; -import java.util.Collections; -import java.util.Set; -import java.util.WeakHashMap; - -/** Helper class that deals with emitting Scroll Events. */ -@Nullsafe(Nullsafe.Mode.LOCAL) -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"; - public static final String AUTO = "auto"; - public static final String OVER_SCROLL_NEVER = "never"; - - public static final int SNAP_ALIGNMENT_DISABLED = 0; - public static final int SNAP_ALIGNMENT_START = 1; - public static final int SNAP_ALIGNMENT_CENTER = 2; - public static final int SNAP_ALIGNMENT_END = 3; - - public interface ScrollListener { - void onScroll( - ViewGroup scrollView, ScrollEventType scrollEventType, float xVelocity, float yVelocity); - - void onLayout(ViewGroup scrollView); - } - - public interface HasStateWrapper { - @Nullable - StateWrapper getStateWrapper(); - } - - // Support global native listeners for scroll events - private static final Set sScrollListeners = - Collections.newSetFromMap(new WeakHashMap()); - - // If all else fails, this is the hardcoded value in OverScroller.java, in AOSP. - // The default is defined here (as of this diff): - // https://android.googlesource.com/platform/frameworks/base/+/ae5bcf23b5f0875e455790d6af387184dbd009c1/core/java/android/widget/OverScroller.java#44 - private static int SMOOTH_SCROLL_DURATION = 250; - private static boolean mSmoothScrollDurationInitialized = false; - - /** Shared by {@link ReactScrollView} and {@link ReactHorizontalScrollView}. */ - public static void emitScrollEvent( - T scrollView, float xVelocity, float yVelocity) { - emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity); - } - - public static void emitScrollBeginDragEvent( - T scrollView) { - emitScrollEvent(scrollView, ScrollEventType.BEGIN_DRAG); - } - - public static void emitScrollEndDragEvent( - T scrollView, float xVelocity, float yVelocity) { - emitScrollEvent(scrollView, ScrollEventType.END_DRAG, xVelocity, yVelocity); - } - - public static void emitScrollMomentumBeginEvent( - T scrollView, int xVelocity, int yVelocity) { - emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_BEGIN, xVelocity, yVelocity); - } - - public static void emitScrollMomentumEndEvent( - T scrollView) { - emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_END); - } - - private static void emitScrollEvent( - T scrollView, ScrollEventType scrollEventType) { - emitScrollEvent(scrollView, scrollEventType, 0, 0); - } - - private static void emitScrollEvent( - T scrollView, ScrollEventType scrollEventType, float xVelocity, float yVelocity) { - long now = System.currentTimeMillis(); - // Throttle the scroll event if scrollEventThrottle is set to be equal or more than 17 ms. - // We limit the delta to 17ms so that small throttles intended to enable 60fps updates will not - // inadvertently filter out any scroll events. - if (scrollView.getScrollEventThrottle() - >= Math.max(17, now - scrollView.getLastScrollDispatchTime())) { - // Scroll events are throttled. - return; - } - - View contentView = scrollView.getChildAt(0); - - if (contentView == null) { - return; - } - - for (ScrollListener scrollListener : sScrollListeners) { - scrollListener.onScroll(scrollView, scrollEventType, xVelocity, yVelocity); - } - - ReactContext reactContext = (ReactContext) scrollView.getContext(); - int surfaceId = UIManagerHelper.getSurfaceId(reactContext); - - // It's possible for the EventDispatcher to go away - for example, - // if there's a crash initiated from JS and we tap on a ScrollView - // around teardown of RN, this will cause a NPE. We can safely ignore - // this since the crash is usually a red herring. - EventDispatcher eventDispatcher = - UIManagerHelper.getEventDispatcherForReactTag(reactContext, scrollView.getId()); - if (eventDispatcher != null) { - eventDispatcher.dispatchEvent( - ScrollEvent.obtain( - surfaceId, - scrollView.getId(), - scrollEventType, - scrollView.getScrollX(), - scrollView.getScrollY(), - xVelocity, - yVelocity, - contentView.getWidth(), - contentView.getHeight(), - scrollView.getWidth(), - scrollView.getHeight())); - scrollView.setLastScrollDispatchTime(now); - } - } - - /** This is only for Java listeners. onLayout events emitted to JS are handled elsewhere. */ - public static void emitLayoutEvent(ViewGroup scrollView) { - for (ScrollListener scrollListener : sScrollListeners) { - scrollListener.onLayout(scrollView); - } - } - - public static int parseOverScrollMode(String jsOverScrollMode) { - if (jsOverScrollMode == null || jsOverScrollMode.equals(AUTO)) { - return View.OVER_SCROLL_IF_CONTENT_SCROLLS; - } else if (jsOverScrollMode.equals(OVER_SCROLL_ALWAYS)) { - return View.OVER_SCROLL_ALWAYS; - } else if (jsOverScrollMode.equals(OVER_SCROLL_NEVER)) { - return View.OVER_SCROLL_NEVER; - } else { - FLog.w(ReactConstants.TAG, "wrong overScrollMode: " + jsOverScrollMode); - return View.OVER_SCROLL_IF_CONTENT_SCROLLS; - } - } - - public static int parseSnapToAlignment(@Nullable String alignment) { - if (alignment == null) { - return SNAP_ALIGNMENT_DISABLED; - } else if ("start".equalsIgnoreCase(alignment)) { - return SNAP_ALIGNMENT_START; - } else if ("center".equalsIgnoreCase(alignment)) { - return SNAP_ALIGNMENT_CENTER; - } else if ("end".equals(alignment)) { - return SNAP_ALIGNMENT_END; - } else { - FLog.w(ReactConstants.TAG, "wrong snap alignment value: " + alignment); - return SNAP_ALIGNMENT_DISABLED; - } - } - - public static int getDefaultScrollAnimationDuration(Context context) { - if (!mSmoothScrollDurationInitialized) { - mSmoothScrollDurationInitialized = true; - - try { - OverScrollerDurationGetter overScrollerDurationGetter = - new OverScrollerDurationGetter(context); - SMOOTH_SCROLL_DURATION = overScrollerDurationGetter.getScrollAnimationDuration(); - } catch (Throwable e) { - } - } - - return SMOOTH_SCROLL_DURATION; - } - - private static class OverScrollerDurationGetter extends OverScroller { - // This is the default in AOSP, hardcoded in OverScroller.java. - private int mScrollAnimationDuration = 250; - - OverScrollerDurationGetter(Context context) { - // We call with a null context because OverScroller does not use the context - // in the execution path we're interested in, unless heavily modified in an AOSP fork. - super(context); - } - - public int getScrollAnimationDuration() { - // If startScroll is called without a duration, OverScroller will call `startScroll(x, y, dx, - // dy, duration)` with the default duration. - super.startScroll(0, 0, 0, 0); - - return mScrollAnimationDuration; - } - - @Override - public void startScroll(int startX, int startY, int dx, int dy, int duration) { - mScrollAnimationDuration = duration; - } - } - - /** - * Adds a scroll listener. - * - *

Note that you must keep a reference to this scroll listener because this class only keeps a - * weak reference to it (to prevent memory leaks). This means that code like - * addScrollListener(new ScrollListener() {...}) won't work, you need to do this instead: - * - * mScrollListener = new ScrollListener() {...}; - * ReactScrollViewHelper.addScrollListener(mScrollListener); - * instead. - * - * @param listener - */ - public static void addScrollListener(ScrollListener listener) { - sScrollListeners.add(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); - private boolean mIsCanceled = false; - private boolean mIsFinished = true; - private float mDecelerationRate = 0.985f; - - 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; - } - - /** Get true if the previous animation was canceled */ - public boolean getIsCanceled() { - return mIsCanceled; - } - - /** Set the state of current animation is canceled or not */ - public ReactScrollViewScrollState setIsCanceled(boolean isCanceled) { - mIsCanceled = isCanceled; - return this; - } - - /** Get true if previous animation was finished */ - public boolean getIsFinished() { - return mIsFinished; - } - - /** Set the state of current animation is finished or not */ - public ReactScrollViewScrollState setIsFinished(boolean isFinished) { - mIsFinished = isFinished; - return this; - } - - /** Get true if previous animation was finished */ - public float getDecelerationRate() { - return mDecelerationRate; - } - - /** Set the state of current animation is finished or not */ - public ReactScrollViewScrollState setDecelerationRate(float decelerationRate) { - mDecelerationRate = decelerationRate; - return this; - } - } - - /** - * Scroll the given view to the location (x, y), with provided initial velocity. This method works - * by calculate the "would be" initial velocity with internal friction to move to the point (x, - * y), then apply that to the animator. - */ - public static - 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); - } - - // Register the listeners for the fling animator if there isn't any - final ValueAnimator flingAnimator = scrollView.getFlingAnimator(); - if (flingAnimator.getListeners() == null || flingAnimator.getListeners().size() == 0) { - registerFlingAnimator(scrollView); - } - - final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); - scrollState.setFinalAnimatedPositionScroll(x, y); - - final int scrollX = scrollView.getScrollX(); - final int scrollY = scrollView.getScrollY(); - // Only one fling animator will be started. For the horizontal scroll view, scrollY will always - // be the same to y. This is the same to the vertical scroll view. - if (scrollX != x) { - scrollView.startFlingAnimator(scrollX, x); - } - if (scrollY != y) { - scrollView.startFlingAnimator(scrollY, y); - } - - updateFabricScrollState(scrollView, x, y); - } - - /** Get current position or position after current animation finishes, if any. */ - public static - int getNextFlingStartValue( - final T scrollView, - final int currentValue, - final int postAnimationValue, - final int velocity) { - final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); - final int velocityDirectionMask = velocity != 0 ? velocity / Math.abs(velocity) : 0; - final boolean isMovingTowardsAnimatedValue = - velocityDirectionMask * (postAnimationValue - currentValue) > 0; - - // When the fling animation is not finished, or it was canceled and now we are moving towards - // the final animated value, we will return the final animated value. This is because follow up - // animation should consider the "would be" animated location, so that previous quick small - // scrolls are still working. - return !scrollState.getIsFinished() - || (scrollState.getIsCanceled() && isMovingTowardsAnimatedValue) - ? postAnimationValue - : currentValue; - } - - public static - void updateFabricScrollState(final T scrollView) { - updateFabricScrollState(scrollView, scrollView.getScrollX(), scrollView.getScrollY()); - } - - /** - * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. - */ - public static - void updateFabricScrollState(final T scrollView, final int scrollX, final int scrollY) { - if (DEBUG_MODE) { - FLog.i( - TAG, - "updateFabricScrollState[%d] scrollX %d scrollY %d", - scrollView.getId(), - scrollX, - scrollY); - } - - if (ViewUtil.getUIManagerType(scrollView.getId()) == UIManagerType.DEFAULT) { - return; - } - - final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); - // Dedupe events to reduce JNI traffic - if (scrollState.getLastStateUpdateScroll().equals(scrollX, scrollY)) { - return; - } - - scrollState.setLastStateUpdateScroll(scrollX, scrollY); - forceUpdateState(scrollView); - return; - } - - public static - 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, - "updateFabricScrollState[%d] scrollX %d scrollY %d fabricScrollX", - scrollView.getId(), - scrollX, - scrollY, - fabricScrollX); - } - - StateWrapper stateWrapper = scrollView.getStateWrapper(); - if (stateWrapper != null) { - WritableMap newStateData = new WritableNativeMap(); - newStateData.putDouble(CONTENT_OFFSET_LEFT, PixelUtil.toDIPFromPixel(scrollX)); - newStateData.putDouble(CONTENT_OFFSET_TOP, PixelUtil.toDIPFromPixel(scrollY)); - newStateData.putDouble( - SCROLL_AWAY_PADDING_TOP, PixelUtil.toDIPFromPixel(scrollAwayPaddingTop)); - stateWrapper.updateState(newStateData); - } - } - - public static < - T extends - ViewGroup & HasStateWrapper & HasScrollState & HasFlingAnimator - & HasScrollEventThrottle> - void updateStateOnScrollChanged( - final T scrollView, final float xVelocity, final float yVelocity) { - // Race an UpdateState with every onScroll. This makes it more likely that, in Fabric, - // 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. - updateFabricScrollState(scrollView); - emitScrollEvent(scrollView, xVelocity, yVelocity); - } - - public static - void registerFlingAnimator(final T scrollView) { - scrollView - .getFlingAnimator() - .addListener( - new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) { - final ReactScrollViewScrollState scrollState = - scrollView.getReactScrollViewScrollState(); - scrollState.setIsCanceled(false); - scrollState.setIsFinished(false); - } - - @Override - public void onAnimationEnd(Animator animator) { - scrollView.getReactScrollViewScrollState().setIsFinished(true); - updateFabricScrollState(scrollView); - } - - @Override - public void onAnimationCancel(Animator animator) { - scrollView.getReactScrollViewScrollState().setIsCanceled(true); - } - - @Override - public void onAnimationRepeat(Animator animator) {} - }); - } - - public static - Point predictFinalScrollPosition( - final T scrollView, - final int velocityX, - final int velocityY, - final int maximumOffsetX, - final int maximumOffsetY) { - final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); - // 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 - // so we can predict where a fling would land and snap to nearby that point. - OverScroller scroller = new OverScroller(scrollView.getContext()); - scroller.setFriction(1.0f - scrollState.getDecelerationRate()); - - // predict where a fling would end up so we can scroll to the nearest snap offset - int width = - scrollView.getWidth() - - ViewCompat.getPaddingStart(scrollView) - - ViewCompat.getPaddingEnd(scrollView); - int height = - scrollView.getHeight() - scrollView.getPaddingBottom() - scrollView.getPaddingTop(); - Point finalAnimatedPositionScroll = scrollState.getFinalAnimatedPositionScroll(); - scroller.fling( - getNextFlingStartValue( - scrollView, - scrollView.getScrollX(), - finalAnimatedPositionScroll.x, - velocityX), // startX - getNextFlingStartValue( - scrollView, - scrollView.getScrollY(), - finalAnimatedPositionScroll.y, - velocityY), // startY - velocityX, // velocityX - velocityY, // velocityY - 0, // minX - maximumOffsetX, // maxX - 0, // minY - maximumOffsetY, // maxY - width / 2, // overX - height / 2 // overY - ); - return new Point(scroller.getFinalX(), scroller.getFinalY()); - } - - public interface HasScrollState { - /** Get the scroll state for the current ScrollView */ - ReactScrollViewScrollState getReactScrollViewScrollState(); - } - - public interface HasFlingAnimator { - /** - * Start the fling animator that the ScrollView has to go from the start position to end - * position. - */ - void startFlingAnimator(int start, int end); - - /** Get the fling animator that is reused for the ScrollView to handle fling animation. */ - ValueAnimator getFlingAnimator(); - - /** Get the fling distance with current velocity for prediction */ - int getFlingExtrapolatedDistance(int velocity); - } - - public interface HasScrollEventThrottle { - /** - * Set the scroll event throttle in ms. This number is used to throttle the scroll events. The - * default value is zero, which means the scroll events are sent with no throttle. - */ - void setScrollEventThrottle(int scrollEventThrottle); - - /** Get the scroll event throttle in ms. */ - int getScrollEventThrottle(); - - /** Set the scroll view's last dispatch time for throttling */ - void setLastScrollDispatchTime(long lastScrollDispatchTime); - - /** Get the scroll view dispatch time for throttling */ - long getLastScrollDispatchTime(); - } - - public interface HasSmoothScroll { - void reactSmoothScrollTo(int x, int y); - - void scrollToPreservingMomentum(int x, int y); - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt new file mode 100644 index 00000000000..44f42539444 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -0,0 +1,551 @@ +/* + * 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.animation.Animator +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.core.view.ViewCompat +import com.facebook.common.logging.FLog +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.common.ReactConstants +import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel +import com.facebook.react.uimanager.StateWrapper +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.WeakHashMap + +/** Helper class that deals with emitting Scroll Events. */ +public object ReactScrollViewHelper { + private val TAG = ReactHorizontalScrollView::class.java.simpleName + private val DEBUG_MODE = false // ReactBuildConfig.DEBUG + private const val CONTENT_OFFSET_LEFT = "contentOffsetLeft" + private const val CONTENT_OFFSET_TOP = "contentOffsetTop" + private const val SCROLL_AWAY_PADDING_TOP = "scrollAwayPaddingTop" + + public const val MOMENTUM_DELAY: Long = 20 + public const val OVER_SCROLL_ALWAYS: String = "always" + public const val AUTO: String = "auto" + public const val OVER_SCROLL_NEVER: String = "never" + public const val SNAP_ALIGNMENT_DISABLED: Int = 0 + public const val SNAP_ALIGNMENT_START: Int = 1 + public const val SNAP_ALIGNMENT_CENTER: Int = 2 + public const val SNAP_ALIGNMENT_END: Int = 3 + + // Support global native listeners for scroll events + private val scrollListeners = Collections.newSetFromMap(WeakHashMap()) + + // If all else fails, this is the hardcoded value in OverScroller.java, in AOSP. + // The default is defined here (as of this diff): + // https://android.googlesource.com/platform/frameworks/base/+/ae5bcf23b5f0875e455790d6af387184dbd009c1/core/java/android/widget/OverScroller.java#44 + private var SMOOTH_SCROLL_DURATION = 250 + private var smoothScrollDurationInitialized = false + + /** Shared by [ReactScrollView] and [ReactHorizontalScrollView]. */ + @JvmStatic + public fun emitScrollEvent(scrollView: T, xVelocity: Float, yVelocity: Float) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity) + } + + @JvmStatic + public fun emitScrollBeginDragEvent(scrollView: T) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent(scrollView, ScrollEventType.BEGIN_DRAG) + } + + @JvmStatic + public fun emitScrollEndDragEvent(scrollView: T, xVelocity: Float, yVelocity: Float) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent(scrollView, ScrollEventType.END_DRAG, xVelocity, yVelocity) + } + + @JvmStatic + public fun emitScrollMomentumBeginEvent(scrollView: T, xVelocity: Int, yVelocity: Int) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent( + scrollView, ScrollEventType.MOMENTUM_BEGIN, xVelocity.toFloat(), yVelocity.toFloat()) + } + + @JvmStatic + public fun emitScrollMomentumEndEvent(scrollView: T) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_END) + } + + private fun emitScrollEvent(scrollView: T, scrollEventType: ScrollEventType) where + T : HasScrollEventThrottle?, + T : ViewGroup { + emitScrollEvent(scrollView, scrollEventType, 0f, 0f) + } + + private fun emitScrollEvent( + scrollView: T, + scrollEventType: ScrollEventType, + xVelocity: Float, + yVelocity: Float + ) where T : HasScrollEventThrottle?, T : ViewGroup { + val now = System.currentTimeMillis() + // Throttle the scroll event if scrollEventThrottle is set to be equal or more than 17 ms. + // We limit the delta to 17ms so that small throttles intended to enable 60fps updates will not + // inadvertently filter out any scroll events. + if (scrollView.scrollEventThrottle >= Math.max(17, now - scrollView.lastScrollDispatchTime)) { + // Scroll events are throttled. + return + } + val contentView = scrollView.getChildAt(0) ?: return + for (scrollListener in scrollListeners) { + scrollListener.onScroll(scrollView, scrollEventType, xVelocity, yVelocity) + } + val reactContext = scrollView.context as ReactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + + // It's possible for the EventDispatcher to go away - for example, + // if there's a crash initiated from JS and we tap on a ScrollView + // around teardown of RN, this will cause a NPE. We can safely ignore + // this since the crash is usually a red herring. + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, scrollView.id) + if (eventDispatcher != null) { + eventDispatcher.dispatchEvent( + ScrollEvent.obtain( + surfaceId, + scrollView.id, + scrollEventType, + scrollView.scrollX.toFloat(), + scrollView.scrollY.toFloat(), + xVelocity, + yVelocity, + contentView.width, + contentView.height, + scrollView.width, + scrollView.height)) + scrollView.lastScrollDispatchTime = now + } + } + + /** This is only for Java listeners. onLayout events emitted to JS are handled elsewhere. */ + @JvmStatic + public fun emitLayoutEvent(scrollView: ViewGroup) { + for (scrollListener in scrollListeners) { + scrollListener.onLayout(scrollView) + } + } + + @JvmStatic + public fun parseOverScrollMode(jsOverScrollMode: String?): Int { + return if (jsOverScrollMode == null || jsOverScrollMode == AUTO) { + View.OVER_SCROLL_IF_CONTENT_SCROLLS + } else if (jsOverScrollMode == OVER_SCROLL_ALWAYS) { + View.OVER_SCROLL_ALWAYS + } else if (jsOverScrollMode == OVER_SCROLL_NEVER) { + View.OVER_SCROLL_NEVER + } else { + FLog.w(ReactConstants.TAG, "wrong overScrollMode: $jsOverScrollMode") + View.OVER_SCROLL_IF_CONTENT_SCROLLS + } + } + + @JvmStatic + public fun parseSnapToAlignment(alignment: String?): Int { + return if (alignment == null) { + SNAP_ALIGNMENT_DISABLED + } else if ("start".equals(alignment, ignoreCase = true)) { + SNAP_ALIGNMENT_START + } else if ("center".equals(alignment, ignoreCase = true)) { + SNAP_ALIGNMENT_CENTER + } else if ("end" == alignment) { + SNAP_ALIGNMENT_END + } else { + FLog.w(ReactConstants.TAG, "wrong snap alignment value: $alignment") + SNAP_ALIGNMENT_DISABLED + } + } + + @JvmStatic + public fun getDefaultScrollAnimationDuration(context: Context?): Int { + if (!smoothScrollDurationInitialized) { + smoothScrollDurationInitialized = true + try { + val overScrollerDurationGetter = OverScrollerDurationGetter(context) + SMOOTH_SCROLL_DURATION = overScrollerDurationGetter.scrollAnimationDuration + } catch (e: Throwable) {} + } + return SMOOTH_SCROLL_DURATION + } + + /** + * Adds a scroll listener. + * + * Note that you must keep a reference to this scroll listener because this class only keeps a + * weak reference to it (to prevent memory leaks). This means that code like ` + * addScrollListener(new ScrollListener() {...})` won't work, you need to do this instead: ` + * mScrollListener = new ScrollListener() {...}; + * ReactScrollViewHelper.addScrollListener(mScrollListener); ` * instead. + * + * @param listener + */ + @JvmStatic + public fun addScrollListener(listener: ScrollListener?) { + if (listener != null) { + scrollListeners.add(listener) + } + } + + @JvmStatic + public fun removeScrollListener(listener: ScrollListener?) { + if (listener != null) { + scrollListeners.remove(listener) + } + } + + /** + * Scroll the given view to the location (x, y), with provided initial velocity. This method works + * by calculate the "would be" initial velocity with internal friction to move to the point (x, + * y), then apply that to the animator. + */ + @JvmStatic + public fun smoothScrollTo(scrollView: T, x: Int, y: Int) where + T : HasFlingAnimator?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + if (DEBUG_MODE) { + FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.id, x, y) + } + + // Register the listeners for the fling animator if there isn't any + val flingAnimator = scrollView.getFlingAnimator() + if (flingAnimator.listeners == null || flingAnimator.listeners.size == 0) { + registerFlingAnimator(scrollView) + } + val scrollState = scrollView.reactScrollViewScrollState + scrollState.setFinalAnimatedPositionScroll(x, y) + val scrollX = scrollView.scrollX + val scrollY = scrollView.scrollY + // Only one fling animator will be started. For the horizontal scroll view, scrollY will always + // be the same to y. This is the same to the vertical scroll view. + if (scrollX != x) { + scrollView.startFlingAnimator(scrollX, x) + } + if (scrollY != y) { + scrollView.startFlingAnimator(scrollY, y) + } + updateFabricScrollState(scrollView, x, y) + } + + /** Get current position or position after current animation finishes, if any. */ + @JvmStatic + public fun getNextFlingStartValue( + scrollView: T, + currentValue: Int, + postAnimationValue: Int, + velocity: Int + ): Int where T : HasFlingAnimator?, T : HasScrollState?, T : ViewGroup { + val scrollState = scrollView.reactScrollViewScrollState + val velocityDirectionMask = if (velocity != 0) velocity / Math.abs(velocity) else 0 + val isMovingTowardsAnimatedValue = + velocityDirectionMask * (postAnimationValue - currentValue) > 0 + + // When the fling animation is not finished, or it was canceled and now we are moving towards + // the final animated value, we will return the final animated value. This is because follow up + // animation should consider the "would be" animated location, so that previous quick small + // scrolls are still working. + return if (!scrollState.isFinished || + (scrollState.isCanceled && isMovingTowardsAnimatedValue)) { + postAnimationValue + } else { + currentValue + } + } + + @JvmStatic + public fun updateFabricScrollState(scrollView: T) where + T : HasFlingAnimator?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + updateFabricScrollState(scrollView, scrollView.scrollX, scrollView.scrollY) + } + + /** + * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. + */ + public fun updateFabricScrollState(scrollView: T, scrollX: Int, scrollY: Int) where + T : HasFlingAnimator?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + if (DEBUG_MODE) { + FLog.i( + TAG, "updateFabricScrollState[%d] scrollX %d scrollY %d", scrollView.id, scrollX, scrollY) + } + if (ViewUtil.getUIManagerType(scrollView.id) == UIManagerType.DEFAULT) { + return + } + val scrollState = scrollView.reactScrollViewScrollState + // Dedupe events to reduce JNI traffic + if (scrollState.lastStateUpdateScroll.equals(scrollX, scrollY)) { + return + } + scrollState.setLastStateUpdateScroll(scrollX, scrollY) + forceUpdateState(scrollView) + return + } + + @JvmStatic + public fun forceUpdateState(scrollView: T) where + T : HasFlingAnimator?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + val scrollState = scrollView.reactScrollViewScrollState + val scrollAwayPaddingTop = scrollState.scrollAwayPaddingTop + val scrollPos = scrollState.lastStateUpdateScroll + val scrollX = scrollPos.x + val scrollY = scrollPos.y + val fabricScrollX: Int + val layoutDirection = scrollState.layoutDirection + fabricScrollX = + if (layoutDirection == View.LAYOUT_DIRECTION_RTL) { + // getScrollX returns offset from left even when layout direction is RTL. + // The following line calculates offset from right. + val child = scrollView.getChildAt(0) + val contentWidth = child?.width ?: 0 + -(contentWidth - scrollX - scrollView.width) + } else { + scrollX + } + if (DEBUG_MODE) { + FLog.i( + TAG, + "updateFabricScrollState[%d] scrollX %d scrollY %d fabricScrollX", + scrollView.id, + scrollX, + scrollY, + fabricScrollX) + } + val stateWrapper = scrollView.stateWrapper + if (stateWrapper != null) { + val newStateData: WritableMap = WritableNativeMap() + newStateData.putDouble(CONTENT_OFFSET_LEFT, toDIPFromPixel(scrollX.toFloat()).toDouble()) + newStateData.putDouble(CONTENT_OFFSET_TOP, toDIPFromPixel(scrollY.toFloat()).toDouble()) + newStateData.putDouble( + SCROLL_AWAY_PADDING_TOP, toDIPFromPixel(scrollAwayPaddingTop.toFloat()).toDouble()) + stateWrapper.updateState(newStateData) + } + } + + @JvmStatic + public fun updateStateOnScrollChanged( + scrollView: T, + xVelocity: Float, + yVelocity: Float + ) where + T : HasFlingAnimator?, + T : HasScrollEventThrottle?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + // Race an UpdateState with every onScroll. This makes it more likely that, in Fabric, + // 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. + updateFabricScrollState(scrollView) + emitScrollEvent(scrollView, xVelocity, yVelocity) + } + + public fun registerFlingAnimator(scrollView: T) where + T : HasFlingAnimator?, + T : HasScrollState?, + T : HasStateWrapper?, + T : ViewGroup { + scrollView + .getFlingAnimator() + .addListener( + object : Animator.AnimatorListener { + override fun onAnimationStart(animator: Animator) { + val scrollState = scrollView.reactScrollViewScrollState + scrollState.isCanceled = false + scrollState.isFinished = false + } + + override fun onAnimationEnd(animator: Animator) { + scrollView.reactScrollViewScrollState.isFinished = true + updateFabricScrollState(scrollView) + } + + override fun onAnimationCancel(animator: Animator) { + scrollView.reactScrollViewScrollState.isCanceled = true + } + + override fun onAnimationRepeat(animator: Animator) = Unit + }) + } + + @JvmStatic + public fun predictFinalScrollPosition( + scrollView: T, + velocityX: Int, + velocityY: Int, + maximumOffsetX: Int, + maximumOffsetY: Int + ): Point where T : HasFlingAnimator?, T : HasScrollState?, T : ViewGroup { + val scrollState = scrollView.reactScrollViewScrollState + // 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 + // so we can predict where a fling would land and snap to nearby that point. + val scroller = OverScroller(scrollView.context) + scroller.setFriction(1.0f - scrollState.decelerationRate) + + // predict where a fling would end up so we can scroll to the nearest snap offset + val width = + (scrollView.width - + ViewCompat.getPaddingStart(scrollView) - + ViewCompat.getPaddingEnd(scrollView)) + val height = scrollView.height - scrollView.paddingBottom - scrollView.paddingTop + val finalAnimatedPositionScroll = scrollState.finalAnimatedPositionScroll + scroller.fling( + getNextFlingStartValue( + scrollView, scrollView.scrollX, finalAnimatedPositionScroll.x, velocityX), // startX + getNextFlingStartValue( + scrollView, scrollView.scrollY, finalAnimatedPositionScroll.y, velocityY), // startY + velocityX, // velocityX + velocityY, // velocityY + 0, // minX + maximumOffsetX, // maxX + 0, // minY + maximumOffsetY, // maxY + width / 2, // overX + height / 2 // overY + ) + return Point(scroller.finalX, scroller.finalY) + } + + public interface ScrollListener { + public fun onScroll( + scrollView: ViewGroup?, + scrollEventType: ScrollEventType?, + xVelocity: Float, + yVelocity: Float + ) + + public fun onLayout(scrollView: ViewGroup?) + } + + public interface HasStateWrapper { + public val stateWrapper: StateWrapper? + } + + private class OverScrollerDurationGetter internal constructor(context: Context?) : + OverScroller(context) { + // This is the default in AOSP, hardcoded in OverScroller.java. + private var currentScrollAnimationDuration = 250 + val scrollAnimationDuration: Int + get() { + // If startScroll is called without a duration, OverScroller will call `startScroll(x, y, + // dx, + // dy, duration)` with the default duration. + super.startScroll(0, 0, 0, 0) + return currentScrollAnimationDuration + } + + override fun startScroll(startX: Int, startY: Int, dx: Int, dy: Int, duration: Int) { + currentScrollAnimationDuration = duration + } + } + + public class ReactScrollViewScrollState( + /** + * 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 val layoutDirection: Int + ) { + + /** Get the position after current animation is finished */ + public val finalAnimatedPositionScroll: Point = Point() + /** Get the padding on the top for nav bar */ + public var scrollAwayPaddingTop: Int = 0 + /** Get the Fabric state of last scroll position */ + public val lastStateUpdateScroll: Point = Point(-1, -1) + /** Get true if the previous animation was canceled */ + public var isCanceled: Boolean = false + /** Get true if previous animation was finished */ + public var isFinished: Boolean = true + /** Get true if previous animation was finished */ + public var decelerationRate: Float = 0.985f + + /** Set the final scroll position after scrolling animation is finished */ + public fun setFinalAnimatedPositionScroll( + finalAnimatedPositionScrollX: Int, + finalAnimatedPositionScrollY: Int + ): ReactScrollViewScrollState { + finalAnimatedPositionScroll[finalAnimatedPositionScrollX] = finalAnimatedPositionScrollY + return this + } + + /** Set the Fabric state of last scroll position */ + public fun setLastStateUpdateScroll( + lastStateUpdateScrollX: Int, + lastStateUpdateScrollY: Int + ): ReactScrollViewScrollState { + lastStateUpdateScroll[lastStateUpdateScrollX] = lastStateUpdateScrollY + return this + } + } + + public interface HasScrollState { + /** Get the scroll state for the current ScrollView */ + public val reactScrollViewScrollState: ReactScrollViewScrollState + } + + public interface HasFlingAnimator { + /** + * Start the fling animator that the ScrollView has to go from the start position to end + * position. + */ + public fun startFlingAnimator(start: Int, end: Int) + + /** Get the fling animator that is reused for the ScrollView to handle fling animation. */ + public fun getFlingAnimator(): ValueAnimator + + /** Get the fling distance with current velocity for prediction */ + public fun getFlingExtrapolatedDistance(velocity: Int): Int + } + + public interface HasScrollEventThrottle { + /** Get the scroll event throttle in ms. */ + /** + * Set the scroll event throttle in ms. This number is used to throttle the scroll events. The + * default value is zero, which means the scroll events are sent with no throttle. + */ + public var scrollEventThrottle: Int + /** Get the scroll view dispatch time for throttling */ + /** Set the scroll view's last dispatch time for throttling */ + public var lastScrollDispatchTime: Long + } + + public interface HasSmoothScroll { + public fun reactSmoothScrollTo(x: Int, y: Int) + + public fun scrollToPreservingMomentum(x: Int, y: Int) + } +}