ReactScrollViewHelper -> Kotlin (#43735)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/43735

## Changelog:
[Internal] -

As in the title.

Reviewed By: tdn120

Differential Revision: D55576494

fbshipit-source-id: ee056b6ba629614a65440f68ad4dee5f53674879
This commit is contained in:
Ruslan Shestopalyuk
2024-04-08 03:14:36 -07:00
committed by Facebook GitHub Bot
parent 8ad308e847
commit c35cc43a6c
3 changed files with 586 additions and 638 deletions
@@ -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 <init> ()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 <init> (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 {
@@ -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<ScrollListener> sScrollListeners =
Collections.newSetFromMap(new WeakHashMap<ScrollListener, Boolean>());
// 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 <T extends ViewGroup & HasScrollEventThrottle> void emitScrollEvent(
T scrollView, float xVelocity, float yVelocity) {
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity);
}
public static <T extends ViewGroup & HasScrollEventThrottle> void emitScrollBeginDragEvent(
T scrollView) {
emitScrollEvent(scrollView, ScrollEventType.BEGIN_DRAG);
}
public static <T extends ViewGroup & HasScrollEventThrottle> void emitScrollEndDragEvent(
T scrollView, float xVelocity, float yVelocity) {
emitScrollEvent(scrollView, ScrollEventType.END_DRAG, xVelocity, yVelocity);
}
public static <T extends ViewGroup & HasScrollEventThrottle> void emitScrollMomentumBeginEvent(
T scrollView, int xVelocity, int yVelocity) {
emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_BEGIN, xVelocity, yVelocity);
}
public static <T extends ViewGroup & HasScrollEventThrottle> void emitScrollMomentumEndEvent(
T scrollView) {
emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_END);
}
private static <T extends ViewGroup & HasScrollEventThrottle> void emitScrollEvent(
T scrollView, ScrollEventType scrollEventType) {
emitScrollEvent(scrollView, scrollEventType, 0, 0);
}
private static <T extends ViewGroup & HasScrollEventThrottle> 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.
*
* <p>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 <code>
* addScrollListener(new ScrollListener() {...})</code> won't work, you need to do this instead:
* <code>
* mScrollListener = new ScrollListener() {...};
* ReactScrollViewHelper.addScrollListener(mScrollListener);
* </code> 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 <T extends ViewGroup & HasStateWrapper & HasScrollState & HasFlingAnimator>
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 <T extends ViewGroup & HasScrollState & HasFlingAnimator>
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 <T extends ViewGroup & HasStateWrapper & HasScrollState & HasFlingAnimator>
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 <T extends ViewGroup & HasStateWrapper & HasScrollState & HasFlingAnimator>
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 <T extends ViewGroup & HasScrollState & HasStateWrapper & HasFlingAnimator>
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 <T extends ViewGroup & HasStateWrapper & HasScrollState & HasFlingAnimator>
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 <T extends ViewGroup & HasScrollState & HasFlingAnimator>
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);
}
}
@@ -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<ScrollListener, Boolean>())
// 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 <T> emitScrollEvent(scrollView: T, xVelocity: Float, yVelocity: Float) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity)
}
@JvmStatic
public fun <T> emitScrollBeginDragEvent(scrollView: T) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.BEGIN_DRAG)
}
@JvmStatic
public fun <T> emitScrollEndDragEvent(scrollView: T, xVelocity: Float, yVelocity: Float) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.END_DRAG, xVelocity, yVelocity)
}
@JvmStatic
public fun <T> emitScrollMomentumBeginEvent(scrollView: T, xVelocity: Int, yVelocity: Int) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(
scrollView, ScrollEventType.MOMENTUM_BEGIN, xVelocity.toFloat(), yVelocity.toFloat())
}
@JvmStatic
public fun <T> emitScrollMomentumEndEvent(scrollView: T) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(scrollView, ScrollEventType.MOMENTUM_END)
}
private fun <T> emitScrollEvent(scrollView: T, scrollEventType: ScrollEventType) where
T : HasScrollEventThrottle?,
T : ViewGroup {
emitScrollEvent(scrollView, scrollEventType, 0f, 0f)
}
private fun <T> 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 <T> 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<T>(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<T>(scrollView, x, y)
}
/** Get current position or position after current animation finishes, if any. */
@JvmStatic
public fun <T> 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 <T> 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 <T> 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<T>(scrollView)
return
}
@JvmStatic
public fun <T> 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 <T> 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 <T> 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<T>(scrollView)
}
override fun onAnimationCancel(animator: Animator) {
scrollView.reactScrollViewScrollState.isCanceled = true
}
override fun onAnimationRepeat(animator: Animator) = Unit
})
}
@JvmStatic
public fun <T> 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<T>(
scrollView, scrollView.scrollX, finalAnimatedPositionScroll.x, velocityX), // startX
getNextFlingStartValue<T>(
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)
}
}