From 138d9d63ab046cc3deb57753139d931d1868e5f0 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:32:01 +0000 Subject: [PATCH] fix(android): keep panel overscroll inside inverted timelines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android forwards a nested panel’s unconsumed scroll and fling to its inverted parent, moving the timeline in the opposite visual direction. Keep that motion inside the panel while preserving ordinary parent scrolling and nested-scroll registration. Co-authored-by: luckxine <91582901+luckxine@users.noreply.github.com> --- docs/android.md | 12 +- .../src/androidTest/AndroidManifest.xml | 5 + .../java/sh/paseo/scroll/NestedScrollTest.kt | 134 ++++++++++++++++++ .../sh/paseo/scroll/ScrollSelectionTest.kt | 3 +- .../sh/paseo/scroll/ScrollTestContext.java | 19 +++ .../sh/paseo/scroll/PaseoScrollPackage.kt | 24 +++- 6 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml create mode 100644 packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/NestedScrollTest.kt create mode 100644 packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/ScrollTestContext.java diff --git a/docs/android.md b/docs/android.md index 17bafd6b24..00b3e2a824 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 scrolling 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,16 @@ 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. +Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of +moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling +inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild: + +```bash +cd packages/app/android +./gradlew :paseo-scroll:connectedDebugAndroidTest \ + --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` + 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/AndroidManifest.xml b/packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..221da4fdc2 --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/NestedScrollTest.kt b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/NestedScrollTest.kt new file mode 100644 index 0000000000..8c81ba7fff --- /dev/null +++ b/packages/app/modules/paseo-scroll/android/src/androidTest/java/sh/paseo/scroll/NestedScrollTest.kt @@ -0,0 +1,134 @@ +package sh.paseo.scroll + +import android.app.Activity +import android.content.Intent +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.JavaOnlyArray +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ReactStylesDiffMap +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager +import com.facebook.soloader.SoLoader +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +class NestedScrollActivity : Activity() + +@RunWith(AndroidJUnit4::class) +class NestedScrollTest { + @Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child -> + val before = parent.scrollY + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.scrollTo(0, 600) + assertEquals(600, child.scrollY) + assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null)) + assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY) + child.scrollTo(0, 0) + assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null)) + assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY) + child.stopNestedScroll() + } + + @Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child -> + val before = parent.scrollY + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.scrollTo(0, 600) + assertEquals(600, child.scrollY) + assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null)) + assertEquals(before + 100, parent.scrollY) + child.stopNestedScroll() + } + + @Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child -> + val before = parent.scrollY + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.scrollTo(0, 100) + assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null)) + assertEquals(100, child.scrollY) + assertEquals(before, parent.scrollY) + child.stopNestedScroll() + parent.scrollTo(0, before + 100) + assertEquals(before + 100, parent.scrollY) + } + + @Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true) + + @Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false) + + private fun checkFling(inverted: Boolean) = withNestedScroll( + inverted = inverted, + afterFrames = { parent -> + if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY) + else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400) + } + ) { parent, child -> + parent.scrollTo(0, 400) + assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL)) + child.scrollTo(0, 600) + assertEquals(600, child.scrollY) + assertTrue(child.dispatchNestedFling(0f, 1500f, false)) + child.stopNestedScroll() + } + + private fun withNestedScroll( + inverted: Boolean, + afterFrames: ((ReactScrollView) -> Unit)? = null, + check: (ReactScrollView, ReactScrollView) -> Unit + ) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val activity = instrumentation.startActivitySync( + Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + lateinit var parent: ReactScrollView + try { + instrumentation.runOnMainSync { + val appContext = instrumentation.targetContext + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) + SoLoader.init(appContext, OpenSourceMergedSoMapping) + val reactContext = ScrollTestContext(appContext) + val context = ThemedReactContext(reactContext, appContext) + val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager + parent = manager.createViewInstance(context) + val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true } + val panelContent = View(context) + child.addView(panelContent, FrameLayout.LayoutParams(300, 800)) + val content = FrameLayout(context) + content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 }) + parent.addView(content, FrameLayout.LayoutParams(300, 2000)) + panelContent.measure(exact(300), exact(800)) + panelContent.layout(0, 0, 300, 800) + child.measure(exact(300), exact(200)) + child.layout(0, 500, 300, 700) + content.measure(exact(300), exact(2000)) + content.layout(0, 0, 300, 2000) + parent.measure(exact(300), exact(400)) + parent.layout(0, 0, 300, 400) + // The same transform React Native applies to an inverted FlatList on Android. + manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of( + "transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0)) + ))) + activity.setContentView(parent, ViewGroup.LayoutParams(300, 400)) + parent.scrollTo(0, 400) + check(parent, child) + } + if (afterFrames != null) { + // Let the framework's real fling animation advance, with an ordinary-parent control. + SystemClock.sleep(500) + instrumentation.runOnMainSync { afterFrames(parent) } + } + } finally { + instrumentation.runOnMainSync { activity.finish() } + } + } + + private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY) +} 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..622720d951 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 @@ -8,7 +8,6 @@ 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 @@ -70,7 +69,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) 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..bbffe6f7d9 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,7 @@ package sh.paseo.scroll import android.graphics.Matrix import android.graphics.Rect +import android.view.View import com.facebook.react.ReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage import com.facebook.react.bridge.NativeModule @@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() { private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { private val transformValues = FloatArray(9) + private val isInverted: Boolean + get() { + // RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY. + matrix.getValues(transformValues) + return transformValues[Matrix.MSCALE_Y] < 0 + } + // 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 + if (isInverted) return 0 return super.computeScrollDeltaToGetChildRectOnScreen(rect) } + + // A nested panel's remaining delta uses its own visual direction. Consuming it in + // an inverted timeline moves the timeline the opposite way when the panel hits an edge. + override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) { + if (isInverted) return + super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed) + } + + override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean { + if (isInverted && !consumed) return true + return super.onNestedFling(target, velocityX, velocityY, consumed) + } }