From 9dda7cab6951cea46607ffde1246984f2d030d97 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Tue, 20 Jan 2026 17:01:36 -0800 Subject: [PATCH] Generate nested scroll view (#55239) Summary: This diff adds the ability to experiment with using `NestedScrollView` instead of `ScrollView` as the parent class for `ReactScrollView` on Android. Since Java doesn't support multiple inheritance or conditional parent class selection, this is implemented using code generation: - A Python script (`generate-nested-scroll-view.py`) generates `ReactNestedScrollView.java` and `ReactNestedScrollViewManager.kt` from their respective source files - The generated files are identical to the originals except they extend `NestedScrollView` instead of `ScrollView` - A Buck genrule verifies the generated files stay in sync with source files at build time - The `useNestedScrollViewAndroid` feature flag controls which implementation is used at runtime This approach allows us to safely A/B test the NestedScrollView implementation without requiring JS changes, since both managers register with the same `REACT_CLASS` name (`"RCTScrollView"`). Changelog: [Internal] Differential Revision: D90902079 --- .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../facebook/react/shell/MainReactPackage.kt | 10 +- .../views/scroll/ReactNestedScrollView.java | 1634 +++++++++++++++++ .../scroll/ReactNestedScrollViewManager.kt | 466 +++++ .../scroll/generate-nested-scroll-view.js | 270 +++ .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 32 +- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- 24 files changed, 2521 insertions(+), 28 deletions(-) create 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/ReactNestedScrollViewManager.kt create mode 100755 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 5e6dab12003c7a..01f74d9001fd29 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.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<<27dfa9832869aa05987fa7cb9740f7ba>> + * @generated SignedSource<<1d13409e4db7a5a48e5d2caf59a5fbcf>> */ /** @@ -456,6 +456,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun useNativeViewConfigsInBridgelessMode(): Boolean = accessor.useNativeViewConfigsInBridgelessMode() + /** + * When enabled, ReactScrollView will extend NestedScrollView instead of ScrollView on Android for improved nested scrolling support. + */ + @JvmStatic + public fun useNestedScrollViewAndroid(): Boolean = accessor.useNestedScrollViewAndroid() + /** * Use shared animation backend in C++ Animated */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index feb2512c61458b..3568c257dd10b4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.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<<6fb19235ba5049e07952cfa1b564e9e2>> + * @generated SignedSource<<58a693fe003a9a19311fc0134d071154>> */ /** @@ -91,6 +91,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var useAlwaysAvailableJSErrorHandlingCache: Boolean? = null private var useFabricInteropCache: Boolean? = null private var useNativeViewConfigsInBridgelessModeCache: Boolean? = null + private var useNestedScrollViewAndroidCache: Boolean? = null private var useSharedAnimatedBackendCache: Boolean? = null private var useTraitHiddenOnAndroidCache: Boolean? = null private var useTurboModuleInteropCache: Boolean? = null @@ -737,6 +738,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun useNestedScrollViewAndroid(): Boolean { + var cached = useNestedScrollViewAndroidCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.useNestedScrollViewAndroid() + useNestedScrollViewAndroidCache = cached + } + return cached + } + override fun useSharedAnimatedBackend(): Boolean { var cached = useSharedAnimatedBackendCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 2ea5986878f1d6..77f91846cc951d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.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<<6410500cbf5a05b1f34efd12d1e83bdf>> + * @generated SignedSource<<698641fe5c1a9f4933321d5b155467d1>> */ /** @@ -170,6 +170,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun useNativeViewConfigsInBridgelessMode(): Boolean + @DoNotStrip @JvmStatic public external fun useNestedScrollViewAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun useSharedAnimatedBackend(): Boolean @DoNotStrip @JvmStatic public external fun useTraitHiddenOnAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index 917c6803923a5c..d8570ccd3c9147 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.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<<6c664176cf41ffb9f2bf820f15ed7463>> + * @generated SignedSource<<6ab1616102fb0807ad936f4333cb44c8>> */ /** @@ -165,6 +165,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun useNativeViewConfigsInBridgelessMode(): Boolean = false + override fun useNestedScrollViewAndroid(): Boolean = false + override fun useSharedAnimatedBackend(): Boolean = false override fun useTraitHiddenOnAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 3a389ccea99dbf..635e4bf9ada956 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.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<<1403b30f4dc3d18f39303d91d6824bda>> + * @generated SignedSource<> */ /** @@ -95,6 +95,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var useAlwaysAvailableJSErrorHandlingCache: Boolean? = null private var useFabricInteropCache: Boolean? = null private var useNativeViewConfigsInBridgelessModeCache: Boolean? = null + private var useNestedScrollViewAndroidCache: Boolean? = null private var useSharedAnimatedBackendCache: Boolean? = null private var useTraitHiddenOnAndroidCache: Boolean? = null private var useTurboModuleInteropCache: Boolean? = null @@ -812,6 +813,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun useNestedScrollViewAndroid(): Boolean { + var cached = useNestedScrollViewAndroidCache + if (cached == null) { + cached = currentProvider.useNestedScrollViewAndroid() + accessedFeatureFlags.add("useNestedScrollViewAndroid") + useNestedScrollViewAndroidCache = cached + } + return cached + } + override fun useSharedAnimatedBackend(): Boolean { var cached = useSharedAnimatedBackendCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 542075e9b2305a..ebce9a4ec34f73 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.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<> + * @generated SignedSource<> */ /** @@ -165,6 +165,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun useNativeViewConfigsInBridgelessMode(): Boolean + @DoNotStrip public fun useNestedScrollViewAndroid(): Boolean + @DoNotStrip public fun useSharedAnimatedBackend(): Boolean @DoNotStrip public fun useTraitHiddenOnAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt index 36684e8d8c3468..64452e80f3d2c7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.kt @@ -52,6 +52,7 @@ import com.facebook.react.views.progressbar.ReactProgressBarViewManager import com.facebook.react.views.safeareaview.ReactSafeAreaViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollContainerViewManager import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager +import com.facebook.react.views.scroll.ReactNestedScrollViewManager import com.facebook.react.views.scroll.ReactScrollViewManager import com.facebook.react.views.swiperefresh.SwipeRefreshLayoutManager import com.facebook.react.views.switchview.ReactSwitchManager @@ -139,7 +140,8 @@ constructor(private val config: MainPackageConfig? = null) : ReactHorizontalScrollViewManager(), ReactHorizontalScrollContainerViewManager(), ReactProgressBarViewManager(), - ReactScrollViewManager(), + if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) ReactNestedScrollViewManager() + else ReactScrollViewManager(), ReactSwitchManager(), ReactSafeAreaViewManager(), SwipeRefreshLayoutManager(), @@ -173,7 +175,11 @@ constructor(private val config: MainPackageConfig? = null) : ReactSafeAreaViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactSafeAreaViewManager() }, ReactScrollViewManager.REACT_CLASS to - ModuleSpec.viewManagerSpec { ReactScrollViewManager() }, + ModuleSpec.viewManagerSpec { + if (ReactNativeFeatureFlags.useNestedScrollViewAndroid()) + ReactNestedScrollViewManager() + else ReactScrollViewManager() + }, ReactSwitchManager.REACT_CLASS to ModuleSpec.viewManagerSpec { ReactSwitchManager() }, SwipeRefreshLayoutManager.REACT_CLASS to ModuleSpec.viewManagerSpec { SwipeRefreshLayoutManager() }, 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 new file mode 100644 index 00000000000000..482de2098449b6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java @@ -0,0 +1,1634 @@ +/* + * 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 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 FpsListener mFpsListener; + 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; + } + + 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(); + } + + return super.onTouchEvent(ev); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent ev) { + // 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 + 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 = + 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); + } + }; + postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); + } + 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 + 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. + */ + 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 (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.updateScrollPosition(); + } + + 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 setScrollAwayTopPaddingEnabledUnstable(int topPadding) { + setScrollAwayTopPaddingEnabledUnstable(topPadding, true); + } + + public void setScrollAwayTopPaddingEnabledUnstable(int topPadding, 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); + } + + if (updateState) { + updateScrollAwayState(topPadding); + } + setRemoveClippedSubviews(mRemoveClippedSubviews); + } + + private void updateScrollAwayState(int scrollAwayPaddingTop) { + getReactScrollViewScrollState().setScrollAwayPaddingTop(scrollAwayPaddingTop); + ReactScrollViewHelper.forceUpdateState(this); + } + + @Override + public void setReactScrollViewScrollState(ReactScrollViewScrollState scrollState) { + mReactScrollViewScrollState = scrollState; + if (ReactNativeFeatureFlags.enableViewCulling() + || ReactNativeFeatureFlags.useTraitHiddenOnAndroid()) { + setScrollAwayTopPaddingEnabledUnstable(scrollState.getScrollAwayPaddingTop(), 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/ReactNestedScrollViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt new file mode 100644 index 00000000000000..21b6f9fad97991 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollViewManager.kt @@ -0,0 +1,466 @@ +/* + * 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<<585a90a2f11c5bba8b3e2d011912d302>> + */ + +/** + * THIS FILE IS GENERATED - DO NOT EDIT DIRECTLY + * Source: ReactScrollViewManager.kt + * Run: node generate-nested-scroll-view.js + */ + +package com.facebook.react.views.scroll + +import android.graphics.Color +import android.view.View +import androidx.core.view.ViewCompat +import com.facebook.react.bridge.Dynamic +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.ReadableType +import com.facebook.react.bridge.RetryableMountingLayerException +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderColor +import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderRadius +import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderStyle +import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth +import com.facebook.react.uimanager.LengthPercentage +import com.facebook.react.uimanager.LengthPercentageType +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.facebook.react.uimanager.PixelUtil.getDisplayMetricDensity +import com.facebook.react.uimanager.PointerEvents.Companion.parsePointerEvents +import com.facebook.react.uimanager.ReactClippingViewGroupHelper +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewProps +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.uimanager.annotations.ReactPropGroup +import com.facebook.react.uimanager.style.BorderRadiusProp +import com.facebook.react.uimanager.style.BorderStyle.Companion.fromString +import com.facebook.react.uimanager.style.LogicalEdge +import com.facebook.react.views.scroll.ReactScrollViewCommandHelper.Companion.receiveCommand +import com.facebook.react.views.scroll.ReactScrollViewCommandHelper.ScrollCommandHandler +import com.facebook.react.views.scroll.ReactScrollViewCommandHelper.ScrollToCommandData +import com.facebook.react.views.scroll.ReactScrollViewCommandHelper.ScrollToEndCommandData +import com.facebook.react.views.scroll.ReactScrollViewHelper.parseOverScrollMode +import com.facebook.react.views.scroll.ReactScrollViewHelper.parseSnapToAlignment +import com.facebook.react.views.scroll.ScrollEventType.Companion.getJSEventName + +/** + * View manager for [ReactNestedScrollView] components. + * + * Note that [ReactNestedScrollView] and [ReactHorizontalScrollView] are exposed to JS as a single + * ScrollView component, configured via the `horizontal` boolean property. + */ +@ReactModule(name = ReactNestedScrollViewManager.REACT_CLASS) +internal open class ReactNestedScrollViewManager +@JvmOverloads +constructor(private val fpsListener: FpsListener? = null) : + ViewGroupManager(), ScrollCommandHandler { + + init { + if (ReactNativeFeatureFlags.enableViewRecyclingForScrollView()) { + setupViewRecycling() + } + } + + override fun prepareToRecycleView( + reactContext: ThemedReactContext, + view: ReactNestedScrollView, + ): ReactNestedScrollView? { + // BaseViewManager + val preparedView = super.prepareToRecycleView(reactContext, view) + if (preparedView != null) { + preparedView.recycleView() + } + return preparedView + } + + override fun getName(): String = REACT_CLASS + + public override fun createViewInstance(context: ThemedReactContext): ReactNestedScrollView = + ReactNestedScrollView(context, fpsListener) + + @ReactProp(name = "scrollEnabled", defaultBoolean = true) + public fun setScrollEnabled(view: ReactNestedScrollView, value: Boolean) { + view.setScrollEnabled(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. + view.isFocusable = value + } + + @ReactProp(name = "showsVerticalScrollIndicator", defaultBoolean = true) + public fun setShowsVerticalScrollIndicator(view: ReactNestedScrollView, value: Boolean) { + view.isVerticalScrollBarEnabled = value + } + + @ReactProp(name = "decelerationRate") + public fun setDecelerationRate(view: ReactNestedScrollView, decelerationRate: Float) { + view.setDecelerationRate(decelerationRate) + } + + @ReactProp(name = "disableIntervalMomentum") + public fun setDisableIntervalMomentum(view: ReactNestedScrollView, disableIntervalMomentum: Boolean) { + view.setDisableIntervalMomentum(disableIntervalMomentum) + } + + @ReactProp(name = "scrollsChildToFocus", defaultBoolean = true) + public fun setScrollsChildToFocus(view: ReactNestedScrollView, scrollsChildToFocus: Boolean) { + view.setScrollsChildToFocus(scrollsChildToFocus) + } + + @ReactProp(name = "snapToInterval") + public fun setSnapToInterval(view: ReactNestedScrollView, snapToInterval: Float) { + // snapToInterval needs to be exposed as a float because of the Javascript interface. + val density = getDisplayMetricDensity() + view.setSnapInterval((snapToInterval * density).toInt()) + } + + @ReactProp(name = "snapToOffsets") + public fun setSnapToOffsets(view: ReactNestedScrollView, snapToOffsets: ReadableArray?) { + if (snapToOffsets == null || snapToOffsets.size() == 0) { + view.setSnapOffsets(null) + return + } + + val density = getDisplayMetricDensity() + val offsets: MutableList = ArrayList() + for (i in 0 until snapToOffsets.size()) { + offsets.add((snapToOffsets.getDouble(i) * density).toInt()) + } + view.setSnapOffsets(offsets) + } + + @ReactProp(name = "snapToAlignment") + public fun setSnapToAlignment(view: ReactNestedScrollView, alignment: String?) { + view.setSnapToAlignment(parseSnapToAlignment(alignment)) + } + + @ReactProp(name = "snapToStart") + public fun setSnapToStart(view: ReactNestedScrollView, snapToStart: Boolean) { + view.setSnapToStart(snapToStart) + } + + @ReactProp(name = "snapToEnd") + public fun setSnapToEnd(view: ReactNestedScrollView, snapToEnd: Boolean) { + view.setSnapToEnd(snapToEnd) + } + + @ReactProp(name = ReactClippingViewGroupHelper.PROP_REMOVE_CLIPPED_SUBVIEWS) + public fun setRemoveClippedSubviews(view: ReactNestedScrollView, removeClippedSubviews: Boolean) { + view.removeClippedSubviews = removeClippedSubviews + } + + /** + * Computing momentum events is potentially expensive since we post a runnable on the UI thread to + * see when it is done. We only do that if {@param sendMomentumEvents} is set to true. This is + * handled automatically in js by checking if there is a listener on the momentum events. + * + * @param view + * @param sendMomentumEvents + */ + @ReactProp(name = "sendMomentumEvents") + public fun setSendMomentumEvents(view: ReactNestedScrollView, sendMomentumEvents: Boolean) { + view.setSendMomentumEvents(sendMomentumEvents) + } + + /** + * Tag used for logging scroll performance on this scroll view. Will force momentum events to be + * turned on (see setSendMomentumEvents). + * + * @param view + * @param scrollPerfTag + */ + @ReactProp(name = "scrollPerfTag") + public fun setScrollPerfTag(view: ReactNestedScrollView, scrollPerfTag: String?) { + view.setScrollPerfTag(scrollPerfTag) + } + + @ReactProp(name = "pagingEnabled") + public fun setPagingEnabled(view: ReactNestedScrollView, pagingEnabled: Boolean) { + view.setPagingEnabled(pagingEnabled) + } + + /** + * When set, fills the rest of the scrollview with a color to avoid setting a background and + * creating unnecessary overdraw. + * + * @param view + * @param color + */ + @ReactProp(name = "endFillColor", defaultInt = Color.TRANSPARENT, customType = "Color") + public fun setBottomFillColor(view: ReactNestedScrollView, color: Int) { + view.setEndFillColor(color) + } + + /** Controls overScroll behaviour */ + @ReactProp(name = "overScrollMode") + public open fun setOverScrollMode(view: ReactNestedScrollView, value: String?) { + view.overScrollMode = parseOverScrollMode(value) + } + + @ReactProp(name = "nestedScrollEnabled") + public fun setNestedScrollEnabled(view: ReactNestedScrollView?, value: Boolean) { + if (view != null) { + ViewCompat.setNestedScrollingEnabled(view, value) + } + } + + override fun getCommandsMap(): Map? = ReactScrollViewCommandHelper.getCommandsMap() + + @Deprecated( + message = + "ReceiveCommand with an int commandId param is deprecated. Use the overload where commandId is a string.", + ReplaceWith("receiveCommand(scrollView, commandId, args)"), + ) + override fun receiveCommand(scrollView: ReactNestedScrollView, commandId: Int, args: ReadableArray?) { + receiveCommand(this, scrollView, commandId, args) + } + + override fun receiveCommand( + scrollView: ReactNestedScrollView, + commandId: String, + args: ReadableArray?, + ) { + receiveCommand(this, scrollView, commandId, args) + } + + override fun flashScrollIndicators(scrollView: ReactNestedScrollView) { + scrollView.flashScrollIndicators() + } + + override fun scrollTo(scrollView: ReactNestedScrollView, data: ScrollToCommandData) { + scrollView.abortAnimation() + if (data.mAnimated) { + scrollView.reactSmoothScrollTo(data.mDestX, data.mDestY) + } else { + scrollView.scrollTo(data.mDestX, data.mDestY) + } + } + + @ReactPropGroup( + names = + [ + ViewProps.BORDER_RADIUS, + ViewProps.BORDER_TOP_LEFT_RADIUS, + ViewProps.BORDER_TOP_RIGHT_RADIUS, + ViewProps.BORDER_BOTTOM_RIGHT_RADIUS, + ViewProps.BORDER_BOTTOM_LEFT_RADIUS, + ], + defaultFloat = Float.NaN, + ) + public fun setBorderRadius(view: ReactNestedScrollView?, index: Int, borderRadius: Float) { + if (view != null) { + val radius = + if (borderRadius.isNaN()) null + else LengthPercentage(borderRadius, LengthPercentageType.POINT) + setBorderRadius(view, BorderRadiusProp.entries[index], radius) + } + } + + @ReactProp(name = "borderStyle") + public fun setBorderStyle(view: ReactNestedScrollView?, borderStyle: String?) { + if (view != null) { + val parsedBorderStyle = if (borderStyle == null) null else fromString(borderStyle) + setBorderStyle(view, parsedBorderStyle) + } + } + + @ReactPropGroup( + names = + [ + ViewProps.BORDER_WIDTH, + ViewProps.BORDER_LEFT_WIDTH, + ViewProps.BORDER_RIGHT_WIDTH, + ViewProps.BORDER_TOP_WIDTH, + ViewProps.BORDER_BOTTOM_WIDTH, + ], + defaultFloat = Float.NaN, + ) + public fun setBorderWidth(view: ReactNestedScrollView?, index: Int, width: Float) { + if (view != null) { + setBorderWidth(view, LogicalEdge.entries[index], width) + } + } + + @ReactPropGroup( + names = + [ + "borderColor", + "borderLeftColor", + "borderRightColor", + "borderTopColor", + "borderBottomColor", + ], + customType = "Color", + ) + @Suppress("UNUSED_PARAMETER") + public fun setBorderColor(view: ReactNestedScrollView?, index: Int, color: Int?) { + if (view != null) { + setBorderColor(view, LogicalEdge.ALL, color) + } + } + + @ReactProp(name = "overflow") + public fun setOverflow(view: ReactNestedScrollView, overflow: String?) { + view.setOverflow(overflow) + } + + override fun scrollToEnd(scrollView: ReactNestedScrollView, data: ScrollToEndCommandData) { + // ScrollView always has one child - the scrollable area. However, it's possible today that we + // execute this method as view command before the child view is mounted. Here we will retry the + // view commands as a workaround. + val child = + scrollView.getChildAt(0) + ?: throw RetryableMountingLayerException( + "scrollToEnd called on ScrollView without child" + ) + + // ScrollView always has one child - the scrollable area + val bottom = child.height + scrollView.paddingBottom + scrollView.abortAnimation() + if (data.mAnimated) { + scrollView.reactSmoothScrollTo(scrollView.scrollX, bottom) + } else { + scrollView.scrollTo(scrollView.scrollX, bottom) + } + } + + @ReactProp(name = "persistentScrollbar") + public fun setPersistentScrollbar(view: ReactNestedScrollView, value: Boolean) { + view.isScrollbarFadingEnabled = !value + } + + @ReactProp(name = "fadingEdgeLength") + public fun setFadingEdgeLength(view: ReactNestedScrollView, value: Dynamic) { + when (value.type) { + ReadableType.Number -> { + view.setFadingEdgeLengthStart(value.asInt()) + view.setFadingEdgeLengthEnd(value.asInt()) + } + ReadableType.Map -> { + value.asMap()?.let { map -> + var start = 0 + var end = 0 + if (map.hasKey("start") && map.getInt("start") > 0) { + start = map.getInt("start") + } + if (map.hasKey("end") && map.getInt("end") > 0) { + end = map.getInt("end") + } + view.setFadingEdgeLengthStart(start) + view.setFadingEdgeLengthEnd(end) + } + } + else -> { + // no-op + } + } + if (view.fadingEdgeLengthStart > 0 || view.fadingEdgeLengthEnd > 0) { + view.isVerticalFadingEdgeEnabled = true + view.setFadingEdgeLength( + Math.round(Math.max(view.fadingEdgeLengthStart, view.fadingEdgeLengthEnd).dpToPx()) + ) + } else { + view.isVerticalFadingEdgeEnabled = false + view.setFadingEdgeLength(0) + } + } + + @ReactProp(name = "contentOffset", customType = "Point") + public fun setContentOffset(view: ReactNestedScrollView, value: ReadableMap?) { + view.setContentOffset(value) + } + + @ReactProp(name = "maintainVisibleContentPosition") + public fun setMaintainVisibleContentPosition(view: ReactNestedScrollView, value: ReadableMap?) { + if (value != null) { + view.setMaintainVisibleContentPosition( + MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value) + ) + } else { + view.setMaintainVisibleContentPosition(null) + } + } + + override fun updateState( + view: ReactNestedScrollView, + props: ReactStylesDiffMap, + stateWrapper: StateWrapper, + ): Any? { + view.setStateWrapper(stateWrapper) + if ( + ReactNativeFeatureFlags.enableViewCulling() || + ReactNativeFeatureFlags.useTraitHiddenOnAndroid() + ) { + ReactScrollViewHelper.loadFabricScrollState(view, stateWrapper) + } + return null + } + + override fun getExportedCustomDirectEventTypeConstants(): Map? { + val baseEventTypeConstants = super.getExportedCustomDirectEventTypeConstants() + val eventTypeConstants = baseEventTypeConstants ?: HashMap() + eventTypeConstants.putAll(createExportedCustomDirectEventTypeConstants()) + return eventTypeConstants + } + + @ReactProp(name = ViewProps.POINTER_EVENTS) + public fun setPointerEvents(view: ReactNestedScrollView, pointerEventsStr: String?) { + view.pointerEvents = parsePointerEvents(pointerEventsStr) + } + + @ReactProp(name = "scrollEventThrottle") + public fun setScrollEventThrottle(view: ReactNestedScrollView, scrollEventThrottle: Int) { + view.scrollEventThrottle = scrollEventThrottle + } + + @ReactProp(name = "horizontal") + @Suppress("UNUSED_PARAMETER") + public fun setHorizontal(view: ReactNestedScrollView?, horizontal: Boolean) { + // Do Nothing: Align with static ViewConfigs + } + + @ReactProp(name = "isInvertedVirtualizedList") + public fun setIsInvertedVirtualizedList(view: ReactNestedScrollView, applyFix: Boolean) { + // Usually when inverting the scroll view we are using scaleY: -1 on the list + // and on the parent container. HOWEVER, starting from android API 33 there is + // a bug that can cause an ANR due to that. Thus we are using different transform + // commands to circumvent the ANR. This however causes the vertical scrollbar to + // be on the wrong side. Thus we are moving it to the other side, when the list + // is inverted. + // See also: + // - https://github.com/facebook/react-native/issues/35350 + // - https://issuetracker.google.com/issues/287304310 + if (applyFix) { + view.verticalScrollbarPosition = View.SCROLLBAR_POSITION_LEFT + } else { + view.verticalScrollbarPosition = View.SCROLLBAR_POSITION_DEFAULT + } + } + + public companion object { + public const val REACT_CLASS: String = "RCTScrollView" + + public fun createExportedCustomDirectEventTypeConstants(): Map = + mapOf( + getJSEventName(ScrollEventType.SCROLL) to mapOf("registrationName" to "onScroll"), + getJSEventName(ScrollEventType.BEGIN_DRAG) to + mapOf("registrationName" to "onScrollBeginDrag"), + getJSEventName(ScrollEventType.END_DRAG) to + mapOf("registrationName" to "onScrollEndDrag"), + getJSEventName(ScrollEventType.MOMENTUM_BEGIN) to + mapOf("registrationName" to "onMomentumScrollBegin"), + getJSEventName(ScrollEventType.MOMENTUM_END) to + mapOf("registrationName" to "onMomentumScrollEnd"), + ) + } +} 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 new file mode 100755 index 00000000000000..94edb0e8bbded4 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/generate-nested-scroll-view.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * 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. + * + * @format + * @noflow + * @oncall react_native + */ + +/** + * Generates ReactNestedScrollView.java and ReactNestedScrollViewManager.kt from + * ReactScrollView.java and ReactScrollViewManager.kt respectively. + * + * This script creates variants that use NestedScrollView instead of ScrollView + * for experimentation purposes. + * + * Usage: + * node generate-nested-scroll-view.js [--verify] + * + * Options: + * --verify Check if generated files are up-to-date (exit 1 if not) + */ + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// SignedSource token - this placeholder gets replaced with the actual hash +// Use string concatenation to avoid marking THIS script as generated +const SIGNING_TOKEN = '<>'; +const GENERATED_ANNOTATION = '@' + 'generated'; + +/** + * Sign the file content by replacing the signing token with a SignedSource hash. + */ +function signFileContent(content) { + // Compute MD5 hash with the token in place + const md5Hash = crypto + .createHash('md5') + .update(content, 'utf8') + .digest('hex'); + + // Replace the token with the actual signature + return content.replace(SIGNING_TOKEN, `SignedSource<<${md5Hash}>>`); +} + +/** + * Get the signing token placeholder to embed in the file. + */ +function getSigningToken() { + return SIGNING_TOKEN; +} + +/** + * Generate the header comment for a generated file. + */ +function generatedHeader(sourceFile) { + return `/* + * 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_ANNOTATION} ${getSigningToken()} + */ + +/** + * THIS FILE IS GENERATED - DO NOT EDIT DIRECTLY + * Source: ${sourceFile} + * Run: node generate-nested-scroll-view.js + */ + +`; +} + +// Regex pattern for replacing ReactScrollView with ReactNestedScrollView +// Excludes: Helper, CommandHelper, Accessible, Accessibility, ScrollState +const REACT_SCROLL_VIEW_PATTERN = + /ReactScrollView(?!Helper|CommandHelper|Accessible|Accessibility|ScrollState)/g; + +// Regex pattern for matching the original copyright header +const COPYRIGHT_HEADER_PATTERN = /\/\*\s*\n\s*\* Copyright.*?\*\/\s*\n/s; + +/** + * Replace ReactScrollView with ReactNestedScrollView in content. + */ +function replaceClassNames(content) { + return content.replace(REACT_SCROLL_VIEW_PATTERN, 'ReactNestedScrollView'); +} + +/** + * Replace the original copyright header with the generated header. + */ +function replaceCopyrightHeader(content, sourceFile) { + return content.replace(COPYRIGHT_HEADER_PATTERN, generatedHeader(sourceFile)); +} + +/** + * Transform ReactScrollView.java to ReactNestedScrollView.java + */ +function transformScrollView(content) { + // Replace import + content = content.replace( + '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. + // But NOT: ReactScrollView, NestedScrollView, ScrollViewHelper, etc. + content = content.replace( + /(?> + * @generated SignedSource<> */ /** @@ -465,6 +465,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool useNestedScrollViewAndroid() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("useNestedScrollViewAndroid"); + return method(javaProvider_); + } + bool useSharedAnimatedBackend() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("useSharedAnimatedBackend"); @@ -860,6 +866,11 @@ bool JReactNativeFeatureFlagsCxxInterop::useNativeViewConfigsInBridgelessMode( return ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode(); } +bool JReactNativeFeatureFlagsCxxInterop::useNestedScrollViewAndroid( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::useNestedScrollViewAndroid(); +} + bool JReactNativeFeatureFlagsCxxInterop::useSharedAnimatedBackend( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::useSharedAnimatedBackend(); @@ -1134,6 +1145,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "useNativeViewConfigsInBridgelessMode", JReactNativeFeatureFlagsCxxInterop::useNativeViewConfigsInBridgelessMode), + makeNativeMethod( + "useNestedScrollViewAndroid", + JReactNativeFeatureFlagsCxxInterop::useNestedScrollViewAndroid), makeNativeMethod( "useSharedAnimatedBackend", JReactNativeFeatureFlagsCxxInterop::useSharedAnimatedBackend), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 6b46be118790a3..130b580e434844 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<> + * @generated SignedSource<<08d4a471097f96e6aecffe0d9fbd1e81>> */ /** @@ -243,6 +243,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool useNativeViewConfigsInBridgelessMode( facebook::jni::alias_ref); + static bool useNestedScrollViewAndroid( + facebook::jni::alias_ref); + static bool useSharedAnimatedBackend( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index 9534f6c2bdeab5..e7e8f4f17a96d0 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<> + * @generated SignedSource<> */ /** @@ -310,6 +310,10 @@ bool ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode() { return getAccessor().useNativeViewConfigsInBridgelessMode(); } +bool ReactNativeFeatureFlags::useNestedScrollViewAndroid() { + return getAccessor().useNestedScrollViewAndroid(); +} + bool ReactNativeFeatureFlags::useSharedAnimatedBackend() { return getAccessor().useSharedAnimatedBackend(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 542d8ca8311344..8ce54c5aa82368 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<<66c8c46d66dde5eb2fd19a67459c83af>> + * @generated SignedSource<<2c1823de395b96869083f3b085347417>> */ /** @@ -394,6 +394,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool useNativeViewConfigsInBridgelessMode(); + /** + * When enabled, ReactScrollView will extend NestedScrollView instead of ScrollView on Android for improved nested scrolling support. + */ + RN_EXPORT static bool useNestedScrollViewAndroid(); + /** * Use shared animation backend in C++ Animated */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index a49d225d6eda2a..b2585938899d7b 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<> + * @generated SignedSource<<0e5c63bf372a9bbeba48c43280eb9032>> */ /** @@ -1307,6 +1307,24 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { + auto flagValue = useNestedScrollViewAndroid_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(71, "useNestedScrollViewAndroid"); + + flagValue = currentProvider_->useNestedScrollViewAndroid(); + useNestedScrollViewAndroid_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { auto flagValue = useSharedAnimatedBackend_.load(); @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "useSharedAnimatedBackend"); + markFlagAsAccessed(72, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1334,7 +1352,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(73, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "useTurboModuleInterop"); + markFlagAsAccessed(74, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModules() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "useTurboModules"); + markFlagAsAccessed(75, "useTurboModules"); flagValue = currentProvider_->useTurboModules(); useTurboModules_ = flagValue; @@ -1388,7 +1406,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "viewCullingOutsetRatio"); + markFlagAsAccessed(76, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1406,7 +1424,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "virtualViewPrerenderRatio"); + markFlagAsAccessed(77, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index f88206c070a11a..6308b0eca1a411 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<<40870eeb15b0536567bf7bc6c95b7f45>> + * @generated SignedSource<> */ /** @@ -103,6 +103,7 @@ class ReactNativeFeatureFlagsAccessor { bool useAlwaysAvailableJSErrorHandling(); bool useFabricInterop(); bool useNativeViewConfigsInBridgelessMode(); + bool useNestedScrollViewAndroid(); bool useSharedAnimatedBackend(); bool useTraitHiddenOnAndroid(); bool useTurboModuleInterop(); @@ -120,7 +121,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 77> accessedFeatureFlags_; + std::array, 78> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -193,6 +194,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> useAlwaysAvailableJSErrorHandling_; std::atomic> useFabricInterop_; std::atomic> useNativeViewConfigsInBridgelessMode_; + std::atomic> useNestedScrollViewAndroid_; std::atomic> useSharedAnimatedBackend_; std::atomic> useTraitHiddenOnAndroid_; std::atomic> useTurboModuleInterop_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index cec64942779084..a1fcfa7cc751a2 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<046cb410368bfb2d3ba6ed3364376f28>> + * @generated SignedSource<<97923f4eaf47f2c9bca01e1d126100df>> */ /** @@ -311,6 +311,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool useNestedScrollViewAndroid() override { + return false; + } + bool useSharedAnimatedBackend() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index fca7c12df6c72d..e438ec5d3102c5 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<> + * @generated SignedSource<<44b032b505e706f08c9ff4ed4d223296>> */ /** @@ -684,6 +684,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::useNativeViewConfigsInBridgelessMode(); } + bool useNestedScrollViewAndroid() override { + auto value = values_["useNestedScrollViewAndroid"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::useNestedScrollViewAndroid(); + } + bool useSharedAnimatedBackend() override { auto value = values_["useSharedAnimatedBackend"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index a1c4c62046e6d8..8b4be9a2bd30af 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<> + * @generated SignedSource<<3f42a04772080f39f59e18cedee2fa7b>> */ /** @@ -96,6 +96,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool useAlwaysAvailableJSErrorHandling() = 0; virtual bool useFabricInterop() = 0; virtual bool useNativeViewConfigsInBridgelessMode() = 0; + virtual bool useNestedScrollViewAndroid() = 0; virtual bool useSharedAnimatedBackend() = 0; virtual bool useTraitHiddenOnAndroid() = 0; virtual bool useTurboModuleInterop() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index a3d6ab4a3dce1f..8c5c2bee3f2e69 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<> + * @generated SignedSource<<43f5d6bbd8c44c0e79fda1575035b062>> */ /** @@ -399,6 +399,11 @@ bool NativeReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode( return ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode(); } +bool NativeReactNativeFeatureFlags::useNestedScrollViewAndroid( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::useNestedScrollViewAndroid(); +} + bool NativeReactNativeFeatureFlags::useSharedAnimatedBackend( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::useSharedAnimatedBackend(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 7463e69666cd41..00ab3bd25c64f8 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<<6d28ea5d159f53988ca76188bc2fa5c0>> + * @generated SignedSource<<075b9b4dcd0feab84605f35b054f58e3>> */ /** @@ -178,6 +178,8 @@ class NativeReactNativeFeatureFlags bool useNativeViewConfigsInBridgelessMode(jsi::Runtime& runtime); + bool useNestedScrollViewAndroid(jsi::Runtime& runtime); + bool useSharedAnimatedBackend(jsi::Runtime& runtime); bool useTraitHiddenOnAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 79e040c25cac54..edb0bd5c935248 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -806,6 +806,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'canary', }, + useNestedScrollViewAndroid: { + defaultValue: false, + metadata: { + dateAdded: '2026-01-16', + description: + 'When enabled, ReactScrollView will extend NestedScrollView instead of ScrollView on Android for improved nested scrolling support.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, useSharedAnimatedBackend: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index a8a675b19ef559..83307cbbd993b6 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<> + * @generated SignedSource<<39c23183566c77830778d64ad4cc72ad>> * @flow strict * @noformat */ @@ -118,6 +118,7 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ useAlwaysAvailableJSErrorHandling: Getter, useFabricInterop: Getter, useNativeViewConfigsInBridgelessMode: Getter, + useNestedScrollViewAndroid: Getter, useSharedAnimatedBackend: Getter, useTraitHiddenOnAndroid: Getter, useTurboModuleInterop: Getter, @@ -474,6 +475,10 @@ export const useFabricInterop: Getter = createNativeFlagGetter('useFabr * When enabled, the native view configs are used in bridgeless mode. */ export const useNativeViewConfigsInBridgelessMode: Getter = createNativeFlagGetter('useNativeViewConfigsInBridgelessMode', false); +/** + * When enabled, ReactScrollView will extend NestedScrollView instead of ScrollView on Android for improved nested scrolling support. + */ +export const useNestedScrollViewAndroid: Getter = createNativeFlagGetter('useNestedScrollViewAndroid', false); /** * Use shared animation backend in C++ Animated */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 9a82ef4f17bab2..8bc569560fc471 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -96,6 +96,7 @@ export interface Spec extends TurboModule { +useAlwaysAvailableJSErrorHandling?: () => boolean; +useFabricInterop?: () => boolean; +useNativeViewConfigsInBridgelessMode?: () => boolean; + +useNestedScrollViewAndroid?: () => boolean; +useSharedAnimatedBackend?: () => boolean; +useTraitHiddenOnAndroid?: () => boolean; +useTurboModuleInterop?: () => boolean;