Skip to content

Commit 9fc8545

Browse files
William Khoemeta-codesync[bot]
authored andcommitted
Fix reversed wheel/joystick scrolling in inverted lists
Summary: - On Android, an inverted `FlatList` flips its `ScrollView` with a transform. Android maps touch coordinates through that transform, but `ACTION_SCROLL` axis values (mouse wheel, trackpad, joystick) reach the view unchanged, so those inputs scroll inverted lists backwards. - `ReactScrollView.onGenericMotionEvent` now negates the vertical scroll axis when transforms on the view or its ancestors flip it vertically, and the snap velocity after a wheel scroll is flipped to match. Children of the list still receive the original event. - Checking the transforms rather than `isInvertedVirtualizedList` also covers other flipped `ScrollView`s, including an inverted list with a `refreshControl`, where the transform sits on the wrapping refresh layout. - `ReactNestedScrollView` is regenerated from the `ReactScrollView` sources. Changelog: [Android][Fixed] - Fix mouse wheel and joystick scrolling direction in inverted `FlatList` Reviewed By: GijsWeterings, javache Differential Revision: D122871988
1 parent ba89b39 commit 9fc8545

4 files changed

Lines changed: 259 additions & 3 deletions

File tree

‎packages/react-native/ReactAndroid/api/ReactAndroid.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5656,6 +5656,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
56565656
public fun onChildViewRemoved (Landroid/view/View;Landroid/view/View;)V
56575657
protected fun onDetachedFromWindow ()V
56585658
public fun onDraw (Landroid/graphics/Canvas;)V
5659+
public fun onGenericMotionEvent (Landroid/view/MotionEvent;)Z
56595660
public fun onInitializeAccessibilityNodeInfo (Landroid/view/accessibility/AccessibilityNodeInfo;)V
56605661
public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z
56615662
protected fun onLayout (ZIIII)V

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt‎

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<2f9a2ea0c223b08d9d1ea4517fd9671b>>
7+
* @generated SignedSource<<37e2e57a137f899bb94e78bd265de562>>
88
*/
99

1010
/**
@@ -644,7 +644,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
644644
}
645645
postTouchRunnable = Runnable {
646646
postTouchRunnable = null
647-
var velocityY = (-vScroll.sign).toInt()
647+
var velocityY = (if (isVerticallyInverted()) vScroll.sign else -vScroll.sign).toInt()
648648
if (disableIntervalMomentum) {
649649
velocityY = 0
650650
}
@@ -662,6 +662,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
662662
return super.dispatchGenericMotionEvent(ev)
663663
}
664664

665+
override fun onGenericMotionEvent(ev: MotionEvent): Boolean {
666+
if (ev.actionMasked != MotionEvent.ACTION_SCROLL || !isVerticallyInverted()) {
667+
return super.onGenericMotionEvent(ev)
668+
}
669+
// Android maps pointer coordinates through view transforms but not scroll axis values, so wheel
670+
// and joystick input would otherwise scroll a vertically inverted view backwards.
671+
val invertedEvent = obtainWithInvertedVerticalScroll(ev)
672+
return try {
673+
super.onGenericMotionEvent(invertedEvent)
674+
} finally {
675+
invertedEvent.recycle()
676+
}
677+
}
678+
665679
override fun executeKeyEvent(event: KeyEvent): Boolean {
666680
val eventKeyCode = event.keyCode
667681
if (
@@ -1322,3 +1336,45 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
13221336
override fun getFlingExtrapolatedDistance(velocity: Int): Int =
13231337
ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y
13241338
}
1339+
1340+
/** Whether transforms on this view or its ancestors flip it vertically on screen. */
1341+
private fun View.isVerticallyInverted(): Boolean {
1342+
val down = floatArrayOf(0f, 1f)
1343+
var view: View? = this
1344+
while (view != null) {
1345+
view.matrix.mapVectors(down)
1346+
view = view.parent as? View
1347+
}
1348+
return down[1] < 0
1349+
}
1350+
1351+
/** Copies [event] with its vertical scroll axis negated. The caller must recycle the copy. */
1352+
private fun obtainWithInvertedVerticalScroll(event: MotionEvent): MotionEvent {
1353+
val properties =
1354+
Array(event.pointerCount) { i ->
1355+
MotionEvent.PointerProperties().also { event.getPointerProperties(i, it) }
1356+
}
1357+
val coords =
1358+
Array(event.pointerCount) { i ->
1359+
MotionEvent.PointerCoords().apply {
1360+
event.getPointerCoords(i, this)
1361+
setAxisValue(MotionEvent.AXIS_VSCROLL, -getAxisValue(MotionEvent.AXIS_VSCROLL))
1362+
}
1363+
}
1364+
return MotionEvent.obtain(
1365+
event.downTime,
1366+
event.eventTime,
1367+
event.action,
1368+
event.pointerCount,
1369+
properties,
1370+
coords,
1371+
event.metaState,
1372+
event.buttonState,
1373+
event.xPrecision,
1374+
event.yPrecision,
1375+
event.deviceId,
1376+
event.edgeFlags,
1377+
event.source,
1378+
event.flags,
1379+
)
1380+
}

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -636,7 +636,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
636636
}
637637
postTouchRunnable = Runnable {
638638
postTouchRunnable = null
639-
var velocityY = (-vScroll.sign).toInt()
639+
var velocityY = (if (isVerticallyInverted()) vScroll.sign else -vScroll.sign).toInt()
640640
if (disableIntervalMomentum) {
641641
velocityY = 0
642642
}
@@ -654,6 +654,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
654654
return super.dispatchGenericMotionEvent(ev)
655655
}
656656

657+
override fun onGenericMotionEvent(ev: MotionEvent): Boolean {
658+
if (ev.actionMasked != MotionEvent.ACTION_SCROLL || !isVerticallyInverted()) {
659+
return super.onGenericMotionEvent(ev)
660+
}
661+
// Android maps pointer coordinates through view transforms but not scroll axis values, so wheel
662+
// and joystick input would otherwise scroll a vertically inverted view backwards.
663+
val invertedEvent = obtainWithInvertedVerticalScroll(ev)
664+
return try {
665+
super.onGenericMotionEvent(invertedEvent)
666+
} finally {
667+
invertedEvent.recycle()
668+
}
669+
}
670+
657671
override fun executeKeyEvent(event: KeyEvent): Boolean {
658672
val eventKeyCode = event.keyCode
659673
if (
@@ -1314,3 +1328,45 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
13141328
override fun getFlingExtrapolatedDistance(velocity: Int): Int =
13151329
ReactScrollViewHelper.predictFinalScrollPosition(this, 0, velocity, 0, getMaxScrollY()).y
13161330
}
1331+
1332+
/** Whether transforms on this view or its ancestors flip it vertically on screen. */
1333+
private fun View.isVerticallyInverted(): Boolean {
1334+
val down = floatArrayOf(0f, 1f)
1335+
var view: View? = this
1336+
while (view != null) {
1337+
view.matrix.mapVectors(down)
1338+
view = view.parent as? View
1339+
}
1340+
return down[1] < 0
1341+
}
1342+
1343+
/** Copies [event] with its vertical scroll axis negated. The caller must recycle the copy. */
1344+
private fun obtainWithInvertedVerticalScroll(event: MotionEvent): MotionEvent {
1345+
val properties =
1346+
Array(event.pointerCount) { i ->
1347+
MotionEvent.PointerProperties().also { event.getPointerProperties(i, it) }
1348+
}
1349+
val coords =
1350+
Array(event.pointerCount) { i ->
1351+
MotionEvent.PointerCoords().apply {
1352+
event.getPointerCoords(i, this)
1353+
setAxisValue(MotionEvent.AXIS_VSCROLL, -getAxisValue(MotionEvent.AXIS_VSCROLL))
1354+
}
1355+
}
1356+
return MotionEvent.obtain(
1357+
event.downTime,
1358+
event.eventTime,
1359+
event.action,
1360+
event.pointerCount,
1361+
properties,
1362+
coords,
1363+
event.metaState,
1364+
event.buttonState,
1365+
event.xPrecision,
1366+
event.yPrecision,
1367+
event.deviceId,
1368+
event.edgeFlags,
1369+
event.source,
1370+
event.flags,
1371+
)
1372+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
// The deprecated APIs exercised here remain public and required by this Robolectric setup.
9+
@file:Suppress("DEPRECATION")
10+
11+
package com.facebook.react.views.scroll
12+
13+
import android.annotation.SuppressLint
14+
import android.util.DisplayMetrics
15+
import android.view.InputDevice
16+
import android.view.MotionEvent
17+
import android.view.View
18+
import android.widget.FrameLayout
19+
import com.facebook.react.bridge.BridgeReactContext
20+
import com.facebook.react.bridge.JavaOnlyArray
21+
import com.facebook.react.bridge.JavaOnlyMap
22+
import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance
23+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
24+
import com.facebook.react.uimanager.DisplayMetricsHolder
25+
import com.facebook.react.uimanager.ReactStylesDiffMap
26+
import com.facebook.react.uimanager.ThemedReactContext
27+
import com.facebook.react.uimanager.UIManagerHelper
28+
import com.facebook.react.uimanager.events.EventDispatcher
29+
import com.facebook.soloader.SoLoader
30+
import org.assertj.core.api.Assertions.assertThat
31+
import org.junit.After
32+
import org.junit.Before
33+
import org.junit.Test
34+
import org.junit.runner.RunWith
35+
import org.mockito.MockedStatic
36+
import org.mockito.Mockito.mockStatic
37+
import org.mockito.kotlin.any
38+
import org.mockito.kotlin.mock
39+
import org.robolectric.RobolectricTestRunner
40+
import org.robolectric.RuntimeEnvironment
41+
42+
/** Verifies the direction of wheel and joystick scrolling in regular and inverted ScrollViews. */
43+
@SuppressLint("DeprecatedClass", "DeprecatedMethod")
44+
@RunWith(RobolectricTestRunner::class)
45+
class ReactScrollViewInvertedScrollTest {
46+
47+
private lateinit var themedContext: ThemedReactContext
48+
private lateinit var uiManagerHelperMock: MockedStatic<UIManagerHelper>
49+
50+
@Before
51+
fun setup() {
52+
ReactNativeFeatureFlagsForTests.setUp()
53+
SoLoader.setInTestMode()
54+
val context = BridgeReactContext(RuntimeEnvironment.getApplication())
55+
context.initializeWithInstance(createMockCatalystInstance())
56+
themedContext = ThemedReactContext(context, context, null, -1)
57+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics())
58+
uiManagerHelperMock = mockStatic(UIManagerHelper::class.java)
59+
uiManagerHelperMock
60+
.`when`<EventDispatcher?> { UIManagerHelper.getEventDispatcher(any()) }
61+
.thenReturn(mock())
62+
}
63+
64+
@After
65+
fun teardown() {
66+
uiManagerHelperMock.close()
67+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
68+
}
69+
70+
@Test
71+
fun testWheelScrollUpScrollsTowardStart() {
72+
val view = createScrolledToMiddle()
73+
74+
view.onGenericMotionEvent(obtainWheelScroll(1f))
75+
76+
assertThat(view.scrollY).isLessThan(MIDDLE_SCROLL_Y)
77+
}
78+
79+
@Test
80+
fun testInvertedWheelScrollUpScrollsTowardEnd() {
81+
// The transform an inverted VirtualizedList applies on Android.
82+
val view = createScrolledToMiddle(JavaOnlyArray.of(JavaOnlyMap.of("scale", -1.0)))
83+
84+
view.onGenericMotionEvent(obtainWheelScroll(1f))
85+
86+
assertThat(view.scrollY).isGreaterThan(MIDDLE_SCROLL_Y)
87+
}
88+
89+
@Test
90+
fun testInvertedParentWheelScrollUpScrollsTowardEnd() {
91+
val view = createScrolledToMiddle()
92+
// With a refreshControl, ScrollView moves its transform to the wrapping refresh layout.
93+
FrameLayout(themedContext).apply { scaleY = -1f }.addView(view)
94+
95+
view.onGenericMotionEvent(obtainWheelScroll(1f))
96+
97+
assertThat(view.scrollY).isGreaterThan(MIDDLE_SCROLL_Y)
98+
}
99+
100+
private fun createScrolledToMiddle(transform: JavaOnlyArray? = null): ReactScrollView {
101+
val manager = ReactScrollViewManager()
102+
val view = manager.createViewInstance(themedContext)
103+
if (transform != null) {
104+
manager.updateProperties(view, ReactStylesDiffMap(JavaOnlyMap.of("transform", transform)))
105+
}
106+
val content = View(themedContext)
107+
view.addView(content)
108+
content.layout(0, 0, VIEWPORT_SIZE, CONTENT_HEIGHT)
109+
view.layout(0, 0, VIEWPORT_SIZE, VIEWPORT_SIZE)
110+
view.scrollTo(0, MIDDLE_SCROLL_Y)
111+
assertThat(view.scrollY).isEqualTo(MIDDLE_SCROLL_Y)
112+
return view
113+
}
114+
115+
private fun obtainWheelScroll(vScroll: Float): MotionEvent {
116+
val properties =
117+
MotionEvent.PointerProperties().apply { toolType = MotionEvent.TOOL_TYPE_MOUSE }
118+
val coords =
119+
MotionEvent.PointerCoords().apply { setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll) }
120+
return MotionEvent.obtain(
121+
0L,
122+
0L,
123+
MotionEvent.ACTION_SCROLL,
124+
1,
125+
arrayOf(properties),
126+
arrayOf(coords),
127+
0,
128+
0,
129+
1f,
130+
1f,
131+
0,
132+
0,
133+
InputDevice.SOURCE_MOUSE,
134+
0,
135+
)
136+
}
137+
138+
private companion object {
139+
const val VIEWPORT_SIZE = 100
140+
const val CONTENT_HEIGHT = 1000
141+
const val MIDDLE_SCROLL_Y = 450
142+
}
143+
}

0 commit comments

Comments
 (0)