From 9e1c1aebe2a5620ee5c8ea2571c0a073e8fd1397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= Date: Wed, 28 Oct 2026 12:56:48 +0100 Subject: [PATCH 1/2] Fix ScrollView drag stutter on Android when the ScrollView moves during a drag --- .../views/scroll/ReactHorizontalScrollView.kt | 9 +- .../react/views/scroll/ReactScrollView.kt | 9 +- .../scroll/StableTouchCoordinatesHelper.kt | 46 +++++ .../StableTouchCoordinatesHelperTest.kt | 100 +++++++++ .../ScrollView/MovingScrollViewExample.js | 190 ++++++++++++++++++ .../js/utils/RNTesterList.android.js | 5 + 6 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt create mode 100644 packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index d2ccdb5e4684..6c2f75e6c4e8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -144,6 +144,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : private val onScrollDispatchHelper = OnScrollDispatchHelper() private val scroller: OverScroller? = getOverScrollerFromParent() private val velocityHelper = VelocityHelper() + private val stableTouchCoordinatesHelper = StableTouchCoordinatesHelper(this) private val tempRect = Rect() private val defaultFlingAnimator: ValueAnimator = ObjectAnimator.ofInt(this, "scrollX", 0, 0) @@ -659,7 +660,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return true try { - if (super.onInterceptTouchEvent(ev)) { + if ( + stableTouchCoordinatesHelper.withStableCoordinates(ev) { super.onInterceptTouchEvent(it) } + ) { handleInterceptedTouchEvent(ev) return true } @@ -738,7 +741,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : // We do not accept the touch event if this view is not supposed to receive it. if (!PointerEvents.canBeTouchTarget(pointerEvents)) return false - velocityHelper.calculateVelocity(ev) + stableTouchCoordinatesHelper.withStableCoordinates(ev) { velocityHelper.calculateVelocity(it) } val action = ev.actionMasked if ((action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) && dragging) { ReactScrollViewHelper.updateFabricScrollState(this) @@ -760,7 +763,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } return try { - super.onTouchEvent(ev) + stableTouchCoordinatesHelper.withStableCoordinates(ev) { super.onTouchEvent(it) } } catch (e: IllegalArgumentException) { // Log and ignore the error. This seems to be a bug in the android SDK and // this is the commonly accepted workaround. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt index bbe7af9cabfc..88162e77d728 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -169,6 +169,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : private val onScrollDispatchHelper = OnScrollDispatchHelper() private val scroller: OverScroller? = getOverScrollerFromParent() private val velocityHelper = VelocityHelper() + private val stableTouchCoordinatesHelper = StableTouchCoordinatesHelper(this) private val tempRect = Rect() private val defaultFlingAnimator: ValueAnimator = ObjectAnimator.ofInt(this, "scrollY", 0, 0) @@ -562,7 +563,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (!PointerEvents.canChildrenBeTouchTarget(pointerEvents)) return true try { - if (super.onInterceptTouchEvent(ev)) { + if ( + stableTouchCoordinatesHelper.withStableCoordinates(ev) { super.onInterceptTouchEvent(it) } + ) { handleInterceptedTouchEvent(ev) return true } @@ -588,7 +591,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (!scrollEnabled) return false if (!PointerEvents.canBeTouchTarget(pointerEvents)) return false - velocityHelper.calculateVelocity(ev) + stableTouchCoordinatesHelper.withStableCoordinates(ev) { velocityHelper.calculateVelocity(it) } val action = ev.actionMasked if ((action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) && dragging) { ReactScrollViewHelper.updateFabricScrollState(this) @@ -607,7 +610,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } return try { - super.onTouchEvent(ev) + stableTouchCoordinatesHelper.withStableCoordinates(ev) { super.onTouchEvent(it) } } catch (e: IllegalArgumentException) { FLog.w(ReactConstants.TAG, "Error handling touch event.", e) false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt new file mode 100644 index 000000000000..523e04501687 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll + +import android.view.MotionEvent +import android.view.View + +internal class StableTouchCoordinatesHelper(private val view: View) { + private val location = IntArray(2) + private var downLocationX = 0 + private var downLocationY = 0 + + fun withStableCoordinates(ev: MotionEvent, handler: (MotionEvent) -> T): T { + getLayoutLocationOnScreen(location) + if (ev.actionMasked == MotionEvent.ACTION_DOWN) { + downLocationX = location[0] + downLocationY = location[1] + } + val dx = (location[0] - downLocationX).toFloat() + val dy = (location[1] - downLocationY).toFloat() + if (dx == 0f && dy == 0f) { + return handler(ev) + } + ev.offsetLocation(dx, dy) + try { + return handler(ev) + } finally { + ev.offsetLocation(-dx, -dy) + } + } + + private fun getLayoutLocationOnScreen(outLocation: IntArray) { + view.getLocationOnScreen(outLocation) + var ancestor = view.parent + while (ancestor is View) { + outLocation[0] += ancestor.scrollX + outLocation[1] += ancestor.scrollY + ancestor = ancestor.parent + } + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt new file mode 100644 index 000000000000..721c1661cd7f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll + +import android.app.Activity +import android.view.MotionEvent +import android.view.View +import android.widget.FrameLayout +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StableTouchCoordinatesHelperTest { + private lateinit var parent: FrameLayout + private lateinit var view: View + private lateinit var helper: StableTouchCoordinatesHelper + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + parent = FrameLayout(activity) + view = View(activity) + parent.addView(view) + activity.setContentView(parent) + parent.layout(0, 0, 1000, 2000) + view.layout(0, 500, 1000, 1500) + helper = StableTouchCoordinatesHelper(view) + } + + @Test + fun passesEventUnchangedWhenViewDidNotMove() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 80f)).isEqualTo(80f) + } + + @Test + fun compensatesLayoutMovementSinceDown() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.offsetTopAndBottom(-50) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 130f)).isEqualTo(80f) + } + + @Test + fun compensatesTranslationSinceDown() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.translationY = 40f + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 40f)).isEqualTo(80f) + } + + @Test + fun ignoresAncestorScroll() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + parent.scrollTo(0, 50) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 130f)).isEqualTo(130f) + } + + @Test + fun resetsReferenceOnNextDown() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.offsetTopAndBottom(-50) + dispatch(MotionEvent.ACTION_UP, 100f) + + dispatch(MotionEvent.ACTION_DOWN, 100f) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 80f)).isEqualTo(80f) + } + + @Test + fun restoresEventCoordinatesAfterHandler() { + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.offsetTopAndBottom(-50) + val event = MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 10f, 130f, 0) + + helper.withStableCoordinates(event) {} + + assertThat(event.x).isEqualTo(10f) + assertThat(event.y).isEqualTo(130f) + event.recycle() + } + + private fun dispatch(action: Int, y: Float): Float { + val event = MotionEvent.obtain(0, 0, action, 10f, y, 0) + val seenY = helper.withStableCoordinates(event) { it.y } + event.recycle() + return seenY + } +} diff --git a/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js new file mode 100644 index 000000000000..26e747b45d84 --- /dev/null +++ b/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js @@ -0,0 +1,190 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +'use strict'; + +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; + +import * as React from 'react'; +import {useRef, useState} from 'react'; +import { + FlatList, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; + +const DATA = Array.from({length: 60}, (_, index) => ({ + id: String(index + 1), + text: `Item ${index + 1}`, +})); + +const START = 400; + +type Mode = 'top' | 'translateY' | 'left' | 'nested'; +const MODES: Array = ['top', 'translateY', 'left', 'nested']; +const MULTIPLIERS = [0, 1, 2]; + +function MovingScrollViewExample(): React.Node { + const [mode, setMode] = useState('top'); + const [multiplier, setMultiplier] = useState(1); + return ( + + + {MODES.map(m => ( + setMode(m)} + style={[styles.button, m === mode && styles.active]}> + {m} + + ))} + + + {MULTIPLIERS.map(m => ( + setMultiplier(m)} + style={[styles.button, m === multiplier && styles.active]}> + x{m} + + ))} + + {mode === 'nested' ? ( + + ) : ( + + )} + + ); +} + +function MovingCase({ + mode, + multiplier, +}: { + mode: Mode, + multiplier: number, +}): React.Node { + const [offset, setOffset] = useState(0); + const [stutters, setStutters] = useState(0); + const previous = useRef(0); + const horizontal = mode === 'left'; + const shift = Math.max( + 0, + (horizontal ? START / 2 : START) - offset * multiplier, + ); + const moveStyle = + mode === 'top' + ? {top: shift} + : mode === 'translateY' + ? {transform: [{translateY: shift}]} + : {left: shift}; + return ( + <> + + mode={mode} multiplier={multiplier} stutters={stutters} offset= + {offset.toFixed(1)} + + + ( + + {item.text} + + )} + style={[styles.list, moveStyle]} + scrollEventThrottle={16} + onScroll={event => { + const {x, y} = event.nativeEvent.contentOffset; + const value = horizontal ? x : y; + if (value < previous.current) { + setStutters(s => s + 1); + } + previous.current = value; + setOffset(value); + }} + /> + + + ); +} + +function NestedCase(): React.Node { + const [outer, setOuter] = useState(0); + const [inner, setInner] = useState(0); + return ( + <> + + outer={outer.toFixed(1)} inner={inner.toFixed(1)} sum= + {(outer + inner).toFixed(1)} + + setOuter(e.nativeEvent.contentOffset.y)}> + + setInner(e.nativeEvent.contentOffset.y)}> + {DATA.slice(0, 12).map(item => ( + + {item.text} + + ))} + + + + + ); +} + +const styles = StyleSheet.create({ + container: {flex: 1}, + toolbar: {flexDirection: 'row', gap: 8, padding: 4}, + button: {padding: 8, backgroundColor: '#ddd'}, + active: {backgroundColor: '#8f8'}, + area: {flex: 1, backgroundColor: 'red', overflow: 'hidden'}, + list: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + backgroundColor: 'green', + }, + row: {height: 50}, + column: {width: 80, justifyContent: 'center'}, + nestedHeader: {height: 100, backgroundColor: 'red'}, + nestedInner: {height: 400, backgroundColor: 'green'}, + nestedFooter: {height: 2000, backgroundColor: 'blue'}, +}); + +exports.title = 'MovingScrollViewExample'; +exports.category = 'Basic'; +exports.description = 'ScrollView that moves while it is dragged (#38470).'; +exports.examples = [ + { + title: 'Moving scroll view', + render(): React.MixedElement { + return ; + }, + }, +] as Array; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index 61844bb5f196..191cea62bf9b 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -95,6 +95,11 @@ const Components: Array = [ category: 'Basic', module: require('../examples/ScrollView/ScrollViewMaintainVisibleContentPositionExample'), }, + { + key: 'MovingScrollViewExample', + category: 'Basic', + module: require('../examples/ScrollView/MovingScrollViewExample'), + }, { key: 'ScrollViewSimpleExample', category: 'Basic', From 7ce65079244c257e463070596f55d7b7693e5e33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20=C5=BBelawski?= Date: Wed, 28 Oct 2026 15:05:11 +0100 Subject: [PATCH 2/2] Map the ScrollView movement into its local axes for flipped and scaled views --- .../scroll/StableTouchCoordinatesHelper.kt | 33 +++++++++++++++++-- .../StableTouchCoordinatesHelperTest.kt | 18 ++++++++++ .../ScrollView/MovingScrollViewExample.js | 15 +++++---- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt index 523e04501687..55868a0a6723 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt @@ -21,11 +21,18 @@ internal class StableTouchCoordinatesHelper(private val view: View) { downLocationX = location[0] downLocationY = location[1] } - val dx = (location[0] - downLocationX).toFloat() - val dy = (location[1] - downLocationY).toFloat() - if (dx == 0f && dy == 0f) { + val screenDx = location[0] - downLocationX + val screenDy = location[1] - downLocationY + if (screenDx == 0 && screenDy == 0) { return handler(ev) } + val scaleX = getCumulativeScaleX() + val scaleY = getCumulativeScaleY() + if (scaleX == 0f || scaleY == 0f) { + return handler(ev) + } + val dx = screenDx / scaleX + val dy = screenDy / scaleY ev.offsetLocation(dx, dy) try { return handler(ev) @@ -34,6 +41,26 @@ internal class StableTouchCoordinatesHelper(private val view: View) { } } + private fun getCumulativeScaleX(): Float { + var scale = view.scaleX + var ancestor = view.parent + while (ancestor is View) { + scale *= ancestor.scaleX + ancestor = ancestor.parent + } + return scale + } + + private fun getCumulativeScaleY(): Float { + var scale = view.scaleY + var ancestor = view.parent + while (ancestor is View) { + scale *= ancestor.scaleY + ancestor = ancestor.parent + } + return scale + } + private fun getLayoutLocationOnScreen(outLocation: IntArray) { view.getLocationOnScreen(outLocation) var ancestor = view.parent diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt index 721c1661cd7f..740ad274f09e 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt @@ -59,6 +59,24 @@ class StableTouchCoordinatesHelperTest { assertThat(dispatch(MotionEvent.ACTION_MOVE, 40f)).isEqualTo(80f) } + @Test + fun compensatesMovementOfFlippedView() { + view.scaleY = -1f + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.offsetTopAndBottom(-50) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 70f)).isEqualTo(120f) + } + + @Test + fun compensatesMovementOfScaledAncestor() { + parent.scaleY = 2f + dispatch(MotionEvent.ACTION_DOWN, 100f) + view.offsetTopAndBottom(-50) + + assertThat(dispatch(MotionEvent.ACTION_MOVE, 150f)).isEqualTo(100f) + } + @Test fun ignoresAncestorScroll() { dispatch(MotionEvent.ACTION_DOWN, 100f) diff --git a/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js index 26e747b45d84..e5de97d661d3 100644 --- a/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js @@ -30,8 +30,8 @@ const DATA = Array.from({length: 60}, (_, index) => ({ const START = 400; -type Mode = 'top' | 'translateY' | 'left' | 'nested'; -const MODES: Array = ['top', 'translateY', 'left', 'nested']; +type Mode = 'top' | 'translateY' | 'left' | 'inverted' | 'nested'; +const MODES: Array = ['top', 'translateY', 'left', 'inverted', 'nested']; const MULTIPLIERS = [0, 1, 2]; function MovingScrollViewExample(): React.Node { @@ -85,17 +85,18 @@ function MovingCase({ const horizontal = mode === 'left'; const shift = Math.max( 0, - (horizontal ? START / 2 : START) - offset * multiplier, + (horizontal ? START / 2 : mode === 'inverted' ? 300 : START) - + offset * multiplier, ); const moveStyle = - mode === 'top' + mode === 'top' || mode === 'inverted' ? {top: shift} : mode === 'translateY' ? {transform: [{translateY: shift}]} : {left: shift}; return ( <> - + mode={mode} multiplier={multiplier} stutters={stutters} offset= {offset.toFixed(1)} @@ -103,6 +104,7 @@ function MovingCase({ ( {item.text} @@ -130,7 +132,7 @@ function NestedCase(): React.Node { const [inner, setInner] = useState(0); return ( <> - + outer={outer.toFixed(1)} inner={inner.toFixed(1)} sum= {(outer + inner).toFixed(1)} @@ -158,6 +160,7 @@ function NestedCase(): React.Node { const styles = StyleSheet.create({ container: {flex: 1}, + status: {color: 'white', backgroundColor: '#333', fontSize: 18}, toolbar: {flexDirection: 'row', gap: 8, padding: 4}, button: {padding: 8, backgroundColor: '#ddd'}, active: {backgroundColor: '#8f8'},