[Android] Don't let an awaiting parent handler cancel the child it is waiting for - #4476
[Android] Don't let an awaiting parent handler cancel the child it is waiting for#4476j-piasecki wants to merge 1 commit into
Conversation
… 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 <details> <summary>Tested on updated repro from issue</summary> ```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 #3326 // Expected: dragging the yellow ScrollView never begins/activates the drawer pan. type AnyPanGesture = PanGesture | LegacyPanGesture; type AnyNativeGesture = NativeGesture | ReturnType<typeof Gesture.Native>; type SetStatus = React.Dispatch<React.SetStateAction<string>>; 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<Mode>('requireToFail-parent'); return ( <View style={styles.container}> <View style={styles.modes}> {MODES.map((m) => ( <Pressable key={m} testID={`mode-${m}`} onPress={() => setMode(m)} style={[styles.modeButton, m === mode && styles.modeButtonActive]}> <Text style={styles.modeText}>{m}</Text> </Pressable> ))} </View> {mode === 'requireToFail-parent' && <RequireToFailInParent key={mode} />} {mode === 'block-parent' && <BlockInParent key={mode} />} {mode === 'block-child' && <BlockInChild key={mode} />} {mode === 'v2' && <V2BlockInChild key={mode} />} {mode === 'noPager' && <BlockInChild key={mode} pager={false} />} {mode === 'noPaging' && <BlockInChild key={mode} paging={false} />} {mode === 'nestedPan' && <NestedPanInScrollView key={mode} />} </View> ); } 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 ( <Drawer pan={pan} style={style} status={status}> <Pager native={pagerNative} setSwipeEnabled={setSwipeEnabled} renderPage={(index) => index === 0 ? ( <InnerScrollView innerNative={innerNative} /> ) : ( <Text style={styles.status}>Page {index + 1}</Text> ) } /> </Drawer> ); } 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 ( <Drawer pan={pan} style={style} status={status}> <Pager native={pagerNative} setSwipeEnabled={setSwipeEnabled} renderPage={(index) => index === 0 ? ( <InnerScrollView innerNative={innerNative} /> ) : ( <Text style={styles.status}>Page {index + 1}</Text> ) } /> </Drawer> ); } 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 ( <Drawer pan={pan} style={style} status={status}> <Pager native={pager ? pagerNative : undefined} setSwipeEnabled={setSwipeEnabled} renderPage={() => ( <InnerScrollViewBlockingPan pan={pan} paging={paging} /> )} /> </Drawer> ); } // 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 ( <View style={styles.container}> <Text testID="status" style={styles.status}> {status} </Text> <Text testID="scrollY" style={styles.status}> scrollY: {Math.round(scrollY)} </Text> <GestureDetector gesture={native}> <ScrollView testID="outer-scroll" style={styles.pager} scrollEventThrottle={16} onScroll={(e) => setScrollY(e.nativeEvent.contentOffset.y)}> <View style={styles.spacer} /> <GestureDetector gesture={pan}> <View testID="nested-box" style={styles.nestedBox} /> </GestureDetector> <View style={styles.spacer} /> <View style={styles.spacer} /> <View style={styles.spacer} /> </ScrollView> </GestureDetector> </View> ); } 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 ( <Drawer pan={pan} style={style} status={status}> <Pager native={pagerNative} setSwipeEnabled={setSwipeEnabled} renderPage={() => <V2InnerScrollView pan={pan} />} /> </Drawer> ); } function V2InnerScrollView({ pan }: { pan: LegacyPanGesture }) { const innerNative = Gesture.Native().blocksExternalGesture(pan); return <InnerScrollView innerNative={innerNative} />; } function Drawer({ pan, style, status, children, }: { pan: AnyPanGesture; style: ReturnType<typeof useAnimatedStyle>; status: string; children: React.ReactNode; }) { return ( <GestureDetector gesture={pan as PanGesture}> <Animated.View style={style}> <Text testID="status" style={styles.status}> {status} </Text> {children} </Animated.View> </GestureDetector> ); } 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 <View style={styles.pager}>{renderPage(0)}</View>; } return ( <GestureDetector gesture={native as NativeGesture}> <PagerView overdrag initialPage={0} style={styles.pager} onPageSelected={(e: PagerViewOnPageSelectedEvent) => { setSwipeEnabled(e.nativeEvent.position === 0); setPage(e.nativeEvent.position); }}> <View key="1"> <Text testID="page" style={styles.status}> page: {page} </Text> {renderPage(0)} </View> <View key="2">{renderPage(1)}</View> <View key="3">{renderPage(2)}</View> </PagerView> </GestureDetector> ); } function InnerScrollViewBlockingPan({ pan, paging, }: { pan: PanGesture; paging: boolean; }) { const innerNative = useNativeGesture({ block: pan }); return <InnerScrollView innerNative={innerNative} paging={paging} />; } function InnerScrollView({ innerNative, paging = true, }: { innerNative: AnyNativeGesture; paging?: boolean; }) { const [scrollX, setScrollX] = useState(0); return ( <View style={styles.scrollContainer}> <Text testID="scrollX" style={styles.status}> scrollX: {Math.round(scrollX)} </Text> <GestureDetector gesture={innerNative as NativeGesture}> <ScrollView horizontal pagingEnabled={paging} testID="inner-scroll" onScroll={(e) => setScrollX(e.nativeEvent.contentOffset.x)} scrollEventThrottle={16} style={styles.scroll}> <Text style={styles.scrollText}> 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 </Text> </ScrollView> </GestureDetector> </View> ); } 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', }, }); ``` </details>
2be8fe0 to
8b59f1c
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (1)
Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughThe Android gesture orchestrator now uses each handler's ChangesGesture cancellation
Suggested reviewers: Merge Risk: ⚪ Minimal · up to This change prevents an awaiting parent gesture from cancelling the child gesture it depends on, without changing public APIs or deployment behavior. No actionable merge-blocking risk remains beyond normal checks and review. 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
Full details: Linked Issues checkExplanation The change directly addresses issue Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🔵 Needs a closer look
The reproduction harness is currently placed in (and still named as) the shared “Empty Example” screen, which is misleading in the app’s screen lists and should be moved/renamed for clarity.
Pull request overview
This PR updates Android gesture orchestration logic to rely on the orchestrator-managed “active” flag when deciding cancellations, and adds a Common App screen intended to reproduce a nested-gesture interaction issue.
Changes:
- Android: switch
shouldBeCancelledByActiveHandlerto checkGestureHandler.isActiveinstead ofstate == STATE_ACTIVE. - Common App: replace the “Empty Example” screen with a multi-mode reproduction harness (PagerView + nested ScrollView + drawer pan).
File summaries
| File | Description |
|---|---|
| packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | Uses the orchestrator’s isActive flag to decide whether another handler should cancel a candidate handler. |
| apps/common-app/src/empty/index.tsx | Adds a UI harness to reproduce and compare gesture interaction behaviors across multiple configuration modes. |
Review details
- Files reviewed: 1/1 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Description
Fixes #3326
Replaces
state == ACTIVEcheck withhandler.isActiveto 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