diff --git a/docs/android.md b/docs/android.md index 17bafd6b24..6b10a56892 100644 --- a/docs/android.md +++ b/docs/android.md @@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. -## Inverted timeline selection +## Inverted timeline input Android focus and selection visibility requests must not reposition inverted timelines. The `modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for @@ -183,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat `scaleY`; checking the scale property alone misses it. Dragging and explicit scroll commands still work; non-inverted scroll views keep Android's default behavior. +Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical +wheel axis for inverted scroll views before delegating to Android, preserving native scroll +bounds and React Native's scroll events and disabled-scroll behavior. + Register this package before React Native's core package through its Expo config plugin. Normal Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does not change the shipped scroll view. Keep this behavior in the app's compiled native module. diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt index c7007776ba..0ed8e69626 100644 --- a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollSelectionTest.kt @@ -4,11 +4,13 @@ import android.graphics.Rect import android.text.Selection import android.text.Spannable import android.view.View +import android.view.InputDevice +import android.view.MotionEvent +import android.os.SystemClock import android.widget.FrameLayout import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.soloader.OpenSourceMergedSoMapping @@ -24,6 +26,40 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class ScrollSelectionTest { + @Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ -> + val before = scroll.scrollY + withWheel(1f) { event -> + assertTrue(scroll.dispatchGenericMotionEvent(event)) + assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before) + assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL)) + } + val afterUp = scroll.scrollY + withWheel(-1f) { event -> + assertTrue(scroll.dispatchGenericMotionEvent(event)) + assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp) + } + } + + @Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ -> + val before = scroll.scrollY + withWheel(1f) { event -> + assertTrue(scroll.dispatchGenericMotionEvent(event)) + assertTrue(scroll.scrollY < before) + } + val afterUp = scroll.scrollY + withWheel(-1f) { event -> + assertTrue(scroll.dispatchGenericMotionEvent(event)) + assertTrue(scroll.scrollY > afterUp) + } + } + + @Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ -> + scroll.setScrollEnabled(false) + val before = scroll.scrollY + withWheel(1f) { scroll.dispatchGenericMotionEvent(it) } + assertEquals(before, scroll.scrollY) + } + @Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text -> val before = scroll.scrollY assertTrue(text.requestFocus()) @@ -70,7 +106,7 @@ class ScrollSelectionTest { val appContext = instrumentation.targetContext DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) SoLoader.init(appContext, OpenSourceMergedSoMapping) - val reactContext = BridgeReactContext(appContext) + val reactContext = ScrollTestContext(appContext) val context = ThemedReactContext(reactContext, appContext) val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager val scroll = manager.createViewInstance(context) @@ -98,4 +134,26 @@ class ScrollSelectionTest { } private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) + + private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) { + val time = SystemClock.uptimeMillis() + val properties = MotionEvent.PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_MOUSE + } + val coordinates = MotionEvent.PointerCoords().apply { + x = 150f + y = 150f + setAxisValue(MotionEvent.AXIS_VSCROLL, axis) + } + val event = MotionEvent.obtain( + time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates), + 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0, + ) + try { + check(event) + } finally { + event.recycle() + } + } } diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java new file mode 100644 index 0000000000..d2e64c6339 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java @@ -0,0 +1,19 @@ +package sh.paseo.scroll; + +import android.content.Context; +import com.facebook.react.bridge.BridgeReactContext; +import com.facebook.react.uimanager.events.BlackHoleEventDispatcher; +import com.facebook.react.uimanager.events.EventDispatcher; +import com.facebook.react.uimanager.events.EventDispatcherProvider; + +/** Native-only tests use RN's dispatcher for a context without a JS instance. */ +final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider { + ScrollTestContext(Context context) { + super(context); + } + + @Override + public EventDispatcher getEventDispatcher() { + return BlackHoleEventDispatcher.INSTANCE; + } +} diff --git a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt index 71ff179cb1..e887d67a31 100644 --- a/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt +++ b/packages/app/modules/paseo-scroll/android/src/main/java/sh/paseo/scroll/PaseoScrollPackage.kt @@ -2,6 +2,8 @@ package sh.paseo.scroll import android.graphics.Matrix import android.graphics.Rect +import android.view.InputDevice +import android.view.MotionEvent import com.facebook.react.ReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage import com.facebook.react.bridge.NativeModule @@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() { private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { private val transformValues = FloatArray(9) + private fun isVerticallyInverted(): Boolean { + // Android FlatList uses scale: -1, which RN decomposes into a 180-degree + // rotation with positive scaleY. Read the resulting transform, not scaleY. + matrix.getValues(transformValues) + return transformValues[Matrix.MSCALE_Y] < 0 + } + + override fun onGenericMotionEvent(event: MotionEvent): Boolean { + if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL || + !event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) { + return super.onGenericMotionEvent(event) + } + if (!scrollEnabled) return false + + // Android applies wheel axes directly to scrollY; the inversion transform only + // transforms pointer coordinates. Reverse the vertical axis before delegating so + // platform scroll bounds and scroll events still apply. + val properties = Array(event.pointerCount) { index -> + MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) } + } + val coordinates = Array(event.pointerCount) { index -> + MotionEvent.PointerCoords().also { + event.getPointerCoords(index, it) + it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL)) + } + } + val correctedEvent = MotionEvent.obtain( + event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates, + event.metaState, event.buttonState, event.xPrecision, event.yPrecision, + event.deviceId, event.edgeFlags, event.source, event.flags, + ) + return try { + super.onGenericMotionEvent(correctedEvent) + } finally { + correctedEvent.recycle() + } + } + // Both RN focus handling and Android selection visibility requests use this calculation. // Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline // movement to user gestures and explicit scroll commands. - override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int { - // RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY. - matrix.getValues(transformValues) - if (transformValues[Matrix.MSCALE_Y] < 0) return 0 - return super.computeScrollDeltaToGetChildRectOnScreen(rect) - } + override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int = + if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect) }