diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index ff33c2d5bdd6..2aa6ba5da679 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5656,6 +5656,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun onChildViewRemoved (Landroid/view/View;Landroid/view/View;)V protected fun onDetachedFromWindow ()V public fun onDraw (Landroid/graphics/Canvas;)V + public fun onGenericMotionEvent (Landroid/view/MotionEvent;)Z public fun onInitializeAccessibilityNodeInfo (Landroid/view/accessibility/AccessibilityNodeInfo;)V public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z protected fun onLayout (ZIIII)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt index 45fee3cd19d1..e3c1dfd891af 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt @@ -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<<2f9a2ea0c223b08d9d1ea4517fd9671b>> + * @generated SignedSource<<37e2e57a137f899bb94e78bd265de562>> */ /** @@ -644,7 +644,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } postTouchRunnable = Runnable { postTouchRunnable = null - var velocityY = (-vScroll.sign).toInt() + var velocityY = (if (isVerticallyInverted()) vScroll.sign else -vScroll.sign).toInt() if (disableIntervalMomentum) { velocityY = 0 } @@ -662,6 +662,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : return super.dispatchGenericMotionEvent(ev) } + override fun onGenericMotionEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked != MotionEvent.ACTION_SCROLL || !isVerticallyInverted()) { + return super.onGenericMotionEvent(ev) + } + // Android maps pointer coordinates through view transforms but not scroll axis values, so wheel + // and joystick input would otherwise scroll a vertically inverted view backwards. + val invertedEvent = obtainWithInvertedVerticalScroll(ev) + return try { + super.onGenericMotionEvent(invertedEvent) + } finally { + invertedEvent.recycle() + } + } + override fun executeKeyEvent(event: KeyEvent): Boolean { val eventKeyCode = event.keyCode if ( @@ -1322,3 +1336,45 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : override fun getFlingExtrapolatedDistance(velocity: Int): Int = ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y } + +/** Whether transforms on this view or its ancestors flip it vertically on screen. */ +private fun View.isVerticallyInverted(): Boolean { + val down = floatArrayOf(0f, 1f) + var view: View? = this + while (view != null) { + view.matrix.mapVectors(down) + view = view.parent as? View + } + return down[1] < 0 +} + +/** Copies [event] with its vertical scroll axis negated. The caller must recycle the copy. */ +private fun obtainWithInvertedVerticalScroll(event: MotionEvent): MotionEvent { + val properties = + Array(event.pointerCount) { i -> + MotionEvent.PointerProperties().also { event.getPointerProperties(i, it) } + } + val coords = + Array(event.pointerCount) { i -> + MotionEvent.PointerCoords().apply { + event.getPointerCoords(i, this) + setAxisValue(MotionEvent.AXIS_VSCROLL, -getAxisValue(MotionEvent.AXIS_VSCROLL)) + } + } + return MotionEvent.obtain( + event.downTime, + event.eventTime, + event.action, + event.pointerCount, + properties, + coords, + event.metaState, + event.buttonState, + event.xPrecision, + event.yPrecision, + event.deviceId, + event.edgeFlags, + event.source, + event.flags, + ) +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt index b553f6af997d..8db61a4d0433 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -636,7 +636,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } postTouchRunnable = Runnable { postTouchRunnable = null - var velocityY = (-vScroll.sign).toInt() + var velocityY = (if (isVerticallyInverted()) vScroll.sign else -vScroll.sign).toInt() if (disableIntervalMomentum) { velocityY = 0 } @@ -654,6 +654,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : return super.dispatchGenericMotionEvent(ev) } + override fun onGenericMotionEvent(ev: MotionEvent): Boolean { + if (ev.actionMasked != MotionEvent.ACTION_SCROLL || !isVerticallyInverted()) { + return super.onGenericMotionEvent(ev) + } + // Android maps pointer coordinates through view transforms but not scroll axis values, so wheel + // and joystick input would otherwise scroll a vertically inverted view backwards. + val invertedEvent = obtainWithInvertedVerticalScroll(ev) + return try { + super.onGenericMotionEvent(invertedEvent) + } finally { + invertedEvent.recycle() + } + } + override fun executeKeyEvent(event: KeyEvent): Boolean { val eventKeyCode = event.keyCode if ( @@ -1314,3 +1328,45 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : override fun getFlingExtrapolatedDistance(velocity: Int): Int = ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y } + +/** Whether transforms on this view or its ancestors flip it vertically on screen. */ +private fun View.isVerticallyInverted(): Boolean { + val down = floatArrayOf(0f, 1f) + var view: View? = this + while (view != null) { + view.matrix.mapVectors(down) + view = view.parent as? View + } + return down[1] < 0 +} + +/** Copies [event] with its vertical scroll axis negated. The caller must recycle the copy. */ +private fun obtainWithInvertedVerticalScroll(event: MotionEvent): MotionEvent { + val properties = + Array(event.pointerCount) { i -> + MotionEvent.PointerProperties().also { event.getPointerProperties(i, it) } + } + val coords = + Array(event.pointerCount) { i -> + MotionEvent.PointerCoords().apply { + event.getPointerCoords(i, this) + setAxisValue(MotionEvent.AXIS_VSCROLL, -getAxisValue(MotionEvent.AXIS_VSCROLL)) + } + } + return MotionEvent.obtain( + event.downTime, + event.eventTime, + event.action, + event.pointerCount, + properties, + coords, + event.metaState, + event.buttonState, + event.xPrecision, + event.yPrecision, + event.deviceId, + event.edgeFlags, + event.source, + event.flags, + ) +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewInvertedScrollTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewInvertedScrollTest.kt new file mode 100644 index 000000000000..32fcb811a2a3 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewInvertedScrollTest.kt @@ -0,0 +1,143 @@ +/* + * 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.util.DisplayMetrics +import android.view.InputDevice +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +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.ReactStylesDiffMap +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 the direction of wheel and joystick scrolling in regular and inverted ScrollViews. */ +@SuppressLint("DeprecatedClass", "DeprecatedMethod") +@RunWith(RobolectricTestRunner::class) +class ReactScrollViewInvertedScrollTest { + + 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 testWheelScrollUpScrollsTowardStart() { + val view = createScrolledToMiddle() + + view.onGenericMotionEvent(obtainWheelScroll(1f)) + + assertThat(view.scrollY).isLessThan(MIDDLE_SCROLL_Y) + } + + @Test + fun testInvertedWheelScrollUpScrollsTowardEnd() { + // The transform an inverted VirtualizedList applies on Android. + val view = createScrolledToMiddle(JavaOnlyArray.of(JavaOnlyMap.of("scale", -1.0))) + + view.onGenericMotionEvent(obtainWheelScroll(1f)) + + assertThat(view.scrollY).isGreaterThan(MIDDLE_SCROLL_Y) + } + + @Test + fun testInvertedParentWheelScrollUpScrollsTowardEnd() { + val view = createScrolledToMiddle() + // With a refreshControl, ScrollView moves its transform to the wrapping refresh layout. + FrameLayout(themedContext).apply { scaleY = -1f }.addView(view) + + view.onGenericMotionEvent(obtainWheelScroll(1f)) + + assertThat(view.scrollY).isGreaterThan(MIDDLE_SCROLL_Y) + } + + private fun createScrolledToMiddle(transform: JavaOnlyArray? = null): ReactScrollView { + val manager = ReactScrollViewManager() + val view = manager.createViewInstance(themedContext) + if (transform != null) { + manager.updateProperties(view, ReactStylesDiffMap(JavaOnlyMap.of("transform", transform))) + } + val content = View(themedContext) + view.addView(content) + content.layout(0, 0, VIEWPORT_SIZE, CONTENT_HEIGHT) + view.layout(0, 0, VIEWPORT_SIZE, VIEWPORT_SIZE) + view.scrollTo(0, MIDDLE_SCROLL_Y) + assertThat(view.scrollY).isEqualTo(MIDDLE_SCROLL_Y) + return view + } + + private fun obtainWheelScroll(vScroll: Float): MotionEvent { + val properties = + MotionEvent.PointerProperties().apply { toolType = MotionEvent.TOOL_TYPE_MOUSE } + val coords = + MotionEvent.PointerCoords().apply { setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll) } + return MotionEvent.obtain( + 0L, + 0L, + MotionEvent.ACTION_SCROLL, + 1, + arrayOf(properties), + arrayOf(coords), + 0, + 0, + 1f, + 1f, + 0, + 0, + InputDevice.SOURCE_MOUSE, + 0, + ) + } + + private companion object { + const val VIEWPORT_SIZE = 100 + const val CONTENT_HEIGHT = 1000 + const val MIDDLE_SCROLL_Y = 450 + } +}