diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index ffcfb0b074a3..40103eb37417 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5429,6 +5429,30 @@ public abstract interface class com/facebook/react/views/scroll/FpsListener { public abstract fun isEnabled ()Z } +public final class com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper : com/facebook/react/bridge/UIManagerListener { + public fun (Landroid/view/ViewGroup;Z)V + public fun didDispatchMountItems (Lcom/facebook/react/bridge/UIManager;)V + public fun didMountItems (Lcom/facebook/react/bridge/UIManager;)V + public fun didScheduleMountItems (Lcom/facebook/react/bridge/UIManager;)V + public final fun getConfig ()Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config; + public final fun setConfig (Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config;)V + public final fun start ()V + public final fun stop ()V + public fun willDispatchViewUpdates (Lcom/facebook/react/bridge/UIManager;)V + public fun willMountItems (Lcom/facebook/react/bridge/UIManager;)V +} + +public final class com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config { + public static final field Companion Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config$Companion; + public static final fun fromReadableMap (Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config; + public final fun getAutoScrollToTopThreshold ()Ljava/lang/Integer; + public final fun getMinIndexForVisible ()I +} + +public final class com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config$Companion { + public final fun fromReadableMap (Lcom/facebook/react/bridge/ReadableMap;)Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config; +} + public final class com/facebook/react/views/scroll/OnScrollDispatchHelper { public fun ()V public final fun getXFlingVelocity ()F @@ -5451,6 +5475,7 @@ public final class com/facebook/react/views/scroll/ReactHorizontalScrollContaine public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android/widget/HorizontalScrollView, android/view/View$OnLayoutChangeListener, android/view/ViewGroup$OnHierarchyChangeListener, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/views/scroll/ReactAccessibleScrollView, com/facebook/react/views/scroll/ReactScrollViewHelper$HasFlingAnimator, com/facebook/react/views/scroll/ReactScrollViewHelper$HasScrollEventThrottle, com/facebook/react/views/scroll/ReactScrollViewHelper$HasScrollState, com/facebook/react/views/scroll/ReactScrollViewHelper$HasSmoothScroll, com/facebook/react/views/scroll/ReactScrollViewHelper$HasStateWrapper, com/facebook/react/views/scroll/VirtualViewContainer { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;)V + public synthetic fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun abortAnimation ()V public fun addFocusables (Ljava/util/ArrayList;II)V public fun arrowScroll (I)Z @@ -5598,6 +5623,7 @@ public final class com/facebook/react/views/scroll/ReactHorizontalScrollViewMana public class com/facebook/react/views/scroll/ReactScrollView : android/widget/ScrollView, android/view/View$OnLayoutChangeListener, android/view/ViewGroup$OnHierarchyChangeListener, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/views/scroll/ReactAccessibleScrollView, com/facebook/react/views/scroll/ReactScrollViewHelper$HasFlingAnimator, com/facebook/react/views/scroll/ReactScrollViewHelper$HasScrollEventThrottle, com/facebook/react/views/scroll/ReactScrollViewHelper$HasScrollState, com/facebook/react/views/scroll/ReactScrollViewHelper$HasSmoothScroll, com/facebook/react/views/scroll/ReactScrollViewHelper$HasStateWrapper, com/facebook/react/views/scroll/VirtualViewContainer { public fun (Landroid/content/Context;)V public fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;)V + public synthetic fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun abortAnimation ()V public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z public fun draw (Landroid/graphics/Canvas;)V @@ -5925,6 +5951,28 @@ public abstract interface class com/facebook/react/views/scroll/VirtualView { public abstract fun onModeChange (Lcom/facebook/react/views/virtual/VirtualViewMode;Landroid/graphics/Rect;)V } +public abstract class com/facebook/react/views/scroll/VirtualViewContainerState { + public static final field Companion Lcom/facebook/react/views/scroll/VirtualViewContainerState$Companion; + public fun (Landroid/view/ViewGroup;)V + public static final fun create (Landroid/view/ViewGroup;)Lcom/facebook/react/views/scroll/VirtualViewContainerState; + protected final fun getEmptyRect ()Landroid/graphics/Rect; + protected final fun getPrerenderRatio ()D + protected final fun getPrerenderRect ()Landroid/graphics/Rect; + protected final fun getScrollView ()Landroid/view/ViewGroup; + protected abstract fun getVirtualViews ()Ljava/util/Collection; + protected final fun getVisibleRect ()Landroid/graphics/Rect; + public fun onChange (Lcom/facebook/react/views/scroll/VirtualView;)V + public fun remove (Lcom/facebook/react/views/scroll/VirtualView;)V + protected abstract fun updateModes (Lcom/facebook/react/views/scroll/VirtualView;)V + public static synthetic fun updateModes$default (Lcom/facebook/react/views/scroll/VirtualViewContainerState;Lcom/facebook/react/views/scroll/VirtualView;ILjava/lang/Object;)V + protected final fun updateRects ()V + public final fun updateState ()V +} + +public final class com/facebook/react/views/scroll/VirtualViewContainerState$Companion { + public final fun create (Landroid/view/ViewGroup;)Lcom/facebook/react/views/scroll/VirtualViewContainerState; +} + public class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout { public fun (Lcom/facebook/react/bridge/ReactContext;)V public fun canChildScrollUp ()Z diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt index 2bee605a15c2..c0d841890845 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.kt @@ -28,12 +28,12 @@ import java.lang.ref.WeakReference * This uses UIManager to listen to updates and capture position of items before and after layout. */ @OptIn(UnstableReactNativeAPI::class) -internal class MaintainVisibleScrollPositionHelper( +public class MaintainVisibleScrollPositionHelper( private val scrollView: ScrollViewT, private val horizontal: Boolean, ) : UIManagerListener where ScrollViewT : HasSmoothScroll?, ScrollViewT : ViewGroup? { - var config: Config? = null + public var config: Config? = null private var firstVisibleViewRef: WeakReference? = null private var prevFirstVisibleFrame: Rect? = null private var isListening = false @@ -50,11 +50,14 @@ internal class MaintainVisibleScrollPositionHelper( ) ) - class Config - internal constructor(val minIndexForVisible: Int, val autoScrollToTopThreshold: Int?) { - companion object { + public class Config + internal constructor( + public val minIndexForVisible: Int, + public val autoScrollToTopThreshold: Int?, + ) { + public companion object { @JvmStatic - fun fromReadableMap(value: ReadableMap): Config { + public fun fromReadableMap(value: ReadableMap): Config { val minIndexForVisible = value.getInt("minIndexForVisible") val autoScrollToTopThreshold = if (value.hasKey("autoscrollToTopThreshold")) value.getInt("autoscrollToTopThreshold") @@ -65,7 +68,7 @@ internal class MaintainVisibleScrollPositionHelper( } /** Start listening to view hierarchy updates. Should be called when this is created. */ - fun start() { + public fun start() { if (isListening) { return } @@ -74,7 +77,7 @@ internal class MaintainVisibleScrollPositionHelper( } /** Stop listening to view hierarchy updates. Should be called before this is destroyed. */ - fun stop() { + public fun stop() { if (!isListening) { return } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java deleted file mode 100644 index a153bea92bb4..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ /dev/null @@ -1,1846 +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 static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView; - -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.view.FocusFinder; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.view.accessibility.AccessibilityNodeInfo; -import android.widget.HorizontalScrollView; -import android.widget.OverScroller; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.ViewCompat.FocusDirection; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Assertions; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.R; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.common.build.ReactBuildConfig; -import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags; -import com.facebook.react.uimanager.BackgroundStyleApplicator; -import com.facebook.react.uimanager.LengthPercentage; -import com.facebook.react.uimanager.LengthPercentageType; -import com.facebook.react.uimanager.MeasureSpecAssertions; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.PointerEvents; -import com.facebook.react.uimanager.ReactClippingViewGroup; -import com.facebook.react.uimanager.ReactClippingViewGroupHelper; -import com.facebook.react.uimanager.ReactOverflowViewWithInset; -import com.facebook.react.uimanager.StateWrapper; -import com.facebook.react.uimanager.events.NativeGestureUtil; -import com.facebook.react.uimanager.style.BorderRadiusProp; -import com.facebook.react.uimanager.style.BorderStyle; -import com.facebook.react.uimanager.style.LogicalEdge; -import com.facebook.react.uimanager.style.Overflow; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper; -import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; -import com.facebook.systrace.Systrace; -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -/** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class ReactHorizontalScrollView extends HorizontalScrollView - implements ReactClippingViewGroup, - ViewGroup.OnHierarchyChangeListener, - View.OnLayoutChangeListener, - ReactAccessibleScrollView, - ReactOverflowViewWithInset, - HasScrollState, - HasStateWrapper, - HasFlingAnimator, - HasScrollEventThrottle, - HasSmoothScroll, - VirtualViewContainer { - - private static final boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG; - private static final String TAG = ReactHorizontalScrollView.class.getSimpleName(); - - private static final int NO_SCROLL_POSITION = Integer.MIN_VALUE; - - private static @Nullable Field sScrollerField; - private static boolean sTriedToGetScrollerField = false; - - private int mScrollXAfterMeasure = NO_SCROLL_POSITION; - - private static final int UNSET_CONTENT_OFFSET = -1; - - private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper(); - private final @Nullable OverScroller mScroller; - private final VelocityHelper mVelocityHelper = new VelocityHelper(); - private final Rect mTempRect = new Rect(); - private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollX", 0, 0); - private final @Nullable FpsListener mFpsListener; - - private Rect mOverflowInset = new Rect(); - private @Nullable VirtualViewContainerState mVirtualViewContainerState; - private boolean mActivelyScrolling; - private @Nullable Rect mClippingRect; - private Overflow mOverflow = Overflow.SCROLL; - private boolean mDragging; - private boolean mPagingEnabled = false; - private @Nullable Runnable mPostTouchRunnable; - private boolean mRemoveClippedSubviews; - private boolean mScrollEnabled = true; - private boolean mSendMomentumEvents; - private @Nullable String mScrollPerfTag; - private @Nullable Drawable mEndBackground; - private int mEndFillColor = Color.TRANSPARENT; - private boolean mDisableIntervalMomentum = false; - private int mSnapInterval = 0; - private @Nullable List mSnapOffsets; - private boolean mSnapToStart = true; - private boolean mSnapToEnd = true; - private int mSnapToAlignment = SNAP_ALIGNMENT_DISABLED; - private boolean mPagedArrowScrolling = false; - private int mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - private int mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - private @Nullable StateWrapper mStateWrapper = null; - private ReactScrollViewScrollState mReactScrollViewScrollState; - private PointerEvents mPointerEvents = PointerEvents.AUTO; - private long mLastScrollDispatchTime = 0; - private int mScrollEventThrottle = 0; - private @Nullable View mContentView; - private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper; - private int mFadingEdgeLengthStart = 0; - private int mFadingEdgeLengthEnd = 0; - private boolean mEmittedOverScrollSinceScrollBegin = false; - private boolean mScrollsChildToFocus = true; - - public ReactHorizontalScrollView(Context context) { - this(context, null); - } - - public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListener) { - super(context); - mFpsListener = fpsListener; - - ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); - - mScroller = getOverScrollerFromParent(); - - setOnHierarchyChangeListener(this); - setClipChildren(false); - initView(); - } - - /** - * Set all default values here as opposed to in the constructor or field defaults. It is important - * that these properties are set during the constructor, but also on-demand whenever an existing - * ReactHorizontalScrollView is recycled. - */ - private void initView() { - mOverflowInset = new Rect(); - mVirtualViewContainerState = null; - mActivelyScrolling = false; - mClippingRect = null; - // The default value for `overflow` is set to `Visible` in the Yoga style props. - mOverflow = - ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL; - - mDragging = false; - mPagingEnabled = false; - mPostTouchRunnable = null; - mRemoveClippedSubviews = false; - mScrollEnabled = true; - mSendMomentumEvents = false; - mScrollPerfTag = null; - mEndBackground = null; - mEndFillColor = Color.TRANSPARENT; - mDisableIntervalMomentum = false; - mSnapInterval = 0; - mSnapOffsets = null; - mSnapToStart = true; - mSnapToEnd = true; - mSnapToAlignment = SNAP_ALIGNMENT_DISABLED; - mPagedArrowScrolling = false; - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - mStateWrapper = null; - mReactScrollViewScrollState = new ReactScrollViewScrollState(); - - mPointerEvents = PointerEvents.AUTO; - mLastScrollDispatchTime = 0; - mScrollEventThrottle = 0; - mContentView = null; - mMaintainVisibleContentPositionHelper = null; - mFadingEdgeLengthStart = 0; - mFadingEdgeLengthEnd = 0; - mScrollsChildToFocus = true; - } - - /* package */ void recycleView() { - // Set default field values - initView(); - - // If the view is still attached to a parent, we need to remove it from the parent - // before we can recycle it. - if (getParent() != null) { - ((ViewGroup) getParent()).removeView(this); - } - updateView(); - } - - private void updateView() {} - - @Override - public VirtualViewContainerState getVirtualViewContainerState() { - if (mVirtualViewContainerState == null) { - mVirtualViewContainerState = VirtualViewContainerState.create(this); - } - - return mVirtualViewContainerState; - } - - @Override - public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { - super.onInitializeAccessibilityNodeInfo(info); - - // Expose the testID prop as the resource-id name of the view. Black-box E2E/UI testing - // frameworks, which interact with the UI through the accessibility framework, do not have - // access to view tags. This allows developers/testers to avoid polluting the - // content-description with test identifiers. - final String testId = (String) this.getTag(R.id.react_test_id); - if (testId != null) { - info.setViewIdResourceName(testId); - } - } - - @Override - public boolean getScrollEnabled() { - return mScrollEnabled; - } - - @Override - public boolean canScrollHorizontally(int direction) { - return mScrollEnabled && super.canScrollHorizontally(direction); - } - - @Nullable - private OverScroller getOverScrollerFromParent() { - OverScroller scroller; - - if (!sTriedToGetScrollerField) { - sTriedToGetScrollerField = true; - try { - sScrollerField = HorizontalScrollView.class.getDeclaredField("mScroller"); - sScrollerField.setAccessible(true); - } catch (NoSuchFieldException e) { - FLog.w( - TAG, - "Failed to get mScroller field for HorizontalScrollView! " - + "This app will exhibit the bounce-back scrolling bug :("); - } - } - - if (sScrollerField != null) { - try { - Object scrollerValue = sScrollerField.get(this); - if (scrollerValue instanceof OverScroller) { - scroller = (OverScroller) scrollerValue; - } else { - FLog.w( - TAG, - "Failed to cast mScroller field in HorizontalScrollView (probably due to OEM changes" - + " to AOSP)! This app will exhibit the bounce-back scrolling bug :("); - scroller = null; - } - } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to get mScroller from HorizontalScrollView!", e); - } - } else { - scroller = null; - } - - return scroller; - } - - public void setScrollPerfTag(@Nullable String scrollPerfTag) { - mScrollPerfTag = scrollPerfTag; - } - - @Override - public void setRemoveClippedSubviews(boolean removeClippedSubviews) { - if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) { - return; - } - - if (removeClippedSubviews && mClippingRect == null) { - mClippingRect = new Rect(); - } - mRemoveClippedSubviews = removeClippedSubviews; - updateClippingRect(); - } - - @Override - public boolean getRemoveClippedSubviews() { - return mRemoveClippedSubviews; - } - - public void setDisableIntervalMomentum(boolean disableIntervalMomentum) { - mDisableIntervalMomentum = disableIntervalMomentum; - } - - public void setSendMomentumEvents(boolean sendMomentumEvents) { - mSendMomentumEvents = sendMomentumEvents; - } - - public void setScrollEnabled(boolean scrollEnabled) { - mScrollEnabled = scrollEnabled; - } - - public void setPagingEnabled(boolean pagingEnabled) { - mPagingEnabled = pagingEnabled; - } - - public void setScrollsChildToFocus(boolean scrollsChildToFocus) { - mScrollsChildToFocus = scrollsChildToFocus; - } - - public void setDecelerationRate(float decelerationRate) { - getReactScrollViewScrollState().setDecelerationRate(decelerationRate); - - if (mScroller != null) { - mScroller.setFriction(1.0f - decelerationRate); - } - } - - public void abortAnimation() { - if (mScroller != null && !mScroller.isFinished()) { - mScroller.abortAnimation(); - } - } - - public void setSnapInterval(int snapInterval) { - mSnapInterval = snapInterval; - } - - public void setSnapOffsets(@Nullable List snapOffsets) { - mSnapOffsets = snapOffsets; - } - - public void setSnapToStart(boolean snapToStart) { - mSnapToStart = snapToStart; - } - - public void setSnapToEnd(boolean snapToEnd) { - mSnapToEnd = snapToEnd; - } - - public void setSnapToAlignment(int snapToAlignment) { - mSnapToAlignment = snapToAlignment; - } - - public void flashScrollIndicators() { - awakenScrollBars(); - } - - public int getFadingEdgeLengthStart() { - return mFadingEdgeLengthStart; - } - - public int getFadingEdgeLengthEnd() { - return mFadingEdgeLengthEnd; - } - - public void setFadingEdgeLengthStart(int start) { - mFadingEdgeLengthStart = start; - invalidate(); - } - - public void setFadingEdgeLengthEnd(int end) { - mFadingEdgeLengthEnd = end; - invalidate(); - } - - @Override - protected float getLeftFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - int value = - getLayoutDirection() == LAYOUT_DIRECTION_RTL - ? mFadingEdgeLengthEnd - : mFadingEdgeLengthStart; - return (value / max); - } - - @Override - protected float getRightFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - int value = - getLayoutDirection() == LAYOUT_DIRECTION_RTL - ? mFadingEdgeLengthStart - : mFadingEdgeLengthEnd; - return (value / max); - } - - public void setOverflow(@Nullable String overflow) { - if (overflow == null) { - mOverflow = Overflow.SCROLL; - } else { - @Nullable Overflow parsedOverflow = Overflow.fromString(overflow); - mOverflow = - parsedOverflow == null - ? (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL) - : parsedOverflow; - } - - invalidate(); - } - - public void setMaintainVisibleContentPosition( - @Nullable MaintainVisibleScrollPositionHelper.Config config) { - if (config != null && mMaintainVisibleContentPositionHelper == null) { - mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, true); - mMaintainVisibleContentPositionHelper.start(); - } else if (config == null && mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - mMaintainVisibleContentPositionHelper = null; - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.setConfig(config); - } - } - - @Override - public @Nullable String getOverflow() { - switch (mOverflow) { - case HIDDEN: - return "hidden"; - case SCROLL: - return "scroll"; - case VISIBLE: - return "visible"; - } - - return null; - } - - @Override - public void setOverflowInset(int left, int top, int right, int bottom) { - mOverflowInset.set(left, top, right, bottom); - } - - @Override - public Rect getOverflowInset() { - return mOverflowInset; - } - - @Override - public void onDraw(Canvas canvas) { - if (mOverflow != Overflow.VISIBLE) { - BackgroundStyleApplicator.clipToPaddingBox(this, canvas); - } - super.onDraw(canvas); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec); - - int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); - int measuredHeight = MeasureSpec.getSize(heightMeasureSpec); - - if (DEBUG_MODE) { - FLog.i( - TAG, - "onMeasure[%d] measured width: %d measured height: %d", - getId(), - measuredWidth, - measuredHeight); - } - - boolean measuredHeightChanged = getMeasuredHeight() != measuredHeight; - - setMeasuredDimension(measuredWidth, measuredHeight); - - // See how `mScrollXAfterMeasure` is used in `onLayout`, and why we only enable the - // hack if the height has changed. - if (measuredHeightChanged && mScroller != null) { - mScrollXAfterMeasure = mScroller.getCurrX(); - } - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - if (DEBUG_MODE) { - FLog.i(TAG, "onLayout[%d] l %d t %d r %d b %d", getId(), l, t, r, b); - } - - // Has the scrollX changed between the last onMeasure and this layout? - // If so, cancel the animation. - // Essentially, if the height changes (due to keyboard popping up, for instance) the - // underlying View.layout method will in some cases scroll to an incorrect X position - - // see also the hacks in `fling`. The order of layout is called in the order of: onMeasure, - // layout, onLayout. - // We cannot override `layout` but we can detect the sequence of events between onMeasure - // and onLayout. - if (mScrollXAfterMeasure != NO_SCROLL_POSITION - && mScroller != null - && mScrollXAfterMeasure != mScroller.getFinalX() - && !mScroller.isFinished()) { - if (DEBUG_MODE) { - FLog.i( - TAG, - "onLayout[%d] scroll hack enabled: reset to previous scrollX position of %d", - getId(), - mScrollXAfterMeasure); - } - mScroller.startScroll(mScrollXAfterMeasure, mScroller.getFinalY(), 0, 0); - mScroller.forceFinished(true); - mScrollXAfterMeasure = NO_SCROLL_POSITION; - } - - // Apply pending contentOffset in case it was set before the view was laid out. - if (isContentReady()) { - // If a "pending" content offset value has been set, we restore that value. - // Upon call to scrollTo, the "pending" values will be re-set. - int scrollToX = - mPendingContentOffsetX != UNSET_CONTENT_OFFSET ? mPendingContentOffsetX : getScrollX(); - int scrollToY = - mPendingContentOffsetY != UNSET_CONTENT_OFFSET ? mPendingContentOffsetY : getScrollY(); - scrollTo(scrollToX, scrollToY); - } - - ReactScrollViewHelper.emitLayoutEvent(this); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - /** - * Since ReactHorizontalScrollView handles layout changes on JS side, it does not call - * super.onLayout due to which mIsLayoutDirty flag in HorizontalScrollView remains true and - * prevents scrolling to child when requestChildFocus is called. Overriding this method and - * scrolling to child without checking any layout dirty flag. This will fix focus navigation issue - * for KeyEvents which are not handled in HorizontalScrollView, for example: KEYCODE_TAB. - */ - @Override - public void requestChildFocus(View child, View focused) { - if (focused != null && !mPagingEnabled && mScrollsChildToFocus) { - scrollToChild(focused); - } - requestChildFocusWithoutScroll(child, focused); - } - - /** - * In rare cases where an app overrides the built-in ReactScrollView by overriding it, and also - * needs to customize scroll into view on focus behaviors, this protected method can be used to - * unblock such customization. - */ - protected void requestChildFocusWithoutScroll(View child, View focused) { - super.requestChildFocus(child, focused); - } - - @Override - public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { - if (!mScrollsChildToFocus) { - return false; - } - return super.requestChildRectangleOnScreen(child, rectangle, immediate); - } - - @Override - public void addFocusables(ArrayList views, int direction, int focusableMode) { - if (mPagingEnabled && !mPagedArrowScrolling) { - // Only add elements within the current page to list of focusables - ArrayList candidateViews = new ArrayList(); - super.addFocusables(candidateViews, direction, focusableMode); - for (View candidate : candidateViews) { - // We must also include the currently focused in the focusables list or focus search will - // always - // return the first element within the focusables list - if (isScrolledInView(candidate) - || isPartiallyScrolledInView(candidate) - || candidate.isFocused()) { - views.add(candidate); - } - } - } else { - super.addFocusables(views, direction, focusableMode); - } - } - - /** Calculates the x delta required to scroll the given descendent into view */ - private int getScrollDelta(View descendent) { - descendent.getDrawingRect(mTempRect); - offsetDescendantRectToMyCoords(descendent, mTempRect); - return computeScrollDeltaToGetChildRectOnScreen(mTempRect); - } - - /** Returns whether the given descendent is scrolled fully in view */ - private boolean isScrolledInView(View descendent) { - return getScrollDelta(descendent) == 0; - } - - /** Returns whether the given descendent is partially scrolled in view */ - @Override - public boolean isPartiallyScrolledInView(View descendent) { - int scrollDelta = getScrollDelta(descendent); - descendent.getDrawingRect(mTempRect); - return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width(); - } - - /** Returns whether the given descendent is "mostly" (>50%) scrolled in view */ - private boolean isMostlyScrolledInView(View descendent) { - int scrollDelta = getScrollDelta(descendent); - descendent.getDrawingRect(mTempRect); - return scrollDelta != 0 && Math.abs(scrollDelta) < (mTempRect.width() / 2); - } - - private void scrollToChild(View child) { - int scrollDelta = getScrollDelta(child); - - if (scrollDelta != 0) { - scrollBy(scrollDelta, 0); - } - } - - @Override - protected void onScrollChanged(int x, int y, int oldX, int oldY) { - if (DEBUG_MODE) { - FLog.i(TAG, "onScrollChanged[%d] x %d y %d oldx %d oldy %d", getId(), x, y, oldX, oldY); - } - - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactHorizontalScrollView.onScrollChanged"); - try { - super.onScrollChanged(x, y, oldX, oldY); - - mActivelyScrolling = true; - - if (mOnScrollDispatchHelper.onScrollChanged(x, y)) { - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - ReactScrollViewHelper.updateStateOnScrollChanged( - this, - mOnScrollDispatchHelper.getXFlingVelocity(), - mOnScrollDispatchHelper.getYFlingVelocity()); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Nullable - private static HorizontalScrollView findDeepestScrollViewForMotionEvent( - View view, MotionEvent ev) { - return findDeepestScrollViewForMotionEvent(view, ev, true); - } - - @Nullable - private static HorizontalScrollView findDeepestScrollViewForMotionEvent( - View view, MotionEvent ev, boolean skipInitialView) { - if (view == null) { - return null; - } - - Rect rectOnScreen = new Rect(); - view.getGlobalVisibleRect(rectOnScreen); - if (!rectOnScreen.contains((int) ev.getRawX(), (int) ev.getRawY())) { - return null; - } - - // Only consider the current view if it's not the initial view. We check the - // current view first to bail out of recursion. Essentially if there's any - // nested horizontal scrollview with nested scrolling enabled, the parent - // scroll view shouldn't pick up the down event. - if (!skipInitialView - && view instanceof HorizontalScrollView - && ViewCompat.isNestedScrollingEnabled(view) - && (view instanceof ReactHorizontalScrollView - && ((ReactHorizontalScrollView) view).mScrollEnabled)) { - return (HorizontalScrollView) view; - } - - // First, check child views recursively before considering this view. - if (view instanceof ViewGroup) { - for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) { - HorizontalScrollView foundScrollView = - findDeepestScrollViewForMotionEvent(((ViewGroup) view).getChildAt(i), ev, false); - - if (foundScrollView != null) { - // If a deeper HorizontalScrollView is found in child views, return it. - return foundScrollView; - } - } - } - - // Return null if no matching view is found. - return null; - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - if ((ev.getAction() == MotionEvent.ACTION_DOWN) - && findDeepestScrollViewForMotionEvent(this, ev) != null) { - return false; - } - - // We intercept the touch event if the children are not supposed to receive it. - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return true; - } - - try { - if (super.onInterceptTouchEvent(ev)) { - handleInterceptedTouchEvent(ev); - return true; - } - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e); - } - - return false; - } - - protected void handleInterceptedTouchEvent(MotionEvent ev) { - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureStarted(this, ev); - } - ReactScrollViewHelper.emitScrollBeginDragEvent(this); - mDragging = true; - mEmittedOverScrollSinceScrollBegin = false; - enableFpsListener(); - getFlingAnimator().cancel(); - } - - @Override - public boolean pageScroll(int direction) { - boolean handled = super.pageScroll(direction); - - if (mPagingEnabled && handled) { - handlePostTouchScrolling(0, 0); - } - - return handled; - } - - private boolean isDescendantOf(View parent, View view) { - if (view == null || parent == null) { - return false; - } - ViewParent p = view.getParent(); - while (p != null && p.getParent() != null) { - if (p == parent) { - return true; - } - p = p.getParent(); - } - return false; - } - - @Override - public boolean arrowScroll(int direction) { - boolean handled = false; - - if (mPagingEnabled) { - mPagedArrowScrolling = true; - - if (getChildCount() > 0) { - View currentFocused = findFocus(); - View nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction); - View rootChild = getContentView(); - if (isDescendantOf(rootChild, nextFocused)) { - if (!isScrolledInView(nextFocused) && !isMostlyScrolledInView(nextFocused)) { - smoothScrollToNextPage(direction); - } - nextFocused.requestFocus(); - handled = true; - } else { - smoothScrollToNextPage(direction); - handled = true; - } - } - - mPagedArrowScrolling = false; - } else { - handled = super.arrowScroll(direction); - } - - return handled; - } - - @Override - public boolean onTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - // We do not accept the touch event if this view is not supposed to receive it. - if (!PointerEvents.canBeTouchTarget(mPointerEvents)) { - return false; - } - - mVelocityHelper.calculateVelocity(ev); - int action = ev.getActionMasked(); - if (action == MotionEvent.ACTION_UP && mDragging) { - ReactScrollViewHelper.updateFabricScrollState(this); - - float velocityX = mVelocityHelper.getXVelocity(); - float velocityY = mVelocityHelper.getYVelocity(); - ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY); - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureEnded(this, ev); - } - mDragging = false; - // After the touch finishes, we may need to do some scrolling afterwards either as a result - // of a fling or because we need to page align the content - handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)); - } - - if (action == MotionEvent.ACTION_DOWN) { - cancelPostTouchScrolling(); - } - - try { - return super.onTouchEvent(ev); - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error handling touch event.", e); - return false; - } - } - - @Override - public boolean dispatchGenericMotionEvent(MotionEvent ev) { - // Ignore generic motion events (joystick, mouse wheel, trackpad) if scrolling is disabled - if (!mScrollEnabled) { - return false; - } - - // We do not dispatch the motion event if its children are not supposed to receive it - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return false; - } - - // Handle ACTION_SCROLL events (mouse wheel, trackpad, joystick) - if (ev.getActionMasked() == MotionEvent.ACTION_SCROLL) { - float hScroll = ev.getAxisValue(MotionEvent.AXIS_HSCROLL); - if (hScroll != 0) { - // Perform the scroll - enableFpsListener(); - boolean result = super.dispatchGenericMotionEvent(ev); - // Schedule snap alignment to run after scrolling stops - if (result - && (mPagingEnabled - || mSnapInterval != 0 - || mSnapOffsets != null - || mSnapToAlignment != SNAP_ALIGNMENT_DISABLED)) { - // Cancel any pending post-touch runnable and reschedule - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - } - mPostTouchRunnable = - new Runnable() { - @Override - public void run() { - mPostTouchRunnable = null; - // +1/-1 velocity if scrolling right or left. This is to ensure that the - // next/previous page is picked rather than sliding backwards to the current page - int velocityX = (int) Math.signum(hScroll); - if (mDisableIntervalMomentum) { - velocityX = 0; - } - flingAndSnap(velocityX); - handlePostTouchScrolling(velocityX, 0); - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - handlePostTouchScrolling(0, 0); - } - return result; - } - } - - return super.dispatchGenericMotionEvent(ev); - } - - @Override - public boolean executeKeyEvent(KeyEvent event) { - int eventKeyCode = event.getKeyCode(); - if (!mScrollEnabled - && (eventKeyCode == KeyEvent.KEYCODE_DPAD_LEFT - || eventKeyCode == KeyEvent.KEYCODE_DPAD_RIGHT)) { - return false; - } - return super.executeKeyEvent(event); - } - - @Override - public void fling(int velocityX) { - if (DEBUG_MODE) { - FLog.i(TAG, "fling[%d] velocityX %d", getId(), velocityX); - } - - // Workaround. - // On Android P if a ScrollView is inverted, we will get a wrong sign for - // velocityX (see https://issuetracker.google.com/issues/112385925). - // At the same time, mOnScrollDispatchHelper tracks the correct velocity direction. - // - // Hence, we can use the absolute value from whatever the OS gives - // us and use the sign of what mOnScrollDispatchHelper has tracked. - final int correctedVelocityX = - Build.VERSION.SDK_INT == Build.VERSION_CODES.P - ? (int) (Math.abs(velocityX) * Math.signum(mOnScrollDispatchHelper.getXFlingVelocity())) - : velocityX; - - if (mPagingEnabled) { - flingAndSnap(correctedVelocityX); - } else if (mScroller != null) { - // FB SCROLLVIEW CHANGE - - // We provide our own version of fling that uses a different call to the standard OverScroller - // which takes into account the possibility of adding new content while the ScrollView is - // animating. Because we give essentially no max X for the fling, the fling will continue as - // long - // as there is content. See #onOverScrolled() to see the second part of this change which - // properly - // aborts the scroller animation when we get to the bottom of the ScrollView content. - - int scrollWindowWidth = - getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this); - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - correctedVelocityX, // velocityX - 0, // velocityY - 0, // minX - Integer.MAX_VALUE, // maxX - 0, // minY - 0, // maxY - scrollWindowWidth / 2, // overX - 0 // overY - ); - - ViewCompat.postInvalidateOnAnimation(this); - - // END FB SCROLLVIEW CHANGE - } else { - super.fling(correctedVelocityX); - } - handlePostTouchScrolling(correctedVelocityX, 0); - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.start(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - } - } - - @Override - public @Nullable View focusSearch(View focused, @FocusDirection int direction) { - View nextFocus = super.focusSearch(focused, direction); - - if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { - // If we can find the next focus and it is a child of this view, return it, else it means we - // are leaving the scroll view and we should try to find a clipped element - if (nextFocus != null && this.findViewById(nextFocus.getId()) != null) { - return nextFocus; - } - - @Nullable View nextFocusableView = findNextFocusableView(this, focused, direction); - - if (nextFocusableView != null) { - return nextFocusableView; - } - } - - return nextFocus; - } - - @Override - public void updateClippingRect() { - updateClippingRect(null); - } - - @Override - public void updateClippingRect(@Nullable Set excludedViewId) { - if (!mRemoveClippedSubviews) { - return; - } - - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactHorizontalScrollView.updateClippingRect"); - try { - Assertions.assertNotNull(mClippingRect); - - ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect); - View contentView = getContentView(); - if (contentView instanceof ReactClippingViewGroup) { - ((ReactClippingViewGroup) contentView).updateClippingRect(excludedViewId); - } - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Override - public void getClippingRect(Rect outClippingRect) { - outClippingRect.set(Assertions.assertNotNull(mClippingRect)); - } - - @Override - public boolean getChildVisibleRect(View child, Rect r, android.graphics.Point offset) { - return super.getChildVisibleRect(child, r, offset); - } - - private int getSnapInterval() { - if (mSnapInterval != 0) { - return mSnapInterval; - } - return getWidth(); - } - - private View getContentView() { - return getChildAt(0); - } - - public void setEndFillColor(int color) { - if (color != mEndFillColor) { - mEndFillColor = color; - mEndBackground = new ColorDrawable(mEndFillColor); - } - } - - @Override - protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { - if (DEBUG_MODE) { - FLog.i( - TAG, - "onOverScrolled[%d] scrollX %d scrollY %d clampedX %b clampedY %b", - getId(), - scrollX, - scrollY, - clampedX, - clampedY); - } - - if (mScroller != null) { - // FB SCROLLVIEW CHANGE - - // This is part two of the reimplementation of fling to fix the bounce-back bug. See #fling() - // for - // more information. - - if (!mScroller.isFinished() && mScroller.getCurrX() != mScroller.getFinalX()) { - int scrollRange = Math.max(computeHorizontalScrollRange() - getWidth(), 0); - if (scrollX >= scrollRange) { - mScroller.abortAnimation(); - scrollX = scrollRange; - } - } - - // END FB SCROLLVIEW CHANGE - } - - if (ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() - && clampedX - && mEmittedOverScrollSinceScrollBegin == false) { - ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f); - mEmittedOverScrollSinceScrollBegin = true; - } - - super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); - } - - @Override - public void onChildViewAdded(View parent, View child) { - mContentView = child; - mContentView.addOnLayoutChangeListener(this); - } - - @Override - public @Nullable void onChildViewRemoved(View parent, View child) { - if (mContentView != null) { - mContentView.removeOnLayoutChangeListener(this); - } - mContentView = null; - } - - private void enableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.enable(mScrollPerfTag); - } - } - - private void disableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.disable(mScrollPerfTag); - } - } - - private boolean isScrollPerfLoggingEnabled() { - return mFpsListener != null && mScrollPerfTag != null && !mScrollPerfTag.isEmpty(); - } - - @Override - public void draw(Canvas canvas) { - if (mEndFillColor != Color.TRANSPARENT) { - final View content = getContentView(); - if (mEndBackground != null && content != null && content.getRight() < getWidth()) { - mEndBackground.setBounds(content.getRight(), 0, getWidth(), getHeight()); - mEndBackground.draw(canvas); - } - } - super.draw(canvas); - } - - /** - * This handles any sort of scrolling that may occur after a touch is finished. This may be - * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we - * don't get any events from Android about this lifecycle, we do all our detection by creating a - * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. - */ - private void handlePostTouchScrolling(int velocityX, int velocityY) { - if (DEBUG_MODE) { - FLog.i( - TAG, - "handlePostTouchScrolling[%d] velocityX %d velocityY %d", - getId(), - velocityX, - velocityY); - } - - // Check if we are already handling this which may occur if this is called by both the touch up - // and a fling call - if (mPostTouchRunnable != null) { - return; - } - - if (mSendMomentumEvents) { - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY); - } - - mActivelyScrolling = false; - mPostTouchRunnable = - new Runnable() { - - private boolean mSnappingToPage = false; - private int mStableFrames = 0; - - @Override - public void run() { - if (mActivelyScrolling) { - // We are still scrolling. - mActivelyScrolling = false; - mStableFrames = 0; - ReactHorizontalScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - // There has not been a scroll update since the last time this Runnable executed. - ReactScrollViewHelper.updateFabricScrollState(ReactHorizontalScrollView.this); - - // We keep checking for updates until the ScrollView has "stabilized" and hasn't - // scrolled for N consecutive frames. This number is arbitrary: big enough to catch - // a number of race conditions, but small enough to not cause perf regressions, etc. - // In anecdotal testing, it seemed like a decent number. - // Without this check, sometimes this Runnable stops executing too soon - it will - // fire before the first scroll event of an animated scroll/fling, and stop - // immediately. - mStableFrames++; - - if (mStableFrames >= 3) { - mPostTouchRunnable = null; - if (mSendMomentumEvents) { - ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactHorizontalScrollView.this); - } - ReactScrollViewHelper.notifyUserDrivenScrollEnded_internal( - ReactHorizontalScrollView.this); - disableFpsListener(); - } else { - if (mPagingEnabled && !mSnappingToPage) { - // If we have pagingEnabled and we have not snapped to the page - // we need to cause that scroll by asking for it - mSnappingToPage = true; - flingAndSnap(0); - } - // The scrollview has not "stabilized" so we just post to check again a frame later - ReactHorizontalScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } - } - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } - - private void cancelPostTouchScrolling() { - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - getFlingAnimator().cancel(); - } - } - - private int predictFinalScrollPosition(int velocityX) { - // predict where a fling would end up so we can scroll to the nearest snap offset - final int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); - // TODO(T106335409): Existing prediction still uses overscroller. Consider change this to use - // fling animator instead. - return getFlingAnimator() == DEFAULT_FLING_ANIMATOR - ? ReactScrollViewHelper.predictFinalScrollPosition(this, velocityX, 0, maximumOffset, 0).x - : ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollX(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().x, - velocityX) - + getFlingExtrapolatedDistance(velocityX); - } - - /** - * This will smooth scroll us to the nearest snap offset point. It currently just looks at where - * the content is and slides to the nearest point. It is intended to be run after we are done - * scrolling, and handling any momentum scrolling. - */ - private void smoothScrollAndSnap(int velocity) { - if (DEBUG_MODE) { - FLog.i(TAG, "smoothScrollAndSnap[%d] velocity %d", getId(), velocity); - } - - double interval = (double) getSnapInterval(); - double currentOffset = - (double) - (ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollX(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().x, - velocity)); - double targetOffset = (double) predictFinalScrollPosition(velocity); - - int previousPage = (int) Math.floor(currentOffset / interval); - int nextPage = (int) Math.ceil(currentOffset / interval); - int currentPage = (int) Math.round(currentOffset / interval); - int targetPage = (int) Math.round(targetOffset / interval); - - if (velocity > 0 && nextPage == previousPage) { - nextPage++; - } else if (velocity < 0 && previousPage == nextPage) { - previousPage--; - } - - if ( - // if scrolling towards next page - velocity > 0 - && - // and the middle of the page hasn't been crossed already - currentPage < nextPage - && - // and it would have been crossed after flinging - targetPage > previousPage) { - currentPage = nextPage; - } else if ( - // if scrolling towards previous page - velocity < 0 - && - // and the middle of the page hasn't been crossed already - currentPage > previousPage - && - // and it would have been crossed after flinging - targetPage < nextPage) { - currentPage = previousPage; - } - - targetOffset = currentPage * interval; - if (targetOffset != currentOffset) { - mActivelyScrolling = true; - reactSmoothScrollTo((int) targetOffset, getScrollY()); - } - } - - private void flingAndSnap(int velocityX) { - if (DEBUG_MODE) { - FLog.i(TAG, "smoothScrollAndSnap[%d] velocityX %d", getId(), velocityX); - } - - if (getChildCount() <= 0) { - return; - } - - // pagingEnabled only allows snapping one interval at a time - if (mSnapInterval == 0 && mSnapOffsets == null && mSnapToAlignment == SNAP_ALIGNMENT_DISABLED) { - smoothScrollAndSnap(velocityX); - return; - } - - boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR; - int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); - int targetOffset = predictFinalScrollPosition(velocityX); - if (mDisableIntervalMomentum) { - targetOffset = getScrollX(); - } - - int smallerOffset = 0; - int largerOffset = maximumOffset; - int firstOffset = 0; - int lastOffset = maximumOffset; - int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this); - - // offsets are from the right edge in RTL layouts - if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) { - targetOffset = maximumOffset - targetOffset; - velocityX = -velocityX; - } - - // get the nearest snap points to the target offset - if (mSnapOffsets != null && !mSnapOffsets.isEmpty()) { - firstOffset = mSnapOffsets.get(0); - lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1); - - for (int i = 0; i < mSnapOffsets.size(); i++) { - int offset = mSnapOffsets.get(i); - - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset; - } - } - - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset; - } - } - } - } else if (mSnapToAlignment != SNAP_ALIGNMENT_DISABLED) { - if (mSnapInterval > 0) { - double ratio = (double) targetOffset / mSnapInterval; - smallerOffset = - Math.max( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.floor(ratio) * mSnapInterval), - mSnapInterval, - width), - 0); - largerOffset = - Math.min( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.ceil(ratio) * mSnapInterval), - mSnapInterval, - width), - maximumOffset); - } else { - ViewGroup contentView = (ViewGroup) getContentView(); - int smallerChildOffset = largerOffset; - int largerChildOffset = smallerOffset; - for (int i = 0; i < contentView.getChildCount(); i++) { - View item = contentView.getChildAt(i); - int itemStartOffset = - getItemStartOffset(mSnapToAlignment, item.getLeft(), item.getWidth(), width); - if (itemStartOffset <= targetOffset) { - if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { - smallerOffset = itemStartOffset; - } - } - - if (itemStartOffset >= targetOffset) { - if (itemStartOffset - targetOffset < largerOffset - targetOffset) { - largerOffset = itemStartOffset; - } - } - - smallerChildOffset = Math.min(smallerChildOffset, itemStartOffset); - largerChildOffset = Math.max(largerChildOffset, itemStartOffset); - } - - // For Recycler ViewGroup, the maximumOffset can be much larger than the total heights of - // items in the layout. In this case snapping is not possible beyond the currently rendered - // children. - smallerOffset = Math.max(smallerOffset, smallerChildOffset); - largerOffset = Math.min(largerOffset, largerChildOffset); - } - } else { - double interval = getSnapInterval(); - double ratio = (double) targetOffset / interval; - smallerOffset = (int) (Math.floor(ratio) * interval); - largerOffset = Math.min((int) (Math.ceil(ratio) * interval), maximumOffset); - } - - // Calculate the nearest offset - int nearestOffset = - Math.abs(targetOffset - smallerOffset) < Math.abs(largerOffset - targetOffset) - ? smallerOffset - : largerOffset; - - // if scrolling after the last snap offset and snapping to the - // end of the list is disabled, then we allow free scrolling - int currentOffset = getScrollX(); - if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) { - currentOffset = maximumOffset - currentOffset; - } - if (!mSnapToEnd && targetOffset >= lastOffset) { - if (currentOffset >= lastOffset) { - // free scrolling - } else { - // snap to end - targetOffset = lastOffset; - } - } else if (!mSnapToStart && targetOffset <= firstOffset) { - if (currentOffset <= firstOffset) { - // free scrolling - } else { - // snap to beginning - targetOffset = firstOffset; - } - } else if (velocityX > 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityX += (int) ((largerOffset - targetOffset) * 10.0); - } - - targetOffset = largerOffset; - } else if (velocityX < 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityX -= (int) ((targetOffset - smallerOffset) * 10.0); - } - - targetOffset = smallerOffset; - } else { - targetOffset = nearestOffset; - } - - // Make sure the new offset isn't out of bounds - targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset); - - if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) { - targetOffset = maximumOffset - targetOffset; - velocityX = -velocityX; - } - - if (hasCustomizedFlingAnimator || mScroller == null) { - reactSmoothScrollTo(targetOffset, getScrollY()); - } else { - // smoothScrollTo will always scroll over 250ms which is often *waaay* - // too short and will cause the scrolling to feel almost instant - // try to manually interact with OverScroller instead - // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo - mActivelyScrolling = true; - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - // velocity = 0 doesn't work with fling() so we pretend there's a reasonable - // initial velocity going on when a touch is released without any movement - velocityX != 0 ? velocityX : targetOffset - getScrollX(), // velocityX - 0, // velocityY - // setting both minX and maxX to the same value will guarantee that we scroll to it - // but using the standard fling-style easing rather than smoothScrollTo's 250ms animation - targetOffset, // minX - targetOffset, // maxX - 0, // minY - 0, // maxY - // we only want to allow overscrolling if the final offset is at the very edge of the view - (targetOffset == 0 || targetOffset == maximumOffset) ? width / 2 : 0, // overX - 0 // overY - ); - - postInvalidateOnAnimation(); - } - } - - private int getItemStartOffset( - int snapToAlignment, int itemStartPosition, int itemWidth, int viewPortWidth) { - int itemStartOffset; - switch (snapToAlignment) { - case SNAP_ALIGNMENT_CENTER: - itemStartOffset = itemStartPosition - (viewPortWidth - itemWidth) / 2; - break; - case SNAP_ALIGNMENT_START: - itemStartOffset = itemStartPosition; - break; - case SNAP_ALIGNMENT_END: - itemStartOffset = itemStartPosition - (viewPortWidth - itemWidth); - break; - default: - throw new IllegalStateException("Invalid SnapToAlignment value: " + mSnapToAlignment); - } - return itemStartOffset; - } - - private void smoothScrollToNextPage(int direction) { - if (DEBUG_MODE) { - FLog.i(TAG, "smoothScrollToNextPage[%d] direction %d", getId(), direction); - } - - int width = getWidth(); - int currentX = getScrollX(); - - int page = currentX / width; - if (currentX % width != 0) { - page++; - } - - if (direction == View.FOCUS_LEFT) { - page = page - 1; - } else { - page = page + 1; - } - - if (page < 0) { - page = 0; - } - - reactSmoothScrollTo(page * width, getScrollY()); - handlePostTouchScrolling(0, 0); - } - - @Override - public void setBackgroundColor(int color) { - BackgroundStyleApplicator.setBackgroundColor(this, color); - } - - public void setBorderWidth(int position, float width) { - BackgroundStyleApplicator.setBorderWidth( - this, LogicalEdge.values()[position], PixelUtil.toDIPFromPixel(width)); - } - - public void setBorderColor(int position, @Nullable Integer color) { - BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.values()[position], color); - } - - public void setBorderRadius(float borderRadius) { - setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal()); - } - - public void setBorderRadius(float borderRadius, int position) { - @Nullable - LengthPercentage radius = - Float.isNaN(borderRadius) - ? null - : new LengthPercentage( - PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT); - BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.values()[position], radius); - } - - public void setBorderStyle(@Nullable String style) { - BackgroundStyleApplicator.setBorderStyle( - this, style == null ? null : BorderStyle.fromString(style)); - } - - /** - * Calls `smoothScrollTo` and updates state. - * - *

`smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. - */ - @Override - public void reactSmoothScrollTo(int x, int y) { - ReactScrollViewHelper.smoothScrollTo(this, x, y); - setPendingContentOffsets(x, y); - } - - /** - * Calls `super.scrollTo` and updates state. - * - *

`super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. - * - *

Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it - * is final. See `reactSmoothScrollTo`. - */ - @Override - public void scrollTo(int x, int y) { - if (DEBUG_MODE) { - FLog.i(TAG, "scrollTo[%d] x %d y %d", getId(), x, y); - } - - super.scrollTo(x, y); - ReactScrollViewHelper.updateFabricScrollState(this); - setPendingContentOffsets(x, y); - } - - /** Scrolls to a new position preserving any momentum scrolling animation. */ - @Override - public void scrollToPreservingMomentum(int x, int y) { - scrollTo(x, y); - recreateFlingAnimation(x, Integer.MAX_VALUE); - } - - private boolean isContentReady() { - View child = getContentView(); - return child != null && child.getWidth() != 0 && child.getHeight() != 0; - } - - /** - * If contentOffset is set before the View has been laid out, store the values and set them when - * `onLayout` is called. - * - * @param x - * @param y - */ - private void setPendingContentOffsets(int x, int y) { - if (DEBUG_MODE) { - FLog.i(TAG, "setPendingContentOffsets[%d] x %d y %d", getId(), x, y); - } - - if (isContentReady()) { - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - } else { - mPendingContentOffsetX = x; - mPendingContentOffsetY = y; - } - } - - @Override - public void onLayoutChange( - View v, - int left, - int top, - int right, - int bottom, - int oldLeft, - int oldTop, - int oldRight, - int oldBottom) { - if (mContentView == null) { - return; - } - - // Adjust the scroll position to follow new content. In RTL, this means we keep a constant - // offset from the right edge instead of the left edge, so content added to the end of the flow - // does not shift layout. If `maintainVisibleContentPosition` is enabled, we try to adjust - // position so that the viewport keeps the same insets to previously visible views. TODO: MVCP - // does not work in RTL. - if (v.getLayoutDirection() == LAYOUT_DIRECTION_RTL) { - adjustPositionForContentChangeRTL(left, right, oldLeft, oldRight); - } - ReactScrollViewHelper.emitLayoutChangeEvent(this); - } - - /** - * If we are in the middle of a fling animation from the user removing their finger (OverScroller - * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against - * outdated scroll offsets. - */ - private void recreateFlingAnimation(int scrollX, int maxX) { - // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap - // point), cancel them. - // TODO: Can we be more graceful (like OverScroller flings)? - if (getFlingAnimator().isRunning()) { - getFlingAnimator().cancel(); - } - - if (mScroller != null && !mScroller.isFinished()) { - // Calculate the velocity and position of the fling animation at the time of this layout - // event, which may be later than the last ScrollView tick. These values are not committed to - // the underlying ScrollView, which will recalculate positions on its next tick. - int scrollerXBeforeTick = mScroller.getCurrX(); - boolean hasMoreTicks = mScroller.computeScrollOffset(); - - // Stop the existing animation at the current state of the scroller. We will then recreate - // it starting at the adjusted x offset. - mScroller.forceFinished(true); - - if (hasMoreTicks) { - // OverScroller.getCurrVelocity() returns an absolute value of the velocity a current fling - // animation (only FLING_MODE animations). We derive direction along the X axis from the - // start and end of the, animation assuming HorizontalScrollView never fires vertical fling - // animations. - // TODO: This does not fully handle overscroll. - float direction = Math.signum(mScroller.getFinalX() - mScroller.getStartX()); - float flingVelocityX = mScroller.getCurrVelocity() * direction; - - mScroller.fling(scrollX, getScrollY(), (int) flingVelocityX, 0, 0, maxX, 0, 0); - } else { - scrollTo(scrollX + (mScroller.getCurrX() - scrollerXBeforeTick), getScrollY()); - } - } - } - - private void adjustPositionForContentChangeRTL(int left, int right, int oldLeft, int oldRight) { - // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap - // point), finish them, committing the final `scrollX`. - // TODO: Can we be more graceful (like OverScroller flings)? - if (getFlingAnimator().isRunning()) { - getFlingAnimator().end(); - } - - int distanceToRightEdge = oldRight - getScrollX(); - int newWidth = right - left; - int scrollX = newWidth - distanceToRightEdge; - scrollTo(scrollX, getScrollY()); - - recreateFlingAnimation(scrollX, newWidth - getWidth()); - } - - @Nullable - @Override - public StateWrapper getStateWrapper() { - return mStateWrapper; - } - - public void setStateWrapper(StateWrapper stateWrapper) { - mStateWrapper = stateWrapper; - } - - @Override - public void setReactScrollViewScrollState(ReactScrollViewScrollState scrollState) { - mReactScrollViewScrollState = scrollState; - if (ReactNativeFeatureFlags.enableViewCulling() - || ReactNativeFeatureFlags.useTraitHiddenOnAndroid()) { - Point scrollPosition = scrollState.getLastStateUpdateScroll(); - restoreScrollTo(scrollPosition.x, scrollPosition.y); - } - } - - protected void restoreScrollTo(int x, int y) { - scrollTo(x, y); - } - - @Override - public ReactScrollViewScrollState getReactScrollViewScrollState() { - return mReactScrollViewScrollState; - } - - @Override - public void startFlingAnimator(int start, int end) { - // Always cancel existing animator before starting the new one. `smoothScrollTo` contains some - // logic that, if called multiple times in a short amount of time, will treat all calls as part - // of the same animation and will not lengthen the duration of the animation. This means that, - // for example, if the user is scrolling rapidly, multiple pages could be considered part of one - // animation, causing some page animations to be animated very rapidly - looking like they're - // not animated at all. - DEFAULT_FLING_ANIMATOR.cancel(); - - // Update the fling animator with new values - int duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()); - DEFAULT_FLING_ANIMATOR.setDuration(duration).setIntValues(start, end); - - // Start the animator - DEFAULT_FLING_ANIMATOR.start(); - - if (mSendMomentumEvents) { - int xVelocity = 0; - if (duration > 0) { - xVelocity = (end - start) / duration; - } - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, xVelocity, 0); - ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this); - } - } - - @Override - public ValueAnimator getFlingAnimator() { - return DEFAULT_FLING_ANIMATOR; - } - - @Override - public int getFlingExtrapolatedDistance(int velocityX) { - // The DEFAULT_FLING_ANIMATOR uses AccelerateDecelerateInterpolator, which is not depending on - // the init velocity. We use the overscroller to decide the fling distance. - return ReactScrollViewHelper.predictFinalScrollPosition( - this, velocityX, 0, Math.max(0, computeHorizontalScrollRange() - getWidth()), 0) - .x; - } - - public void setPointerEvents(PointerEvents pointerEvents) { - mPointerEvents = pointerEvents; - } - - public PointerEvents getPointerEvents() { - return mPointerEvents; - } - - @Override - public void setScrollEventThrottle(int scrollEventThrottle) { - mScrollEventThrottle = scrollEventThrottle; - } - - @Override - public int getScrollEventThrottle() { - return mScrollEventThrottle; - } - - @Override - public void setLastScrollDispatchTime(long lastScrollDispatchTime) { - mLastScrollDispatchTime = lastScrollDispatchTime; - } - - @Override - public long getLastScrollDispatchTime() { - return mLastScrollDispatchTime; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt new file mode 100644 index 000000000000..17f7fefbf35b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -0,0 +1,1625 @@ +/* + * 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.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Point +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.FocusFinder +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewParent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.HorizontalScrollView +import android.widget.OverScroller +import androidx.core.view.ViewCompat +import androidx.core.view.ViewCompat.FocusDirection +import com.facebook.common.logging.FLog +import com.facebook.react.R +import com.facebook.react.common.ReactConstants +import com.facebook.react.common.build.ReactBuildConfig +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.MeasureSpecAssertions +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactClippingViewGroup +import com.facebook.react.uimanager.ReactClippingViewGroupHelper +import com.facebook.react.uimanager.ReactOverflowViewWithInset +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.events.NativeGestureUtil +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle +import com.facebook.react.uimanager.style.LogicalEdge +import com.facebook.react.uimanager.style.Overflow +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START +import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.systrace.Systrace +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.round +import kotlin.math.sign + +/** Similar to [ReactScrollView] but only supports horizontal scrolling. */ +public open class ReactHorizontalScrollView +@JvmOverloads +constructor(context: Context, private val fpsListener: FpsListener? = null) : + HorizontalScrollView(context), + ReactClippingViewGroup, + ViewGroup.OnHierarchyChangeListener, + View.OnLayoutChangeListener, + ReactAccessibleScrollView, + ReactOverflowViewWithInset, + HasScrollState, + HasStateWrapper, + HasFlingAnimator, + HasScrollEventThrottle, + HasSmoothScroll, + VirtualViewContainer { + + private companion object { + private val DEBUG_MODE = false && ReactBuildConfig.DEBUG + private val TAG = ReactHorizontalScrollView::class.java.simpleName + + private const val NO_SCROLL_POSITION = Int.MIN_VALUE + private const val UNSET_CONTENT_OFFSET = -1 + + private var scrollerField: java.lang.reflect.Field? = null + private var triedToGetScrollerField = false + + private fun findDeepestScrollViewForMotionEvent( + view: View, + ev: MotionEvent, + ): HorizontalScrollView? = findDeepestScrollViewForMotionEvent(view, ev, true) + + private fun findDeepestScrollViewForMotionEvent( + view: View?, + ev: MotionEvent, + skipInitialView: Boolean, + ): HorizontalScrollView? { + if (view == null) return null + + val rectOnScreen = Rect() + view.getGlobalVisibleRect(rectOnScreen) + if (!rectOnScreen.contains(ev.rawX.toInt(), ev.rawY.toInt())) { + return null + } + + if ( + !skipInitialView && + view is HorizontalScrollView && + ViewCompat.isNestedScrollingEnabled(view) && + (view is ReactHorizontalScrollView && view.scrollEnabled) + ) { + return view + } + + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val foundScrollView = findDeepestScrollViewForMotionEvent(view.getChildAt(i), ev, false) + if (foundScrollView != null) { + return foundScrollView + } + } + } + + return null + } + } + + private var scrollXAfterMeasure = NO_SCROLL_POSITION + + private val onScrollDispatchHelper = OnScrollDispatchHelper() + private val scroller: OverScroller? = getOverScrollerFromParent() + private val velocityHelper = VelocityHelper() + private val tempRect = Rect() + private val defaultFlingAnimator: ValueAnimator = ObjectAnimator.ofInt(this, "scrollX", 0, 0) + + // Backing fields for interface properties with custom logic + private var _overflowInset = Rect() + private var _virtualViewContainerState: VirtualViewContainerState? = null + private var _removeClippedSubviews = false + private var _reactScrollViewScrollState = ReactScrollViewScrollState() + + // Private state + private var activelyScrolling = false + private var clippingRect: Rect? = null + private var _overflow: Overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) Overflow.VISIBLE + else Overflow.SCROLL + private var dragging = false + private var pagingEnabled = false + private var postTouchRunnable: Runnable? = null + private var sendMomentumEvents = false + private var scrollPerfTag: String? = null + private var endBackground: Drawable? = null + private var endFillColor = Color.TRANSPARENT + private var disableIntervalMomentum = false + private var snapInterval = 0 + private var snapOffsets: List? = null + private var snapToStart = true + private var snapToEnd = true + private var snapToAlignment = SNAP_ALIGNMENT_DISABLED + private var pagedArrowScrolling = false + private var pendingContentOffsetX = UNSET_CONTENT_OFFSET + private var pendingContentOffsetY = UNSET_CONTENT_OFFSET + public open var pointerEvents: PointerEvents = PointerEvents.AUTO + private var contentView: View? = null + private var maintainVisibleContentPositionHelper: + MaintainVisibleScrollPositionHelper? = + null + public open var fadingEdgeLengthStart: Int = 0 + set(value) { + field = value + invalidate() + } + + public open var fadingEdgeLengthEnd: Int = 0 + set(value) { + field = value + invalidate() + } + + private var emittedOverScrollSinceScrollBegin = false + private var scrollsChildToFocus = true + + // Interface property overrides + override var scrollEnabled: Boolean = true + override var stateWrapper: StateWrapper? = null + override var scrollEventThrottle: Int = 0 + override var lastScrollDispatchTime: Long = 0L + + override val virtualViewContainerState: VirtualViewContainerState + get() = + _virtualViewContainerState + ?: VirtualViewContainerState.create(this).also { _virtualViewContainerState = it } + + override val overflowInset: Rect + get() = _overflowInset + + override val overflow: String? + get() = + when (_overflow) { + Overflow.HIDDEN -> "hidden" + Overflow.SCROLL -> "scroll" + Overflow.VISIBLE -> "visible" + } + + override var removeClippedSubviews: Boolean + get() = _removeClippedSubviews + set(value) { + if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) return + if (value && clippingRect == null) clippingRect = Rect() + _removeClippedSubviews = value + updateClippingRect() + } + + override var reactScrollViewScrollState: ReactScrollViewScrollState + get() = _reactScrollViewScrollState + set(value) { + _reactScrollViewScrollState = value + if ( + ReactNativeFeatureFlags.enableViewCulling() || + ReactNativeFeatureFlags.useTraitHiddenOnAndroid() + ) { + val scrollPosition = value.lastStateUpdateScroll + restoreScrollTo(scrollPosition.x, scrollPosition.y) + } + } + + init { + ViewCompat.setAccessibilityDelegate(this, ReactScrollViewAccessibilityDelegate()) + setOnHierarchyChangeListener(this) + setClipChildren(false) + initView() + } + + /** + * Set all default values here as opposed to in the constructor or field defaults. It is important + * that these properties are set during the constructor, but also on-demand whenever an existing + * ReactHorizontalScrollView is recycled. + */ + private fun initView() { + _overflowInset = Rect() + _virtualViewContainerState = null + activelyScrolling = false + clippingRect = null + _overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) Overflow.VISIBLE + else Overflow.SCROLL + dragging = false + pagingEnabled = false + postTouchRunnable = null + _removeClippedSubviews = false + scrollEnabled = true + sendMomentumEvents = false + scrollPerfTag = null + endBackground = null + endFillColor = Color.TRANSPARENT + disableIntervalMomentum = false + snapInterval = 0 + snapOffsets = null + snapToStart = true + snapToEnd = true + snapToAlignment = SNAP_ALIGNMENT_DISABLED + pagedArrowScrolling = false + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + stateWrapper = null + _reactScrollViewScrollState = ReactScrollViewScrollState() + pointerEvents = PointerEvents.AUTO + lastScrollDispatchTime = 0 + scrollEventThrottle = 0 + contentView = null + maintainVisibleContentPositionHelper = null + fadingEdgeLengthStart = 0 + fadingEdgeLengthEnd = 0 + emittedOverScrollSinceScrollBegin = false + scrollsChildToFocus = true + } + + internal fun recycleView() { + initView() + if (parent != null) { + (parent as ViewGroup).removeView(this) + } + updateView() + } + + private fun updateView() {} + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + val testId = getTag(R.id.react_test_id) as? String + if (testId != null) { + info.viewIdResourceName = testId + } + } + + override fun canScrollHorizontally(direction: Int): Boolean { + return scrollEnabled && super.canScrollHorizontally(direction) + } + + private fun getOverScrollerFromParent(): OverScroller? { + if (!triedToGetScrollerField) { + triedToGetScrollerField = true + try { + scrollerField = HorizontalScrollView::class.java.getDeclaredField("mScroller") + scrollerField?.isAccessible = true + } catch (e: NoSuchFieldException) { + FLog.w( + TAG, + "Failed to get mScroller field for HorizontalScrollView! " + + "This app will exhibit the bounce-back scrolling bug :(", + ) + } + } + + val field = scrollerField ?: return null + return try { + val scrollerValue = field.get(this) + if (scrollerValue is OverScroller) { + scrollerValue + } else { + FLog.w( + TAG, + "Failed to cast mScroller field in HorizontalScrollView (probably due to OEM changes" + + " to AOSP)! This app will exhibit the bounce-back scrolling bug :(", + ) + null + } + } catch (e: IllegalAccessException) { + throw RuntimeException("Failed to get mScroller from HorizontalScrollView!", e) + } + } + + public open fun setScrollPerfTag(scrollPerfTag: String?) { + this.scrollPerfTag = scrollPerfTag + } + + public open fun setDisableIntervalMomentum(disableIntervalMomentum: Boolean) { + this.disableIntervalMomentum = disableIntervalMomentum + } + + public open fun setSendMomentumEvents(sendMomentumEvents: Boolean) { + this.sendMomentumEvents = sendMomentumEvents + } + + public open fun setPagingEnabled(pagingEnabled: Boolean) { + this.pagingEnabled = pagingEnabled + } + + public open fun setScrollsChildToFocus(scrollsChildToFocus: Boolean) { + this.scrollsChildToFocus = scrollsChildToFocus + } + + public open fun setDecelerationRate(decelerationRate: Float) { + reactScrollViewScrollState.decelerationRate = decelerationRate + scroller?.setFriction(1.0f - decelerationRate) + } + + public open fun abortAnimation() { + if (scroller != null && !scroller.isFinished) { + scroller.abortAnimation() + } + } + + public open fun setSnapInterval(snapInterval: Int) { + this.snapInterval = snapInterval + } + + public open fun setSnapOffsets(snapOffsets: List?) { + this.snapOffsets = snapOffsets + } + + public open fun setSnapToStart(snapToStart: Boolean) { + this.snapToStart = snapToStart + } + + public open fun setSnapToEnd(snapToEnd: Boolean) { + this.snapToEnd = snapToEnd + } + + public open fun setSnapToAlignment(snapToAlignment: Int) { + this.snapToAlignment = snapToAlignment + } + + public open fun flashScrollIndicators() { + awakenScrollBars() + } + + override fun getLeftFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + val value = + if (layoutDirection == LAYOUT_DIRECTION_RTL) fadingEdgeLengthEnd else fadingEdgeLengthStart + return value / max + } + + override fun getRightFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + val value = + if (layoutDirection == LAYOUT_DIRECTION_RTL) fadingEdgeLengthStart else fadingEdgeLengthEnd + return value / max + } + + public open fun setOverflow(overflow: String?) { + _overflow = + if (overflow == null) { + Overflow.SCROLL + } else { + Overflow.fromString(overflow) + ?: if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) + Overflow.VISIBLE + else Overflow.SCROLL + } + invalidate() + } + + public open fun setMaintainVisibleContentPosition( + config: MaintainVisibleScrollPositionHelper.Config? + ) { + if (config != null && maintainVisibleContentPositionHelper == null) { + maintainVisibleContentPositionHelper = MaintainVisibleScrollPositionHelper(this, true) + maintainVisibleContentPositionHelper!!.start() + } else if (config == null && maintainVisibleContentPositionHelper != null) { + maintainVisibleContentPositionHelper!!.stop() + maintainVisibleContentPositionHelper = null + } + maintainVisibleContentPositionHelper?.let { it.config = config } + } + + override fun setOverflowInset(left: Int, top: Int, right: Int, bottom: Int) { + _overflowInset.set(left, top, right, bottom) + } + + public override fun onDraw(canvas: Canvas) { + if (_overflow != Overflow.VISIBLE) { + BackgroundStyleApplicator.clipToPaddingBox(this, canvas) + } + super.onDraw(canvas) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec) + + val measuredWidth = MeasureSpec.getSize(widthMeasureSpec) + val measuredHeight = MeasureSpec.getSize(heightMeasureSpec) + + if (DEBUG_MODE) { + FLog.i( + TAG, + "onMeasure[%d] measured width: %d measured height: %d", + id, + measuredWidth, + measuredHeight, + ) + } + + val measuredHeightChanged = getMeasuredHeight() != measuredHeight + + setMeasuredDimension(measuredWidth, measuredHeight) + + // See how `scrollXAfterMeasure` is used in `onLayout`, and why we only enable the + // hack if the height has changed. + if (measuredHeightChanged && scroller != null) { + scrollXAfterMeasure = scroller.currX + } + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "onLayout[%d] l %d t %d r %d b %d", id, l, t, r, b) + } + + // Has the scrollX changed between the last onMeasure and this layout? + // If so, cancel the animation. + // Essentially, if the height changes (due to keyboard popping up, for instance) the + // underlying View.layout method will in some cases scroll to an incorrect X position - + // see also the hacks in `fling`. The order of layout is called in the order of: onMeasure, + // layout, onLayout. + // We cannot override `layout` but we can detect the sequence of events between onMeasure + // and onLayout. + if ( + scrollXAfterMeasure != NO_SCROLL_POSITION && + scroller != null && + scrollXAfterMeasure != scroller.finalX && + !scroller.isFinished + ) { + if (DEBUG_MODE) { + FLog.i( + TAG, + "onLayout[%d] scroll hack enabled: reset to previous scrollX position of %d", + id, + scrollXAfterMeasure, + ) + } + scroller.startScroll(scrollXAfterMeasure, scroller.finalY, 0, 0) + scroller.forceFinished(true) + scrollXAfterMeasure = NO_SCROLL_POSITION + } + + // Apply pending contentOffset in case it was set before the view was laid out. + if (isContentReady()) { + val scrollToX = + if (pendingContentOffsetX != UNSET_CONTENT_OFFSET) pendingContentOffsetX else scrollX + val scrollToY = + if (pendingContentOffsetY != UNSET_CONTENT_OFFSET) pendingContentOffsetY else scrollY + scrollTo(scrollToX, scrollToY) + } + + ReactScrollViewHelper.emitLayoutEvent(this) + _virtualViewContainerState?.updateState() + } + + /** + * Since ReactHorizontalScrollView handles layout changes on JS side, it does not call + * super.onLayout due to which mIsLayoutDirty flag in HorizontalScrollView remains true and + * prevents scrolling to child when requestChildFocus is called. Overriding this method and + * scrolling to child without checking any layout dirty flag. This will fix focus navigation issue + * for KeyEvents which are not handled in HorizontalScrollView, for example: KEYCODE_TAB. + */ + override fun requestChildFocus(child: View, focused: View?) { + if (focused != null && !pagingEnabled && scrollsChildToFocus) { + scrollToChild(focused) + } + requestChildFocusWithoutScroll(child, focused) + } + + /** + * In rare cases where an app overrides the built-in ReactScrollView by overriding it, and also + * needs to customize scroll into view on focus behaviors, this protected method can be used to + * unblock such customization. + */ + protected open fun requestChildFocusWithoutScroll(child: View, focused: View?) { + super.requestChildFocus(child, focused) + } + + override fun requestChildRectangleOnScreen( + child: View, + rectangle: Rect, + immediate: Boolean, + ): Boolean { + if (!scrollsChildToFocus) return false + return super.requestChildRectangleOnScreen(child, rectangle, immediate) + } + + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + if (pagingEnabled && !pagedArrowScrolling) { + // Only add elements within the current page to list of focusables + val candidateViews = ArrayList() + super.addFocusables(candidateViews, direction, focusableMode) + for (candidate in candidateViews) { + // We must also include the currently focused in the focusables list or focus search will + // always return the first element within the focusables list + if ( + isScrolledInView(candidate) || + isPartiallyScrolledInView(candidate) || + candidate.isFocused + ) { + views.add(candidate) + } + } + } else { + super.addFocusables(views, direction, focusableMode) + } + } + + /** Calculates the x delta required to scroll the given descendent into view */ + private fun getScrollDelta(descendent: View): Int { + descendent.getDrawingRect(tempRect) + offsetDescendantRectToMyCoords(descendent, tempRect) + return computeScrollDeltaToGetChildRectOnScreen(tempRect) + } + + /** Returns whether the given descendent is scrolled fully in view */ + private fun isScrolledInView(descendent: View): Boolean { + return getScrollDelta(descendent) == 0 + } + + override fun isPartiallyScrolledInView(view: View): Boolean { + val scrollDelta = getScrollDelta(view) + view.getDrawingRect(tempRect) + return scrollDelta != 0 && abs(scrollDelta) < tempRect.width() + } + + /** Returns whether the given descendent is "mostly" (>50%) scrolled in view */ + private fun isMostlyScrolledInView(descendent: View): Boolean { + val scrollDelta = getScrollDelta(descendent) + descendent.getDrawingRect(tempRect) + return scrollDelta != 0 && abs(scrollDelta) < (tempRect.width() / 2) + } + + private fun scrollToChild(child: View) { + val scrollDelta = getScrollDelta(child) + if (scrollDelta != 0) { + scrollBy(scrollDelta, 0) + } + } + + override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "onScrollChanged[%d] x %d y %d oldx %d oldy %d", id, x, y, oldX, oldY) + } + + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactHorizontalScrollView.onScrollChanged") + try { + super.onScrollChanged(x, y, oldX, oldY) + activelyScrolling = true + if (onScrollDispatchHelper.onScrollChanged(x, y)) { + if (_removeClippedSubviews) { + updateClippingRect() + } + ReactScrollViewHelper.updateStateOnScrollChanged( + this, + onScrollDispatchHelper.xFlingVelocity, + onScrollDispatchHelper.yFlingVelocity, + ) + _virtualViewContainerState?.updateState() + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + + if ( + ev.action == MotionEvent.ACTION_DOWN && + findDeepestScrollViewForMotionEvent(this, ev) != null + ) { + return false + } + + // We intercept the touch event if the children are not supposed to receive it. + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return true + + try { + if (super.onInterceptTouchEvent(ev)) { + handleInterceptedTouchEvent(ev) + return true + } + } catch (e: IllegalArgumentException) { + // Log and ignore the error. This seems to be a bug in the android SDK and + // this is the commonly accepted workaround. + // https://tinyurl.com/mw6qkod (Stack Overflow) + FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e) + } + + return false + } + + protected open fun handleInterceptedTouchEvent(ev: MotionEvent) { + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureStarted(this, ev) + } + ReactScrollViewHelper.emitScrollBeginDragEvent(this) + dragging = true + emittedOverScrollSinceScrollBegin = false + enableFpsListener() + getFlingAnimator().cancel() + } + + override fun pageScroll(direction: Int): Boolean { + val handled = super.pageScroll(direction) + if (pagingEnabled && handled) { + handlePostTouchScrolling(0, 0) + } + return handled + } + + private fun isDescendantOf(parent: View?, view: View?): Boolean { + if (view == null || parent == null) return false + var p: ViewParent? = view.parent + while (p != null && p.parent != null) { + if (p === parent) return true + p = p.parent + } + return false + } + + override fun arrowScroll(direction: Int): Boolean { + var handled = false + + if (pagingEnabled) { + pagedArrowScrolling = true + + if (childCount > 0) { + val currentFocused = findFocus() + val nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction) + val rootChild = getContentView() + if (nextFocused != null && isDescendantOf(rootChild, nextFocused)) { + if (!isScrolledInView(nextFocused) && !isMostlyScrolledInView(nextFocused)) { + smoothScrollToNextPage(direction) + } + nextFocused.requestFocus() + handled = true + } else { + smoothScrollToNextPage(direction) + handled = true + } + } + + pagedArrowScrolling = false + } else { + handled = super.arrowScroll(direction) + } + + return handled + } + + override fun onTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + + // We do not accept the touch event if this view is not supposed to receive it. + if (!PointerEvents.canBeTouchTarget(pointerEvents)) return false + + velocityHelper.calculateVelocity(ev) + val action = ev.actionMasked + if (action == MotionEvent.ACTION_UP && dragging) { + ReactScrollViewHelper.updateFabricScrollState(this) + + val velocityX = velocityHelper.xVelocity + val velocityY = velocityHelper.yVelocity + ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY) + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureEnded(this, ev) + } + dragging = false + // After the touch finishes, we may need to do some scrolling afterwards either as a result + // of a fling or because we need to page align the content + handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)) + } + + if (action == MotionEvent.ACTION_DOWN) { + cancelPostTouchScrolling() + } + + return try { + super.onTouchEvent(ev) + } catch (e: IllegalArgumentException) { + // Log and ignore the error. This seems to be a bug in the android SDK and + // this is the commonly accepted workaround. + // https://tinyurl.com/mw6qkod (Stack Overflow) + FLog.w(ReactConstants.TAG, "Error handling touch event.", e) + false + } + } + + override fun dispatchGenericMotionEvent(ev: MotionEvent): Boolean { + // Ignore generic motion events (joystick, mouse wheel, trackpad) if scrolling is disabled + if (!scrollEnabled) return false + + // We do not dispatch the motion event if its children are not supposed to receive it + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return false + + // Handle ACTION_SCROLL events (mouse wheel, trackpad, joystick) + if (ev.actionMasked == MotionEvent.ACTION_SCROLL) { + val hScroll = ev.getAxisValue(MotionEvent.AXIS_HSCROLL) + if (hScroll != 0f) { + // Perform the scroll + enableFpsListener() + val result = super.dispatchGenericMotionEvent(ev) + // Schedule snap alignment to run after scrolling stops + if ( + result && + (pagingEnabled || + snapInterval != 0 || + snapOffsets != null || + snapToAlignment != SNAP_ALIGNMENT_DISABLED) + ) { + // Cancel any pending post-touch runnable and reschedule + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + } + postTouchRunnable = Runnable { + postTouchRunnable = null + // +1/-1 velocity if scrolling right or left. This is to ensure that the + // next/previous page is picked rather than sliding backwards to the current page + var velocityX = hScroll.sign.toInt() + if (disableIntervalMomentum) { + velocityX = 0 + } + flingAndSnap(velocityX) + handlePostTouchScrolling(velocityX, 0) + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } else { + handlePostTouchScrolling(0, 0) + } + return result + } + } + + return super.dispatchGenericMotionEvent(ev) + } + + override fun executeKeyEvent(event: KeyEvent): Boolean { + val eventKeyCode = event.keyCode + if ( + !scrollEnabled && + (eventKeyCode == KeyEvent.KEYCODE_DPAD_LEFT || + eventKeyCode == KeyEvent.KEYCODE_DPAD_RIGHT) + ) { + return false + } + return super.executeKeyEvent(event) + } + + override fun fling(velocityX: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "fling[%d] velocityX %d", id, velocityX) + } + + // Workaround. + // On Android P if a ScrollView is inverted, we will get a wrong sign for + // velocityX (see https://issuetracker.google.com/issues/112385925). + // At the same time, onScrollDispatchHelper tracks the correct velocity direction. + // + // Hence, we can use the absolute value from whatever the OS gives + // us and use the sign of what onScrollDispatchHelper has tracked. + val correctedVelocityX = + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.P) { + (abs(velocityX) * onScrollDispatchHelper.xFlingVelocity.sign).toInt() + } else { + velocityX + } + + if (pagingEnabled) { + flingAndSnap(correctedVelocityX) + } else if (scroller != null) { + // FB SCROLLVIEW CHANGE + + // We provide our own version of fling that uses a different call to the standard OverScroller + // which takes into account the possibility of adding new content while the ScrollView is + // animating. Because we give essentially no max X for the fling, the fling will continue as + // long as there is content. See onOverScrolled() to see the second part of this change which + // properly aborts the scroller animation when we get to the bottom of the ScrollView content. + + val scrollWindowWidth = width - paddingStart - paddingEnd + + scroller.fling( + scrollX, // startX + scrollY, // startY + correctedVelocityX, // velocityX + 0, // velocityY + 0, // minX + Int.MAX_VALUE, // maxX + 0, // minY + 0, // maxY + scrollWindowWidth / 2, // overX + 0, // overY + ) + + postInvalidateOnAnimation() + + // END FB SCROLLVIEW CHANGE + } else { + super.fling(correctedVelocityX) + } + handlePostTouchScrolling(correctedVelocityX, 0) + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (_removeClippedSubviews) { + updateClippingRect() + } + _virtualViewContainerState?.updateState() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (_removeClippedSubviews) { + updateClippingRect() + } + maintainVisibleContentPositionHelper?.start() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + maintainVisibleContentPositionHelper?.stop() + } + + override fun focusSearch(focused: View, @FocusDirection direction: Int): View? { + val nextFocus = super.focusSearch(focused, direction) + + if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { + // If we can find the next focus and it is a child of this view, return it, else it means we + // are leaving the scroll view and we should try to find a clipped element + if (nextFocus != null && findViewById(nextFocus.id) != null) { + return nextFocus + } + val nextFocusableView = findNextFocusableView(this, focused, direction) + if (nextFocusableView != null) { + return nextFocusableView + } + } + + return nextFocus + } + + override fun updateClippingRect() { + updateClippingRect(null) + } + + override fun updateClippingRect(excludedViews: Set?) { + if (!_removeClippedSubviews) return + + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactHorizontalScrollView.updateClippingRect") + try { + val rect = clippingRect!! + ReactClippingViewGroupHelper.calculateClippingRect(this, rect) + val cv = getContentView() + if (cv is ReactClippingViewGroup) { + cv.updateClippingRect(excludedViews) + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun getClippingRect(outClippingRect: Rect) { + outClippingRect.set(clippingRect!!) + } + + override fun getChildVisibleRect(child: View, r: Rect, offset: android.graphics.Point?): Boolean { + return super.getChildVisibleRect(child, r, offset) + } + + private fun getSnapInterval(): Int = if (snapInterval != 0) snapInterval else width + + private fun getContentView(): View? = getChildAt(0) + + public open fun setEndFillColor(color: Int) { + if (color != endFillColor) { + endFillColor = color + endBackground = ColorDrawable(endFillColor) + } + } + + override fun onOverScrolled(scrollX: Int, scrollY: Int, clampedX: Boolean, clampedY: Boolean) { + if (DEBUG_MODE) { + FLog.i( + TAG, + "onOverScrolled[%d] scrollX %d scrollY %d clampedX %b clampedY %b", + id, + scrollX, + scrollY, + clampedX, + clampedY, + ) + } + + @Suppress("NAME_SHADOWING") var scrollX = scrollX + + if (scroller != null) { + // FB SCROLLVIEW CHANGE + + // This is part two of the reimplementation of fling to fix the bounce-back bug. See fling() + // for more information. + + if (!scroller.isFinished && scroller.currX != scroller.finalX) { + val scrollRange = max(computeHorizontalScrollRange() - width, 0) + if (scrollX >= scrollRange) { + scroller.abortAnimation() + scrollX = scrollRange + } + } + + // END FB SCROLLVIEW CHANGE + } + + if ( + ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() && + clampedX && + !emittedOverScrollSinceScrollBegin + ) { + ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f) + emittedOverScrollSinceScrollBegin = true + } + + super.onOverScrolled(scrollX, scrollY, clampedX, clampedY) + } + + override fun onChildViewAdded(parent: View, child: View) { + contentView = child + child.addOnLayoutChangeListener(this) + } + + override fun onChildViewRemoved(parent: View, child: View) { + contentView?.removeOnLayoutChangeListener(this) + contentView = null + } + + private fun enableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + fpsListener!!.enable(scrollPerfTag!!) + } + } + + private fun disableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + fpsListener!!.disable(scrollPerfTag!!) + } + } + + private fun isScrollPerfLoggingEnabled(): Boolean { + return fpsListener != null && scrollPerfTag != null && scrollPerfTag!!.isNotEmpty() + } + + override fun draw(canvas: Canvas) { + if (endFillColor != Color.TRANSPARENT) { + val content = getContentView() + if (endBackground != null && content != null && content.right < width) { + endBackground!!.setBounds(content.right, 0, width, height) + endBackground!!.draw(canvas) + } + } + super.draw(canvas) + } + + /** + * This handles any sort of scrolling that may occur after a touch is finished. This may be + * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we + * don't get any events from Android about this lifecycle, we do all our detection by creating a + * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. + */ + private fun handlePostTouchScrolling(velocityX: Int, velocityY: Int) { + if (DEBUG_MODE) { + FLog.i( + TAG, + "handlePostTouchScrolling[%d] velocityX %d velocityY %d", + id, + velocityX, + velocityY, + ) + } + + // Check if we are already handling this which may occur if this is called by both the touch up + // and a fling call + if (postTouchRunnable != null) return + + if (sendMomentumEvents) { + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY) + } + + activelyScrolling = false + postTouchRunnable = + object : Runnable { + private var snappingToPage = false + private var stableFrames = 0 + + override fun run() { + if (activelyScrolling) { + // We are still scrolling. + activelyScrolling = false + stableFrames = 0 + this@ReactHorizontalScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } else { + // There has not been a scroll update since the last time this Runnable executed. + ReactScrollViewHelper.updateFabricScrollState(this@ReactHorizontalScrollView) + + // We keep checking for updates until the ScrollView has "stabilized" and hasn't + // scrolled for N consecutive frames. This number is arbitrary: big enough to catch + // a number of race conditions, but small enough to not cause perf regressions, etc. + // In anecdotal testing, it seemed like a decent number. + // Without this check, sometimes this Runnable stops executing too soon - it will + // fire before the first scroll event of an animated scroll/fling, and stop + // immediately. + stableFrames++ + + if (stableFrames >= 3) { + postTouchRunnable = null + if (sendMomentumEvents) { + ReactScrollViewHelper.emitScrollMomentumEndEvent(this@ReactHorizontalScrollView) + } + // Kotlin name is notifyUserDrivenScrollEnded; the _internal suffix is + // only a @JvmName alias for Java callers. + ReactScrollViewHelper.notifyUserDrivenScrollEnded(this@ReactHorizontalScrollView) + disableFpsListener() + } else { + if (pagingEnabled && !snappingToPage) { + // If we have pagingEnabled and we have not snapped to the page + // we need to cause that scroll by asking for it + snappingToPage = true + flingAndSnap(0) + } + // The scrollview has not "stabilized" so we just post to check again a frame later + this@ReactHorizontalScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } + } + } + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } + + private fun cancelPostTouchScrolling() { + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + getFlingAnimator().cancel() + } + } + + private fun predictFinalScrollPosition(velocityX: Int): Int { + // predict where a fling would end up so we can scroll to the nearest snap offset + val maximumOffset = max(0, computeHorizontalScrollRange() - width) + // TODO(T106335409): Existing prediction still uses overscroller. Consider change this to use + // fling animator instead. + return if (getFlingAnimator() === defaultFlingAnimator) { + ReactScrollViewHelper.predictFinalScrollPosition(this, velocityX, 0, maximumOffset, 0).x + } else { + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollX, + reactScrollViewScrollState.finalAnimatedPositionScroll.x, + velocityX, + ) + getFlingExtrapolatedDistance(velocityX) + } + } + + /** + * This will smooth scroll us to the nearest snap offset point. It currently just looks at where + * the content is and slides to the nearest point. It is intended to be run after we are done + * scrolling, and handling any momentum scrolling. + */ + private fun smoothScrollAndSnap(velocity: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "smoothScrollAndSnap[%d] velocity %d", id, velocity) + } + + val interval = getSnapInterval().toDouble() + val currentOffset = + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollX, + reactScrollViewScrollState.finalAnimatedPositionScroll.x, + velocity, + ) + .toDouble() + val targetOffset = predictFinalScrollPosition(velocity).toDouble() + + var previousPage = floor(currentOffset / interval).toInt() + var nextPage = ceil(currentOffset / interval).toInt() + var currentPage = round(currentOffset / interval).toInt() + val targetPage = round(targetOffset / interval).toInt() + + if (velocity > 0 && nextPage == previousPage) { + nextPage++ + } else if (velocity < 0 && previousPage == nextPage) { + previousPage-- + } + + if (velocity > 0 && currentPage < nextPage && targetPage > previousPage) { + currentPage = nextPage + } else if (velocity < 0 && currentPage > previousPage && targetPage < nextPage) { + currentPage = previousPage + } + + val finalTargetOffset = currentPage * interval + if (finalTargetOffset != currentOffset) { + activelyScrolling = true + reactSmoothScrollTo(finalTargetOffset.toInt(), scrollY) + } + } + + private fun flingAndSnap(velocityX: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "smoothScrollAndSnap[%d] velocityX %d", id, velocityX) + } + + if (childCount <= 0) return + + // pagingEnabled only allows snapping one interval at a time + if (snapInterval == 0 && snapOffsets == null && snapToAlignment == SNAP_ALIGNMENT_DISABLED) { + smoothScrollAndSnap(velocityX) + return + } + + @Suppress("NAME_SHADOWING") var velocityX = velocityX + val hasCustomizedFlingAnimator = getFlingAnimator() !== defaultFlingAnimator + val maximumOffset = max(0, computeHorizontalScrollRange() - width) + var targetOffset = predictFinalScrollPosition(velocityX) + if (disableIntervalMomentum) { + targetOffset = scrollX + } + + var smallerOffset = 0 + var largerOffset = maximumOffset + var firstOffset = 0 + var lastOffset = maximumOffset + val viewportWidth = width - paddingStart - paddingEnd + + // offsets are from the right edge in RTL layouts + if (layoutDirection == LAYOUT_DIRECTION_RTL) { + targetOffset = maximumOffset - targetOffset + velocityX = -velocityX + } + + // get the nearest snap points to the target offset + if (snapOffsets != null && snapOffsets!!.isNotEmpty()) { + firstOffset = snapOffsets!![0] + lastOffset = snapOffsets!![snapOffsets!!.size - 1] + + for (i in snapOffsets!!.indices) { + val offset = snapOffsets!![i] + if (offset <= targetOffset) { + if (targetOffset - offset < targetOffset - smallerOffset) { + smallerOffset = offset + } + } + if (offset >= targetOffset) { + if (offset - targetOffset < largerOffset - targetOffset) { + largerOffset = offset + } + } + } + } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + if (snapInterval > 0) { + val ratio = targetOffset.toDouble() / snapInterval + smallerOffset = + max( + getItemStartOffset( + snapToAlignment, + (floor(ratio) * snapInterval).toInt(), + snapInterval, + viewportWidth, + ), + 0, + ) + largerOffset = + kotlin.math.min( + getItemStartOffset( + snapToAlignment, + (ceil(ratio) * snapInterval).toInt(), + snapInterval, + viewportWidth, + ), + maximumOffset, + ) + } else { + val cv = getContentView() as ViewGroup + var smallerChildOffset = largerOffset + var largerChildOffset = smallerOffset + for (i in 0 until cv.childCount) { + val item = cv.getChildAt(i) + val itemStartOffset = + getItemStartOffset(snapToAlignment, item.left, item.width, viewportWidth) + if (itemStartOffset <= targetOffset) { + if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { + smallerOffset = itemStartOffset + } + } + if (itemStartOffset >= targetOffset) { + if (itemStartOffset - targetOffset < largerOffset - targetOffset) { + largerOffset = itemStartOffset + } + } + smallerChildOffset = kotlin.math.min(smallerChildOffset, itemStartOffset) + largerChildOffset = max(largerChildOffset, itemStartOffset) + } + + // For Recycler ViewGroup, the maximumOffset can be much larger than the total heights of + // items in the layout. In this case snapping is not possible beyond the currently rendered + // children. + smallerOffset = max(smallerOffset, smallerChildOffset) + largerOffset = kotlin.math.min(largerOffset, largerChildOffset) + } + } else { + val interval = getSnapInterval().toDouble() + val ratio = targetOffset.toDouble() / interval + smallerOffset = (floor(ratio) * interval).toInt() + largerOffset = kotlin.math.min((ceil(ratio) * interval).toInt(), maximumOffset) + } + + // Calculate the nearest offset + val nearestOffset = + if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset + else largerOffset + + // if scrolling after the last snap offset and snapping to the + // end of the list is disabled, then we allow free scrolling + var currentOffset = scrollX + if (layoutDirection == LAYOUT_DIRECTION_RTL) { + currentOffset = maximumOffset - currentOffset + } + if (!snapToEnd && targetOffset >= lastOffset) { + if (currentOffset >= lastOffset) { + // free scrolling + } else { + // snap to end + targetOffset = lastOffset + } + } else if (!snapToStart && targetOffset <= firstOffset) { + if (currentOffset <= firstOffset) { + // free scrolling + } else { + // snap to beginning + targetOffset = firstOffset + } + } else if (velocityX > 0) { + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityX += ((largerOffset - targetOffset) * 10.0).toInt() + } + targetOffset = largerOffset + } else if (velocityX < 0) { + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityX -= ((targetOffset - smallerOffset) * 10.0).toInt() + } + targetOffset = smallerOffset + } else { + targetOffset = nearestOffset + } + + // Make sure the new offset isn't out of bounds + targetOffset = kotlin.math.min(max(0, targetOffset), maximumOffset) + + if (layoutDirection == LAYOUT_DIRECTION_RTL) { + targetOffset = maximumOffset - targetOffset + velocityX = -velocityX + } + + if (hasCustomizedFlingAnimator || scroller == null) { + reactSmoothScrollTo(targetOffset, scrollY) + } else { + // smoothScrollTo will always scroll over 250ms which is often *waaay* + // too short and will cause the scrolling to feel almost instant + // try to manually interact with OverScroller instead + // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo + activelyScrolling = true + + scroller.fling( + scrollX, // startX + scrollY, // startY + // velocity = 0 doesn't work with fling() so we pretend there's a reasonable + // initial velocity going on when a touch is released without any movement + if (velocityX != 0) velocityX else targetOffset - scrollX, // velocityX + 0, // velocityY + // setting both minX and maxX to the same value will guarantee that we scroll to it + // but using the standard fling-style easing rather than smoothScrollTo's 250ms animation + targetOffset, // minX + targetOffset, // maxX + 0, // minY + 0, // maxY + // we only want to allow overscrolling if the final offset is at the very edge of the view + if (targetOffset == 0 || targetOffset == maximumOffset) viewportWidth / 2 else 0, // overX + 0, // overY + ) + + postInvalidateOnAnimation() + } + } + + private fun getItemStartOffset( + snapToAlignment: Int, + itemStartPosition: Int, + itemWidth: Int, + viewPortWidth: Int, + ): Int = + when (snapToAlignment) { + SNAP_ALIGNMENT_CENTER -> itemStartPosition - (viewPortWidth - itemWidth) / 2 + SNAP_ALIGNMENT_START -> itemStartPosition + SNAP_ALIGNMENT_END -> itemStartPosition - (viewPortWidth - itemWidth) + else -> + throw IllegalStateException("Invalid SnapToAlignment value: ${this.snapToAlignment}") + } + + private fun smoothScrollToNextPage(direction: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "smoothScrollToNextPage[%d] direction %d", id, direction) + } + + val w = width + val currentX = scrollX + + var page = currentX / w + if (currentX % w != 0) { + page++ + } + + if (direction == View.FOCUS_LEFT) { + page -= 1 + } else { + page += 1 + } + + if (page < 0) { + page = 0 + } + + reactSmoothScrollTo(page * w, scrollY) + handlePostTouchScrolling(0, 0) + } + + override fun setBackgroundColor(color: Int) { + BackgroundStyleApplicator.setBackgroundColor(this, color) + } + + public open fun setBorderWidth(position: Int, width: Float) { + BackgroundStyleApplicator.setBorderWidth( + this, + LogicalEdge.entries[position], + PixelUtil.toDIPFromPixel(width), + ) + } + + public open fun setBorderColor(position: Int, color: Int?) { + BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.entries[position], color) + } + + public open fun setBorderRadius(borderRadius: Float) { + setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal) + } + + public open fun setBorderRadius(borderRadius: Float, position: Int) { + val radius = + if (borderRadius.isNaN()) null + else LengthPercentage(PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT) + BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.entries[position], radius) + } + + public open fun setBorderStyle(style: String?) { + BackgroundStyleApplicator.setBorderStyle( + this, + if (style == null) null else BorderStyle.fromString(style), + ) + } + + /** + * Calls `smoothScrollTo` and updates state. + * + * `smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. + */ + override fun reactSmoothScrollTo(x: Int, y: Int) { + ReactScrollViewHelper.smoothScrollTo(this, x, y) + setPendingContentOffsets(x, y) + } + + /** + * Calls `super.scrollTo` and updates state. + * + * `super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. + * + * Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it is + * final. See `reactSmoothScrollTo`. + */ + override fun scrollTo(x: Int, y: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "scrollTo[%d] x %d y %d", id, x, y) + } + + super.scrollTo(x, y) + ReactScrollViewHelper.updateFabricScrollState(this) + setPendingContentOffsets(x, y) + } + + /** Scrolls to a new position preserving any momentum scrolling animation. */ + override fun scrollToPreservingMomentum(x: Int, y: Int) { + scrollTo(x, y) + recreateFlingAnimation(x, Int.MAX_VALUE) + } + + protected open fun restoreScrollTo(x: Int, y: Int) { + scrollTo(x, y) + } + + private fun isContentReady(): Boolean { + val child = getContentView() + return child != null && child.width != 0 && child.height != 0 + } + + private fun setPendingContentOffsets(x: Int, y: Int) { + if (DEBUG_MODE) { + FLog.i(TAG, "setPendingContentOffsets[%d] x %d y %d", id, x, y) + } + + if (isContentReady()) { + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + } else { + pendingContentOffsetX = x + pendingContentOffsetY = y + } + } + + override fun onLayoutChange( + v: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if (contentView == null) return + + // Adjust the scroll position to follow new content. In RTL, this means we keep a constant + // offset from the right edge instead of the left edge, so content added to the end of the flow + // does not shift layout. If `maintainVisibleContentPosition` is enabled, we try to adjust + // position so that the viewport keeps the same insets to previously visible views. TODO: MVCP + // does not work in RTL. + if (v.layoutDirection == LAYOUT_DIRECTION_RTL) { + adjustPositionForContentChangeRTL(left, right, oldLeft, oldRight) + } + ReactScrollViewHelper.emitLayoutChangeEvent(this) + } + + /** + * If we are in the middle of a fling animation from the user removing their finger (OverScroller + * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against + * outdated scroll offsets. + */ + private fun recreateFlingAnimation(scrollX: Int, maxX: Int) { + // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap + // point), cancel them. + // TODO: Can we be more graceful (like OverScroller flings)? + if (getFlingAnimator().isRunning) { + getFlingAnimator().cancel() + } + + if (scroller != null && !scroller.isFinished) { + // Calculate the velocity and position of the fling animation at the time of this layout + // event, which may be later than the last ScrollView tick. These values are not committed to + // the underlying ScrollView, which will recalculate positions on its next tick. + val scrollerXBeforeTick = scroller.currX + val hasMoreTicks = scroller.computeScrollOffset() + + // Stop the existing animation at the current state of the scroller. We will then recreate + // it starting at the adjusted x offset. + scroller.forceFinished(true) + + if (hasMoreTicks) { + // OverScroller.getCurrVelocity() returns an absolute value of the velocity a current fling + // animation (only FLING_MODE animations). We derive direction along the X axis from the + // start and end of the animation, assuming HorizontalScrollView never fires vertical fling + // animations. + // TODO: This does not fully handle overscroll. + val direction = (scroller.finalX - scroller.startX).toFloat().sign + val flingVelocityX = scroller.currVelocity * direction + + scroller.fling(scrollX, scrollY, flingVelocityX.toInt(), 0, 0, maxX, 0, 0) + } else { + scrollTo(scrollX + (scroller.currX - scrollerXBeforeTick), scrollY) + } + } + } + + private fun adjustPositionForContentChangeRTL( + left: Int, + right: Int, + oldLeft: Int, + oldRight: Int, + ) { + // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap + // point), finish them, committing the final `scrollX`. + // TODO: Can we be more graceful (like OverScroller flings)? + if (getFlingAnimator().isRunning) { + getFlingAnimator().end() + } + + val distanceToRightEdge = oldRight - scrollX + val newWidth = right - left + val newScrollX = newWidth - distanceToRightEdge + scrollTo(newScrollX, scrollY) + + recreateFlingAnimation(newScrollX, newWidth - width) + } + + override fun startFlingAnimator(start: Int, end: Int) { + // Always cancel existing animator before starting the new one. `smoothScrollTo` contains some + // logic that, if called multiple times in a short amount of time, will treat all calls as part + // of the same animation and will not lengthen the duration of the animation. This means that, + // for example, if the user is scrolling rapidly, multiple pages could be considered part of one + // animation, causing some page animations to be animated very rapidly - looking like they're + // not animated at all. + defaultFlingAnimator.cancel() + + // Update the fling animator with new values + val duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(context) + defaultFlingAnimator.setDuration(duration.toLong()).setIntValues(start, end) + + // Start the animator + defaultFlingAnimator.start() + + if (sendMomentumEvents) { + val xVelocity = if (duration > 0) (end - start) / duration else 0 + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, xVelocity, 0) + ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this) + } + } + + override fun getFlingAnimator(): ValueAnimator = defaultFlingAnimator + + override fun getFlingExtrapolatedDistance(velocity: Int): Int = + ReactScrollViewHelper.predictFinalScrollPosition( + this, + velocity, + 0, + max(0, computeHorizontalScrollRange() - width), + 0, + ) + .x +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt index f2bdae139bbe..e13b17a04846 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt @@ -84,7 +84,7 @@ constructor(private val fpsListener: FpsListener? = null) : props: ReactStylesDiffMap, stateWrapper: StateWrapper, ): Any? { - view.setStateWrapper(stateWrapper) + view.stateWrapper = stateWrapper if ( ReactNativeFeatureFlags.enableViewCulling() || ReactNativeFeatureFlags.useTraitHiddenOnAndroid() @@ -96,7 +96,7 @@ constructor(private val fpsListener: FpsListener? = null) : @ReactProp(name = "scrollEnabled", defaultBoolean = true) public fun setScrollEnabled(view: ReactHorizontalScrollView, value: Boolean) { - view.setScrollEnabled(value) + view.scrollEnabled = value } @ReactProp(name = "showsHorizontalScrollIndicator", defaultBoolean = true) @@ -351,8 +351,8 @@ constructor(private val fpsListener: FpsListener? = null) : public fun setFadingEdgeLength(view: ReactHorizontalScrollView, value: Dynamic) { when (value.type) { ReadableType.Number -> { - view.setFadingEdgeLengthStart(value.asInt()) - view.setFadingEdgeLengthEnd(value.asInt()) + view.fadingEdgeLengthStart = value.asInt() + view.fadingEdgeLengthEnd = value.asInt() } ReadableType.Map -> { value.asMap()?.let { map -> @@ -364,8 +364,8 @@ constructor(private val fpsListener: FpsListener? = null) : if (map.hasKey("end") && map.getInt("end") > 0) { end = map.getInt("end") } - view.setFadingEdgeLengthStart(start) - view.setFadingEdgeLengthEnd(end) + view.fadingEdgeLengthStart = start + view.fadingEdgeLengthEnd = end } } else -> { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java deleted file mode 100644 index 02a05589d36d..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java +++ /dev/null @@ -1,1654 +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. - * - * @generated SignedSource<> - */ - -/** - * THIS FILE IS GENERATED - DO NOT EDIT DIRECTLY - * Source: ReactScrollView.java - * Run: node generate-nested-scroll-view.js - */ - -package com.facebook.react.views.scroll; - -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView; - -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityNodeInfo; -import android.widget.OverScroller; -import androidx.core.widget.NestedScrollView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.ViewCompat.FocusDirection; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Assertions; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.R; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags; -import com.facebook.react.uimanager.BackgroundStyleApplicator; -import com.facebook.react.uimanager.LengthPercentage; -import com.facebook.react.uimanager.LengthPercentageType; -import com.facebook.react.uimanager.MeasureSpecAssertions; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.PointerEvents; -import com.facebook.react.uimanager.ReactClippingViewGroup; -import com.facebook.react.uimanager.ReactClippingViewGroupHelper; -import com.facebook.react.uimanager.ReactOverflowViewWithInset; -import com.facebook.react.uimanager.StateWrapper; -import com.facebook.react.uimanager.events.NativeGestureUtil; -import com.facebook.react.uimanager.style.BorderRadiusProp; -import com.facebook.react.uimanager.style.BorderStyle; -import com.facebook.react.uimanager.style.LogicalEdge; -import com.facebook.react.uimanager.style.Overflow; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper; -import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; -import com.facebook.systrace.Systrace; -import java.lang.reflect.Field; -import java.util.List; -import java.util.Set; - -/** - * A simple subclass of NestedScrollView that doesn't dispatch measure and layout to its children and has - * a scroll listener to send scroll events to JS. - * - *

ReactNestedScrollView only supports vertical scrolling. For horizontal scrolling, use {@link - * ReactHorizontalScrollView}. - */ -@Nullsafe(Nullsafe.Mode.LOCAL) -class ReactNestedScrollView extends NestedScrollView - implements ReactClippingViewGroup, - ViewGroup.OnHierarchyChangeListener, - View.OnLayoutChangeListener, - ReactAccessibleScrollView, - ReactOverflowViewWithInset, - HasScrollState, - HasStateWrapper, - HasFlingAnimator, - HasScrollEventThrottle, - HasSmoothScroll, - VirtualViewContainer { - - private static @Nullable Field sScrollerField; - private static boolean sTriedToGetScrollerField = false; - - private static final int UNSET_CONTENT_OFFSET = -1; - - private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper(); - private final @Nullable OverScroller mScroller; - private final VelocityHelper mVelocityHelper = new VelocityHelper(); - private final Rect mTempRect = new Rect(); - private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollY", 0, 0); - private final @Nullable FpsListener mFpsListener; - - private Rect mOverflowInset; - private @Nullable VirtualViewContainerState mVirtualViewContainerState; - private boolean mActivelyScrolling; - private @Nullable Rect mClippingRect; - private Overflow mOverflow; - private boolean mDragging; - private boolean mPagingEnabled; - private @Nullable Runnable mPostTouchRunnable; - private boolean mRemoveClippedSubviews; - private boolean mScrollEnabled; - private boolean mSendMomentumEvents; - private @Nullable String mScrollPerfTag; - private @Nullable Drawable mEndBackground; - private int mEndFillColor; - private boolean mDisableIntervalMomentum; - private int mSnapInterval; - private @Nullable List mSnapOffsets; - private boolean mSnapToStart; - private boolean mSnapToEnd; - private int mSnapToAlignment; - private @Nullable View mContentView; - private @Nullable ReadableMap mCurrentContentOffset; - private int mPendingContentOffsetX; - private int mPendingContentOffsetY; - private @Nullable StateWrapper mStateWrapper; - private ReactScrollViewScrollState mReactScrollViewScrollState; - private PointerEvents mPointerEvents; - private long mLastScrollDispatchTime; - private int mScrollEventThrottle; - private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper; - private int mFadingEdgeLengthStart; - private int mFadingEdgeLengthEnd; - private boolean mEmittedOverScrollSinceScrollBegin; - private boolean mScrollsChildToFocus = true; - - public ReactNestedScrollView(Context context) { - this(context, null); - } - - public ReactNestedScrollView(Context context, @Nullable FpsListener fpsListener) { - super(context); - mFpsListener = fpsListener; - - mScroller = getOverScrollerFromParent(); - setOnHierarchyChangeListener(this); - setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY); - setClipChildren(false); - - ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); - initView(); - } - - /** - * Set all default values here as opposed to in the constructor or field defaults. It is important - * that these properties are set during the constructor, but also on-demand whenever an existing - * ReactTextView is recycled. - */ - private void initView() { - mOverflowInset = new Rect(); - mVirtualViewContainerState = null; - mActivelyScrolling = false; - mClippingRect = null; - - // The default value for `overflow` is set to `Visible` in the Yoga style props. - mOverflow = - ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL; - - mDragging = false; - mPagingEnabled = false; - mPostTouchRunnable = null; - mRemoveClippedSubviews = false; - mScrollEnabled = true; - mSendMomentumEvents = false; - mScrollPerfTag = null; - mEndBackground = null; - mEndFillColor = Color.TRANSPARENT; - mDisableIntervalMomentum = false; - mSnapInterval = 0; - mSnapOffsets = null; - mSnapToStart = true; - mSnapToEnd = true; - mSnapToAlignment = SNAP_ALIGNMENT_DISABLED; - mContentView = null; - mCurrentContentOffset = null; - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - mStateWrapper = null; - mReactScrollViewScrollState = new ReactScrollViewScrollState(); - mPointerEvents = PointerEvents.AUTO; - mLastScrollDispatchTime = 0; - mScrollEventThrottle = 0; - mMaintainVisibleContentPositionHelper = null; - mFadingEdgeLengthStart = 0; - mFadingEdgeLengthEnd = 0; - mEmittedOverScrollSinceScrollBegin = false; - mScrollsChildToFocus = true; - } - - /* package */ void recycleView() { - // Set default field values - initView(); - - // If the view is still attached to a parent, we need to remove it from the parent - // before we can recycle it. - if (getParent() != null) { - ((ViewGroup) getParent()).removeView(this); - } - updateView(); - } - - private void updateView() {} - - @Override - public VirtualViewContainerState getVirtualViewContainerState() { - if (mVirtualViewContainerState == null) { - mVirtualViewContainerState = VirtualViewContainerState.create(this); - } - - return mVirtualViewContainerState; - } - - @Override - public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { - super.onInitializeAccessibilityNodeInfo(info); - - // Expose the testID prop as the resource-id name of the view. Black-box E2E/UI testing - // frameworks, which interact with the UI through the accessibility framework, do not have - // access to view tags. This allows developers/testers to avoid polluting the - // content-description with test identifiers. - final String testId = (String) this.getTag(R.id.react_test_id); - if (testId != null) { - info.setViewIdResourceName(testId); - } - } - - @Nullable - protected OverScroller getOverScrollerFromParent() { - OverScroller scroller; - - if (!sTriedToGetScrollerField) { - sTriedToGetScrollerField = true; - try { - sScrollerField = NestedScrollView.class.getDeclaredField("mScroller"); - sScrollerField.setAccessible(true); - } catch (NoSuchFieldException e) { - FLog.w( - ReactConstants.TAG, - "Failed to get mScroller field for NestedScrollView! " - + "This app will exhibit the bounce-back scrolling bug :("); - } - } - - if (sScrollerField != null) { - try { - Object scrollerValue = sScrollerField.get(this); - if (scrollerValue instanceof OverScroller) { - scroller = (OverScroller) scrollerValue; - } else { - FLog.w( - ReactConstants.TAG, - "Failed to cast mScroller field in NestedScrollView (probably due to OEM changes to AOSP)! " - + "This app will exhibit the bounce-back scrolling bug :("); - scroller = null; - } - } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to get mScroller from NestedScrollView!", e); - } - } else { - scroller = null; - } - - return scroller; - } - - public void setDisableIntervalMomentum(boolean disableIntervalMomentum) { - mDisableIntervalMomentum = disableIntervalMomentum; - } - - public void setSendMomentumEvents(boolean sendMomentumEvents) { - mSendMomentumEvents = sendMomentumEvents; - } - - public void setScrollPerfTag(@Nullable String scrollPerfTag) { - mScrollPerfTag = scrollPerfTag; - } - - public void setScrollEnabled(boolean scrollEnabled) { - mScrollEnabled = scrollEnabled; - } - - @Override - public boolean getScrollEnabled() { - return mScrollEnabled; - } - - public void setPagingEnabled(boolean pagingEnabled) { - mPagingEnabled = pagingEnabled; - } - - public void setScrollsChildToFocus(boolean scrollsChildToFocus) { - mScrollsChildToFocus = scrollsChildToFocus; - } - - public void setDecelerationRate(float decelerationRate) { - getReactScrollViewScrollState().setDecelerationRate(decelerationRate); - - if (mScroller != null) { - mScroller.setFriction(1.0f - decelerationRate); - } - } - - public void abortAnimation() { - if (mScroller != null && !mScroller.isFinished()) { - mScroller.abortAnimation(); - } - } - - public void setSnapInterval(int snapInterval) { - mSnapInterval = snapInterval; - } - - public void setSnapOffsets(@Nullable List snapOffsets) { - mSnapOffsets = snapOffsets; - } - - public void setSnapToStart(boolean snapToStart) { - mSnapToStart = snapToStart; - } - - public void setSnapToEnd(boolean snapToEnd) { - mSnapToEnd = snapToEnd; - } - - public void setSnapToAlignment(int snapToAlignment) { - mSnapToAlignment = snapToAlignment; - } - - public void flashScrollIndicators() { - awakenScrollBars(); - } - - public int getFadingEdgeLengthStart() { - return mFadingEdgeLengthStart; - } - - public int getFadingEdgeLengthEnd() { - return mFadingEdgeLengthEnd; - } - - public void setFadingEdgeLengthStart(int start) { - mFadingEdgeLengthStart = start; - invalidate(); - } - - public void setFadingEdgeLengthEnd(int end) { - mFadingEdgeLengthEnd = end; - invalidate(); - } - - @Override - protected float getTopFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - return (mFadingEdgeLengthStart / max); - } - - @Override - protected float getBottomFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - return (mFadingEdgeLengthEnd / max); - } - - public void setOverflow(@Nullable String overflow) { - if (overflow == null) { - mOverflow = Overflow.SCROLL; - } else { - @Nullable Overflow parsedOverflow = Overflow.fromString(overflow); - mOverflow = - parsedOverflow == null - ? (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL) - : parsedOverflow; - } - - invalidate(); - } - - public void setMaintainVisibleContentPosition( - @Nullable MaintainVisibleScrollPositionHelper.Config config) { - if (config != null && mMaintainVisibleContentPositionHelper == null) { - mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, false); - mMaintainVisibleContentPositionHelper.start(); - } else if (config == null && mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - mMaintainVisibleContentPositionHelper = null; - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.setConfig(config); - } - } - - @Override - public @Nullable String getOverflow() { - switch (mOverflow) { - case HIDDEN: - return "hidden"; - case SCROLL: - return "scroll"; - case VISIBLE: - return "visible"; - } - - return null; - } - - @Override - public void setOverflowInset(int left, int top, int right, int bottom) { - mOverflowInset.set(left, top, right, bottom); - } - - @Override - public Rect getOverflowInset() { - return mOverflowInset; - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec); - - setMeasuredDimension( - MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)); - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - // Apply pending contentOffset in case it was set before the view was laid out. - if (isContentReady()) { - // If a "pending" content offset value has been set, we restore that value. - // Upon call to scrollTo, the "pending" values will be re-set. - int scrollToX = - mPendingContentOffsetX != UNSET_CONTENT_OFFSET ? mPendingContentOffsetX : getScrollX(); - int scrollToY = - mPendingContentOffsetY != UNSET_CONTENT_OFFSET ? mPendingContentOffsetY : getScrollY(); - scrollTo(scrollToX, scrollToY); - } - - ReactScrollViewHelper.emitLayoutEvent(this); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - @Override - public void onAttachedToWindow() { - super.onAttachedToWindow(); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.start(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - } - } - - @Override - public @Nullable View focusSearch(View focused, @FocusDirection int direction) { - View nextFocus = super.focusSearch(focused, direction); - - if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { - // If we can find the next focus and it is a child of this view, return it, else it means we - // are leaving the scroll view and we should try to find a clipped element - if (nextFocus != null && this.findViewById(nextFocus.getId()) != null) { - return nextFocus; - } - - @Nullable View nextfocusableView = findNextFocusableView(this, focused, direction); - - if (nextfocusableView != null) { - return nextfocusableView; - } - } - - return nextFocus; - } - - /** - * Since ReactNestedScrollView handles layout changes on JS side, it does not call super.onlayout due to - * which mIsLayoutDirty flag in NestedScrollView remains true and prevents scrolling to child when - * requestChildFocus is called. Overriding this method and scrolling to child without checking any - * layout dirty flag. This will fix focus navigation issue for KeyEvents which are not handled by - * NestedScrollView, for example: KEYCODE_TAB. - */ - @Override - public void requestChildFocus(View child, View focused) { - if (focused != null && mScrollsChildToFocus) { - scrollToChild(focused); - } - requestChildFocusWithoutScroll(child, focused); - } - - /** - * In rare cases where an app overrides the built-in ReactNestedScrollView by overriding it, and also - * needs to customize scroll into view on focus behaviors, this protected method can be used to - * unblocks such customization. - */ - protected void requestChildFocusWithoutScroll(View child, View focused) { - super.requestChildFocus(child, focused); - } - - @Override - public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { - if (!mScrollsChildToFocus) { - return false; - } - return super.requestChildRectangleOnScreen(child, rectangle, immediate); - } - - private int getScrollDelta(View descendent) { - descendent.getDrawingRect(mTempRect); - offsetDescendantRectToMyCoords(descendent, mTempRect); - return computeScrollDeltaToGetChildRectOnScreen(mTempRect); - } - - /** Returns whether the given descendent is partially scrolled in view */ - @Override - public boolean isPartiallyScrolledInView(View descendent) { - int scrollDelta = getScrollDelta(descendent); - descendent.getDrawingRect(mTempRect); - return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width(); - } - - private void scrollToChild(View child) { - // Only scroll the nearest ReactNestedScrollView ancestor into view, rather than the focused child. - // Nested NestedScrollView instances will handle scrolling the child into their respective viewports. - View parent = child; - View scrollViewAncestor = null; - while (parent != null && parent != this) { - if (parent instanceof ReactNestedScrollView) { - scrollViewAncestor = parent; - } - parent = (View) parent.getParent(); - } - - View scrollIntoViewTarget = scrollViewAncestor != null ? scrollViewAncestor : child; - - Rect tempRect = new Rect(); - scrollIntoViewTarget.getDrawingRect(tempRect); - - /* Offset from child's local coordinates to NestedScrollView coordinates */ - offsetDescendantRectToMyCoords(scrollIntoViewTarget, tempRect); - - int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(tempRect); - - if (scrollDelta != 0) { - scrollBy(0, scrollDelta); - } - } - - @Override - protected void onScrollChanged(int x, int y, int oldX, int oldY) { - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactNestedScrollView.onScrollChanged"); - try { - super.onScrollChanged(x, y, oldX, oldY); - - mActivelyScrolling = true; - - if (mOnScrollDispatchHelper.onScrollChanged(x, y)) { - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - ReactScrollViewHelper.updateStateOnScrollChanged( - this, - mOnScrollDispatchHelper.getXFlingVelocity(), - mOnScrollDispatchHelper.getYFlingVelocity()); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - // We intercept the touch event if the children are not supposed to receive it. - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return true; - } - - try { - if (super.onInterceptTouchEvent(ev)) { - handleInterceptedTouchEvent(ev); - return true; - } - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e); - } - - return false; - } - - protected void handleInterceptedTouchEvent(MotionEvent ev) { - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureStarted(this, ev); - } - ReactScrollViewHelper.emitScrollBeginDragEvent(this); - mDragging = true; - mEmittedOverScrollSinceScrollBegin = false; - enableFpsListener(); - getFlingAnimator().cancel(); - } - - @Override - public boolean onTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - // We do not accept the touch event if this view is not supposed to receive it. - if (!PointerEvents.canBeTouchTarget(mPointerEvents)) { - return false; - } - - mVelocityHelper.calculateVelocity(ev); - int action = ev.getActionMasked(); - if (action == MotionEvent.ACTION_UP && mDragging) { - ReactScrollViewHelper.updateFabricScrollState(this); - - float velocityX = mVelocityHelper.getXVelocity(); - float velocityY = mVelocityHelper.getYVelocity(); - ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY); - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureEnded(this, ev); - } - mDragging = false; - // After the touch finishes, we may need to do some scrolling afterwards either as a result - // of a fling or because we need to page align the content - handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)); - } - - if (action == MotionEvent.ACTION_DOWN) { - cancelPostTouchScrolling(); - } - - try { - return super.onTouchEvent(ev); - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error handling touch event.", e); - return false; - } - } - - @Override - public boolean dispatchGenericMotionEvent(MotionEvent ev) { - // Ignore generic motion events (joystick, mouse wheel, trackpad) if scrolling is disabled - if (!mScrollEnabled) { - return false; - } - - // We do not dispatch the motion event if its children are not supposed to receive it - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return false; - } - - // Handle ACTION_SCROLL events (mouse wheel, trackpad, joystick) - if (ev.getActionMasked() == MotionEvent.ACTION_SCROLL) { - float vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); - if (vScroll != 0) { - // Perform the scroll - enableFpsListener(); - boolean result = super.dispatchGenericMotionEvent(ev); - // Schedule snap alignment to run after scrolling stops - if (result - && (mPagingEnabled - || mSnapInterval != 0 - || mSnapOffsets != null - || mSnapToAlignment != SNAP_ALIGNMENT_DISABLED)) { - // Cancel any pending post-touch runnable and reschedule - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - } - mPostTouchRunnable = - new Runnable() { - @Override - public void run() { - mPostTouchRunnable = null; - // +1/-1 velocity if scrolling down or up. This is to ensure that the - // next/previous page is picked rather than sliding backwards to the current page - int velocityY = (int) -Math.signum(vScroll); - if (mDisableIntervalMomentum) { - velocityY = 0; - } - flingAndSnap(velocityY); - handlePostTouchScrolling(0, velocityY); - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - handlePostTouchScrolling(0, 0); - } - return result; - } - } - - return super.dispatchGenericMotionEvent(ev); - } - - @Override - public boolean executeKeyEvent(KeyEvent event) { - int eventKeyCode = event.getKeyCode(); - if (!mScrollEnabled - && (eventKeyCode == KeyEvent.KEYCODE_DPAD_UP - || eventKeyCode == KeyEvent.KEYCODE_DPAD_DOWN)) { - return false; - } - return super.executeKeyEvent(event); - } - - @Override - public void setRemoveClippedSubviews(boolean removeClippedSubviews) { - if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) { - return; - } - - if (removeClippedSubviews && mClippingRect == null) { - mClippingRect = new Rect(); - } - mRemoveClippedSubviews = removeClippedSubviews; - updateClippingRect(); - } - - @Override - public boolean getRemoveClippedSubviews() { - return mRemoveClippedSubviews; - } - - @Override - public void updateClippingRect() { - updateClippingRect(null); - } - - @Override - public void updateClippingRect(@Nullable Set excludedViewsSet) { - if (!mRemoveClippedSubviews) { - return; - } - - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactNestedScrollView.updateClippingRect"); - try { - Assertions.assertNotNull(mClippingRect); - - ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect); - View contentView = getContentView(); - if (contentView instanceof ReactClippingViewGroup) { - ((ReactClippingViewGroup) contentView).updateClippingRect(excludedViewsSet); - } - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Override - public void getClippingRect(Rect outClippingRect) { - outClippingRect.set(Assertions.assertNotNull(mClippingRect)); - } - - @Override - public boolean getChildVisibleRect(View child, Rect r, android.graphics.Point offset) { - return super.getChildVisibleRect(child, r, offset); - } - - @Override - public void fling(int velocityY) { - final int correctedVelocityY = correctFlingVelocityY(velocityY); - - if (mPagingEnabled) { - flingAndSnap(correctedVelocityY); - } else if (mScroller != null) { - // We provide our own version of fling that uses a different call to the standard OverScroller - // which takes into account the possibility of adding new content while the NestedScrollView is - // animating. Because we give essentially no max Y for the fling, the fling will continue as - // long - // as there is content. See #onOverScrolled() to see the second part of this change which - // properly - // aborts the scroller animation when we get to the bottom of the NestedScrollView content. - - int scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop(); - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - 0, // velocityX - correctedVelocityY, // velocityY - 0, // minX - 0, // maxX - 0, // minY - Integer.MAX_VALUE, // maxY - 0, // overX - scrollWindowHeight / 2 // overY - ); - - ViewCompat.postInvalidateOnAnimation(this); - } else { - super.fling(correctedVelocityY); - } - handlePostTouchScrolling(0, correctedVelocityY); - } - - private int correctFlingVelocityY(int velocityY) { - if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) { - return velocityY; - } - - // Workaround. - // On Android P if a NestedScrollView is inverted, we will get a wrong sign for - // velocityY (see https://issuetracker.google.com/issues/112385925). - // At the same time, mOnScrollDispatchHelper tracks the correct velocity direction. - // - // Hence, we can use the absolute value from whatever the OS gives - // us and use the sign of what mOnScrollDispatchHelper has tracked. - float signum = Math.signum(mOnScrollDispatchHelper.getYFlingVelocity()); - if (signum == 0) { - signum = Math.signum(velocityY); - } - return (int) (Math.abs(velocityY) * signum); - } - - private void enableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.enable(mScrollPerfTag); - } - } - - private void disableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.disable(mScrollPerfTag); - } - } - - private boolean isScrollPerfLoggingEnabled() { - return mFpsListener != null && mScrollPerfTag != null && !mScrollPerfTag.isEmpty(); - } - - private int getMaxScrollY() { - int contentHeight = mContentView == null ? 0 : mContentView.getHeight(); - int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop(); - return Math.max(0, contentHeight - viewportHeight); - } - - @Nullable - @Override - public StateWrapper getStateWrapper() { - return mStateWrapper; - } - - public void setStateWrapper(StateWrapper stateWrapper) { - mStateWrapper = stateWrapper; - } - - @Override - public void draw(Canvas canvas) { - if (mEndFillColor != Color.TRANSPARENT) { - final View contentView = getContentView(); - if (mEndBackground != null && contentView != null && contentView.getBottom() < getHeight()) { - mEndBackground.setBounds(0, contentView.getBottom(), getWidth(), getHeight()); - mEndBackground.draw(canvas); - } - } - - super.draw(canvas); - } - - @Override - public void onDraw(Canvas canvas) { - if (mOverflow != Overflow.VISIBLE) { - BackgroundStyleApplicator.clipToPaddingBox(this, canvas); - } - super.onDraw(canvas); - } - - /** - * This handles any sort of scrolling that may occur after a touch is finished. This may be - * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we - * don't get any events from Android about this lifecycle, we do all our detection by creating a - * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. - */ - private void handlePostTouchScrolling(int velocityX, int velocityY) { - // Check if we are already handling this which may occur if this is called by both the touch up - // and a fling call - if (mPostTouchRunnable != null) { - return; - } - - if (mSendMomentumEvents) { - enableFpsListener(); - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY); - } - - mActivelyScrolling = false; - mPostTouchRunnable = - new Runnable() { - - private boolean mSnappingToPage = false; - private int mStableFrames = 0; - - @Override - public void run() { - if (mActivelyScrolling) { - // We are still scrolling. - mActivelyScrolling = false; - mStableFrames = 0; - ReactNestedScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - // There has not been a scroll update since the last time this Runnable executed. - ReactScrollViewHelper.updateFabricScrollState(ReactNestedScrollView.this); - - // We keep checking for updates until the NestedScrollView has "stabilized" and hasn't - // scrolled for N consecutive frames. This number is arbitrary: big enough to catch - // a number of race conditions, but small enough to not cause perf regressions, etc. - // In anecdotal testing, it seemed like a decent number. - // Without this check, sometimes this Runnable stops executing too soon - it will - // fire before the first scroll event of an animated scroll/fling, and stop - // immediately. - mStableFrames++; - - if (mStableFrames >= 3) { - mPostTouchRunnable = null; - if (mSendMomentumEvents) { - ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactNestedScrollView.this); - } - ReactScrollViewHelper.notifyUserDrivenScrollEnded_internal(ReactNestedScrollView.this); - disableFpsListener(); - } else { - if (mPagingEnabled && !mSnappingToPage) { - // If we have pagingEnabled and we have not snapped to the page - // we need to cause that scroll by asking for it - mSnappingToPage = true; - flingAndSnap(0); - } - // The scrollview has not "stabilized" so we just post to check again a frame later - ReactNestedScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } - } - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } - - private void cancelPostTouchScrolling() { - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - getFlingAnimator().cancel(); - } - } - - private int predictFinalScrollPosition(int velocityY) { - // predict where a fling would end up so we can scroll to the nearest snap offset - // TODO(T106335409): Existing prediction still uses overscroller. Consider change this to use - // fling animator instead. - return getFlingAnimator() == DEFAULT_FLING_ANIMATOR - ? ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()).y - : ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollY(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().y, - velocityY) - + getFlingExtrapolatedDistance(velocityY); - } - - private View getContentView() { - return getChildAt(0); - } - - /** - * This will smooth scroll us to the nearest snap offset point It currently just looks at where - * the content is and slides to the nearest point. It is intended to be run after we are done - * scrolling, and handling any momentum scrolling. - */ - private void smoothScrollAndSnap(int velocity) { - double interval = (double) getSnapInterval(); - double currentOffset = - (double) - (ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollY(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().y, - velocity)); - double targetOffset = (double) predictFinalScrollPosition(velocity); - - int previousPage = (int) Math.floor(currentOffset / interval); - int nextPage = (int) Math.ceil(currentOffset / interval); - int currentPage = (int) Math.round(currentOffset / interval); - int targetPage = (int) Math.round(targetOffset / interval); - - if (velocity > 0 && nextPage == previousPage) { - nextPage++; - } else if (velocity < 0 && previousPage == nextPage) { - previousPage--; - } - - if ( - // if scrolling towards next page - velocity > 0 - && - // and the middle of the page hasn't been crossed already - currentPage < nextPage - && - // and it would have been crossed after flinging - targetPage > previousPage) { - currentPage = nextPage; - } else if ( - // if scrolling towards previous page - velocity < 0 - && - // and the middle of the page hasn't been crossed already - currentPage > previousPage - && - // and it would have been crossed after flinging - targetPage < nextPage) { - currentPage = previousPage; - } - - targetOffset = currentPage * interval; - if (targetOffset != currentOffset) { - mActivelyScrolling = true; - reactSmoothScrollTo(getScrollX(), (int) targetOffset); - } - } - - private void flingAndSnap(int velocityY) { - if (getChildCount() <= 0) { - return; - } - - // pagingEnabled only allows snapping one interval at a time - if (mSnapInterval == 0 && mSnapOffsets == null && mSnapToAlignment == SNAP_ALIGNMENT_DISABLED) { - smoothScrollAndSnap(velocityY); - return; - } - - boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR; - int maximumOffset = getMaxScrollY(); - int targetOffset = predictFinalScrollPosition(velocityY); - if (mDisableIntervalMomentum) { - targetOffset = getScrollY(); - } - - int smallerOffset = 0; - int largerOffset = maximumOffset; - int firstOffset = 0; - int lastOffset = maximumOffset; - int height = getHeight() - getPaddingBottom() - getPaddingTop(); - - // get the nearest snap points to the target offset - if (mSnapOffsets != null) { - firstOffset = mSnapOffsets.get(0); - lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1); - - for (int i = 0; i < mSnapOffsets.size(); i++) { - int offset = mSnapOffsets.get(i); - - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset; - } - } - - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset; - } - } - } - - } else if (mSnapToAlignment != SNAP_ALIGNMENT_DISABLED) { - if (mSnapInterval > 0) { - double ratio = (double) targetOffset / mSnapInterval; - smallerOffset = - Math.max( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.floor(ratio) * mSnapInterval), - mSnapInterval, - height), - 0); - largerOffset = - Math.min( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.ceil(ratio) * mSnapInterval), - mSnapInterval, - height), - maximumOffset); - } else { - ViewGroup contentView = (ViewGroup) getContentView(); - int smallerChildOffset = largerOffset; - int largerChildOffset = smallerOffset; - for (int i = 0; i < contentView.getChildCount(); i++) { - View item = contentView.getChildAt(i); - int itemStartOffset; - switch (mSnapToAlignment) { - case SNAP_ALIGNMENT_CENTER: - itemStartOffset = item.getTop() - (height - item.getHeight()) / 2; - break; - case SNAP_ALIGNMENT_START: - itemStartOffset = item.getTop(); - break; - case SNAP_ALIGNMENT_END: - itemStartOffset = item.getTop() - (height - item.getHeight()); - break; - default: - throw new IllegalStateException("Invalid SnapToAlignment value: " + mSnapToAlignment); - } - if (itemStartOffset <= targetOffset) { - if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { - smallerOffset = itemStartOffset; - } - } - - if (itemStartOffset >= targetOffset) { - if (itemStartOffset - targetOffset < largerOffset - targetOffset) { - largerOffset = itemStartOffset; - } - } - - smallerChildOffset = Math.min(smallerChildOffset, itemStartOffset); - largerChildOffset = Math.max(largerChildOffset, itemStartOffset); - } - - // For Recycler ViewGroup, the maximumOffset can be much larger than the total heights of - // items in the layout. In this case snapping is not possible beyond the currently rendered - // children. - smallerOffset = Math.max(smallerOffset, smallerChildOffset); - largerOffset = Math.min(largerOffset, largerChildOffset); - } - } else { - double interval = (double) getSnapInterval(); - double ratio = (double) targetOffset / interval; - smallerOffset = (int) (Math.floor(ratio) * interval); - largerOffset = Math.min((int) (Math.ceil(ratio) * interval), maximumOffset); - } - - // Calculate the nearest offset - int nearestOffset = - Math.abs(targetOffset - smallerOffset) < Math.abs(largerOffset - targetOffset) - ? smallerOffset - : largerOffset; - - // if scrolling after the last snap offset and snapping to the - // end of the list is disabled, then we allow free scrolling - if (!mSnapToEnd && targetOffset >= lastOffset) { - if (getScrollY() >= lastOffset) { - // free scrolling - } else { - // snap to end - targetOffset = lastOffset; - } - } else if (!mSnapToStart && targetOffset <= firstOffset) { - if (getScrollY() <= firstOffset) { - // free scrolling - } else { - // snap to beginning - targetOffset = firstOffset; - } - } else if (velocityY > 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityY += (int) ((largerOffset - targetOffset) * 10.0); - } - - targetOffset = largerOffset; - } else if (velocityY < 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityY -= (int) ((targetOffset - smallerOffset) * 10.0); - } - - targetOffset = smallerOffset; - } else { - targetOffset = nearestOffset; - } - - // Make sure the new offset isn't out of bounds - targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset); - - if (hasCustomizedFlingAnimator || mScroller == null) { - reactSmoothScrollTo(getScrollX(), targetOffset); - } else { - // smoothScrollTo will always scroll over 250ms which is often *waaay* - // too short and will cause the scrolling to feel almost instant - // try to manually interact with OverScroller instead - // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo - mActivelyScrolling = true; - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - // velocity = 0 doesn't work with fling() so we pretend there's a reasonable - // initial velocity going on when a touch is released without any movement - 0, // velocityX - velocityY != 0 ? velocityY : targetOffset - getScrollY(), // velocityY - 0, // minX - 0, // maxX - // setting both minY and maxY to the same value will guarantee that we scroll to it - // but using the standard fling-style easing rather than smoothScrollTo's 250ms animation - targetOffset, // minY - targetOffset, // maxY - 0, // overX - // we only want to allow overscrolling if the final offset is at the very edge of the view - (targetOffset == 0 || targetOffset == maximumOffset) ? height / 2 : 0 // overY - ); - - postInvalidateOnAnimation(); - } - } - - private int getItemStartOffset( - int snapToAlignment, int itemStartPosition, int itemHeight, int viewPortHeight) { - int itemStartOffset; - switch (snapToAlignment) { - case SNAP_ALIGNMENT_CENTER: - itemStartOffset = itemStartPosition - (viewPortHeight - itemHeight) / 2; - break; - case SNAP_ALIGNMENT_START: - itemStartOffset = itemStartPosition; - break; - case SNAP_ALIGNMENT_END: - itemStartOffset = itemStartPosition - (viewPortHeight - itemHeight); - break; - default: - throw new IllegalStateException("Invalid SnapToAlignment value: " + mSnapToAlignment); - } - return itemStartOffset; - } - - private int getSnapInterval() { - if (mSnapInterval != 0) { - return mSnapInterval; - } - return getHeight(); - } - - public void setEndFillColor(int color) { - if (color != mEndFillColor) { - mEndFillColor = color; - mEndBackground = new ColorDrawable(mEndFillColor); - } - } - - @Override - protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { - if (mScroller != null && mContentView != null) { - // This is part two of the reimplementation of fling to fix the bounce-back bug. See #fling() - // for more information. - - if (!mScroller.isFinished() && mScroller.getCurrY() != mScroller.getFinalY()) { - int scrollRange = getMaxScrollY(); - if (scrollY >= scrollRange) { - mScroller.abortAnimation(); - scrollY = scrollRange; - } - } - } - - if (ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() - && clampedY - && mEmittedOverScrollSinceScrollBegin == false) { - ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f); - mEmittedOverScrollSinceScrollBegin = true; - } - - super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); - } - - @Override - public void onChildViewAdded(View parent, View child) { - mContentView = child; - mContentView.addOnLayoutChangeListener(this); - } - - @Override - public void onChildViewRemoved(View parent, View child) { - if (mContentView != null) { - mContentView.removeOnLayoutChangeListener(this); - mContentView = null; - } - } - - public void setContentOffset(@Nullable ReadableMap value) { - // When contentOffset={{x:0,y:0}} with lazy load items, setContentOffset - // is repeatedly called, causing an unexpected scroll to top behavior. - // Avoid updating contentOffset if the value has not changed. - if (mCurrentContentOffset == null || !mCurrentContentOffset.equals(value)) { - mCurrentContentOffset = value; - - if (value != null) { - double x = value.hasKey("x") ? value.getDouble("x") : 0; - double y = value.hasKey("y") ? value.getDouble("y") : 0; - scrollTo((int) PixelUtil.toPixelFromDIP(x), (int) PixelUtil.toPixelFromDIP(y)); - } else { - scrollTo(0, 0); - } - } - } - - /** - * Calls `smoothScrollTo` and updates state. - * - *

`smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. - */ - @Override - public void reactSmoothScrollTo(int x, int y) { - ReactScrollViewHelper.smoothScrollTo(this, x, y); - setPendingContentOffsets(x, y); - } - - /** - * Calls `super.scrollTo` and updates state. - * - *

`super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. - * - *

Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it - * is final. See `reactSmoothScrollTo`. - */ - @Override - public void scrollTo(int x, int y) { - super.scrollTo(x, y); - ReactScrollViewHelper.updateFabricScrollState(this); - setPendingContentOffsets(x, y); - } - - /** - * If we are in the middle of a fling animation from the user removing their finger (OverScroller - * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against - * outdated scroll offsets. - */ - private void recreateFlingAnimation(int scrollY) { - // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap - // point), cancel them. - // TODO: Can we be more graceful (like OverScroller flings)? - if (getFlingAnimator().isRunning()) { - getFlingAnimator().cancel(); - } - - if (mScroller != null && !mScroller.isFinished()) { - // Calculate the velocity and position of the fling animation at the time of this layout - // event, which may be later than the last NestedScrollView tick. These values are not committed to - // the underlying NestedScrollView, which will recalculate positions on its next tick. - int scrollerYBeforeTick = mScroller.getCurrY(); - boolean hasMoreTicks = mScroller.computeScrollOffset(); - - // Stop the existing animation at the current state of the scroller. We will then recreate - // it starting at the adjusted y offset. - mScroller.forceFinished(true); - - if (hasMoreTicks) { - // OverScroller.getCurrVelocity() returns an absolute value of the velocity a current fling - // animation (only FLING_MODE animations). We derive direction along the Y axis from the - // start and end of the, animation assuming NestedScrollView never fires horizontal fling - // animations. - // TODO: This does not fully handle overscroll. - float direction = Math.signum(mScroller.getFinalY() - mScroller.getStartY()); - float flingVelocityY = mScroller.getCurrVelocity() * direction; - - mScroller.fling(getScrollX(), scrollY, 0, (int) flingVelocityY, 0, 0, 0, Integer.MAX_VALUE); - } else { - scrollTo(getScrollX(), scrollY + (mScroller.getCurrY() - scrollerYBeforeTick)); - } - } - } - - /** Scrolls to a new position preserving any momentum scrolling animation. */ - @Override - public void scrollToPreservingMomentum(int x, int y) { - scrollTo(x, y); - recreateFlingAnimation(y); - } - - private boolean isContentReady() { - View child = getContentView(); - return child != null && child.getWidth() != 0 && child.getHeight() != 0; - } - - /** - * If contentOffset is set before the View has been laid out, store the values and set them when - * `onLayout` is called. - * - * @param x - * @param y - */ - private void setPendingContentOffsets(int x, int y) { - if (isContentReady()) { - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - } else { - mPendingContentOffsetX = x; - mPendingContentOffsetY = y; - } - } - - /** - * Called when a mContentView's layout has changed. Fixes the scroll position if it's too large - * after the content resizes. Without this, the user would see a blank NestedScrollView when the scroll - * position is larger than the NestedScrollView's max scroll position after the content shrinks. - */ - @Override - public void onLayoutChange( - View v, - int left, - int top, - int right, - int bottom, - int oldLeft, - int oldTop, - int oldRight, - int oldBottom) { - if (mContentView == null) { - return; - } - - if (isShown() && isContentReady()) { - int currentScrollY = getScrollY(); - int maxScrollY = getMaxScrollY(); - if (currentScrollY > maxScrollY) { - scrollTo(getScrollX(), maxScrollY); - } - } - - ReactScrollViewHelper.emitLayoutChangeEvent(this); - } - - @Override - public void setBackgroundColor(int color) { - BackgroundStyleApplicator.setBackgroundColor(this, color); - } - - public void setBorderWidth(int position, float width) { - BackgroundStyleApplicator.setBorderWidth( - this, LogicalEdge.values()[position], PixelUtil.toDIPFromPixel(width)); - } - - public void setBorderColor(int position, @Nullable Integer color) { - BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.values()[position], color); - } - - public void setBorderRadius(float borderRadius) { - setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal()); - } - - public void setBorderRadius(float borderRadius, int position) { - @Nullable - LengthPercentage radius = - Float.isNaN(borderRadius) - ? null - : new LengthPercentage( - PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT); - BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.values()[position], radius); - } - - public void setBorderStyle(@Nullable String style) { - BackgroundStyleApplicator.setBorderStyle( - this, style == null ? null : BorderStyle.fromString(style)); - } - - /** - * ScrollAway: This enables a natively-controlled navbar that optionally obscures the top content - * of the NestedScrollView. Whether or not the navbar is obscuring the React Native surface is - * determined outside of React Native. - * - *

Note: all NestedScrollViews and HorizontalScrollViews in React have exactly one child: the - * "content" View (see NestedScrollView.js). That View is non-collapsable so it will never be - * View-flattened away. However, it is possible to pass custom styles into that View. - * - *

If you are using this feature it is assumed that you have full control over this NestedScrollView - * and that you are **not** overriding the NestedScrollView content view to pass in a `translateY` - * style. `translateY` must never be set from ReactJS while using this feature! - */ - public void setScrollAwayPaddingEnabledUnstable(int topPadding, int bottomPadding) { - setScrollAwayPaddingEnabledUnstable(topPadding, bottomPadding, true); - } - - public void setScrollAwayPaddingEnabledUnstable( - int topPadding, int bottomPadding, boolean updateState) { - int count = getChildCount(); - - Assertions.assertCondition( - count <= 1, - "React Native NestedScrollView should not have more than one child, it should have exactly 1" - + " child; a content View"); - - if (count > 0) { - for (int i = 0; i < count; i++) { - View childView = getChildAt(i); - childView.setTranslationY(topPadding); - } - - // Add the topPadding value as the bottom padding for the NestedScrollView. - // Otherwise, we'll push down the contents of the scroll view down too - // far off screen. - setPadding(0, 0, 0, topPadding + bottomPadding); - } - - if (updateState) { - updateScrollAwayState(topPadding, bottomPadding); - } - setRemoveClippedSubviews(mRemoveClippedSubviews); - } - - private void updateScrollAwayState(int scrollAwayPaddingTop, int scrollAwayPaddingBottom) { - getReactScrollViewScrollState().setScrollAwayPaddingTop(scrollAwayPaddingTop); - getReactScrollViewScrollState().setScrollAwayPaddingBottom(scrollAwayPaddingBottom); - ReactScrollViewHelper.forceUpdateState(this); - } - - @Override - public void setReactScrollViewScrollState(ReactScrollViewScrollState scrollState) { - mReactScrollViewScrollState = scrollState; - if (ReactNativeFeatureFlags.enableViewCulling() - || ReactNativeFeatureFlags.useTraitHiddenOnAndroid()) { - setScrollAwayPaddingEnabledUnstable( - scrollState.getScrollAwayPaddingTop(), scrollState.getScrollAwayPaddingBottom(), false); - - Point scrollPosition = scrollState.getLastStateUpdateScroll(); - scrollTo(scrollPosition.x, scrollPosition.y); - } - } - - @Override - public ReactScrollViewScrollState getReactScrollViewScrollState() { - return mReactScrollViewScrollState; - } - - @Override - public void startFlingAnimator(int start, int end) { - // Always cancel existing animator before starting the new one. `smoothScrollTo` contains some - // logic that, if called multiple times in a short amount of time, will treat all calls as part - // of the same animation and will not lengthen the duration of the animation. This means that, - // for example, if the user is scrolling rapidly, multiple pages could be considered part of one - // animation, causing some page animations to be animated very rapidly - looking like they're - // not animated at all. - DEFAULT_FLING_ANIMATOR.cancel(); - - // Update the fling animator with new values - int duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()); - DEFAULT_FLING_ANIMATOR.setDuration(duration).setIntValues(start, end); - - // Start the animator - DEFAULT_FLING_ANIMATOR.start(); - - if (mSendMomentumEvents) { - int yVelocity = 0; - if (duration > 0) { - yVelocity = (end - start) / duration; - } - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, 0, yVelocity); - ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this); - } - } - - @NonNull - @Override - public ValueAnimator getFlingAnimator() { - return DEFAULT_FLING_ANIMATOR; - } - - @Override - public int getFlingExtrapolatedDistance(int velocityY) { - // The DEFAULT_FLING_ANIMATOR uses AccelerateDecelerateInterpolator, which is not depending on - // the init velocity. We use the overscroller to decide the fling distance. - return ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()) - .y; - } - - public void setPointerEvents(PointerEvents pointerEvents) { - mPointerEvents = pointerEvents; - } - - public PointerEvents getPointerEvents() { - return mPointerEvents; - } - - @Override - public void setScrollEventThrottle(int scrollEventThrottle) { - mScrollEventThrottle = scrollEventThrottle; - } - - @Override - public int getScrollEventThrottle() { - return mScrollEventThrottle; - } - - @Override - public void setLastScrollDispatchTime(long lastScrollDispatchTime) { - mLastScrollDispatchTime = lastScrollDispatchTime; - } - - @Override - public long getLastScrollDispatchTime() { - return mLastScrollDispatchTime; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt new file mode 100644 index 000000000000..3f5e9599bf5d --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt @@ -0,0 +1,1311 @@ +/* + * 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. + * + * @generated SignedSource<> + */ + +/** + * THIS FILE IS GENERATED - DO NOT EDIT DIRECTLY + * Source: ReactScrollView.kt + * Run: node generate-nested-scroll-view.js + */ + +package com.facebook.react.views.scroll + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.OverScroller +import androidx.core.widget.NestedScrollView +import androidx.core.view.ViewCompat +import com.facebook.common.logging.FLog +import com.facebook.react.R +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.common.ReactConstants +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.MeasureSpecAssertions +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactClippingViewGroup +import com.facebook.react.uimanager.ReactClippingViewGroupHelper +import com.facebook.react.uimanager.ReactOverflowViewWithInset +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.events.NativeGestureUtil +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle +import com.facebook.react.uimanager.style.LogicalEdge +import com.facebook.react.uimanager.style.Overflow +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START +import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.systrace.Systrace +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.sign + +/** + * A simple subclass of NestedScrollView that doesn't dispatch measure and layout to its children and has + * a scroll listener to send scroll events to JS. + * + * ReactNestedScrollView only supports vertical scrolling. For horizontal scrolling, use + * [ReactHorizontalScrollView]. + */ +internal open class ReactNestedScrollView +@JvmOverloads +constructor(context: Context, private val fpsListener: FpsListener? = null) : + NestedScrollView(context), + ReactClippingViewGroup, + ViewGroup.OnHierarchyChangeListener, + View.OnLayoutChangeListener, + ReactAccessibleScrollView, + ReactOverflowViewWithInset, + HasScrollState, + HasStateWrapper, + HasFlingAnimator, + HasScrollEventThrottle, + HasSmoothScroll, + VirtualViewContainer { + + private companion object { + private var scrollerField: java.lang.reflect.Field? = null + private var triedToGetScrollerField = false + + private const val UNSET_CONTENT_OFFSET = -1 + } + + // Public / interface property overrides + override var scrollEnabled: Boolean = true + override var stateWrapper: StateWrapper? = null + override var scrollEventThrottle: Int = 0 + override var lastScrollDispatchTime: Long = 0L + + public open var pointerEvents: PointerEvents = PointerEvents.AUTO + + public open var fadingEdgeLengthStart: Int = 0 + set(value) { + field = value + invalidate() + } + + public open var fadingEdgeLengthEnd: Int = 0 + set(value) { + field = value + invalidate() + } + + override val virtualViewContainerState: VirtualViewContainerState + get() = + _virtualViewContainerState + ?: VirtualViewContainerState.create(this).also { _virtualViewContainerState = it } + + override val overflowInset: Rect + get() = _overflowInset + + override val overflow: String? + get() = + when (_overflow) { + Overflow.HIDDEN -> "hidden" + Overflow.SCROLL -> "scroll" + Overflow.VISIBLE -> "visible" + } + + override var removeClippedSubviews: Boolean + get() = _removeClippedSubviews + set(value) { + if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) return + if (value && clippingRect == null) clippingRect = Rect() + _removeClippedSubviews = value + updateClippingRect() + } + + override var reactScrollViewScrollState: ReactScrollViewScrollState + get() = _reactScrollViewScrollState + set(value) { + _reactScrollViewScrollState = value + if ( + ReactNativeFeatureFlags.enableViewCulling() || + ReactNativeFeatureFlags.useTraitHiddenOnAndroid() + ) { + setScrollAwayPaddingEnabledUnstable( + value.scrollAwayPaddingTop, + value.scrollAwayPaddingBottom, + false, + ) + val scrollPosition = value.lastStateUpdateScroll + scrollTo(scrollPosition.x, scrollPosition.y) + } + } + + // Private state + private val onScrollDispatchHelper = OnScrollDispatchHelper() + private val scroller: OverScroller? = getOverScrollerFromParent() + private val velocityHelper = VelocityHelper() + private val tempRect = Rect() + private val defaultFlingAnimator: ValueAnimator = ObjectAnimator.ofInt(this, "scrollY", 0, 0) + + private var _overflowInset = Rect() + private var _virtualViewContainerState: VirtualViewContainerState? = null + private var _removeClippedSubviews = false + private var _reactScrollViewScrollState = ReactScrollViewScrollState() + + private var activelyScrolling = false + private var clippingRect: Rect? = null + private var _overflow: Overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) { + Overflow.VISIBLE + } else { + Overflow.SCROLL + } + private var dragging = false + private var pagingEnabled = false + private var postTouchRunnable: Runnable? = null + private var sendMomentumEvents = false + private var scrollPerfTag: String? = null + private var endBackground: Drawable? = null + private var endFillColor = Color.TRANSPARENT + private var disableIntervalMomentum = false + private var snapInterval = 0 + private var snapOffsets: List? = null + private var snapToStart = true + private var snapToEnd = true + private var snapToAlignment = SNAP_ALIGNMENT_DISABLED + private var contentView: View? = null + private var currentContentOffset: ReadableMap? = null + private var pendingContentOffsetX = UNSET_CONTENT_OFFSET + private var pendingContentOffsetY = UNSET_CONTENT_OFFSET + private var maintainVisibleContentPositionHelper: + MaintainVisibleScrollPositionHelper? = + null + private var emittedOverScrollSinceScrollBegin = false + private var scrollsChildToFocus = true + + init { + setOnHierarchyChangeListener(this) + setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY) + setClipChildren(false) + ViewCompat.setAccessibilityDelegate(this, ReactScrollViewAccessibilityDelegate()) + initView() + } + + /** + * Set all default values here as opposed to in the constructor or field defaults. It is important + * that these properties are set during the constructor, but also on-demand whenever an existing + * ReactNestedScrollView is recycled. + */ + private fun initView() { + _overflowInset = Rect() + _virtualViewContainerState = null + activelyScrolling = false + clippingRect = null + _overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) { + Overflow.VISIBLE + } else { + Overflow.SCROLL + } + dragging = false + pagingEnabled = false + postTouchRunnable = null + _removeClippedSubviews = false + scrollEnabled = true + sendMomentumEvents = false + scrollPerfTag = null + endBackground = null + endFillColor = Color.TRANSPARENT + disableIntervalMomentum = false + snapInterval = 0 + snapOffsets = null + snapToStart = true + snapToEnd = true + snapToAlignment = SNAP_ALIGNMENT_DISABLED + contentView = null + currentContentOffset = null + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + stateWrapper = null + _reactScrollViewScrollState = ReactScrollViewScrollState() + pointerEvents = PointerEvents.AUTO + lastScrollDispatchTime = 0 + scrollEventThrottle = 0 + maintainVisibleContentPositionHelper = null + fadingEdgeLengthStart = 0 + fadingEdgeLengthEnd = 0 + emittedOverScrollSinceScrollBegin = false + scrollsChildToFocus = true + } + + internal fun recycleView() { + initView() + if (parent != null) { + (parent as ViewGroup).removeView(this) + } + updateView() + } + + private fun updateView() {} + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + val testId = getTag(R.id.react_test_id) as? String + if (testId != null) { + info.viewIdResourceName = testId + } + } + + protected open fun getOverScrollerFromParent(): OverScroller? { + if (!triedToGetScrollerField) { + triedToGetScrollerField = true + try { + scrollerField = NestedScrollView::class.java.getDeclaredField("mScroller") + scrollerField?.isAccessible = true + } catch (e: NoSuchFieldException) { + FLog.w( + ReactConstants.TAG, + "Failed to get mScroller field for NestedScrollView! " + + "This app will exhibit the bounce-back scrolling bug :(", + ) + } + } + + val cachedScrollerField = scrollerField ?: return null + return try { + val scrollerValue = cachedScrollerField.get(this) + if (scrollerValue is OverScroller) { + scrollerValue + } else { + FLog.w( + ReactConstants.TAG, + "Failed to cast mScroller field in NestedScrollView (probably due to OEM changes to AOSP)! " + + "This app will exhibit the bounce-back scrolling bug :(", + ) + null + } + } catch (e: IllegalAccessException) { + throw RuntimeException("Failed to get mScroller from NestedScrollView!", e) + } + } + + public open fun setDisableIntervalMomentum(disableIntervalMomentum: Boolean) { + this.disableIntervalMomentum = disableIntervalMomentum + } + + public open fun setSendMomentumEvents(sendMomentumEvents: Boolean) { + this.sendMomentumEvents = sendMomentumEvents + } + + public open fun setScrollPerfTag(scrollPerfTag: String?) { + this.scrollPerfTag = scrollPerfTag + } + + public open fun setPagingEnabled(pagingEnabled: Boolean) { + this.pagingEnabled = pagingEnabled + } + + public open fun setScrollsChildToFocus(scrollsChildToFocus: Boolean) { + this.scrollsChildToFocus = scrollsChildToFocus + } + + public open fun setDecelerationRate(decelerationRate: Float) { + reactScrollViewScrollState.decelerationRate = decelerationRate + scroller?.setFriction(1.0f - decelerationRate) + } + + public open fun abortAnimation() { + if (scroller != null && !scroller.isFinished) { + scroller.abortAnimation() + } + } + + public open fun setSnapInterval(snapInterval: Int) { + this.snapInterval = snapInterval + } + + public open fun setSnapOffsets(snapOffsets: List?) { + this.snapOffsets = snapOffsets + } + + public open fun setSnapToStart(snapToStart: Boolean) { + this.snapToStart = snapToStart + } + + public open fun setSnapToEnd(snapToEnd: Boolean) { + this.snapToEnd = snapToEnd + } + + public open fun setSnapToAlignment(snapToAlignment: Int) { + this.snapToAlignment = snapToAlignment + } + + public open fun flashScrollIndicators() { + awakenScrollBars() + } + + override fun getTopFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + return fadingEdgeLengthStart / max + } + + override fun getBottomFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + return fadingEdgeLengthEnd / max + } + + public open fun setOverflow(overflow: String?) { + _overflow = + if (overflow == null) { + Overflow.SCROLL + } else { + Overflow.fromString(overflow) + ?: if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) + Overflow.VISIBLE + else Overflow.SCROLL + } + invalidate() + } + + public open fun setMaintainVisibleContentPosition( + config: MaintainVisibleScrollPositionHelper.Config? + ) { + if (config != null && maintainVisibleContentPositionHelper == null) { + maintainVisibleContentPositionHelper = + MaintainVisibleScrollPositionHelper(this, false).also { it.start() } + } else if (config == null && maintainVisibleContentPositionHelper != null) { + maintainVisibleContentPositionHelper?.stop() + maintainVisibleContentPositionHelper = null + } + maintainVisibleContentPositionHelper?.let { it.config = config } + } + + override fun setOverflowInset(left: Int, top: Int, right: Int, bottom: Int) { + _overflowInset.set(left, top, right, bottom) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec), + ) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + if (isContentReady()) { + val scrollToX = + if (pendingContentOffsetX != UNSET_CONTENT_OFFSET) pendingContentOffsetX else scrollX + val scrollToY = + if (pendingContentOffsetY != UNSET_CONTENT_OFFSET) pendingContentOffsetY else scrollY + scrollTo(scrollToX, scrollToY) + } + + ReactScrollViewHelper.emitLayoutEvent(this) + _virtualViewContainerState?.updateState() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (_removeClippedSubviews) { + updateClippingRect() + } + _virtualViewContainerState?.updateState() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (_removeClippedSubviews) { + updateClippingRect() + } + maintainVisibleContentPositionHelper?.start() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + maintainVisibleContentPositionHelper?.stop() + } + + override fun focusSearch(focused: View, direction: Int): View? { + val nextFocus = super.focusSearch(focused, direction) + + if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { + if (nextFocus != null && findViewById(nextFocus.id) != null) { + return nextFocus + } + val nextFocusableView = findNextFocusableView(this, focused, direction) + if (nextFocusableView != null) { + return nextFocusableView + } + } + + return nextFocus + } + + /** + * Since ReactNestedScrollView handles layout changes on JS side, it does not call super.onLayout due to + * which mIsLayoutDirty flag in NestedScrollView remains true and prevents scrolling to child when + * requestChildFocus is called. Overriding this method and scrolling to child without checking any + * layout dirty flag. This will fix focus navigation issue for KeyEvents which are not handled by + * NestedScrollView, for example: KEYCODE_TAB. + */ + override fun requestChildFocus(child: View, focused: View?) { + if (focused != null && scrollsChildToFocus) { + scrollToChild(focused) + } + requestChildFocusWithoutScroll(child, focused) + } + + /** + * In rare cases where an app overrides the built-in ReactNestedScrollView by overriding it, and also + * needs to customize scroll into view on focus behaviors, this protected method can be used to + * unblocks such customization. + */ + protected open fun requestChildFocusWithoutScroll(child: View, focused: View?) { + super.requestChildFocus(child, focused) + } + + override fun requestChildRectangleOnScreen( + child: View, + rectangle: Rect, + immediate: Boolean, + ): Boolean { + if (!scrollsChildToFocus) return false + return super.requestChildRectangleOnScreen(child, rectangle, immediate) + } + + private fun getScrollDelta(descendent: View): Int { + descendent.getDrawingRect(tempRect) + offsetDescendantRectToMyCoords(descendent, tempRect) + return computeScrollDeltaToGetChildRectOnScreen(tempRect) + } + + override fun isPartiallyScrolledInView(view: View): Boolean { + val scrollDelta = getScrollDelta(view) + view.getDrawingRect(tempRect) + return scrollDelta != 0 && abs(scrollDelta) < tempRect.width() + } + + private fun scrollToChild(child: View) { + var parent: View? = child + var scrollViewAncestor: View? = null + while (parent != null && parent !== this) { + if (parent is ReactNestedScrollView) { + scrollViewAncestor = parent + } + parent = parent.parent as? View + } + + val scrollIntoViewTarget = scrollViewAncestor ?: child + val tempRect = Rect() + scrollIntoViewTarget.getDrawingRect(tempRect) + offsetDescendantRectToMyCoords(scrollIntoViewTarget, tempRect) + val scrollDelta = computeScrollDeltaToGetChildRectOnScreen(tempRect) + if (scrollDelta != 0) { + scrollBy(0, scrollDelta) + } + } + + override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) { + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactNestedScrollView.onScrollChanged") + try { + super.onScrollChanged(x, y, oldX, oldY) + activelyScrolling = true + if (onScrollDispatchHelper.onScrollChanged(x, y)) { + if (_removeClippedSubviews) { + updateClippingRect() + } + ReactScrollViewHelper.updateStateOnScrollChanged( + this, + onScrollDispatchHelper.xFlingVelocity, + onScrollDispatchHelper.yFlingVelocity, + ) + _virtualViewContainerState?.updateState() + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return true + + try { + if (super.onInterceptTouchEvent(ev)) { + handleInterceptedTouchEvent(ev) + return true + } + } catch (e: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e) + } + + return false + } + + protected open fun handleInterceptedTouchEvent(ev: MotionEvent) { + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureStarted(this, ev) + } + ReactScrollViewHelper.emitScrollBeginDragEvent(this) + dragging = true + emittedOverScrollSinceScrollBegin = false + enableFpsListener() + getFlingAnimator().cancel() + } + + override fun onTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canBeTouchTarget(pointerEvents)) return false + + velocityHelper.calculateVelocity(ev) + val action = ev.actionMasked + if (action == MotionEvent.ACTION_UP && dragging) { + ReactScrollViewHelper.updateFabricScrollState(this) + val velocityX = velocityHelper.xVelocity + val velocityY = velocityHelper.yVelocity + ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY) + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureEnded(this, ev) + } + dragging = false + handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)) + } + + if (action == MotionEvent.ACTION_DOWN) { + cancelPostTouchScrolling() + } + + return try { + super.onTouchEvent(ev) + } catch (e: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "Error handling touch event.", e) + false + } + } + + override fun dispatchGenericMotionEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return false + + if (ev.actionMasked == MotionEvent.ACTION_SCROLL) { + val vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (vScroll != 0f) { + enableFpsListener() + val result = super.dispatchGenericMotionEvent(ev) + if ( + result && + (pagingEnabled || + snapInterval != 0 || + snapOffsets != null || + snapToAlignment != SNAP_ALIGNMENT_DISABLED) + ) { + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + } + postTouchRunnable = Runnable { + postTouchRunnable = null + var velocityY = (-vScroll.sign).toInt() + if (disableIntervalMomentum) { + velocityY = 0 + } + flingAndSnap(velocityY) + handlePostTouchScrolling(0, velocityY) + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } else { + handlePostTouchScrolling(0, 0) + } + return result + } + } + + return super.dispatchGenericMotionEvent(ev) + } + + override fun executeKeyEvent(event: KeyEvent): Boolean { + val eventKeyCode = event.keyCode + if ( + !scrollEnabled && + (eventKeyCode == KeyEvent.KEYCODE_DPAD_UP || eventKeyCode == KeyEvent.KEYCODE_DPAD_DOWN) + ) { + return false + } + return super.executeKeyEvent(event) + } + + override fun updateClippingRect() { + updateClippingRect(null) + } + + override fun updateClippingRect(excludedViews: Set?) { + if (!_removeClippedSubviews) return + + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactNestedScrollView.updateClippingRect") + try { + val rect = checkNotNull(clippingRect) + ReactClippingViewGroupHelper.calculateClippingRect(this, rect) + val cv = getContentView() + if (cv is ReactClippingViewGroup) { + cv.updateClippingRect(excludedViews) + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun getClippingRect(outClippingRect: Rect) { + outClippingRect.set(checkNotNull(clippingRect)) + } + + override fun getChildVisibleRect(child: View, r: Rect, offset: android.graphics.Point?): Boolean { + return super.getChildVisibleRect(child, r, offset) + } + + override fun fling(velocityY: Int) { + val correctedVelocityY = correctFlingVelocityY(velocityY) + + if (pagingEnabled) { + flingAndSnap(correctedVelocityY) + } else if (scroller != null) { + val scrollWindowHeight = height - paddingBottom - paddingTop + scroller.fling( + scrollX, // startX + scrollY, // startY + 0, // velocityX + correctedVelocityY, // velocityY + 0, // minX + 0, // maxX + 0, // minY + Int.MAX_VALUE, // maxY + 0, // overX + scrollWindowHeight / 2, // overY + ) + postInvalidateOnAnimation() + } else { + super.fling(correctedVelocityY) + } + handlePostTouchScrolling(0, correctedVelocityY) + } + + private fun correctFlingVelocityY(velocityY: Int): Int { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) return velocityY + var signum = onScrollDispatchHelper.yFlingVelocity.sign + if (signum == 0f) { + signum = velocityY.toFloat().sign + } + return (abs(velocityY) * signum).toInt() + } + + private fun enableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + val listener = checkNotNull(fpsListener) + val perfTag = checkNotNull(scrollPerfTag) + listener.enable(perfTag) + } + } + + private fun disableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + val listener = checkNotNull(fpsListener) + val perfTag = checkNotNull(scrollPerfTag) + listener.disable(perfTag) + } + } + + private fun isScrollPerfLoggingEnabled(): Boolean { + return fpsListener != null && !scrollPerfTag.isNullOrEmpty() + } + + private fun getMaxScrollY(): Int { + val contentHeight = contentView?.height ?: 0 + val viewportHeight = height - paddingBottom - paddingTop + return max(0, contentHeight - viewportHeight) + } + + override fun draw(canvas: Canvas) { + if (endFillColor != Color.TRANSPARENT) { + val cv = getContentView() + val bg = endBackground + if (bg != null && cv != null && cv.bottom < height) { + bg.setBounds(0, cv.bottom, width, height) + bg.draw(canvas) + } + } + super.draw(canvas) + } + + public override fun onDraw(canvas: Canvas) { + if (_overflow != Overflow.VISIBLE) { + BackgroundStyleApplicator.clipToPaddingBox(this, canvas) + } + super.onDraw(canvas) + } + + /** + * This handles any sort of scrolling that may occur after a touch is finished. This may be + * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we + * don't get any events from Android about this lifecycle, we do all our detection by creating a + * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. + */ + private fun handlePostTouchScrolling(velocityX: Int, velocityY: Int) { + if (postTouchRunnable != null) return + + if (sendMomentumEvents) { + enableFpsListener() + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY) + } + + activelyScrolling = false + postTouchRunnable = + object : Runnable { + private var snappingToPage = false + private var stableFrames = 0 + + override fun run() { + if (activelyScrolling) { + activelyScrolling = false + stableFrames = 0 + this@ReactNestedScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } else { + ReactScrollViewHelper.updateFabricScrollState(this@ReactNestedScrollView) + stableFrames++ + + if (stableFrames >= 3) { + postTouchRunnable = null + if (sendMomentumEvents) { + ReactScrollViewHelper.emitScrollMomentumEndEvent(this@ReactNestedScrollView) + } + // Kotlin name is notifyUserDrivenScrollEnded; the _internal suffix is + // only a @JvmName alias for Java callers. + ReactScrollViewHelper.notifyUserDrivenScrollEnded(this@ReactNestedScrollView) + disableFpsListener() + } else { + if (pagingEnabled && !snappingToPage) { + snappingToPage = true + flingAndSnap(0) + } + this@ReactNestedScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } + } + } + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } + + private fun cancelPostTouchScrolling() { + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + getFlingAnimator().cancel() + } + } + + // Predict where a fling would end up so we can scroll to the nearest snap offset. + // TODO(T106335409): Existing prediction still uses overscroller. Consider changing this to + // use fling animator instead. + private fun predictFinalScrollPosition(velocityY: Int): Int { + return if (getFlingAnimator() === defaultFlingAnimator) { + ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()).y + } else { + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollY, + reactScrollViewScrollState.finalAnimatedPositionScroll.y, + velocityY, + ) + getFlingExtrapolatedDistance(velocityY) + } + } + + private fun getContentView(): View? = getChildAt(0) + + /** + * This will smooth scroll us to the nearest snap offset point. It currently just looks at where + * the content is and slides to the nearest point. It is intended to be run after we are done + * scrolling, and handling any momentum scrolling. + */ + private fun smoothScrollAndSnap(velocity: Int) { + val interval = getSnapInterval().toDouble() + val currentOffset = + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollY, + reactScrollViewScrollState.finalAnimatedPositionScroll.y, + velocity, + ) + .toDouble() + val targetOffset = predictFinalScrollPosition(velocity).toDouble() + + var previousPage = floor(currentOffset / interval).toInt() + var nextPage = ceil(currentOffset / interval).toInt() + var currentPage = round(currentOffset / interval).toInt() + val targetPage = round(targetOffset / interval).toInt() + + if (velocity > 0 && nextPage == previousPage) { + nextPage++ + } else if (velocity < 0 && previousPage == nextPage) { + previousPage-- + } + + if (velocity > 0 && currentPage < nextPage && targetPage > previousPage) { + currentPage = nextPage + } else if (velocity < 0 && currentPage > previousPage && targetPage < nextPage) { + currentPage = previousPage + } + + val finalTargetOffset = currentPage * interval + if (finalTargetOffset != currentOffset) { + activelyScrolling = true + reactSmoothScrollTo(scrollX, finalTargetOffset.toInt()) + } + } + + private fun flingAndSnap(velocityY: Int) { + if (childCount <= 0) return + + if (snapInterval == 0 && snapOffsets == null && snapToAlignment == SNAP_ALIGNMENT_DISABLED) { + smoothScrollAndSnap(velocityY) + return + } + + @Suppress("NAME_SHADOWING") var velocityY = velocityY + val hasCustomizedFlingAnimator = getFlingAnimator() !== defaultFlingAnimator + val maximumOffset = getMaxScrollY() + var targetOffset = predictFinalScrollPosition(velocityY) + if (disableIntervalMomentum) { + targetOffset = scrollY + } + + var smallerOffset = 0 + var largerOffset = maximumOffset + var firstOffset = 0 + var lastOffset = maximumOffset + val viewportHeight = height - paddingBottom - paddingTop + + val currentSnapOffsets = snapOffsets + if (currentSnapOffsets != null) { + firstOffset = currentSnapOffsets[0] + lastOffset = currentSnapOffsets[currentSnapOffsets.size - 1] + + for (i in currentSnapOffsets.indices) { + val offset = currentSnapOffsets[i] + if (offset <= targetOffset) { + if (targetOffset - offset < targetOffset - smallerOffset) { + smallerOffset = offset + } + } + if (offset >= targetOffset) { + if (offset - targetOffset < largerOffset - targetOffset) { + largerOffset = offset + } + } + } + } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + if (snapInterval > 0) { + val ratio = targetOffset.toDouble() / snapInterval + smallerOffset = + max( + getItemStartOffset( + snapToAlignment, + (floor(ratio) * snapInterval).toInt(), + snapInterval, + viewportHeight, + ), + 0, + ) + largerOffset = + min( + getItemStartOffset( + snapToAlignment, + (ceil(ratio) * snapInterval).toInt(), + snapInterval, + viewportHeight, + ), + maximumOffset, + ) + } else { + val cv = getContentView() as ViewGroup + var smallerChildOffset = largerOffset + var largerChildOffset = smallerOffset + for (i in 0 until cv.childCount) { + val item = cv.getChildAt(i) + val itemStartOffset = + when (snapToAlignment) { + SNAP_ALIGNMENT_CENTER -> item.top - (viewportHeight - item.height) / 2 + SNAP_ALIGNMENT_START -> item.top + SNAP_ALIGNMENT_END -> item.top - (viewportHeight - item.height) + else -> + throw IllegalStateException("Invalid SnapToAlignment value: $snapToAlignment") + } + if (itemStartOffset <= targetOffset) { + if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { + smallerOffset = itemStartOffset + } + } + if (itemStartOffset >= targetOffset) { + if (itemStartOffset - targetOffset < largerOffset - targetOffset) { + largerOffset = itemStartOffset + } + } + smallerChildOffset = min(smallerChildOffset, itemStartOffset) + largerChildOffset = max(largerChildOffset, itemStartOffset) + } + smallerOffset = max(smallerOffset, smallerChildOffset) + largerOffset = min(largerOffset, largerChildOffset) + } + } else { + val interval = getSnapInterval().toDouble() + val ratio = targetOffset.toDouble() / interval + smallerOffset = (floor(ratio) * interval).toInt() + largerOffset = min((ceil(ratio) * interval).toInt(), maximumOffset) + } + + val nearestOffset = + if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset + else largerOffset + + if (!snapToEnd && targetOffset >= lastOffset) { + if (scrollY >= lastOffset) { + // free scrolling + } else { + targetOffset = lastOffset + } + } else if (!snapToStart && targetOffset <= firstOffset) { + if (scrollY <= firstOffset) { + // free scrolling + } else { + targetOffset = firstOffset + } + } else if (velocityY > 0) { + if (!hasCustomizedFlingAnimator) { + velocityY += ((largerOffset - targetOffset) * 10.0).toInt() + } + targetOffset = largerOffset + } else if (velocityY < 0) { + if (!hasCustomizedFlingAnimator) { + velocityY -= ((targetOffset - smallerOffset) * 10.0).toInt() + } + targetOffset = smallerOffset + } else { + targetOffset = nearestOffset + } + + targetOffset = min(max(0, targetOffset), maximumOffset) + + if (hasCustomizedFlingAnimator || scroller == null) { + reactSmoothScrollTo(scrollX, targetOffset) + } else { + activelyScrolling = true + scroller.fling( + scrollX, // startX + scrollY, // startY + 0, // velocityX + if (velocityY != 0) velocityY else targetOffset - scrollY, // velocityY + 0, // minX + 0, // maxX + targetOffset, // minY + targetOffset, // maxY + 0, // overX + if (targetOffset == 0 || targetOffset == maximumOffset) viewportHeight / 2 + else 0, // overY + ) + postInvalidateOnAnimation() + } + } + + private fun getItemStartOffset( + snapToAlignment: Int, + itemStartPosition: Int, + itemHeight: Int, + viewPortHeight: Int, + ): Int = + when (snapToAlignment) { + SNAP_ALIGNMENT_CENTER -> itemStartPosition - (viewPortHeight - itemHeight) / 2 + SNAP_ALIGNMENT_START -> itemStartPosition + SNAP_ALIGNMENT_END -> itemStartPosition - (viewPortHeight - itemHeight) + else -> throw IllegalStateException("Invalid SnapToAlignment value: $snapToAlignment") + } + + private fun getSnapInterval(): Int = if (snapInterval != 0) snapInterval else height + + public open fun setEndFillColor(color: Int) { + if (color != endFillColor) { + endFillColor = color + endBackground = ColorDrawable(endFillColor) + } + } + + override fun onOverScrolled(scrollX: Int, scrollY: Int, clampedX: Boolean, clampedY: Boolean) { + @Suppress("NAME_SHADOWING") var scrollY = scrollY + if (scroller != null && contentView != null) { + if (!scroller.isFinished && scroller.currY != scroller.finalY) { + val scrollRange = getMaxScrollY() + if (scrollY >= scrollRange) { + scroller.abortAnimation() + scrollY = scrollRange + } + } + } + + if ( + ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() && + clampedY && + !emittedOverScrollSinceScrollBegin + ) { + ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f) + emittedOverScrollSinceScrollBegin = true + } + + super.onOverScrolled(scrollX, scrollY, clampedX, clampedY) + } + + override fun onChildViewAdded(parent: View, child: View) { + contentView = child + child.addOnLayoutChangeListener(this) + } + + override fun onChildViewRemoved(parent: View, child: View) { + contentView?.removeOnLayoutChangeListener(this) + contentView = null + } + + public open fun setContentOffset(value: ReadableMap?) { + if (currentContentOffset == null || currentContentOffset != value) { + currentContentOffset = value + if (value != null) { + val x = if (value.hasKey("x")) value.getDouble("x") else 0.0 + val y = if (value.hasKey("y")) value.getDouble("y") else 0.0 + scrollTo(PixelUtil.toPixelFromDIP(x).toInt(), PixelUtil.toPixelFromDIP(y).toInt()) + } else { + scrollTo(0, 0) + } + } + } + + /** + * Calls `smoothScrollTo` and updates state. + * + * `smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. + */ + override fun reactSmoothScrollTo(x: Int, y: Int) { + ReactScrollViewHelper.smoothScrollTo(this, x, y) + setPendingContentOffsets(x, y) + } + + /** + * Calls `super.scrollTo` and updates state. + * + * `super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. + * + * Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it is + * final. See `reactSmoothScrollTo`. + */ + override fun scrollTo(x: Int, y: Int) { + super.scrollTo(x, y) + ReactScrollViewHelper.updateFabricScrollState(this) + setPendingContentOffsets(x, y) + } + + /** + * If we are in the middle of a fling animation from the user removing their finger (OverScroller + * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against + * outdated scroll offsets. + */ + private fun recreateFlingAnimation(scrollY: Int) { + if (getFlingAnimator().isRunning) { + getFlingAnimator().cancel() + } + + if (scroller != null && !scroller.isFinished) { + val scrollerYBeforeTick = scroller.currY + val hasMoreTicks = scroller.computeScrollOffset() + scroller.forceFinished(true) + + if (hasMoreTicks) { + val direction = (scroller.finalY - scroller.startY).toFloat().sign + val flingVelocityY = scroller.currVelocity * direction + scroller.fling(scrollX, scrollY, 0, flingVelocityY.toInt(), 0, 0, 0, Int.MAX_VALUE) + } else { + scrollTo(scrollX, scrollY + (scroller.currY - scrollerYBeforeTick)) + } + } + } + + override fun scrollToPreservingMomentum(x: Int, y: Int) { + scrollTo(x, y) + recreateFlingAnimation(y) + } + + private fun isContentReady(): Boolean { + val child = getContentView() + return child != null && child.width != 0 && child.height != 0 + } + + private fun setPendingContentOffsets(x: Int, y: Int) { + if (isContentReady()) { + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + } else { + pendingContentOffsetX = x + pendingContentOffsetY = y + } + } + + override fun onLayoutChange( + v: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if (contentView == null) return + + if (isShown && isContentReady()) { + val currentScrollY = scrollY + val maxScrollY = getMaxScrollY() + if (currentScrollY > maxScrollY) { + scrollTo(scrollX, maxScrollY) + } + } + + ReactScrollViewHelper.emitLayoutChangeEvent(this) + } + + override fun setBackgroundColor(color: Int) { + BackgroundStyleApplicator.setBackgroundColor(this, color) + } + + public open fun setBorderWidth(position: Int, width: Float) { + BackgroundStyleApplicator.setBorderWidth( + this, + LogicalEdge.entries[position], + PixelUtil.toDIPFromPixel(width), + ) + } + + public open fun setBorderColor(position: Int, color: Int?) { + BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.entries[position], color) + } + + public open fun setBorderRadius(borderRadius: Float) { + setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal) + } + + public open fun setBorderRadius(borderRadius: Float, position: Int) { + val radius = + if (borderRadius.isNaN()) null + else LengthPercentage(PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT) + BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.entries[position], radius) + } + + public open fun setBorderStyle(style: String?) { + BackgroundStyleApplicator.setBorderStyle( + this, + if (style == null) null else BorderStyle.fromString(style), + ) + } + + /** + * ScrollAway: This enables a natively-controlled navbar that optionally obscures the top content + * of the NestedScrollView. Whether or not the navbar is obscuring the React Native surface is + * determined outside of React Native. + * + * Note: all NestedScrollViews and HorizontalScrollViews in React have exactly one child: the "content" + * View (see NestedScrollView.js). That View is non-collapsable so it will never be View-flattened away. + * However, it is possible to pass custom styles into that View. + * + * If you are using this feature it is assumed that you have full control over this NestedScrollView and + * that you are **not** overriding the NestedScrollView content view to pass in a `translateY` style. + * `translateY` must never be set from ReactJS while using this feature! + */ + public open fun setScrollAwayPaddingEnabledUnstable(topPadding: Int, bottomPadding: Int) { + setScrollAwayPaddingEnabledUnstable(topPadding, bottomPadding, true) + } + + public open fun setScrollAwayPaddingEnabledUnstable( + topPadding: Int, + bottomPadding: Int, + updateState: Boolean, + ) { + val count = childCount + check(count <= 1) { + "React Native NestedScrollView should not have more than one child, it should have exactly 1" + + " child; a content View" + } + + if (count > 0) { + for (i in 0 until count) { + val childView = getChildAt(i) + childView.translationY = topPadding.toFloat() + } + setPadding(0, 0, 0, topPadding + bottomPadding) + } + + if (updateState) { + updateScrollAwayState(topPadding, bottomPadding) + } + removeClippedSubviews = _removeClippedSubviews + } + + private fun updateScrollAwayState(scrollAwayPaddingTop: Int, scrollAwayPaddingBottom: Int) { + _reactScrollViewScrollState.scrollAwayPaddingTop = scrollAwayPaddingTop + _reactScrollViewScrollState.scrollAwayPaddingBottom = scrollAwayPaddingBottom + ReactScrollViewHelper.forceUpdateState(this) + } + + override fun startFlingAnimator(start: Int, end: Int) { + defaultFlingAnimator.cancel() + val duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(context) + defaultFlingAnimator.setDuration(duration.toLong()).setIntValues(start, end) + defaultFlingAnimator.start() + + if (sendMomentumEvents) { + val yVelocity = if (duration > 0) (end - start) / duration else 0 + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, 0, yVelocity) + ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this) + } + } + + override fun getFlingAnimator(): ValueAnimator = defaultFlingAnimator + + override fun getFlingExtrapolatedDistance(velocity: Int): Int = + ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt index 21b6f9fad979..110cf6ac1d21 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<585a90a2f11c5bba8b3e2d011912d302>> + * @generated SignedSource<> */ /** @@ -90,7 +90,7 @@ constructor(private val fpsListener: FpsListener? = null) : @ReactProp(name = "scrollEnabled", defaultBoolean = true) public fun setScrollEnabled(view: ReactNestedScrollView, value: Boolean) { - view.setScrollEnabled(value) + view.scrollEnabled = value // Set focusable to match whether scroll is enabled. This improves keyboarding // experience by not making scrollview a tab stop when you cannot interact with it. @@ -343,8 +343,8 @@ constructor(private val fpsListener: FpsListener? = null) : public fun setFadingEdgeLength(view: ReactNestedScrollView, value: Dynamic) { when (value.type) { ReadableType.Number -> { - view.setFadingEdgeLengthStart(value.asInt()) - view.setFadingEdgeLengthEnd(value.asInt()) + view.fadingEdgeLengthStart = value.asInt() + view.fadingEdgeLengthEnd = value.asInt() } ReadableType.Map -> { value.asMap()?.let { map -> @@ -356,8 +356,8 @@ constructor(private val fpsListener: FpsListener? = null) : if (map.hasKey("end") && map.getInt("end") > 0) { end = map.getInt("end") } - view.setFadingEdgeLengthStart(start) - view.setFadingEdgeLengthEnd(end) + view.fadingEdgeLengthStart = start + view.fadingEdgeLengthEnd = end } } else -> { @@ -396,7 +396,7 @@ constructor(private val fpsListener: FpsListener? = null) : props: ReactStylesDiffMap, stateWrapper: StateWrapper, ): Any? { - view.setStateWrapper(stateWrapper) + view.stateWrapper = stateWrapper if ( ReactNativeFeatureFlags.enableViewCulling() || ReactNativeFeatureFlags.useTraitHiddenOnAndroid() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java deleted file mode 100644 index 1d3c03da6ef3..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ /dev/null @@ -1,1646 +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 static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; -import static com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView; - -import android.animation.ObjectAnimator; -import android.animation.ValueAnimator; -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.view.KeyEvent; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityNodeInfo; -import android.widget.OverScroller; -import android.widget.ScrollView; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; -import androidx.core.view.ViewCompat.FocusDirection; -import com.facebook.common.logging.FLog; -import com.facebook.infer.annotation.Assertions; -import com.facebook.infer.annotation.Nullsafe; -import com.facebook.react.R; -import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.common.ReactConstants; -import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags; -import com.facebook.react.uimanager.BackgroundStyleApplicator; -import com.facebook.react.uimanager.LengthPercentage; -import com.facebook.react.uimanager.LengthPercentageType; -import com.facebook.react.uimanager.MeasureSpecAssertions; -import com.facebook.react.uimanager.PixelUtil; -import com.facebook.react.uimanager.PointerEvents; -import com.facebook.react.uimanager.ReactClippingViewGroup; -import com.facebook.react.uimanager.ReactClippingViewGroupHelper; -import com.facebook.react.uimanager.ReactOverflowViewWithInset; -import com.facebook.react.uimanager.StateWrapper; -import com.facebook.react.uimanager.events.NativeGestureUtil; -import com.facebook.react.uimanager.style.BorderRadiusProp; -import com.facebook.react.uimanager.style.BorderStyle; -import com.facebook.react.uimanager.style.LogicalEdge; -import com.facebook.react.uimanager.style.Overflow; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; -import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper; -import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; -import com.facebook.systrace.Systrace; -import java.lang.reflect.Field; -import java.util.List; -import java.util.Set; - -/** - * A simple subclass of ScrollView that doesn't dispatch measure and layout to its children and has - * a scroll listener to send scroll events to JS. - * - *

ReactScrollView only supports vertical scrolling. For horizontal scrolling, use {@link - * ReactHorizontalScrollView}. - */ -@Nullsafe(Nullsafe.Mode.LOCAL) -public class ReactScrollView extends ScrollView - implements ReactClippingViewGroup, - ViewGroup.OnHierarchyChangeListener, - View.OnLayoutChangeListener, - ReactAccessibleScrollView, - ReactOverflowViewWithInset, - HasScrollState, - HasStateWrapper, - HasFlingAnimator, - HasScrollEventThrottle, - HasSmoothScroll, - VirtualViewContainer { - - private static @Nullable Field sScrollerField; - private static boolean sTriedToGetScrollerField = false; - - private static final int UNSET_CONTENT_OFFSET = -1; - - private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper(); - private final @Nullable OverScroller mScroller; - private final VelocityHelper mVelocityHelper = new VelocityHelper(); - private final Rect mTempRect = new Rect(); - private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollY", 0, 0); - private final @Nullable FpsListener mFpsListener; - - private Rect mOverflowInset; - private @Nullable VirtualViewContainerState mVirtualViewContainerState; - private boolean mActivelyScrolling; - private @Nullable Rect mClippingRect; - private Overflow mOverflow; - private boolean mDragging; - private boolean mPagingEnabled; - private @Nullable Runnable mPostTouchRunnable; - private boolean mRemoveClippedSubviews; - private boolean mScrollEnabled; - private boolean mSendMomentumEvents; - private @Nullable String mScrollPerfTag; - private @Nullable Drawable mEndBackground; - private int mEndFillColor; - private boolean mDisableIntervalMomentum; - private int mSnapInterval; - private @Nullable List mSnapOffsets; - private boolean mSnapToStart; - private boolean mSnapToEnd; - private int mSnapToAlignment; - private @Nullable View mContentView; - private @Nullable ReadableMap mCurrentContentOffset; - private int mPendingContentOffsetX; - private int mPendingContentOffsetY; - private @Nullable StateWrapper mStateWrapper; - private ReactScrollViewScrollState mReactScrollViewScrollState; - private PointerEvents mPointerEvents; - private long mLastScrollDispatchTime; - private int mScrollEventThrottle; - private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper; - private int mFadingEdgeLengthStart; - private int mFadingEdgeLengthEnd; - private boolean mEmittedOverScrollSinceScrollBegin; - private boolean mScrollsChildToFocus = true; - - public ReactScrollView(Context context) { - this(context, null); - } - - public ReactScrollView(Context context, @Nullable FpsListener fpsListener) { - super(context); - mFpsListener = fpsListener; - - mScroller = getOverScrollerFromParent(); - setOnHierarchyChangeListener(this); - setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY); - setClipChildren(false); - - ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); - initView(); - } - - /** - * Set all default values here as opposed to in the constructor or field defaults. It is important - * that these properties are set during the constructor, but also on-demand whenever an existing - * ReactTextView is recycled. - */ - private void initView() { - mOverflowInset = new Rect(); - mVirtualViewContainerState = null; - mActivelyScrolling = false; - mClippingRect = null; - - // The default value for `overflow` is set to `Visible` in the Yoga style props. - mOverflow = - ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL; - - mDragging = false; - mPagingEnabled = false; - mPostTouchRunnable = null; - mRemoveClippedSubviews = false; - mScrollEnabled = true; - mSendMomentumEvents = false; - mScrollPerfTag = null; - mEndBackground = null; - mEndFillColor = Color.TRANSPARENT; - mDisableIntervalMomentum = false; - mSnapInterval = 0; - mSnapOffsets = null; - mSnapToStart = true; - mSnapToEnd = true; - mSnapToAlignment = SNAP_ALIGNMENT_DISABLED; - mContentView = null; - mCurrentContentOffset = null; - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - mStateWrapper = null; - mReactScrollViewScrollState = new ReactScrollViewScrollState(); - mPointerEvents = PointerEvents.AUTO; - mLastScrollDispatchTime = 0; - mScrollEventThrottle = 0; - mMaintainVisibleContentPositionHelper = null; - mFadingEdgeLengthStart = 0; - mFadingEdgeLengthEnd = 0; - mEmittedOverScrollSinceScrollBegin = false; - mScrollsChildToFocus = true; - } - - /* package */ void recycleView() { - // Set default field values - initView(); - - // If the view is still attached to a parent, we need to remove it from the parent - // before we can recycle it. - if (getParent() != null) { - ((ViewGroup) getParent()).removeView(this); - } - updateView(); - } - - private void updateView() {} - - @Override - public VirtualViewContainerState getVirtualViewContainerState() { - if (mVirtualViewContainerState == null) { - mVirtualViewContainerState = VirtualViewContainerState.create(this); - } - - return mVirtualViewContainerState; - } - - @Override - public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { - super.onInitializeAccessibilityNodeInfo(info); - - // Expose the testID prop as the resource-id name of the view. Black-box E2E/UI testing - // frameworks, which interact with the UI through the accessibility framework, do not have - // access to view tags. This allows developers/testers to avoid polluting the - // content-description with test identifiers. - final String testId = (String) this.getTag(R.id.react_test_id); - if (testId != null) { - info.setViewIdResourceName(testId); - } - } - - @Nullable - protected OverScroller getOverScrollerFromParent() { - OverScroller scroller; - - if (!sTriedToGetScrollerField) { - sTriedToGetScrollerField = true; - try { - sScrollerField = ScrollView.class.getDeclaredField("mScroller"); - sScrollerField.setAccessible(true); - } catch (NoSuchFieldException e) { - FLog.w( - ReactConstants.TAG, - "Failed to get mScroller field for ScrollView! " - + "This app will exhibit the bounce-back scrolling bug :("); - } - } - - if (sScrollerField != null) { - try { - Object scrollerValue = sScrollerField.get(this); - if (scrollerValue instanceof OverScroller) { - scroller = (OverScroller) scrollerValue; - } else { - FLog.w( - ReactConstants.TAG, - "Failed to cast mScroller field in ScrollView (probably due to OEM changes to AOSP)! " - + "This app will exhibit the bounce-back scrolling bug :("); - scroller = null; - } - } catch (IllegalAccessException e) { - throw new RuntimeException("Failed to get mScroller from ScrollView!", e); - } - } else { - scroller = null; - } - - return scroller; - } - - public void setDisableIntervalMomentum(boolean disableIntervalMomentum) { - mDisableIntervalMomentum = disableIntervalMomentum; - } - - public void setSendMomentumEvents(boolean sendMomentumEvents) { - mSendMomentumEvents = sendMomentumEvents; - } - - public void setScrollPerfTag(@Nullable String scrollPerfTag) { - mScrollPerfTag = scrollPerfTag; - } - - public void setScrollEnabled(boolean scrollEnabled) { - mScrollEnabled = scrollEnabled; - } - - @Override - public boolean getScrollEnabled() { - return mScrollEnabled; - } - - public void setPagingEnabled(boolean pagingEnabled) { - mPagingEnabled = pagingEnabled; - } - - public void setScrollsChildToFocus(boolean scrollsChildToFocus) { - mScrollsChildToFocus = scrollsChildToFocus; - } - - public void setDecelerationRate(float decelerationRate) { - getReactScrollViewScrollState().setDecelerationRate(decelerationRate); - - if (mScroller != null) { - mScroller.setFriction(1.0f - decelerationRate); - } - } - - public void abortAnimation() { - if (mScroller != null && !mScroller.isFinished()) { - mScroller.abortAnimation(); - } - } - - public void setSnapInterval(int snapInterval) { - mSnapInterval = snapInterval; - } - - public void setSnapOffsets(@Nullable List snapOffsets) { - mSnapOffsets = snapOffsets; - } - - public void setSnapToStart(boolean snapToStart) { - mSnapToStart = snapToStart; - } - - public void setSnapToEnd(boolean snapToEnd) { - mSnapToEnd = snapToEnd; - } - - public void setSnapToAlignment(int snapToAlignment) { - mSnapToAlignment = snapToAlignment; - } - - public void flashScrollIndicators() { - awakenScrollBars(); - } - - public int getFadingEdgeLengthStart() { - return mFadingEdgeLengthStart; - } - - public int getFadingEdgeLengthEnd() { - return mFadingEdgeLengthEnd; - } - - public void setFadingEdgeLengthStart(int start) { - mFadingEdgeLengthStart = start; - invalidate(); - } - - public void setFadingEdgeLengthEnd(int end) { - mFadingEdgeLengthEnd = end; - invalidate(); - } - - @Override - protected float getTopFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - return (mFadingEdgeLengthStart / max); - } - - @Override - protected float getBottomFadingEdgeStrength() { - float max = Math.max(mFadingEdgeLengthStart, mFadingEdgeLengthEnd); - return (mFadingEdgeLengthEnd / max); - } - - public void setOverflow(@Nullable String overflow) { - if (overflow == null) { - mOverflow = Overflow.SCROLL; - } else { - @Nullable Overflow parsedOverflow = Overflow.fromString(overflow); - mOverflow = - parsedOverflow == null - ? (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid() - ? Overflow.VISIBLE - : Overflow.SCROLL) - : parsedOverflow; - } - - invalidate(); - } - - public void setMaintainVisibleContentPosition( - @Nullable MaintainVisibleScrollPositionHelper.Config config) { - if (config != null && mMaintainVisibleContentPositionHelper == null) { - mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, false); - mMaintainVisibleContentPositionHelper.start(); - } else if (config == null && mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - mMaintainVisibleContentPositionHelper = null; - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.setConfig(config); - } - } - - @Override - public @Nullable String getOverflow() { - switch (mOverflow) { - case HIDDEN: - return "hidden"; - case SCROLL: - return "scroll"; - case VISIBLE: - return "visible"; - } - - return null; - } - - @Override - public void setOverflowInset(int left, int top, int right, int bottom) { - mOverflowInset.set(left, top, right, bottom); - } - - @Override - public Rect getOverflowInset() { - return mOverflowInset; - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec); - - setMeasuredDimension( - MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec)); - } - - @Override - protected void onLayout(boolean changed, int l, int t, int r, int b) { - // Apply pending contentOffset in case it was set before the view was laid out. - if (isContentReady()) { - // If a "pending" content offset value has been set, we restore that value. - // Upon call to scrollTo, the "pending" values will be re-set. - int scrollToX = - mPendingContentOffsetX != UNSET_CONTENT_OFFSET ? mPendingContentOffsetX : getScrollX(); - int scrollToY = - mPendingContentOffsetY != UNSET_CONTENT_OFFSET ? mPendingContentOffsetY : getScrollY(); - scrollTo(scrollToX, scrollToY); - } - - ReactScrollViewHelper.emitLayoutEvent(this); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - @Override - protected void onSizeChanged(int w, int h, int oldw, int oldh) { - super.onSizeChanged(w, h, oldw, oldh); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.start(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - if (mMaintainVisibleContentPositionHelper != null) { - mMaintainVisibleContentPositionHelper.stop(); - } - } - - @Override - public @Nullable View focusSearch(View focused, @FocusDirection int direction) { - View nextFocus = super.focusSearch(focused, direction); - - if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { - // If we can find the next focus and it is a child of this view, return it, else it means we - // are leaving the scroll view and we should try to find a clipped element - if (nextFocus != null && this.findViewById(nextFocus.getId()) != null) { - return nextFocus; - } - - @Nullable View nextfocusableView = findNextFocusableView(this, focused, direction); - - if (nextfocusableView != null) { - return nextfocusableView; - } - } - - return nextFocus; - } - - /** - * Since ReactScrollView handles layout changes on JS side, it does not call super.onlayout due to - * which mIsLayoutDirty flag in ScrollView remains true and prevents scrolling to child when - * requestChildFocus is called. Overriding this method and scrolling to child without checking any - * layout dirty flag. This will fix focus navigation issue for KeyEvents which are not handled by - * ScrollView, for example: KEYCODE_TAB. - */ - @Override - public void requestChildFocus(View child, View focused) { - if (focused != null && mScrollsChildToFocus) { - scrollToChild(focused); - } - requestChildFocusWithoutScroll(child, focused); - } - - /** - * In rare cases where an app overrides the built-in ReactScrollView by overriding it, and also - * needs to customize scroll into view on focus behaviors, this protected method can be used to - * unblocks such customization. - */ - protected void requestChildFocusWithoutScroll(View child, View focused) { - super.requestChildFocus(child, focused); - } - - @Override - public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) { - if (!mScrollsChildToFocus) { - return false; - } - return super.requestChildRectangleOnScreen(child, rectangle, immediate); - } - - private int getScrollDelta(View descendent) { - descendent.getDrawingRect(mTempRect); - offsetDescendantRectToMyCoords(descendent, mTempRect); - return computeScrollDeltaToGetChildRectOnScreen(mTempRect); - } - - /** Returns whether the given descendent is partially scrolled in view */ - @Override - public boolean isPartiallyScrolledInView(View descendent) { - int scrollDelta = getScrollDelta(descendent); - descendent.getDrawingRect(mTempRect); - return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width(); - } - - private void scrollToChild(View child) { - // Only scroll the nearest ReactScrollView ancestor into view, rather than the focused child. - // Nested ScrollView instances will handle scrolling the child into their respective viewports. - View parent = child; - View scrollViewAncestor = null; - while (parent != null && parent != this) { - if (parent instanceof ReactScrollView) { - scrollViewAncestor = parent; - } - parent = (View) parent.getParent(); - } - - View scrollIntoViewTarget = scrollViewAncestor != null ? scrollViewAncestor : child; - - Rect tempRect = new Rect(); - scrollIntoViewTarget.getDrawingRect(tempRect); - - /* Offset from child's local coordinates to ScrollView coordinates */ - offsetDescendantRectToMyCoords(scrollIntoViewTarget, tempRect); - - int scrollDelta = computeScrollDeltaToGetChildRectOnScreen(tempRect); - - if (scrollDelta != 0) { - scrollBy(0, scrollDelta); - } - } - - @Override - protected void onScrollChanged(int x, int y, int oldX, int oldY) { - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactScrollView.onScrollChanged"); - try { - super.onScrollChanged(x, y, oldX, oldY); - - mActivelyScrolling = true; - - if (mOnScrollDispatchHelper.onScrollChanged(x, y)) { - if (mRemoveClippedSubviews) { - updateClippingRect(); - } - ReactScrollViewHelper.updateStateOnScrollChanged( - this, - mOnScrollDispatchHelper.getXFlingVelocity(), - mOnScrollDispatchHelper.getYFlingVelocity()); - if (mVirtualViewContainerState != null) { - mVirtualViewContainerState.updateState(); - } - } - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - // We intercept the touch event if the children are not supposed to receive it. - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return true; - } - - try { - if (super.onInterceptTouchEvent(ev)) { - handleInterceptedTouchEvent(ev); - return true; - } - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e); - } - - return false; - } - - protected void handleInterceptedTouchEvent(MotionEvent ev) { - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureStarted(this, ev); - } - ReactScrollViewHelper.emitScrollBeginDragEvent(this); - mDragging = true; - mEmittedOverScrollSinceScrollBegin = false; - enableFpsListener(); - getFlingAnimator().cancel(); - } - - @Override - public boolean onTouchEvent(MotionEvent ev) { - if (!mScrollEnabled) { - return false; - } - - // We do not accept the touch event if this view is not supposed to receive it. - if (!PointerEvents.canBeTouchTarget(mPointerEvents)) { - return false; - } - - mVelocityHelper.calculateVelocity(ev); - int action = ev.getActionMasked(); - if (action == MotionEvent.ACTION_UP && mDragging) { - ReactScrollViewHelper.updateFabricScrollState(this); - - float velocityX = mVelocityHelper.getXVelocity(); - float velocityY = mVelocityHelper.getYVelocity(); - ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY); - if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { - NativeGestureUtil.notifyNativeGestureEnded(this, ev); - } - mDragging = false; - // After the touch finishes, we may need to do some scrolling afterwards either as a result - // of a fling or because we need to page align the content - handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)); - } - - if (action == MotionEvent.ACTION_DOWN) { - cancelPostTouchScrolling(); - } - - try { - return super.onTouchEvent(ev); - } catch (IllegalArgumentException e) { - // Log and ignore the error. This seems to be a bug in the android SDK and - // this is the commonly accepted workaround. - // https://tinyurl.com/mw6qkod (Stack Overflow) - FLog.w(ReactConstants.TAG, "Error handling touch event.", e); - return false; - } - } - - @Override - public boolean dispatchGenericMotionEvent(MotionEvent ev) { - // Ignore generic motion events (joystick, mouse wheel, trackpad) if scrolling is disabled - if (!mScrollEnabled) { - return false; - } - - // We do not dispatch the motion event if its children are not supposed to receive it - if (!PointerEvents.canChildrenBeTouchTarget(mPointerEvents)) { - return false; - } - - // Handle ACTION_SCROLL events (mouse wheel, trackpad, joystick) - if (ev.getActionMasked() == MotionEvent.ACTION_SCROLL) { - float vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); - if (vScroll != 0) { - // Perform the scroll - enableFpsListener(); - boolean result = super.dispatchGenericMotionEvent(ev); - // Schedule snap alignment to run after scrolling stops - if (result - && (mPagingEnabled - || mSnapInterval != 0 - || mSnapOffsets != null - || mSnapToAlignment != SNAP_ALIGNMENT_DISABLED)) { - // Cancel any pending post-touch runnable and reschedule - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - } - mPostTouchRunnable = - new Runnable() { - @Override - public void run() { - mPostTouchRunnable = null; - // +1/-1 velocity if scrolling down or up. This is to ensure that the - // next/previous page is picked rather than sliding backwards to the current page - int velocityY = (int) -Math.signum(vScroll); - if (mDisableIntervalMomentum) { - velocityY = 0; - } - flingAndSnap(velocityY); - handlePostTouchScrolling(0, velocityY); - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - handlePostTouchScrolling(0, 0); - } - return result; - } - } - - return super.dispatchGenericMotionEvent(ev); - } - - @Override - public boolean executeKeyEvent(KeyEvent event) { - int eventKeyCode = event.getKeyCode(); - if (!mScrollEnabled - && (eventKeyCode == KeyEvent.KEYCODE_DPAD_UP - || eventKeyCode == KeyEvent.KEYCODE_DPAD_DOWN)) { - return false; - } - return super.executeKeyEvent(event); - } - - @Override - public void setRemoveClippedSubviews(boolean removeClippedSubviews) { - if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) { - return; - } - - if (removeClippedSubviews && mClippingRect == null) { - mClippingRect = new Rect(); - } - mRemoveClippedSubviews = removeClippedSubviews; - updateClippingRect(); - } - - @Override - public boolean getRemoveClippedSubviews() { - return mRemoveClippedSubviews; - } - - @Override - public void updateClippingRect() { - updateClippingRect(null); - } - - @Override - public void updateClippingRect(@Nullable Set excludedViewsSet) { - if (!mRemoveClippedSubviews) { - return; - } - - Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactScrollView.updateClippingRect"); - try { - Assertions.assertNotNull(mClippingRect); - - ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect); - View contentView = getContentView(); - if (contentView instanceof ReactClippingViewGroup) { - ((ReactClippingViewGroup) contentView).updateClippingRect(excludedViewsSet); - } - } finally { - Systrace.endSection(Systrace.TRACE_TAG_REACT); - } - } - - @Override - public void getClippingRect(Rect outClippingRect) { - outClippingRect.set(Assertions.assertNotNull(mClippingRect)); - } - - @Override - public boolean getChildVisibleRect(View child, Rect r, android.graphics.Point offset) { - return super.getChildVisibleRect(child, r, offset); - } - - @Override - public void fling(int velocityY) { - final int correctedVelocityY = correctFlingVelocityY(velocityY); - - if (mPagingEnabled) { - flingAndSnap(correctedVelocityY); - } else if (mScroller != null) { - // We provide our own version of fling that uses a different call to the standard OverScroller - // which takes into account the possibility of adding new content while the ScrollView is - // animating. Because we give essentially no max Y for the fling, the fling will continue as - // long - // as there is content. See #onOverScrolled() to see the second part of this change which - // properly - // aborts the scroller animation when we get to the bottom of the ScrollView content. - - int scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop(); - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - 0, // velocityX - correctedVelocityY, // velocityY - 0, // minX - 0, // maxX - 0, // minY - Integer.MAX_VALUE, // maxY - 0, // overX - scrollWindowHeight / 2 // overY - ); - - ViewCompat.postInvalidateOnAnimation(this); - } else { - super.fling(correctedVelocityY); - } - handlePostTouchScrolling(0, correctedVelocityY); - } - - private int correctFlingVelocityY(int velocityY) { - if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) { - return velocityY; - } - - // Workaround. - // On Android P if a ScrollView is inverted, we will get a wrong sign for - // velocityY (see https://issuetracker.google.com/issues/112385925). - // At the same time, mOnScrollDispatchHelper tracks the correct velocity direction. - // - // Hence, we can use the absolute value from whatever the OS gives - // us and use the sign of what mOnScrollDispatchHelper has tracked. - float signum = Math.signum(mOnScrollDispatchHelper.getYFlingVelocity()); - if (signum == 0) { - signum = Math.signum(velocityY); - } - return (int) (Math.abs(velocityY) * signum); - } - - private void enableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.enable(mScrollPerfTag); - } - } - - private void disableFpsListener() { - if (isScrollPerfLoggingEnabled()) { - Assertions.assertNotNull(mFpsListener); - Assertions.assertNotNull(mScrollPerfTag); - mFpsListener.disable(mScrollPerfTag); - } - } - - private boolean isScrollPerfLoggingEnabled() { - return mFpsListener != null && mScrollPerfTag != null && !mScrollPerfTag.isEmpty(); - } - - private int getMaxScrollY() { - int contentHeight = mContentView == null ? 0 : mContentView.getHeight(); - int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop(); - return Math.max(0, contentHeight - viewportHeight); - } - - @Nullable - @Override - public StateWrapper getStateWrapper() { - return mStateWrapper; - } - - public void setStateWrapper(StateWrapper stateWrapper) { - mStateWrapper = stateWrapper; - } - - @Override - public void draw(Canvas canvas) { - if (mEndFillColor != Color.TRANSPARENT) { - final View contentView = getContentView(); - if (mEndBackground != null && contentView != null && contentView.getBottom() < getHeight()) { - mEndBackground.setBounds(0, contentView.getBottom(), getWidth(), getHeight()); - mEndBackground.draw(canvas); - } - } - - super.draw(canvas); - } - - @Override - public void onDraw(Canvas canvas) { - if (mOverflow != Overflow.VISIBLE) { - BackgroundStyleApplicator.clipToPaddingBox(this, canvas); - } - super.onDraw(canvas); - } - - /** - * This handles any sort of scrolling that may occur after a touch is finished. This may be - * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we - * don't get any events from Android about this lifecycle, we do all our detection by creating a - * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. - */ - private void handlePostTouchScrolling(int velocityX, int velocityY) { - // Check if we are already handling this which may occur if this is called by both the touch up - // and a fling call - if (mPostTouchRunnable != null) { - return; - } - - if (mSendMomentumEvents) { - enableFpsListener(); - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY); - } - - mActivelyScrolling = false; - mPostTouchRunnable = - new Runnable() { - - private boolean mSnappingToPage = false; - private int mStableFrames = 0; - - @Override - public void run() { - if (mActivelyScrolling) { - // We are still scrolling. - mActivelyScrolling = false; - mStableFrames = 0; - ReactScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } else { - // There has not been a scroll update since the last time this Runnable executed. - ReactScrollViewHelper.updateFabricScrollState(ReactScrollView.this); - - // We keep checking for updates until the ScrollView has "stabilized" and hasn't - // scrolled for N consecutive frames. This number is arbitrary: big enough to catch - // a number of race conditions, but small enough to not cause perf regressions, etc. - // In anecdotal testing, it seemed like a decent number. - // Without this check, sometimes this Runnable stops executing too soon - it will - // fire before the first scroll event of an animated scroll/fling, and stop - // immediately. - mStableFrames++; - - if (mStableFrames >= 3) { - mPostTouchRunnable = null; - if (mSendMomentumEvents) { - ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactScrollView.this); - } - ReactScrollViewHelper.notifyUserDrivenScrollEnded_internal(ReactScrollView.this); - disableFpsListener(); - } else { - if (mPagingEnabled && !mSnappingToPage) { - // If we have pagingEnabled and we have not snapped to the page - // we need to cause that scroll by asking for it - mSnappingToPage = true; - flingAndSnap(0); - } - // The scrollview has not "stabilized" so we just post to check again a frame later - ReactScrollView.this.postOnAnimationDelayed( - this, ReactScrollViewHelper.MOMENTUM_DELAY); - } - } - } - }; - postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); - } - - private void cancelPostTouchScrolling() { - if (mPostTouchRunnable != null) { - removeCallbacks(mPostTouchRunnable); - mPostTouchRunnable = null; - getFlingAnimator().cancel(); - } - } - - private int predictFinalScrollPosition(int velocityY) { - // predict where a fling would end up so we can scroll to the nearest snap offset - // TODO(T106335409): Existing prediction still uses overscroller. Consider change this to use - // fling animator instead. - return getFlingAnimator() == DEFAULT_FLING_ANIMATOR - ? ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()).y - : ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollY(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().y, - velocityY) - + getFlingExtrapolatedDistance(velocityY); - } - - private View getContentView() { - return getChildAt(0); - } - - /** - * This will smooth scroll us to the nearest snap offset point It currently just looks at where - * the content is and slides to the nearest point. It is intended to be run after we are done - * scrolling, and handling any momentum scrolling. - */ - private void smoothScrollAndSnap(int velocity) { - double interval = (double) getSnapInterval(); - double currentOffset = - (double) - (ReactScrollViewHelper.getNextFlingStartValue( - this, - getScrollY(), - getReactScrollViewScrollState().getFinalAnimatedPositionScroll().y, - velocity)); - double targetOffset = (double) predictFinalScrollPosition(velocity); - - int previousPage = (int) Math.floor(currentOffset / interval); - int nextPage = (int) Math.ceil(currentOffset / interval); - int currentPage = (int) Math.round(currentOffset / interval); - int targetPage = (int) Math.round(targetOffset / interval); - - if (velocity > 0 && nextPage == previousPage) { - nextPage++; - } else if (velocity < 0 && previousPage == nextPage) { - previousPage--; - } - - if ( - // if scrolling towards next page - velocity > 0 - && - // and the middle of the page hasn't been crossed already - currentPage < nextPage - && - // and it would have been crossed after flinging - targetPage > previousPage) { - currentPage = nextPage; - } else if ( - // if scrolling towards previous page - velocity < 0 - && - // and the middle of the page hasn't been crossed already - currentPage > previousPage - && - // and it would have been crossed after flinging - targetPage < nextPage) { - currentPage = previousPage; - } - - targetOffset = currentPage * interval; - if (targetOffset != currentOffset) { - mActivelyScrolling = true; - reactSmoothScrollTo(getScrollX(), (int) targetOffset); - } - } - - private void flingAndSnap(int velocityY) { - if (getChildCount() <= 0) { - return; - } - - // pagingEnabled only allows snapping one interval at a time - if (mSnapInterval == 0 && mSnapOffsets == null && mSnapToAlignment == SNAP_ALIGNMENT_DISABLED) { - smoothScrollAndSnap(velocityY); - return; - } - - boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR; - int maximumOffset = getMaxScrollY(); - int targetOffset = predictFinalScrollPosition(velocityY); - if (mDisableIntervalMomentum) { - targetOffset = getScrollY(); - } - - int smallerOffset = 0; - int largerOffset = maximumOffset; - int firstOffset = 0; - int lastOffset = maximumOffset; - int height = getHeight() - getPaddingBottom() - getPaddingTop(); - - // get the nearest snap points to the target offset - if (mSnapOffsets != null) { - firstOffset = mSnapOffsets.get(0); - lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1); - - for (int i = 0; i < mSnapOffsets.size(); i++) { - int offset = mSnapOffsets.get(i); - - if (offset <= targetOffset) { - if (targetOffset - offset < targetOffset - smallerOffset) { - smallerOffset = offset; - } - } - - if (offset >= targetOffset) { - if (offset - targetOffset < largerOffset - targetOffset) { - largerOffset = offset; - } - } - } - - } else if (mSnapToAlignment != SNAP_ALIGNMENT_DISABLED) { - if (mSnapInterval > 0) { - double ratio = (double) targetOffset / mSnapInterval; - smallerOffset = - Math.max( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.floor(ratio) * mSnapInterval), - mSnapInterval, - height), - 0); - largerOffset = - Math.min( - getItemStartOffset( - mSnapToAlignment, - (int) (Math.ceil(ratio) * mSnapInterval), - mSnapInterval, - height), - maximumOffset); - } else { - ViewGroup contentView = (ViewGroup) getContentView(); - int smallerChildOffset = largerOffset; - int largerChildOffset = smallerOffset; - for (int i = 0; i < contentView.getChildCount(); i++) { - View item = contentView.getChildAt(i); - int itemStartOffset; - switch (mSnapToAlignment) { - case SNAP_ALIGNMENT_CENTER: - itemStartOffset = item.getTop() - (height - item.getHeight()) / 2; - break; - case SNAP_ALIGNMENT_START: - itemStartOffset = item.getTop(); - break; - case SNAP_ALIGNMENT_END: - itemStartOffset = item.getTop() - (height - item.getHeight()); - break; - default: - throw new IllegalStateException("Invalid SnapToAlignment value: " + mSnapToAlignment); - } - if (itemStartOffset <= targetOffset) { - if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { - smallerOffset = itemStartOffset; - } - } - - if (itemStartOffset >= targetOffset) { - if (itemStartOffset - targetOffset < largerOffset - targetOffset) { - largerOffset = itemStartOffset; - } - } - - smallerChildOffset = Math.min(smallerChildOffset, itemStartOffset); - largerChildOffset = Math.max(largerChildOffset, itemStartOffset); - } - - // For Recycler ViewGroup, the maximumOffset can be much larger than the total heights of - // items in the layout. In this case snapping is not possible beyond the currently rendered - // children. - smallerOffset = Math.max(smallerOffset, smallerChildOffset); - largerOffset = Math.min(largerOffset, largerChildOffset); - } - } else { - double interval = (double) getSnapInterval(); - double ratio = (double) targetOffset / interval; - smallerOffset = (int) (Math.floor(ratio) * interval); - largerOffset = Math.min((int) (Math.ceil(ratio) * interval), maximumOffset); - } - - // Calculate the nearest offset - int nearestOffset = - Math.abs(targetOffset - smallerOffset) < Math.abs(largerOffset - targetOffset) - ? smallerOffset - : largerOffset; - - // if scrolling after the last snap offset and snapping to the - // end of the list is disabled, then we allow free scrolling - if (!mSnapToEnd && targetOffset >= lastOffset) { - if (getScrollY() >= lastOffset) { - // free scrolling - } else { - // snap to end - targetOffset = lastOffset; - } - } else if (!mSnapToStart && targetOffset <= firstOffset) { - if (getScrollY() <= firstOffset) { - // free scrolling - } else { - // snap to beginning - targetOffset = firstOffset; - } - } else if (velocityY > 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityY += (int) ((largerOffset - targetOffset) * 10.0); - } - - targetOffset = largerOffset; - } else if (velocityY < 0) { - if (!hasCustomizedFlingAnimator) { - // The default animator requires boost on initial velocity as when snapping velocity can - // feel sluggish for slow swipes - velocityY -= (int) ((targetOffset - smallerOffset) * 10.0); - } - - targetOffset = smallerOffset; - } else { - targetOffset = nearestOffset; - } - - // Make sure the new offset isn't out of bounds - targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset); - - if (hasCustomizedFlingAnimator || mScroller == null) { - reactSmoothScrollTo(getScrollX(), targetOffset); - } else { - // smoothScrollTo will always scroll over 250ms which is often *waaay* - // too short and will cause the scrolling to feel almost instant - // try to manually interact with OverScroller instead - // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo - mActivelyScrolling = true; - - mScroller.fling( - getScrollX(), // startX - getScrollY(), // startY - // velocity = 0 doesn't work with fling() so we pretend there's a reasonable - // initial velocity going on when a touch is released without any movement - 0, // velocityX - velocityY != 0 ? velocityY : targetOffset - getScrollY(), // velocityY - 0, // minX - 0, // maxX - // setting both minY and maxY to the same value will guarantee that we scroll to it - // but using the standard fling-style easing rather than smoothScrollTo's 250ms animation - targetOffset, // minY - targetOffset, // maxY - 0, // overX - // we only want to allow overscrolling if the final offset is at the very edge of the view - (targetOffset == 0 || targetOffset == maximumOffset) ? height / 2 : 0 // overY - ); - - postInvalidateOnAnimation(); - } - } - - private int getItemStartOffset( - int snapToAlignment, int itemStartPosition, int itemHeight, int viewPortHeight) { - int itemStartOffset; - switch (snapToAlignment) { - case SNAP_ALIGNMENT_CENTER: - itemStartOffset = itemStartPosition - (viewPortHeight - itemHeight) / 2; - break; - case SNAP_ALIGNMENT_START: - itemStartOffset = itemStartPosition; - break; - case SNAP_ALIGNMENT_END: - itemStartOffset = itemStartPosition - (viewPortHeight - itemHeight); - break; - default: - throw new IllegalStateException("Invalid SnapToAlignment value: " + mSnapToAlignment); - } - return itemStartOffset; - } - - private int getSnapInterval() { - if (mSnapInterval != 0) { - return mSnapInterval; - } - return getHeight(); - } - - public void setEndFillColor(int color) { - if (color != mEndFillColor) { - mEndFillColor = color; - mEndBackground = new ColorDrawable(mEndFillColor); - } - } - - @Override - protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) { - if (mScroller != null && mContentView != null) { - // This is part two of the reimplementation of fling to fix the bounce-back bug. See #fling() - // for more information. - - if (!mScroller.isFinished() && mScroller.getCurrY() != mScroller.getFinalY()) { - int scrollRange = getMaxScrollY(); - if (scrollY >= scrollRange) { - mScroller.abortAnimation(); - scrollY = scrollRange; - } - } - } - - if (ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() - && clampedY - && mEmittedOverScrollSinceScrollBegin == false) { - ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f); - mEmittedOverScrollSinceScrollBegin = true; - } - - super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); - } - - @Override - public void onChildViewAdded(View parent, View child) { - mContentView = child; - mContentView.addOnLayoutChangeListener(this); - } - - @Override - public void onChildViewRemoved(View parent, View child) { - if (mContentView != null) { - mContentView.removeOnLayoutChangeListener(this); - mContentView = null; - } - } - - public void setContentOffset(@Nullable ReadableMap value) { - // When contentOffset={{x:0,y:0}} with lazy load items, setContentOffset - // is repeatedly called, causing an unexpected scroll to top behavior. - // Avoid updating contentOffset if the value has not changed. - if (mCurrentContentOffset == null || !mCurrentContentOffset.equals(value)) { - mCurrentContentOffset = value; - - if (value != null) { - double x = value.hasKey("x") ? value.getDouble("x") : 0; - double y = value.hasKey("y") ? value.getDouble("y") : 0; - scrollTo((int) PixelUtil.toPixelFromDIP(x), (int) PixelUtil.toPixelFromDIP(y)); - } else { - scrollTo(0, 0); - } - } - } - - /** - * Calls `smoothScrollTo` and updates state. - * - *

`smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. - */ - @Override - public void reactSmoothScrollTo(int x, int y) { - ReactScrollViewHelper.smoothScrollTo(this, x, y); - setPendingContentOffsets(x, y); - } - - /** - * Calls `super.scrollTo` and updates state. - * - *

`super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between - * scroll view and state. - * - *

Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it - * is final. See `reactSmoothScrollTo`. - */ - @Override - public void scrollTo(int x, int y) { - super.scrollTo(x, y); - ReactScrollViewHelper.updateFabricScrollState(this); - setPendingContentOffsets(x, y); - } - - /** - * If we are in the middle of a fling animation from the user removing their finger (OverScroller - * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against - * outdated scroll offsets. - */ - private void recreateFlingAnimation(int scrollY) { - // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap - // point), cancel them. - // TODO: Can we be more graceful (like OverScroller flings)? - if (getFlingAnimator().isRunning()) { - getFlingAnimator().cancel(); - } - - if (mScroller != null && !mScroller.isFinished()) { - // Calculate the velocity and position of the fling animation at the time of this layout - // event, which may be later than the last ScrollView tick. These values are not committed to - // the underlying ScrollView, which will recalculate positions on its next tick. - int scrollerYBeforeTick = mScroller.getCurrY(); - boolean hasMoreTicks = mScroller.computeScrollOffset(); - - // Stop the existing animation at the current state of the scroller. We will then recreate - // it starting at the adjusted y offset. - mScroller.forceFinished(true); - - if (hasMoreTicks) { - // OverScroller.getCurrVelocity() returns an absolute value of the velocity a current fling - // animation (only FLING_MODE animations). We derive direction along the Y axis from the - // start and end of the, animation assuming ScrollView never fires horizontal fling - // animations. - // TODO: This does not fully handle overscroll. - float direction = Math.signum(mScroller.getFinalY() - mScroller.getStartY()); - float flingVelocityY = mScroller.getCurrVelocity() * direction; - - mScroller.fling(getScrollX(), scrollY, 0, (int) flingVelocityY, 0, 0, 0, Integer.MAX_VALUE); - } else { - scrollTo(getScrollX(), scrollY + (mScroller.getCurrY() - scrollerYBeforeTick)); - } - } - } - - /** Scrolls to a new position preserving any momentum scrolling animation. */ - @Override - public void scrollToPreservingMomentum(int x, int y) { - scrollTo(x, y); - recreateFlingAnimation(y); - } - - private boolean isContentReady() { - View child = getContentView(); - return child != null && child.getWidth() != 0 && child.getHeight() != 0; - } - - /** - * If contentOffset is set before the View has been laid out, store the values and set them when - * `onLayout` is called. - * - * @param x - * @param y - */ - private void setPendingContentOffsets(int x, int y) { - if (isContentReady()) { - mPendingContentOffsetX = UNSET_CONTENT_OFFSET; - mPendingContentOffsetY = UNSET_CONTENT_OFFSET; - } else { - mPendingContentOffsetX = x; - mPendingContentOffsetY = y; - } - } - - /** - * Called when a mContentView's layout has changed. Fixes the scroll position if it's too large - * after the content resizes. Without this, the user would see a blank ScrollView when the scroll - * position is larger than the ScrollView's max scroll position after the content shrinks. - */ - @Override - public void onLayoutChange( - View v, - int left, - int top, - int right, - int bottom, - int oldLeft, - int oldTop, - int oldRight, - int oldBottom) { - if (mContentView == null) { - return; - } - - if (isShown() && isContentReady()) { - int currentScrollY = getScrollY(); - int maxScrollY = getMaxScrollY(); - if (currentScrollY > maxScrollY) { - scrollTo(getScrollX(), maxScrollY); - } - } - - ReactScrollViewHelper.emitLayoutChangeEvent(this); - } - - @Override - public void setBackgroundColor(int color) { - BackgroundStyleApplicator.setBackgroundColor(this, color); - } - - public void setBorderWidth(int position, float width) { - BackgroundStyleApplicator.setBorderWidth( - this, LogicalEdge.values()[position], PixelUtil.toDIPFromPixel(width)); - } - - public void setBorderColor(int position, @Nullable Integer color) { - BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.values()[position], color); - } - - public void setBorderRadius(float borderRadius) { - setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal()); - } - - public void setBorderRadius(float borderRadius, int position) { - @Nullable - LengthPercentage radius = - Float.isNaN(borderRadius) - ? null - : new LengthPercentage( - PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT); - BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.values()[position], radius); - } - - public void setBorderStyle(@Nullable String style) { - BackgroundStyleApplicator.setBorderStyle( - this, style == null ? null : BorderStyle.fromString(style)); - } - - /** - * ScrollAway: This enables a natively-controlled navbar that optionally obscures the top content - * of the ScrollView. Whether or not the navbar is obscuring the React Native surface is - * determined outside of React Native. - * - *

Note: all ScrollViews and HorizontalScrollViews in React have exactly one child: the - * "content" View (see ScrollView.js). That View is non-collapsable so it will never be - * View-flattened away. However, it is possible to pass custom styles into that View. - * - *

If you are using this feature it is assumed that you have full control over this ScrollView - * and that you are **not** overriding the ScrollView content view to pass in a `translateY` - * style. `translateY` must never be set from ReactJS while using this feature! - */ - public void setScrollAwayPaddingEnabledUnstable(int topPadding, int bottomPadding) { - setScrollAwayPaddingEnabledUnstable(topPadding, bottomPadding, true); - } - - public void setScrollAwayPaddingEnabledUnstable( - int topPadding, int bottomPadding, boolean updateState) { - int count = getChildCount(); - - Assertions.assertCondition( - count <= 1, - "React Native ScrollView should not have more than one child, it should have exactly 1" - + " child; a content View"); - - if (count > 0) { - for (int i = 0; i < count; i++) { - View childView = getChildAt(i); - childView.setTranslationY(topPadding); - } - - // Add the topPadding value as the bottom padding for the ScrollView. - // Otherwise, we'll push down the contents of the scroll view down too - // far off screen. - setPadding(0, 0, 0, topPadding + bottomPadding); - } - - if (updateState) { - updateScrollAwayState(topPadding, bottomPadding); - } - setRemoveClippedSubviews(mRemoveClippedSubviews); - } - - private void updateScrollAwayState(int scrollAwayPaddingTop, int scrollAwayPaddingBottom) { - getReactScrollViewScrollState().setScrollAwayPaddingTop(scrollAwayPaddingTop); - getReactScrollViewScrollState().setScrollAwayPaddingBottom(scrollAwayPaddingBottom); - ReactScrollViewHelper.forceUpdateState(this); - } - - @Override - public void setReactScrollViewScrollState(ReactScrollViewScrollState scrollState) { - mReactScrollViewScrollState = scrollState; - if (ReactNativeFeatureFlags.enableViewCulling() - || ReactNativeFeatureFlags.useTraitHiddenOnAndroid()) { - setScrollAwayPaddingEnabledUnstable( - scrollState.getScrollAwayPaddingTop(), scrollState.getScrollAwayPaddingBottom(), false); - - Point scrollPosition = scrollState.getLastStateUpdateScroll(); - scrollTo(scrollPosition.x, scrollPosition.y); - } - } - - @Override - public ReactScrollViewScrollState getReactScrollViewScrollState() { - return mReactScrollViewScrollState; - } - - @Override - public void startFlingAnimator(int start, int end) { - // Always cancel existing animator before starting the new one. `smoothScrollTo` contains some - // logic that, if called multiple times in a short amount of time, will treat all calls as part - // of the same animation and will not lengthen the duration of the animation. This means that, - // for example, if the user is scrolling rapidly, multiple pages could be considered part of one - // animation, causing some page animations to be animated very rapidly - looking like they're - // not animated at all. - DEFAULT_FLING_ANIMATOR.cancel(); - - // Update the fling animator with new values - int duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()); - DEFAULT_FLING_ANIMATOR.setDuration(duration).setIntValues(start, end); - - // Start the animator - DEFAULT_FLING_ANIMATOR.start(); - - if (mSendMomentumEvents) { - int yVelocity = 0; - if (duration > 0) { - yVelocity = (end - start) / duration; - } - ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, 0, yVelocity); - ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this); - } - } - - @NonNull - @Override - public ValueAnimator getFlingAnimator() { - return DEFAULT_FLING_ANIMATOR; - } - - @Override - public int getFlingExtrapolatedDistance(int velocityY) { - // The DEFAULT_FLING_ANIMATOR uses AccelerateDecelerateInterpolator, which is not depending on - // the init velocity. We use the overscroller to decide the fling distance. - return ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()) - .y; - } - - public void setPointerEvents(PointerEvents pointerEvents) { - mPointerEvents = pointerEvents; - } - - public PointerEvents getPointerEvents() { - return mPointerEvents; - } - - @Override - public void setScrollEventThrottle(int scrollEventThrottle) { - mScrollEventThrottle = scrollEventThrottle; - } - - @Override - public int getScrollEventThrottle() { - return mScrollEventThrottle; - } - - @Override - public void setLastScrollDispatchTime(long lastScrollDispatchTime) { - mLastScrollDispatchTime = lastScrollDispatchTime; - } - - @Override - public long getLastScrollDispatchTime() { - return mLastScrollDispatchTime; - } -} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt new file mode 100644 index 000000000000..7a6ef8bff259 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -0,0 +1,1303 @@ +/* + * 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.ObjectAnimator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.OverScroller +import android.widget.ScrollView +import androidx.core.view.ViewCompat +import com.facebook.common.logging.FLog +import com.facebook.react.R +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.common.ReactConstants +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.uimanager.BackgroundStyleApplicator +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.MeasureSpecAssertions +import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.PointerEvents +import com.facebook.react.uimanager.ReactClippingViewGroup +import com.facebook.react.uimanager.ReactClippingViewGroupHelper +import com.facebook.react.uimanager.ReactOverflowViewWithInset +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.events.NativeGestureUtil +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle +import com.facebook.react.uimanager.style.LogicalEdge +import com.facebook.react.uimanager.style.Overflow +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasStateWrapper +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_CENTER +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISABLED +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END +import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START +import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.systrace.Systrace +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.round +import kotlin.math.sign + +/** + * A simple subclass of ScrollView that doesn't dispatch measure and layout to its children and has + * a scroll listener to send scroll events to JS. + * + * ReactScrollView only supports vertical scrolling. For horizontal scrolling, use + * [ReactHorizontalScrollView]. + */ +public open class ReactScrollView +@JvmOverloads +constructor(context: Context, private val fpsListener: FpsListener? = null) : + ScrollView(context), + ReactClippingViewGroup, + ViewGroup.OnHierarchyChangeListener, + View.OnLayoutChangeListener, + ReactAccessibleScrollView, + ReactOverflowViewWithInset, + HasScrollState, + HasStateWrapper, + HasFlingAnimator, + HasScrollEventThrottle, + HasSmoothScroll, + VirtualViewContainer { + + private companion object { + private var scrollerField: java.lang.reflect.Field? = null + private var triedToGetScrollerField = false + + private const val UNSET_CONTENT_OFFSET = -1 + } + + // Public / interface property overrides + override var scrollEnabled: Boolean = true + override var stateWrapper: StateWrapper? = null + override var scrollEventThrottle: Int = 0 + override var lastScrollDispatchTime: Long = 0L + + public open var pointerEvents: PointerEvents = PointerEvents.AUTO + + public open var fadingEdgeLengthStart: Int = 0 + set(value) { + field = value + invalidate() + } + + public open var fadingEdgeLengthEnd: Int = 0 + set(value) { + field = value + invalidate() + } + + override val virtualViewContainerState: VirtualViewContainerState + get() = + _virtualViewContainerState + ?: VirtualViewContainerState.create(this).also { _virtualViewContainerState = it } + + override val overflowInset: Rect + get() = _overflowInset + + override val overflow: String? + get() = + when (_overflow) { + Overflow.HIDDEN -> "hidden" + Overflow.SCROLL -> "scroll" + Overflow.VISIBLE -> "visible" + } + + override var removeClippedSubviews: Boolean + get() = _removeClippedSubviews + set(value) { + if (ReactNativeFeatureFlags.disableSubviewClippingAndroid()) return + if (value && clippingRect == null) clippingRect = Rect() + _removeClippedSubviews = value + updateClippingRect() + } + + override var reactScrollViewScrollState: ReactScrollViewScrollState + get() = _reactScrollViewScrollState + set(value) { + _reactScrollViewScrollState = value + if ( + ReactNativeFeatureFlags.enableViewCulling() || + ReactNativeFeatureFlags.useTraitHiddenOnAndroid() + ) { + setScrollAwayPaddingEnabledUnstable( + value.scrollAwayPaddingTop, + value.scrollAwayPaddingBottom, + false, + ) + val scrollPosition = value.lastStateUpdateScroll + scrollTo(scrollPosition.x, scrollPosition.y) + } + } + + // Private state + private val onScrollDispatchHelper = OnScrollDispatchHelper() + private val scroller: OverScroller? = getOverScrollerFromParent() + private val velocityHelper = VelocityHelper() + private val tempRect = Rect() + private val defaultFlingAnimator: ValueAnimator = ObjectAnimator.ofInt(this, "scrollY", 0, 0) + + private var _overflowInset = Rect() + private var _virtualViewContainerState: VirtualViewContainerState? = null + private var _removeClippedSubviews = false + private var _reactScrollViewScrollState = ReactScrollViewScrollState() + + private var activelyScrolling = false + private var clippingRect: Rect? = null + private var _overflow: Overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) { + Overflow.VISIBLE + } else { + Overflow.SCROLL + } + private var dragging = false + private var pagingEnabled = false + private var postTouchRunnable: Runnable? = null + private var sendMomentumEvents = false + private var scrollPerfTag: String? = null + private var endBackground: Drawable? = null + private var endFillColor = Color.TRANSPARENT + private var disableIntervalMomentum = false + private var snapInterval = 0 + private var snapOffsets: List? = null + private var snapToStart = true + private var snapToEnd = true + private var snapToAlignment = SNAP_ALIGNMENT_DISABLED + private var contentView: View? = null + private var currentContentOffset: ReadableMap? = null + private var pendingContentOffsetX = UNSET_CONTENT_OFFSET + private var pendingContentOffsetY = UNSET_CONTENT_OFFSET + private var maintainVisibleContentPositionHelper: + MaintainVisibleScrollPositionHelper? = + null + private var emittedOverScrollSinceScrollBegin = false + private var scrollsChildToFocus = true + + init { + setOnHierarchyChangeListener(this) + setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY) + setClipChildren(false) + ViewCompat.setAccessibilityDelegate(this, ReactScrollViewAccessibilityDelegate()) + initView() + } + + /** + * Set all default values here as opposed to in the constructor or field defaults. It is important + * that these properties are set during the constructor, but also on-demand whenever an existing + * ReactScrollView is recycled. + */ + private fun initView() { + _overflowInset = Rect() + _virtualViewContainerState = null + activelyScrolling = false + clippingRect = null + _overflow = + if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) { + Overflow.VISIBLE + } else { + Overflow.SCROLL + } + dragging = false + pagingEnabled = false + postTouchRunnable = null + _removeClippedSubviews = false + scrollEnabled = true + sendMomentumEvents = false + scrollPerfTag = null + endBackground = null + endFillColor = Color.TRANSPARENT + disableIntervalMomentum = false + snapInterval = 0 + snapOffsets = null + snapToStart = true + snapToEnd = true + snapToAlignment = SNAP_ALIGNMENT_DISABLED + contentView = null + currentContentOffset = null + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + stateWrapper = null + _reactScrollViewScrollState = ReactScrollViewScrollState() + pointerEvents = PointerEvents.AUTO + lastScrollDispatchTime = 0 + scrollEventThrottle = 0 + maintainVisibleContentPositionHelper = null + fadingEdgeLengthStart = 0 + fadingEdgeLengthEnd = 0 + emittedOverScrollSinceScrollBegin = false + scrollsChildToFocus = true + } + + internal fun recycleView() { + initView() + if (parent != null) { + (parent as ViewGroup).removeView(this) + } + updateView() + } + + private fun updateView() {} + + override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(info) + val testId = getTag(R.id.react_test_id) as? String + if (testId != null) { + info.viewIdResourceName = testId + } + } + + protected open fun getOverScrollerFromParent(): OverScroller? { + if (!triedToGetScrollerField) { + triedToGetScrollerField = true + try { + scrollerField = ScrollView::class.java.getDeclaredField("mScroller") + scrollerField?.isAccessible = true + } catch (e: NoSuchFieldException) { + FLog.w( + ReactConstants.TAG, + "Failed to get mScroller field for ScrollView! " + + "This app will exhibit the bounce-back scrolling bug :(", + ) + } + } + + val cachedScrollerField = scrollerField ?: return null + return try { + val scrollerValue = cachedScrollerField.get(this) + if (scrollerValue is OverScroller) { + scrollerValue + } else { + FLog.w( + ReactConstants.TAG, + "Failed to cast mScroller field in ScrollView (probably due to OEM changes to AOSP)! " + + "This app will exhibit the bounce-back scrolling bug :(", + ) + null + } + } catch (e: IllegalAccessException) { + throw RuntimeException("Failed to get mScroller from ScrollView!", e) + } + } + + public open fun setDisableIntervalMomentum(disableIntervalMomentum: Boolean) { + this.disableIntervalMomentum = disableIntervalMomentum + } + + public open fun setSendMomentumEvents(sendMomentumEvents: Boolean) { + this.sendMomentumEvents = sendMomentumEvents + } + + public open fun setScrollPerfTag(scrollPerfTag: String?) { + this.scrollPerfTag = scrollPerfTag + } + + public open fun setPagingEnabled(pagingEnabled: Boolean) { + this.pagingEnabled = pagingEnabled + } + + public open fun setScrollsChildToFocus(scrollsChildToFocus: Boolean) { + this.scrollsChildToFocus = scrollsChildToFocus + } + + public open fun setDecelerationRate(decelerationRate: Float) { + reactScrollViewScrollState.decelerationRate = decelerationRate + scroller?.setFriction(1.0f - decelerationRate) + } + + public open fun abortAnimation() { + if (scroller != null && !scroller.isFinished) { + scroller.abortAnimation() + } + } + + public open fun setSnapInterval(snapInterval: Int) { + this.snapInterval = snapInterval + } + + public open fun setSnapOffsets(snapOffsets: List?) { + this.snapOffsets = snapOffsets + } + + public open fun setSnapToStart(snapToStart: Boolean) { + this.snapToStart = snapToStart + } + + public open fun setSnapToEnd(snapToEnd: Boolean) { + this.snapToEnd = snapToEnd + } + + public open fun setSnapToAlignment(snapToAlignment: Int) { + this.snapToAlignment = snapToAlignment + } + + public open fun flashScrollIndicators() { + awakenScrollBars() + } + + override fun getTopFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + return fadingEdgeLengthStart / max + } + + override fun getBottomFadingEdgeStrength(): Float { + val max = max(fadingEdgeLengthStart.toFloat(), fadingEdgeLengthEnd.toFloat()) + return fadingEdgeLengthEnd / max + } + + public open fun setOverflow(overflow: String?) { + _overflow = + if (overflow == null) { + Overflow.SCROLL + } else { + Overflow.fromString(overflow) + ?: if (ReactNativeFeatureFlags.enablePropsUpdateReconciliationAndroid()) + Overflow.VISIBLE + else Overflow.SCROLL + } + invalidate() + } + + public open fun setMaintainVisibleContentPosition( + config: MaintainVisibleScrollPositionHelper.Config? + ) { + if (config != null && maintainVisibleContentPositionHelper == null) { + maintainVisibleContentPositionHelper = + MaintainVisibleScrollPositionHelper(this, false).also { it.start() } + } else if (config == null && maintainVisibleContentPositionHelper != null) { + maintainVisibleContentPositionHelper?.stop() + maintainVisibleContentPositionHelper = null + } + maintainVisibleContentPositionHelper?.let { it.config = config } + } + + override fun setOverflowInset(left: Int, top: Int, right: Int, bottom: Int) { + _overflowInset.set(left, top, right, bottom) + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + MeasureSpec.getSize(heightMeasureSpec), + ) + } + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { + if (isContentReady()) { + val scrollToX = + if (pendingContentOffsetX != UNSET_CONTENT_OFFSET) pendingContentOffsetX else scrollX + val scrollToY = + if (pendingContentOffsetY != UNSET_CONTENT_OFFSET) pendingContentOffsetY else scrollY + scrollTo(scrollToX, scrollToY) + } + + ReactScrollViewHelper.emitLayoutEvent(this) + _virtualViewContainerState?.updateState() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (_removeClippedSubviews) { + updateClippingRect() + } + _virtualViewContainerState?.updateState() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (_removeClippedSubviews) { + updateClippingRect() + } + maintainVisibleContentPositionHelper?.start() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + maintainVisibleContentPositionHelper?.stop() + } + + override fun focusSearch(focused: View, direction: Int): View? { + val nextFocus = super.focusSearch(focused, direction) + + if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { + if (nextFocus != null && findViewById(nextFocus.id) != null) { + return nextFocus + } + val nextFocusableView = findNextFocusableView(this, focused, direction) + if (nextFocusableView != null) { + return nextFocusableView + } + } + + return nextFocus + } + + /** + * Since ReactScrollView handles layout changes on JS side, it does not call super.onLayout due to + * which mIsLayoutDirty flag in ScrollView remains true and prevents scrolling to child when + * requestChildFocus is called. Overriding this method and scrolling to child without checking any + * layout dirty flag. This will fix focus navigation issue for KeyEvents which are not handled by + * ScrollView, for example: KEYCODE_TAB. + */ + override fun requestChildFocus(child: View, focused: View?) { + if (focused != null && scrollsChildToFocus) { + scrollToChild(focused) + } + requestChildFocusWithoutScroll(child, focused) + } + + /** + * In rare cases where an app overrides the built-in ReactScrollView by overriding it, and also + * needs to customize scroll into view on focus behaviors, this protected method can be used to + * unblocks such customization. + */ + protected open fun requestChildFocusWithoutScroll(child: View, focused: View?) { + super.requestChildFocus(child, focused) + } + + override fun requestChildRectangleOnScreen( + child: View, + rectangle: Rect, + immediate: Boolean, + ): Boolean { + if (!scrollsChildToFocus) return false + return super.requestChildRectangleOnScreen(child, rectangle, immediate) + } + + private fun getScrollDelta(descendent: View): Int { + descendent.getDrawingRect(tempRect) + offsetDescendantRectToMyCoords(descendent, tempRect) + return computeScrollDeltaToGetChildRectOnScreen(tempRect) + } + + override fun isPartiallyScrolledInView(view: View): Boolean { + val scrollDelta = getScrollDelta(view) + view.getDrawingRect(tempRect) + return scrollDelta != 0 && abs(scrollDelta) < tempRect.width() + } + + private fun scrollToChild(child: View) { + var parent: View? = child + var scrollViewAncestor: View? = null + while (parent != null && parent !== this) { + if (parent is ReactScrollView) { + scrollViewAncestor = parent + } + parent = parent.parent as? View + } + + val scrollIntoViewTarget = scrollViewAncestor ?: child + val tempRect = Rect() + scrollIntoViewTarget.getDrawingRect(tempRect) + offsetDescendantRectToMyCoords(scrollIntoViewTarget, tempRect) + val scrollDelta = computeScrollDeltaToGetChildRectOnScreen(tempRect) + if (scrollDelta != 0) { + scrollBy(0, scrollDelta) + } + } + + override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) { + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactScrollView.onScrollChanged") + try { + super.onScrollChanged(x, y, oldX, oldY) + activelyScrolling = true + if (onScrollDispatchHelper.onScrollChanged(x, y)) { + if (_removeClippedSubviews) { + updateClippingRect() + } + ReactScrollViewHelper.updateStateOnScrollChanged( + this, + onScrollDispatchHelper.xFlingVelocity, + onScrollDispatchHelper.yFlingVelocity, + ) + _virtualViewContainerState?.updateState() + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return true + + try { + if (super.onInterceptTouchEvent(ev)) { + handleInterceptedTouchEvent(ev) + return true + } + } catch (e: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "Error intercepting touch event.", e) + } + + return false + } + + protected open fun handleInterceptedTouchEvent(ev: MotionEvent) { + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureStarted(this, ev) + } + ReactScrollViewHelper.emitScrollBeginDragEvent(this) + dragging = true + emittedOverScrollSinceScrollBegin = false + enableFpsListener() + getFlingAnimator().cancel() + } + + override fun onTouchEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canBeTouchTarget(pointerEvents)) return false + + velocityHelper.calculateVelocity(ev) + val action = ev.actionMasked + if (action == MotionEvent.ACTION_UP && dragging) { + ReactScrollViewHelper.updateFabricScrollState(this) + val velocityX = velocityHelper.xVelocity + val velocityY = velocityHelper.yVelocity + ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY) + if (!ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid()) { + NativeGestureUtil.notifyNativeGestureEnded(this, ev) + } + dragging = false + handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY)) + } + + if (action == MotionEvent.ACTION_DOWN) { + cancelPostTouchScrolling() + } + + return try { + super.onTouchEvent(ev) + } catch (e: IllegalArgumentException) { + FLog.w(ReactConstants.TAG, "Error handling touch event.", e) + false + } + } + + override fun dispatchGenericMotionEvent(ev: MotionEvent): Boolean { + if (!scrollEnabled) return false + if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return false + + if (ev.actionMasked == MotionEvent.ACTION_SCROLL) { + val vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL) + if (vScroll != 0f) { + enableFpsListener() + val result = super.dispatchGenericMotionEvent(ev) + if ( + result && + (pagingEnabled || + snapInterval != 0 || + snapOffsets != null || + snapToAlignment != SNAP_ALIGNMENT_DISABLED) + ) { + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + } + postTouchRunnable = Runnable { + postTouchRunnable = null + var velocityY = (-vScroll.sign).toInt() + if (disableIntervalMomentum) { + velocityY = 0 + } + flingAndSnap(velocityY) + handlePostTouchScrolling(0, velocityY) + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } else { + handlePostTouchScrolling(0, 0) + } + return result + } + } + + return super.dispatchGenericMotionEvent(ev) + } + + override fun executeKeyEvent(event: KeyEvent): Boolean { + val eventKeyCode = event.keyCode + if ( + !scrollEnabled && + (eventKeyCode == KeyEvent.KEYCODE_DPAD_UP || eventKeyCode == KeyEvent.KEYCODE_DPAD_DOWN) + ) { + return false + } + return super.executeKeyEvent(event) + } + + override fun updateClippingRect() { + updateClippingRect(null) + } + + override fun updateClippingRect(excludedViews: Set?) { + if (!_removeClippedSubviews) return + + Systrace.beginSection(Systrace.TRACE_TAG_REACT, "ReactScrollView.updateClippingRect") + try { + val rect = checkNotNull(clippingRect) + ReactClippingViewGroupHelper.calculateClippingRect(this, rect) + val cv = getContentView() + if (cv is ReactClippingViewGroup) { + cv.updateClippingRect(excludedViews) + } + } finally { + Systrace.endSection(Systrace.TRACE_TAG_REACT) + } + } + + override fun getClippingRect(outClippingRect: Rect) { + outClippingRect.set(checkNotNull(clippingRect)) + } + + override fun getChildVisibleRect(child: View, r: Rect, offset: android.graphics.Point?): Boolean { + return super.getChildVisibleRect(child, r, offset) + } + + override fun fling(velocityY: Int) { + val correctedVelocityY = correctFlingVelocityY(velocityY) + + if (pagingEnabled) { + flingAndSnap(correctedVelocityY) + } else if (scroller != null) { + val scrollWindowHeight = height - paddingBottom - paddingTop + scroller.fling( + scrollX, // startX + scrollY, // startY + 0, // velocityX + correctedVelocityY, // velocityY + 0, // minX + 0, // maxX + 0, // minY + Int.MAX_VALUE, // maxY + 0, // overX + scrollWindowHeight / 2, // overY + ) + postInvalidateOnAnimation() + } else { + super.fling(correctedVelocityY) + } + handlePostTouchScrolling(0, correctedVelocityY) + } + + private fun correctFlingVelocityY(velocityY: Int): Int { + if (Build.VERSION.SDK_INT != Build.VERSION_CODES.P) return velocityY + var signum = onScrollDispatchHelper.yFlingVelocity.sign + if (signum == 0f) { + signum = velocityY.toFloat().sign + } + return (abs(velocityY) * signum).toInt() + } + + private fun enableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + val listener = checkNotNull(fpsListener) + val perfTag = checkNotNull(scrollPerfTag) + listener.enable(perfTag) + } + } + + private fun disableFpsListener() { + if (isScrollPerfLoggingEnabled()) { + val listener = checkNotNull(fpsListener) + val perfTag = checkNotNull(scrollPerfTag) + listener.disable(perfTag) + } + } + + private fun isScrollPerfLoggingEnabled(): Boolean { + return fpsListener != null && !scrollPerfTag.isNullOrEmpty() + } + + private fun getMaxScrollY(): Int { + val contentHeight = contentView?.height ?: 0 + val viewportHeight = height - paddingBottom - paddingTop + return max(0, contentHeight - viewportHeight) + } + + override fun draw(canvas: Canvas) { + if (endFillColor != Color.TRANSPARENT) { + val cv = getContentView() + val bg = endBackground + if (bg != null && cv != null && cv.bottom < height) { + bg.setBounds(0, cv.bottom, width, height) + bg.draw(canvas) + } + } + super.draw(canvas) + } + + public override fun onDraw(canvas: Canvas) { + if (_overflow != Overflow.VISIBLE) { + BackgroundStyleApplicator.clipToPaddingBox(this, canvas) + } + super.onDraw(canvas) + } + + /** + * This handles any sort of scrolling that may occur after a touch is finished. This may be + * momentum scrolling (fling) or because you have pagingEnabled on the scroll view. Because we + * don't get any events from Android about this lifecycle, we do all our detection by creating a + * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling. + */ + private fun handlePostTouchScrolling(velocityX: Int, velocityY: Int) { + if (postTouchRunnable != null) return + + if (sendMomentumEvents) { + enableFpsListener() + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY) + } + + activelyScrolling = false + postTouchRunnable = + object : Runnable { + private var snappingToPage = false + private var stableFrames = 0 + + override fun run() { + if (activelyScrolling) { + activelyScrolling = false + stableFrames = 0 + this@ReactScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } else { + ReactScrollViewHelper.updateFabricScrollState(this@ReactScrollView) + stableFrames++ + + if (stableFrames >= 3) { + postTouchRunnable = null + if (sendMomentumEvents) { + ReactScrollViewHelper.emitScrollMomentumEndEvent(this@ReactScrollView) + } + // Kotlin name is notifyUserDrivenScrollEnded; the _internal suffix is + // only a @JvmName alias for Java callers. + ReactScrollViewHelper.notifyUserDrivenScrollEnded(this@ReactScrollView) + disableFpsListener() + } else { + if (pagingEnabled && !snappingToPage) { + snappingToPage = true + flingAndSnap(0) + } + this@ReactScrollView.postOnAnimationDelayed( + this, + ReactScrollViewHelper.MOMENTUM_DELAY, + ) + } + } + } + } + postOnAnimationDelayed(postTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY) + } + + private fun cancelPostTouchScrolling() { + if (postTouchRunnable != null) { + removeCallbacks(postTouchRunnable) + postTouchRunnable = null + getFlingAnimator().cancel() + } + } + + // Predict where a fling would end up so we can scroll to the nearest snap offset. + // TODO(T106335409): Existing prediction still uses overscroller. Consider changing this to + // use fling animator instead. + private fun predictFinalScrollPosition(velocityY: Int): Int { + return if (getFlingAnimator() === defaultFlingAnimator) { + ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocityY, 0, getMaxScrollY()).y + } else { + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollY, + reactScrollViewScrollState.finalAnimatedPositionScroll.y, + velocityY, + ) + getFlingExtrapolatedDistance(velocityY) + } + } + + private fun getContentView(): View? = getChildAt(0) + + /** + * This will smooth scroll us to the nearest snap offset point. It currently just looks at where + * the content is and slides to the nearest point. It is intended to be run after we are done + * scrolling, and handling any momentum scrolling. + */ + private fun smoothScrollAndSnap(velocity: Int) { + val interval = getSnapInterval().toDouble() + val currentOffset = + ReactScrollViewHelper.getNextFlingStartValue( + this, + scrollY, + reactScrollViewScrollState.finalAnimatedPositionScroll.y, + velocity, + ) + .toDouble() + val targetOffset = predictFinalScrollPosition(velocity).toDouble() + + var previousPage = floor(currentOffset / interval).toInt() + var nextPage = ceil(currentOffset / interval).toInt() + var currentPage = round(currentOffset / interval).toInt() + val targetPage = round(targetOffset / interval).toInt() + + if (velocity > 0 && nextPage == previousPage) { + nextPage++ + } else if (velocity < 0 && previousPage == nextPage) { + previousPage-- + } + + if (velocity > 0 && currentPage < nextPage && targetPage > previousPage) { + currentPage = nextPage + } else if (velocity < 0 && currentPage > previousPage && targetPage < nextPage) { + currentPage = previousPage + } + + val finalTargetOffset = currentPage * interval + if (finalTargetOffset != currentOffset) { + activelyScrolling = true + reactSmoothScrollTo(scrollX, finalTargetOffset.toInt()) + } + } + + private fun flingAndSnap(velocityY: Int) { + if (childCount <= 0) return + + if (snapInterval == 0 && snapOffsets == null && snapToAlignment == SNAP_ALIGNMENT_DISABLED) { + smoothScrollAndSnap(velocityY) + return + } + + @Suppress("NAME_SHADOWING") var velocityY = velocityY + val hasCustomizedFlingAnimator = getFlingAnimator() !== defaultFlingAnimator + val maximumOffset = getMaxScrollY() + var targetOffset = predictFinalScrollPosition(velocityY) + if (disableIntervalMomentum) { + targetOffset = scrollY + } + + var smallerOffset = 0 + var largerOffset = maximumOffset + var firstOffset = 0 + var lastOffset = maximumOffset + val viewportHeight = height - paddingBottom - paddingTop + + val currentSnapOffsets = snapOffsets + if (currentSnapOffsets != null) { + firstOffset = currentSnapOffsets[0] + lastOffset = currentSnapOffsets[currentSnapOffsets.size - 1] + + for (i in currentSnapOffsets.indices) { + val offset = currentSnapOffsets[i] + if (offset <= targetOffset) { + if (targetOffset - offset < targetOffset - smallerOffset) { + smallerOffset = offset + } + } + if (offset >= targetOffset) { + if (offset - targetOffset < largerOffset - targetOffset) { + largerOffset = offset + } + } + } + } else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) { + if (snapInterval > 0) { + val ratio = targetOffset.toDouble() / snapInterval + smallerOffset = + max( + getItemStartOffset( + snapToAlignment, + (floor(ratio) * snapInterval).toInt(), + snapInterval, + viewportHeight, + ), + 0, + ) + largerOffset = + min( + getItemStartOffset( + snapToAlignment, + (ceil(ratio) * snapInterval).toInt(), + snapInterval, + viewportHeight, + ), + maximumOffset, + ) + } else { + val cv = getContentView() as ViewGroup + var smallerChildOffset = largerOffset + var largerChildOffset = smallerOffset + for (i in 0 until cv.childCount) { + val item = cv.getChildAt(i) + val itemStartOffset = + when (snapToAlignment) { + SNAP_ALIGNMENT_CENTER -> item.top - (viewportHeight - item.height) / 2 + SNAP_ALIGNMENT_START -> item.top + SNAP_ALIGNMENT_END -> item.top - (viewportHeight - item.height) + else -> + throw IllegalStateException("Invalid SnapToAlignment value: $snapToAlignment") + } + if (itemStartOffset <= targetOffset) { + if (targetOffset - itemStartOffset < targetOffset - smallerOffset) { + smallerOffset = itemStartOffset + } + } + if (itemStartOffset >= targetOffset) { + if (itemStartOffset - targetOffset < largerOffset - targetOffset) { + largerOffset = itemStartOffset + } + } + smallerChildOffset = min(smallerChildOffset, itemStartOffset) + largerChildOffset = max(largerChildOffset, itemStartOffset) + } + smallerOffset = max(smallerOffset, smallerChildOffset) + largerOffset = min(largerOffset, largerChildOffset) + } + } else { + val interval = getSnapInterval().toDouble() + val ratio = targetOffset.toDouble() / interval + smallerOffset = (floor(ratio) * interval).toInt() + largerOffset = min((ceil(ratio) * interval).toInt(), maximumOffset) + } + + val nearestOffset = + if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset + else largerOffset + + if (!snapToEnd && targetOffset >= lastOffset) { + if (scrollY >= lastOffset) { + // free scrolling + } else { + targetOffset = lastOffset + } + } else if (!snapToStart && targetOffset <= firstOffset) { + if (scrollY <= firstOffset) { + // free scrolling + } else { + targetOffset = firstOffset + } + } else if (velocityY > 0) { + if (!hasCustomizedFlingAnimator) { + velocityY += ((largerOffset - targetOffset) * 10.0).toInt() + } + targetOffset = largerOffset + } else if (velocityY < 0) { + if (!hasCustomizedFlingAnimator) { + velocityY -= ((targetOffset - smallerOffset) * 10.0).toInt() + } + targetOffset = smallerOffset + } else { + targetOffset = nearestOffset + } + + targetOffset = min(max(0, targetOffset), maximumOffset) + + if (hasCustomizedFlingAnimator || scroller == null) { + reactSmoothScrollTo(scrollX, targetOffset) + } else { + activelyScrolling = true + scroller.fling( + scrollX, // startX + scrollY, // startY + 0, // velocityX + if (velocityY != 0) velocityY else targetOffset - scrollY, // velocityY + 0, // minX + 0, // maxX + targetOffset, // minY + targetOffset, // maxY + 0, // overX + if (targetOffset == 0 || targetOffset == maximumOffset) viewportHeight / 2 + else 0, // overY + ) + postInvalidateOnAnimation() + } + } + + private fun getItemStartOffset( + snapToAlignment: Int, + itemStartPosition: Int, + itemHeight: Int, + viewPortHeight: Int, + ): Int = + when (snapToAlignment) { + SNAP_ALIGNMENT_CENTER -> itemStartPosition - (viewPortHeight - itemHeight) / 2 + SNAP_ALIGNMENT_START -> itemStartPosition + SNAP_ALIGNMENT_END -> itemStartPosition - (viewPortHeight - itemHeight) + else -> throw IllegalStateException("Invalid SnapToAlignment value: $snapToAlignment") + } + + private fun getSnapInterval(): Int = if (snapInterval != 0) snapInterval else height + + public open fun setEndFillColor(color: Int) { + if (color != endFillColor) { + endFillColor = color + endBackground = ColorDrawable(endFillColor) + } + } + + override fun onOverScrolled(scrollX: Int, scrollY: Int, clampedX: Boolean, clampedY: Boolean) { + @Suppress("NAME_SHADOWING") var scrollY = scrollY + if (scroller != null && contentView != null) { + if (!scroller.isFinished && scroller.currY != scroller.finalY) { + val scrollRange = getMaxScrollY() + if (scrollY >= scrollRange) { + scroller.abortAnimation() + scrollY = scrollRange + } + } + } + + if ( + ReactNativeFeatureFlags.shouldTriggerResponderTransferOnScrollAndroid() && + clampedY && + !emittedOverScrollSinceScrollBegin + ) { + ReactScrollViewHelper.emitScrollEvent(this, 0f, 0f) + emittedOverScrollSinceScrollBegin = true + } + + super.onOverScrolled(scrollX, scrollY, clampedX, clampedY) + } + + override fun onChildViewAdded(parent: View, child: View) { + contentView = child + child.addOnLayoutChangeListener(this) + } + + override fun onChildViewRemoved(parent: View, child: View) { + contentView?.removeOnLayoutChangeListener(this) + contentView = null + } + + public open fun setContentOffset(value: ReadableMap?) { + if (currentContentOffset == null || currentContentOffset != value) { + currentContentOffset = value + if (value != null) { + val x = if (value.hasKey("x")) value.getDouble("x") else 0.0 + val y = if (value.hasKey("y")) value.getDouble("y") else 0.0 + scrollTo(PixelUtil.toPixelFromDIP(x).toInt(), PixelUtil.toPixelFromDIP(y).toInt()) + } else { + scrollTo(0, 0) + } + } + } + + /** + * Calls `smoothScrollTo` and updates state. + * + * `smoothScrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. Calling raw `smoothScrollTo` doesn't update state. + */ + override fun reactSmoothScrollTo(x: Int, y: Int) { + ReactScrollViewHelper.smoothScrollTo(this, x, y) + setPendingContentOffsets(x, y) + } + + /** + * Calls `super.scrollTo` and updates state. + * + * `super.scrollTo` changes `contentOffset` and we need to keep `contentOffset` in sync between + * scroll view and state. + * + * Note that while we can override scrollTo, we *cannot* override `smoothScrollTo` because it is + * final. See `reactSmoothScrollTo`. + */ + override fun scrollTo(x: Int, y: Int) { + super.scrollTo(x, y) + ReactScrollViewHelper.updateFabricScrollState(this) + setPendingContentOffsets(x, y) + } + + /** + * If we are in the middle of a fling animation from the user removing their finger (OverScroller + * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against + * outdated scroll offsets. + */ + private fun recreateFlingAnimation(scrollY: Int) { + if (getFlingAnimator().isRunning) { + getFlingAnimator().cancel() + } + + if (scroller != null && !scroller.isFinished) { + val scrollerYBeforeTick = scroller.currY + val hasMoreTicks = scroller.computeScrollOffset() + scroller.forceFinished(true) + + if (hasMoreTicks) { + val direction = (scroller.finalY - scroller.startY).toFloat().sign + val flingVelocityY = scroller.currVelocity * direction + scroller.fling(scrollX, scrollY, 0, flingVelocityY.toInt(), 0, 0, 0, Int.MAX_VALUE) + } else { + scrollTo(scrollX, scrollY + (scroller.currY - scrollerYBeforeTick)) + } + } + } + + override fun scrollToPreservingMomentum(x: Int, y: Int) { + scrollTo(x, y) + recreateFlingAnimation(y) + } + + private fun isContentReady(): Boolean { + val child = getContentView() + return child != null && child.width != 0 && child.height != 0 + } + + private fun setPendingContentOffsets(x: Int, y: Int) { + if (isContentReady()) { + pendingContentOffsetX = UNSET_CONTENT_OFFSET + pendingContentOffsetY = UNSET_CONTENT_OFFSET + } else { + pendingContentOffsetX = x + pendingContentOffsetY = y + } + } + + override fun onLayoutChange( + v: View, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int, + ) { + if (contentView == null) return + + if (isShown && isContentReady()) { + val currentScrollY = scrollY + val maxScrollY = getMaxScrollY() + if (currentScrollY > maxScrollY) { + scrollTo(scrollX, maxScrollY) + } + } + + ReactScrollViewHelper.emitLayoutChangeEvent(this) + } + + override fun setBackgroundColor(color: Int) { + BackgroundStyleApplicator.setBackgroundColor(this, color) + } + + public open fun setBorderWidth(position: Int, width: Float) { + BackgroundStyleApplicator.setBorderWidth( + this, + LogicalEdge.entries[position], + PixelUtil.toDIPFromPixel(width), + ) + } + + public open fun setBorderColor(position: Int, color: Int?) { + BackgroundStyleApplicator.setBorderColor(this, LogicalEdge.entries[position], color) + } + + public open fun setBorderRadius(borderRadius: Float) { + setBorderRadius(borderRadius, BorderRadiusProp.BORDER_RADIUS.ordinal) + } + + public open fun setBorderRadius(borderRadius: Float, position: Int) { + val radius = + if (borderRadius.isNaN()) null + else LengthPercentage(PixelUtil.toDIPFromPixel(borderRadius), LengthPercentageType.POINT) + BackgroundStyleApplicator.setBorderRadius(this, BorderRadiusProp.entries[position], radius) + } + + public open fun setBorderStyle(style: String?) { + BackgroundStyleApplicator.setBorderStyle( + this, + if (style == null) null else BorderStyle.fromString(style), + ) + } + + /** + * ScrollAway: This enables a natively-controlled navbar that optionally obscures the top content + * of the ScrollView. Whether or not the navbar is obscuring the React Native surface is + * determined outside of React Native. + * + * Note: all ScrollViews and HorizontalScrollViews in React have exactly one child: the "content" + * View (see ScrollView.js). That View is non-collapsable so it will never be View-flattened away. + * However, it is possible to pass custom styles into that View. + * + * If you are using this feature it is assumed that you have full control over this ScrollView and + * that you are **not** overriding the ScrollView content view to pass in a `translateY` style. + * `translateY` must never be set from ReactJS while using this feature! + */ + public open fun setScrollAwayPaddingEnabledUnstable(topPadding: Int, bottomPadding: Int) { + setScrollAwayPaddingEnabledUnstable(topPadding, bottomPadding, true) + } + + public open fun setScrollAwayPaddingEnabledUnstable( + topPadding: Int, + bottomPadding: Int, + updateState: Boolean, + ) { + val count = childCount + check(count <= 1) { + "React Native ScrollView should not have more than one child, it should have exactly 1" + + " child; a content View" + } + + if (count > 0) { + for (i in 0 until count) { + val childView = getChildAt(i) + childView.translationY = topPadding.toFloat() + } + setPadding(0, 0, 0, topPadding + bottomPadding) + } + + if (updateState) { + updateScrollAwayState(topPadding, bottomPadding) + } + removeClippedSubviews = _removeClippedSubviews + } + + private fun updateScrollAwayState(scrollAwayPaddingTop: Int, scrollAwayPaddingBottom: Int) { + _reactScrollViewScrollState.scrollAwayPaddingTop = scrollAwayPaddingTop + _reactScrollViewScrollState.scrollAwayPaddingBottom = scrollAwayPaddingBottom + ReactScrollViewHelper.forceUpdateState(this) + } + + override fun startFlingAnimator(start: Int, end: Int) { + defaultFlingAnimator.cancel() + val duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(context) + defaultFlingAnimator.setDuration(duration.toLong()).setIntValues(start, end) + defaultFlingAnimator.start() + + if (sendMomentumEvents) { + val yVelocity = if (duration > 0) (end - start) / duration else 0 + ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, 0, yVelocity) + ReactScrollViewHelper.dispatchMomentumEndOnAnimationEnd(this) + } + } + + override fun getFlingAnimator(): ValueAnimator = defaultFlingAnimator + + override fun getFlingExtrapolatedDistance(velocity: Int): Int = + ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt index 0de168f067dd..f96baf0200d5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt @@ -82,7 +82,7 @@ constructor(private val fpsListener: FpsListener? = null) : @ReactProp(name = "scrollEnabled", defaultBoolean = true) public fun setScrollEnabled(view: ReactScrollView, value: Boolean) { - view.setScrollEnabled(value) + view.scrollEnabled = value // Set focusable to match whether scroll is enabled. This improves keyboarding // experience by not making scrollview a tab stop when you cannot interact with it. @@ -335,8 +335,8 @@ constructor(private val fpsListener: FpsListener? = null) : public fun setFadingEdgeLength(view: ReactScrollView, value: Dynamic) { when (value.type) { ReadableType.Number -> { - view.setFadingEdgeLengthStart(value.asInt()) - view.setFadingEdgeLengthEnd(value.asInt()) + view.fadingEdgeLengthStart = value.asInt() + view.fadingEdgeLengthEnd = value.asInt() } ReadableType.Map -> { value.asMap()?.let { map -> @@ -348,8 +348,8 @@ constructor(private val fpsListener: FpsListener? = null) : if (map.hasKey("end") && map.getInt("end") > 0) { end = map.getInt("end") } - view.setFadingEdgeLengthStart(start) - view.setFadingEdgeLengthEnd(end) + view.fadingEdgeLengthStart = start + view.fadingEdgeLengthEnd = end } } else -> { @@ -388,7 +388,7 @@ constructor(private val fpsListener: FpsListener? = null) : props: ReactStylesDiffMap, stateWrapper: StateWrapper, ): Any? { - view.setStateWrapper(stateWrapper) + view.stateWrapper = stateWrapper if ( ReactNativeFeatureFlags.enableViewCulling() || ReactNativeFeatureFlags.useTraitHiddenOnAndroid() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/VirtualViewContainer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/VirtualViewContainer.kt index 1022b2abd2ba..eb22943c4172 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/VirtualViewContainer.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/VirtualViewContainer.kt @@ -14,7 +14,7 @@ import com.facebook.react.views.virtual.VirtualViewMode import java.util.* internal interface VirtualViewContainer { - public val virtualViewContainerState: VirtualViewContainerState + val virtualViewContainerState: VirtualViewContainerState } public interface VirtualView { @@ -43,7 +43,17 @@ internal fun rectsOverlap(rect1: Rect, rect2: Rect): Boolean { return true } -internal abstract class VirtualViewContainerState { +/** + * Manages the state and visibility tracking of virtual views within a scroll container. + * + * Virtual views are lightweight representations of off-screen content that can transition between + * rendering modes (e.g., visible, prerendered, hidden) based on their position relative to the + * scroll viewport. Subclasses implement the specific strategy for tracking and updating these + * views. + * + * Use [create] to obtain an instance appropriate for the current feature flag configuration. + */ +public abstract class VirtualViewContainerState { protected val prerenderRatio: Double = ReactNativeFeatureFlags.virtualViewPrerenderRatio() protected abstract val virtualViews: MutableCollection protected val emptyRect: Rect = Rect() @@ -51,9 +61,9 @@ internal abstract class VirtualViewContainerState { protected val prerenderRect: Rect = Rect() protected val scrollView: ViewGroup - companion object { + public companion object { @JvmStatic - fun create(scrollView: ViewGroup): VirtualViewContainerState { + public fun create(scrollView: ViewGroup): VirtualViewContainerState { return if (ReactNativeFeatureFlags.enableVirtualViewContainerStateExperimental()) { VirtualViewContainerStateExperimental(scrollView) } else { @@ -62,23 +72,23 @@ internal abstract class VirtualViewContainerState { } } - constructor(scrollView: ViewGroup) { + public constructor(scrollView: ViewGroup) { this.scrollView = scrollView } - open fun onChange(virtualView: VirtualView) { + public open fun onChange(virtualView: VirtualView) { virtualViews.add(virtualView) updateModes(virtualView) } - open fun remove(virtualView: VirtualView) { + public open fun remove(virtualView: VirtualView) { assert(virtualViews.remove(virtualView)) { "Attempting to remove non-existent VirtualView: ${virtualView.virtualViewID}" } } // Called on ScrollView onLayout or onScroll - fun updateState() { + public fun updateState() { updateModes() } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js index 94edb0e8bbde..ee097c2f801e 100755 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js @@ -11,8 +11,8 @@ */ /** - * Generates ReactNestedScrollView.java and ReactNestedScrollViewManager.kt from - * ReactScrollView.java and ReactScrollViewManager.kt respectively. + * Generates ReactNestedScrollView.kt and ReactNestedScrollViewManager.kt from + * ReactScrollView.kt and ReactScrollViewManager.kt respectively. * * This script creates variants that use NestedScrollView instead of ScrollView * for experimentation purposes. @@ -101,17 +101,17 @@ function replaceCopyrightHeader(content, sourceFile) { } /** - * Transform ReactScrollView.java to ReactNestedScrollView.java + * Transform ReactScrollView.kt to ReactNestedScrollView.kt */ function transformScrollView(content) { - // Replace import + // Replace import (Kotlin imports have no semicolons) content = content.replace( - 'import android.widget.ScrollView;', - 'import androidx.core.widget.NestedScrollView;', + 'import android.widget.ScrollView', + 'import androidx.core.widget.NestedScrollView', ); // Replace standalone ScrollView with NestedScrollView (not when part of another word) - // This handles: "extends ScrollView", "ScrollView.class", etc. + // This handles: ": ScrollView(context)", "ScrollView::class.java", etc. // But NOT: ReactScrollView, NestedScrollView, ScrollViewHelper, etc. content = content.replace( /(?, body: BufferedSource, - done: Boolean, + isLastChunk: Boolean, ) { - super.onChunkComplete(headers, body, done) + super.onChunkComplete(headers, body, isLastChunk) // Lookup using canonical case should still work. assertThat(headers["Content-Type"]).isEqualTo("application/json")