fix(android): keep inverted chat still during text selection

React Native decomposes Android list inversion into a 180-degree rotation with positive scaleY, so the existing visibility-scroll guard never recognized the inverted viewport. Inspect the composed matrix and reproduce the actual list transform through the native view manager in regression tests.
This commit is contained in:
Mohamed Boudra
2026-10-16 07:29:01 +02:00
parent 4962a9a591
commit 89d42e4419
3 changed files with 43 additions and 6 deletions
+9
View File
@@ -2,6 +2,15 @@
Keep assistant prose in one native text view so selection crosses paragraphs, headings and lists. The shared [Markdown renderer](markdown-renderer.md) owns that view.
## Android inverted chat
Keep selection visibility handling in `modules/paseo-scroll`. Android's visibility
rectangles ignore list inversion. React Native decomposes Android's `scale: -1`
into rotation with positive `scaleY`, so test the composed matrix when deciding
whether those requests should move the viewport. Native tests must apply the
list's transform through React Native's view manager; assigning `scaleY` directly
misses that decomposition.
## Diagrams in lists and quotes
Selection stops at a Mermaid diagram. Native extracts Mermaid fences into separate views because the library exposes no native block renderer override. A diagram inside a list or quote therefore appears outside that container, and its following prose starts a separate Markdown fragment.
@@ -8,6 +8,9 @@ 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.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.uimanager.ReactStylesDiffMap
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.ThemedReactContext
@@ -47,16 +50,34 @@ class ScrollSelectionTest {
assertNotEquals(before, scroll.scrollY)
}
@Test fun directVerticalMirroringKeepsSelectionStill() = withScroll(inverted = false) { scroll, text ->
scroll.scaleY = -1f
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals(before, scroll.scrollY)
}
@Test fun horizontalMirroringStillRevealsSelectedText() = withScroll(inverted = false) { scroll, text ->
scroll.scaleX = -1f
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
SoLoader.init(appContext, OpenSourceMergedSoMapping)
com.facebook.react.uimanager.DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
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
// Exercise VirtualizedList's Android transform through RN's real decomposition.
if (inverted) manager.updateProperties(scroll, ReactStylesDiffMap(JavaOnlyMap.of(
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", -1.0))
)))
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
@@ -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) {
// 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)
private val transformValues = FloatArray(9)
// Android VirtualizedList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Inspect the composed transform so both that
// inversion and a direct scaleY: -1 suppress untransformed visibility requests.
// Keep focus/selection bookkeeping; gestures and explicit commands still scroll.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
matrix.getValues(transformValues)
return if (transformValues[Matrix.MSCALE_Y] < 0) 0
else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
}