Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>>
*/

/**
Expand Down Expand Up @@ -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
}
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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<UIManagerHelper>

@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`<EventDispatcher?> { 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
}
}
Loading