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..55868a0a6723 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelper.kt @@ -0,0 +1,73 @@ +/* + * 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 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) + } finally { + ev.offsetLocation(-dx, -dy) + } + } + + 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 + 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..740ad274f09e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/StableTouchCoordinatesHelperTest.kt @@ -0,0 +1,118 @@ +/* + * 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 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) + 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..e5de97d661d3 --- /dev/null +++ b/packages/rn-tester/js/examples/ScrollView/MovingScrollViewExample.js @@ -0,0 +1,193 @@ +/** + * 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' | 'inverted' | 'nested'; +const MODES: Array = ['top', 'translateY', 'left', 'inverted', '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 : mode === 'inverted' ? 300 : START) - + offset * multiplier, + ); + const moveStyle = + mode === 'top' || mode === 'inverted' + ? {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}, + status: {color: 'white', backgroundColor: '#333', fontSize: 18}, + 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',