mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
fix(android): keep panel overscroll inside inverted timelines
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>
This commit is contained in:
co-authored by
luckxine
parent
919e44b319
commit
138d9d63ab
+11
-1
@@ -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:<port>` 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.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
+134
@@ -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)
|
||||
}
|
||||
+1
-2
@@ -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)
|
||||
|
||||
+19
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user