Skip to content

Commit f127c58

Browse files
committed
[Android] Stop nested scroll when a native handler's gesture ends (#4492)
## Description `ScrollView` and `FlatList` from Gesture Handler never fire `onRefresh` when given React Native's `RefreshControl` on Android. The spinner follows the pull but stays parked on release, and the refresh only triggers on the next touch anywhere on the screen. With a `refreshControl` RN wraps the scroll view in a `SwipeRefreshLayout` and enables nested scrolling on it. The pull reaches the layout through the nested-scroll API, and the layout finishes it (fires refresh or snaps back) only in `onStopNestedScroll`. Android calls `stopNestedScroll()` from `View.dispatchTouchEvent` at the end of a gesture, but once `NativeViewGestureHandler` activates it delivers touches straight to the view's `onTouchEvent`, so that cleanup never runs. The nested scroll stays open and the layout is released one touch late, by the CANCEL our root view dispatches on the next DOWN. Gesture Handler's own `RefreshControl` avoids this because its handler drives the `SwipeRefreshLayout` in touch-drag mode, which finishes the spinner in `onTouchEvent` without relying on the nested-scroll cleanup. The handler now calls `stopNestedScroll()` on the view after feeding it the final UP or the synthetic CANCEL, mirroring `View.dispatchTouchEvent`. It only does so while active, since below that the view still receives the events through regular dispatch, and only for views whose hook opts in through the new `shouldStopNestedScroll()`. `ScrollViewHook` opts in. Fixes #4485 ## Test plan - Pulled to refresh repeatedly on GH `ScrollView` + RN `RefreshControl`, GH `FlatList` + RN `RefreshControl`, and `Gesture.Native()` around an RN `ScrollView` + RN `RefreshControl`. `onRefresh` fires on every pull and the spinner retracts. - GH `ScrollView` + GH `RefreshControl` and RN `ScrollView` + RN `RefreshControl` unchanged. - Nested GH `ScrollView` inside GH `ScrollView`, with and without an RN `RefreshControl` on the outer: scroll handover, fling, and pull-to-refresh from inside the inner list work the same as with RN scroll views. <details> <summary>Repro</summary> ```tsx import React, { useCallback, useState } from 'react'; import { RefreshControl, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; export default function App() { const [refreshing, setRefreshing] = useState(false); const onRefresh = useCallback(() => { setRefreshing(true); setTimeout(() => setRefreshing(false), 1000); }, []); return ( <ScrollView style={{ flex: 1 }} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}> {Array.from({ length: 40 }, (_, i) => ( <View key={i} style={{ padding: 20 }}> <Text>Row {i}</Text> </View> ))} </ScrollView> ); } ``` </details>
1 parent 8714f60 commit f127c58

1 file changed

Lines changed: 23 additions & 0 deletions

File tree

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ class NativeViewGestureHandler : GestureHandler() {
109109
cancel()
110110
} else {
111111
hook.sendTouchEvent(view, event)
112+
if (shouldStopNestedScroll()) {
113+
view.stopNestedScroll()
114+
}
112115

113116
if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && hook.canActivate(view)) {
114117
activate()
@@ -156,9 +159,19 @@ class NativeViewGestureHandler : GestureHandler() {
156159
action = MotionEvent.ACTION_CANCEL
157160
}
158161
hook.sendTouchEvent(view, event)
162+
if (shouldStopNestedScroll()) {
163+
view?.stopNestedScroll()
164+
}
159165
event.recycle()
160166
}
161167

168+
// Once the handler is active, it delivers touches straight to the view's `onTouchEvent`. Normally
169+
// touches arrive through `View.dispatchTouchEvent`, which also ends the nested scroll when the finger
170+
// goes up. Because we skip it, the nested scroll stays open and the parent never finds out that the
171+
// gesture is over - e.g. SwipeRefreshLayout never fires refresh (#4485). While the handler is not
172+
// active, the view still receives touches the regular way, so Android takes care of it.
173+
private fun shouldStopNestedScroll() = state == STATE_ACTIVE && hook.shouldStopNestedScroll()
174+
162175
override fun onCancel() = dispatchCancelEventToView()
163176

164177
override fun onFail() = dispatchCancelEventToView()
@@ -211,6 +224,12 @@ class NativeViewGestureHandler : GestureHandler() {
211224
*/
212225
fun canBegin(event: MotionEvent) = true
213226

227+
/**
228+
* Whether the view's nested scroll should be stopped when the active gesture ends. Touches
229+
* are fed through `onTouchEvent`, so `View.dispatchTouchEvent` never gets to do it.
230+
*/
231+
fun shouldStopNestedScroll() = false
232+
214233
/**
215234
* Checks whether handler can activate. Used by TextViewHook.
216235
*/
@@ -336,6 +355,10 @@ class NativeViewGestureHandler : GestureHandler() {
336355

337356
private class ScrollViewHook : NativeViewGestureHandlerHook {
338357
override fun shouldCancelRootViewGestureHandlerIfNecessary() = true
358+
359+
// ScrollView starts a nested scroll on DOWN but never stops it itself. Without this the
360+
// parent's `onStopNestedScroll` never runs, e.g. SwipeRefreshLayout never triggers refresh.
361+
override fun shouldStopNestedScroll() = true
339362
}
340363

341364
private class ReactViewGroupHook : NativeViewGestureHandlerHook {

0 commit comments

Comments
 (0)