From 1f93411fbb26c7e904cbdc589e00145d57c4fcbe Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 02:02:21 +0300 Subject: [PATCH 1/2] Fix horizontal ScrollView resting past its left edge after a fling on Android 14 A horizontal ScrollView resting at its left edge, dragged further left and released while moving, came to rest 6 dp past that edge on Android 14. ReactHorizontalScrollView.fling() starts its own overscroll fling where the platform absorbs a fling into the edge effect the drag is still pulling, and Android 14's consumeFlingInStretch() feeds the spring-back into that stretch, so the view never moves back. Hand a fling into the left edge the view rests at to super.fling(). --- .../views/scroll/ReactHorizontalScrollView.kt | 5 +- .../ReactHorizontalScrollViewFlingTest.kt | 145 ++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewFlingTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index 1bfb84f45368..4bb41bfce24d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -850,7 +850,10 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : velocityX } - if (pagingEnabled) { + if (scrollX <= 0 && correctedVelocityX < 0) { + // The platform absorbs this fling into the edge effect; an overscroll sticks on Android 14. + super.fling(correctedVelocityX) + } else if (pagingEnabled) { flingAndSnap(correctedVelocityX) } else if (scroller != null) { // FB SCROLLVIEW CHANGE diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewFlingTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewFlingTest.kt new file mode 100644 index 000000000000..b881023ce0d0 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewFlingTest.kt @@ -0,0 +1,145 @@ +/* + * 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. + */ + +// The deprecated APIs exercised here remain public and required by this Robolectric setup. +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.scroll + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Looper +import android.os.SystemClock +import android.util.DisplayMetrics +import android.view.MotionEvent +import android.view.View +import android.widget.EdgeEffect +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.EventDispatcher +import com.facebook.soloader.SoLoader +import java.time.Duration +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +/** Verifies where a horizontal ScrollView comes to rest after a drag that ends in a fling. */ +@SuppressLint("DeprecatedClass", "DeprecatedMethod") +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], shadows = [ReactHorizontalScrollViewFlingTest.ShadowStretchEdgeEffect::class]) +class ReactHorizontalScrollViewFlingTest { + + /** Robolectric turns every edge effect off; a device stretches the edge a drag pulls past. */ + @Implements(EdgeEffect::class) + class ShadowStretchEdgeEffect { + @Implementation(minSdk = Build.VERSION_CODES.S) + fun getCurrentEdgeEffectBehavior(): Int = EDGE_EFFECT_TYPE_STRETCH + } + + private lateinit var themedContext: ThemedReactContext + private lateinit var uiManagerHelperMock: MockedStatic + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + SoLoader.setInTestMode() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + uiManagerHelperMock = mockStatic(UIManagerHelper::class.java) + uiManagerHelperMock + .`when` { UIManagerHelper.getEventDispatcher(any()) } + .thenReturn(mock()) + } + + @After + fun teardown() { + uiManagerHelperMock.close() + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun testFlingIntoLeftEdgeAtRestStaysAtLeftEdge() { + val view = createAtLeftEdge() + + dragAndRelease(view, fromX = DRAG_START_X, toX = DRAG_END_X) + settle(view) + + assertThat(view.scrollX).isEqualTo(0) + } + + @Test + fun testFlingFromLeftEdgeIntoContentScrollsPastTheDrag() { + val view = createAtLeftEdge() + + dragAndRelease(view, fromX = DRAG_END_X, toX = DRAG_START_X) + settle(view) + + assertThat(view.scrollX).isGreaterThan((DRAG_END_X - DRAG_START_X).toInt()) + } + + private fun createAtLeftEdge(): ReactHorizontalScrollView { + val view = ReactHorizontalScrollViewManager().createViewInstance(themedContext) + val content = View(themedContext) + view.addView(content) + content.layout(0, 0, CONTENT_WIDTH, VIEWPORT_SIZE) + view.layout(0, 0, VIEWPORT_SIZE, VIEWPORT_SIZE) + assertThat(view.scrollX).isEqualTo(0) + return view + } + + private fun dragAndRelease(view: ReactHorizontalScrollView, fromX: Float, toX: Float) { + val downTime = SystemClock.uptimeMillis() + val stepX = (toX - fromX) / DRAG_STEPS + view.onTouchEvent(obtainTouch(downTime, 0, MotionEvent.ACTION_DOWN, fromX)) + for (step in 1 until DRAG_STEPS) { + view.onTouchEvent(obtainTouch(downTime, step, MotionEvent.ACTION_MOVE, fromX + stepX * step)) + } + view.onTouchEvent(obtainTouch(downTime, DRAG_STEPS, MotionEvent.ACTION_UP, toX)) + } + + private fun obtainTouch(downTime: Long, step: Int, action: Int, x: Float): MotionEvent = + MotionEvent.obtain(downTime, downTime + step * STEP_MILLIS, action, x, TOUCH_Y, 0) + + private fun settle(view: ReactHorizontalScrollView) { + repeat(SETTLE_FRAMES) { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(FRAME_MILLIS)) + view.computeScroll() + } + } + + private companion object { + // EdgeEffect.TYPE_STRETCH, which the SDK hides. + const val EDGE_EFFECT_TYPE_STRETCH = 1 + const val VIEWPORT_SIZE = 1000 + const val CONTENT_WIDTH = 5000 + const val TOUCH_Y = 500f + const val DRAG_START_X = 100f + const val DRAG_END_X = 500f + const val DRAG_STEPS = 4 + const val STEP_MILLIS = 10L + const val FRAME_MILLIS = 16L + const val SETTLE_FRAMES = 180 + } +} From 94782152d365db6655479566a9cebad3eed6447e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 12:31:51 +0300 Subject: [PATCH 2/2] Hand the left-edge fling to the platform only from Android 12 The platform's fling absorbs into an edge the drag is still pulling from Android 12, the release that introduced the stretch; below it there is no stretch to absorb into, so React Native's own fling stays. --- .../facebook/react/views/scroll/ReactHorizontalScrollView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index 4bb41bfce24d..e0621c8c1b6f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -850,8 +850,8 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : velocityX } - if (scrollX <= 0 && correctedVelocityX < 0) { - // The platform absorbs this fling into the edge effect; an overscroll sticks on Android 14. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && scrollX <= 0 && correctedVelocityX < 0) { + // From Android 12 the platform absorbs this fling; an overscroll here sticks on Android 14. super.fling(correctedVelocityX) } else if (pagingEnabled) { flingAndSnap(correctedVelocityX)