Repository navigation
ReanimatedSwipeable: allow passing failOffsetY to the internal pan gesture #4563
Description
Description
ReanimatedSwipeable builds its pan gesture internally with only activeOffsetX: [dragOffsetFromRight, dragOffsetFromLeft]. There is no way to pass failOffsetY (or other pan config) to that gesture.
On Android this breaks pull-to-refresh when swipeable rows are rendered inside a FlatList with a RefreshControl:
- A short, slightly diagonal downward drag on a row first starts the native pull-to-refresh (
SwipeRefreshLayout). - The horizontal component of the same drag then crosses
activeOffsetX, the swipe pan activates and Gesture Handler cancels the native touches (ACTION_CANCEL). - AndroidX
SwipeRefreshLayoutonly retracts its indicator onACTION_UP, not onACTION_CANCEL. The indicator stays parked half-pulled, without spinning and withoutonRefreshfiring, until the next pull picks it up again.
It does not happen when dragging outside the rows or on purely vertical drags.
Expected: a mostly vertical drag that has already started pull-to-refresh should not be taken over by the row swipe.
Proposed solution
An optional failOffsetY prop on ReanimatedSwipeable, forwarded unchanged to the internal pan gesture. Without the prop nothing changes. Setting it to roughly the Android touch slop (e.g. [-8, 8]) makes the swipe fail as soon as the finger moves vertically first, so it no longer takes over a pull that has started, while horizontal swiping keeps working.
We have been running this as a local patch and verified it on device: after the change, 10 out of 10 light (including diagonal) pulls no longer left the indicator stuck. I'll open a PR with the change.
Steps to reproduce
- Render a
FlatListwithrefreshing/onRefreshwhere every row is wrapped inReanimatedSwipeablewithrenderLeftActions(snippet below). - Run on an Android device.
- At the top of the list, drag on a row briefly and slightly diagonally downwards (about 70 px down and 100 px sideways, not past the refresh threshold), then release.
- The refresh indicator stays half-pulled and does not spin;
onRefreshis not called.
import React, { useCallback, useState } from 'react';
import { FlatList, RefreshControl, Text, View } from 'react-native';
import ReanimatedSwipeable from 'react-native-gesture-handler/ReanimatedSwipeable';
const DATA = Array.from({ length: 30 }, (_, i) => `Row ${i}`);
export default function App() {
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(() => {
setRefreshing(true);
setTimeout(() => setRefreshing(false), 1000);
}, []);
return (
<FlatList
data={DATA}
keyExtractor={(item) => item}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({ item }) => (
<ReanimatedSwipeable
// failOffsetY={[-8, 8]} // proposed prop
renderLeftActions={() => (
<View style={{ width: 80, backgroundColor: 'tomato' }} />
)}>
<View style={{ padding: 20, backgroundColor: 'white' }}>
<Text>{item}</Text>
</View>
</ReanimatedSwipeable>
)}
/>
);
}A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.
See the code snippet above.
Gesture Handler version
3.3.0
React Native version
0.87
Platforms
Android
JavaScript runtime
Hermes
Workflow
No response
Build type
No response
Device
Real device
Device model
Samsung tablet (Android 15 / Android 16)
Acknowledgements
Yes
Activity
m-bert commentedon Oct 13, 2026
Hi @willheisenberg! Thank you for reporting this issue! This should be fixed by #4582 and #4592. It would be great if you could confirm that applying #4592 works.
[Android] Stop nested scroll when the root starts intercepting (#4592)