From 48b310c8a281889ca542be52d034b5f91060bb6f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 12:21:58 +0200 Subject: [PATCH] Keep Android chat still when selecting text (#7131) Android's scroll guard now reads the view's transform matrix, so React Native's inverted chat list (scale: -1, decomposed into a 180-degree rotation with positive scaleY) no longer scrolls on text focus or selection. Closes #7125 Co-authored-by: luke Co-authored-by: RoundRedDot --- docs/android.md | 4 ++- .../sh/paseo/scroll/ScrollSelectionTest.kt | 28 +++++++++++++++++-- .../sh/paseo/scroll/PaseoScrollPackage.kt | 11 ++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/android.md b/docs/android.md index f04ce945bb..75908a8ae2 100644 --- a/docs/android.md +++ b/docs/android.md @@ -124,7 +124,9 @@ This is the Android counterpart of the iOS local-simulator flow in [development. 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 -child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll +child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React +Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive +`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll commands still work; non-inverted scroll views keep Android's default behavior. Register this package before React Native's core package through its Expo config plugin. Normal 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 7d6fcee8ed..c7007776ba 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 @@ -9,7 +9,11 @@ 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 +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 @@ -47,16 +51,29 @@ class ScrollSelectionTest { assertNotEquals(before, scroll.scrollY) } - private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) { + @Test fun verticalScaleStillKeepsFocusAndSelectionInPlace() = withScroll(inverted = true, transform = "scaleY") { scroll, text -> + val before = scroll.scrollY + assertTrue(text.requestFocus()) + text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) + assertEquals(before, scroll.scrollY) + } + + @Test fun horizontalScaleStillRevealsSelectedText() = withScroll(inverted = true, transform = "scaleX") { scroll, text -> + val before = scroll.scrollY + text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true) + assertNotEquals(before, scroll.scrollY) + } + + private fun withScroll(inverted: Boolean, transform: String = "scale", check: (ReactScrollView, TextView) -> Unit) { val instrumentation = InstrumentationRegistry.getInstrumentation() instrumentation.runOnMainSync { val appContext = instrumentation.targetContext + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext) SoLoader.init(appContext, OpenSourceMergedSoMapping) val reactContext = BridgeReactContext(appContext) val context = ThemedReactContext(reactContext, appContext) val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager val scroll = manager.createViewInstance(context) - scroll.scaleY = if (inverted) -1f else 1f val content = FrameLayout(context) val text = TextView(context).apply { setText("Selectable timeline text", TextView.BufferType.SPANNABLE) @@ -68,6 +85,13 @@ class ScrollSelectionTest { content.layout(0, 0, 300, 2000) scroll.measure(exact(300), exact(300)) scroll.layout(0, 0, 300, 300) + // Apply RN's actual Android inversion through the manager's transform decomposition. + val transforms = JavaOnlyArray.of(JavaOnlyMap.of(transform, if (inverted) -1.0 else 1.0)) + manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of("transform", transforms))) + if (inverted && transform == "scale") { + assertEquals(1f, scroll.scaleY, 0f) + assertEquals(180f, kotlin.math.abs(scroll.rotation), 0f) + } scroll.scrollTo(0, 400) check(scroll, text) } 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 c3a2a4373d..71ff179cb1 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 @@ -1,5 +1,6 @@ package sh.paseo.scroll +import android.graphics.Matrix import android.graphics.Rect import com.facebook.react.ReactPackage import com.facebook.react.ViewManagerOnDemandReactPackage @@ -27,9 +28,15 @@ private class PaseoScrollViewManager : ReactScrollViewManager() { } private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) { + private val transformValues = FloatArray(9) + // 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 = - if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect) + 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) + } }