From 8b59f1c11df9c036ad52c6d7923a0864d50c6b07 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 2 Sep 2026 15:05:20 +0200 Subject: [PATCH] [Android] Don't let an awaiting parent handler cancel the child it is waiting for ## Description Fixes #3326 Replaces `state == ACTIVE` check with `handler.isActive` to correctly account for handlers that have technically met activation criteria, but are awaiting for the failure of another handler. ## Test plan
Tested on updated repro from issue ```jsx import React, { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import type { LegacyPanGesture, NativeGesture, PanGesture, } from 'react-native-gesture-handler'; import { Gesture, GestureDetector, useNativeGesture, usePanGesture, } from 'react-native-gesture-handler'; import type { PagerViewOnPageSelectedEvent } from 'react-native-pager-view'; import PagerView from 'react-native-pager-view'; import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; // Reproduction of https://github.com/software-mansion/react-native-gesture-handler/issues/3326 // Expected: dragging the yellow ScrollView never begins/activates the drawer pan. type AnyPanGesture = PanGesture | LegacyPanGesture; type AnyNativeGesture = NativeGesture | ReturnType; type SetStatus = React.Dispatch>; type Mode = | 'requireToFail-parent' | 'block-parent' | 'block-child' | 'v2' | 'noPager' | 'noPaging' | 'nestedPan'; const MODES: Mode[] = [ 'requireToFail-parent', 'block-parent', 'block-child', 'v2', 'noPager', 'noPaging', 'nestedPan', ]; export default function EmptyExample() { const [mode, setMode] = useState('requireToFail-parent'); return ( {MODES.map((m) => ( setMode(m)} style={[styles.modeButton, m === mode && styles.modeButtonActive]}> {m} ))} {mode === 'requireToFail-parent' && } {mode === 'block-parent' && } {mode === 'block-child' && } {mode === 'v2' && } {mode === 'noPager' && } {mode === 'noPaging' && } {mode === 'nestedPan' && } ); } function useDrawerPan( innerNative: NativeGesture | undefined, swipeEnabled: boolean, setStatus: SetStatus ) { const val = useSharedValue(0); const pan = usePanGesture({ requireToFail: innerNative, activeOffsetX: swipeEnabled ? 5 : undefined, failOffsetX: swipeEnabled ? -1 : [0, 0], failOffsetY: swipeEnabled ? undefined : [0, 0], runOnJS: true, onBegin: () => { setStatus('pan: begin'); val.set(0); }, onActivate: () => { setStatus((s) => `${s} > ACTIVE`); }, onUpdate: (e) => { val.set(e.translationX); }, onDeactivate: () => { val.set(0); }, onFinalize: (e) => { setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`); val.set(0); }, }); const style = useAnimatedStyle(() => ({ flex: 1, transform: [{ translateX: val.value }], })); return { pan, style }; } function RequireToFailInParent() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const innerNative = useNativeGesture({}); const { pan, style } = useDrawerPan(innerNative, swipeEnabled, setStatus); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( index === 0 ? ( ) : ( Page {index + 1} ) } /> ); } function BlockInParent() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus); const innerNative = useNativeGesture({ block: pan }); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( index === 0 ? ( ) : ( Page {index + 1} ) } /> ); } function BlockInChild({ pager = true, paging = true, }: { pager?: boolean; paging?: boolean; }) { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( ( )} /> ); } // Scenario from PR #3095: a pan nested in a ScrollView must not activate while the ScrollView scrolls. function NestedPanInScrollView() { const [status, setStatus] = useState('pan: idle'); const [scrollY, setScrollY] = useState(0); const native = useNativeGesture({}); const pan = usePanGesture({ runOnJS: true, onBegin: () => setStatus('pan: begin'), onActivate: () => setStatus((s) => `${s} > ACTIVE`), onFinalize: (e) => setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`), }); return ( {status} scrollY: {Math.round(scrollY)} setScrollY(e.nativeEvent.contentOffset.y)}> ); } function V2BlockInChild() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const val = useSharedValue(0); let pan = Gesture.Pan() .runOnJS(true) .onBegin(() => { setStatus('pan: begin'); val.set(0); }) .onStart(() => { setStatus((s) => `${s} > ACTIVE`); }) .onUpdate((e) => { val.set(e.translationX); }) .onEnd(() => { val.set(0); }) .onFinalize((_e, success) => { setStatus((s) => `${s} > finalized (canceled: ${!success})`); val.set(0); }); pan = swipeEnabled ? pan.failOffsetX(-1).activeOffsetX(5) : pan.failOffsetX([0, 0]).failOffsetY([0, 0]); const style = useAnimatedStyle(() => ({ flex: 1, transform: [{ translateX: val.value }], })); const pagerNative = Gesture.Native().requireExternalGestureToFail(pan); return ( } /> ); } function V2InnerScrollView({ pan }: { pan: LegacyPanGesture }) { const innerNative = Gesture.Native().blocksExternalGesture(pan); return ; } function Drawer({ pan, style, status, children, }: { pan: AnyPanGesture; style: ReturnType; status: string; children: React.ReactNode; }) { return ( {status} {children} ); } function Pager({ renderPage, native, setSwipeEnabled, }: { renderPage: (index: number) => React.ReactNode; native: AnyNativeGesture | undefined; setSwipeEnabled: (enabled: boolean) => void; }) { const [page, setPage] = useState(0); if (!native) { return {renderPage(0)}; } return ( { setSwipeEnabled(e.nativeEvent.position === 0); setPage(e.nativeEvent.position); }}> page: {page} {renderPage(0)} {renderPage(1)} {renderPage(2)} ); } function InnerScrollViewBlockingPan({ pan, paging, }: { pan: PanGesture; paging: boolean; }) { const innerNative = useNativeGesture({ block: pan }); return ; } function InnerScrollView({ innerNative, paging = true, }: { innerNative: AnyNativeGesture; paging?: boolean; }) { const [scrollX, setScrollX] = useState(0); return ( scrollX: {Math.round(scrollX)} setScrollX(e.nativeEvent.contentOffset.x)} scrollEventThrottle={16} style={styles.scroll}> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 ); } const styles = StyleSheet.create({ container: { flex: 1, }, modes: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, padding: 4, }, modeButton: { width: '32%', padding: 8, backgroundColor: '#ddd', borderRadius: 6, }, modeButtonActive: { backgroundColor: '#8f8', }, modeText: { fontSize: 11, textAlign: 'center', }, status: { padding: 8, fontSize: 18, textAlign: 'center', }, pager: { flex: 1, backgroundColor: 'green', }, scrollContainer: { paddingTop: 150, alignItems: 'center', }, scroll: { width: 300, height: 200, backgroundColor: 'yellow', }, scrollText: { width: 1000, }, spacer: { height: 400, }, nestedBox: { width: 150, height: 150, alignSelf: 'center', backgroundColor: 'yellow', }, }); ```
--- .../swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 95bc9a10bd..4917f8487a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -124,7 +124,7 @@ class GestureHandlerOrchestrator( private fun shouldBeCancelledByActiveHandler(handler: GestureHandler) = gestureHandlers.any { handler.hasCommonPointers(it) && - it.state == GestureHandler.STATE_ACTIVE && + it.isActive && !canRunSimultaneously(handler, it) && handler.isDescendantOf(it) }