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) + } }