From 1a546814850b91c62dd31eae6d10fe54d91bcea8 Mon Sep 17 00:00:00 2001 From: slvvn <100023512+slvvn@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:52:25 +0200 Subject: [PATCH] fix(android): Clear stale drag state when a nested horizontal ScrollView takes a touch ReactHorizontalScrollView returns early from onInterceptTouchEvent on an ACTION_DOWN over a nested horizontal scroll view, without calling super. That skips HorizontalScrollView's ACTION_DOWN bookkeeping, so the active pointer and last x of a previous gesture that a child claimed are kept, and the nested view's first move is measured from that stale x and intercepted. Send a cancel to super in that branch to clear them. Fixes #59300 --- .../views/scroll/ReactHorizontalScrollView.kt | 8 + ...HorizontalScrollViewNestedInterceptTest.kt | 144 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewNestedInterceptTest.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 1e455eb2ba5c..0157832473a4 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 @@ -644,6 +644,14 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : ev.action == MotionEvent.ACTION_DOWN && findDeepestScrollViewForMotionEvent(this, ev) != null ) { + // Skipping super keeps a stale pointer from a gesture a child claimed; clear it. + // A cancel also stops the scroller, so leave a running fling or snap alone. + if (scroller?.isFinished != false) { + val cancel = MotionEvent.obtain(ev) + cancel.action = MotionEvent.ACTION_CANCEL + super.onInterceptTouchEvent(cancel) + cancel.recycle() + } return false } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewNestedInterceptTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewNestedInterceptTest.kt new file mode 100644 index 000000000000..668106ed4bbe --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewNestedInterceptTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll + +import android.util.DisplayMetrics +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import android.widget.HorizontalScrollView +import android.widget.OverScroller +import androidx.core.view.ViewCompat +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 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 + +/** Verifies that a drag starting on a nested horizontal ScrollView is left to that child. */ +@RunWith(RobolectricTestRunner::class) +class ReactHorizontalScrollViewNestedInterceptTest { + + 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 testDragOnNestedScrollViewIsNotIntercepted() { + val outer = createOuterWithNestedScrollView() + + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_DOWN, 800f, NESTED_Y))) + .isFalse() + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_MOVE, 400f, NESTED_Y))) + .isFalse() + } + + @Test + fun testDragOnNestedScrollViewIsNotInterceptedAfterAnUnfinishedGesture() { + val outer = createOuterWithNestedScrollView() + // Starts outside the nested view, then a child claims it: no UP or CANCEL arrives. + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_DOWN, 200f, OUTSIDE_Y))) + .isFalse() + + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_DOWN, 800f, NESTED_Y))) + .isFalse() + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_MOVE, 780f, NESTED_Y))) + .isFalse() + } + + @Test + fun testDragOutsideNestedScrollViewIsStillIntercepted() { + val outer = createOuterWithNestedScrollView() + + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_DOWN, 800f, OUTSIDE_Y))) + .isFalse() + assertThat(outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_MOVE, 400f, OUTSIDE_Y))) + .isTrue() + } + + @Test + fun testTouchOnNestedScrollViewDoesNotStopAFling() { + val outer = createOuterWithNestedScrollView() + outer.fling(FLING_VELOCITY) + val scroller = outerScroller(outer) + assertThat(scroller.isFinished).isFalse() + + outer.onInterceptTouchEvent(obtain(MotionEvent.ACTION_DOWN, 800f, NESTED_Y)) + + assertThat(scroller.isFinished).isFalse() + } + + private fun outerScroller(view: ReactHorizontalScrollView): OverScroller { + val field = HorizontalScrollView::class.java.getDeclaredField("mScroller") + field.isAccessible = true + return field.get(view) as OverScroller + } + + private fun createOuterWithNestedScrollView(): ReactHorizontalScrollView { + val outer = ReactHorizontalScrollView(themedContext) + val content = FrameLayout(themedContext) + val nested = ReactHorizontalScrollView(themedContext) + ViewCompat.setNestedScrollingEnabled(nested, true) + val nestedContent = View(themedContext) + nested.addView(nestedContent) + content.addView(nested) + outer.addView(content) + + // Top-down: a parent's layout pass resets unmeasured children. + outer.layout(0, 0, VIEWPORT_SIZE, VIEWPORT_SIZE) + content.layout(0, 0, VIEWPORT_SIZE * 2, VIEWPORT_SIZE) + nested.layout(0, NESTED_TOP, VIEWPORT_SIZE, NESTED_TOP + NESTED_HEIGHT) + nestedContent.layout(0, 0, VIEWPORT_SIZE * 3, NESTED_HEIGHT) + return outer + } + + private fun obtain(action: Int, x: Float, y: Float): MotionEvent = + MotionEvent.obtain(0L, 0L, action, x, y, 0) + + private companion object { + const val VIEWPORT_SIZE = 1000 + const val NESTED_TOP = 500 + const val NESTED_HEIGHT = 200 + const val NESTED_Y = 600f + const val OUTSIDE_Y = 200f + const val FLING_VELOCITY = 5000 + } +}