From 503cbebd0b69c8587f16775aa0200a5dc77b7ce1 Mon Sep 17 00:00:00 2001 From: Nicola Corti Date: Wed, 3 Jun 2026 02:55:32 -0700 Subject: [PATCH] Convert `ReactScrollView` and `ReactNestedScrollView` to Kotlin Summary: Convert `ReactScrollView.java` to `ReactScrollView.kt` and update the `generate-nested-scroll-view.js` script to generate `ReactNestedScrollView.kt` from the Kotlin source. Key changes: - `ReactScrollView.java` replaced by idiomatic `ReactScrollView.kt` - `ReactNestedScrollView.java` replaced by generated `ReactNestedScrollView.kt` - Generation script updated for Kotlin source/output file paths and transformations - BUCK file updated to reference `.kt` files in the verification genrule - `ReactScrollViewManager.kt` updated to use Kotlin property access syntax for `scrollEnabled`, `stateWrapper`, and `fadingEdgeLengthStart/End` - Public API surface preserved: only additive `synthetic` constructor from `JvmOverloads` Changelog: [Android][Changed] - Convert `ReactScrollView` and `ReactNestedScrollView` from Java to Kotlin Differential Revision: D107236882 --- .../ReactAndroid/api/ReactAndroid.api | 24 +- .../views/scroll/ReactNestedScrollView.java | 1654 ----------------- .../views/scroll/ReactNestedScrollView.kt | 1311 +++++++++++++ .../scroll/ReactNestedScrollViewManager.kt | 14 +- .../react/views/scroll/ReactScrollView.java | 1646 ---------------- .../react/views/scroll/ReactScrollView.kt | 1303 +++++++++++++ .../views/scroll/ReactScrollViewManager.kt | 12 +- .../views/scroll/VirtualViewContainer.kt | 16 +- .../scroll/generate-nested-scroll-view.js | 33 +- .../devsupport/MultipartStreamReaderTest.kt | 4 +- 10 files changed, 2673 insertions(+), 3344 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index ffcfb0b074a3..7669d32a3987 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5598,6 +5598,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 @@ -5659,7 +5660,6 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun setFadingEdgeLengthEnd (I)V public fun setFadingEdgeLengthStart (I)V public fun setLastScrollDispatchTime (J)V - public fun setMaintainVisibleContentPosition (Lcom/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper$Config;)V public fun setOverflow (Ljava/lang/String;)V public fun setOverflowInset (IIII)V public fun setPagingEnabled (Z)V @@ -5925,6 +5925,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/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..c8adcdfd082a --- /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<<2b0cbc5249ac34ae7f030a9c0fffd1f3>> + */ + +/** + * 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() + } + + internal 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..bd32aba8da9e --- /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() + } + + internal 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..3fdf2f1a470a 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,7 @@ internal fun rectsOverlap(rect1: Rect, rect2: Rect): Boolean { return true } -internal abstract class VirtualViewContainerState { +public abstract class VirtualViewContainerState { protected val prerenderRatio: Double = ReactNativeFeatureFlags.virtualViewPrerenderRatio() protected abstract val virtualViews: MutableCollection protected val emptyRect: Rect = Rect() @@ -51,9 +51,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 +62,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")