11import React , { useCallback , useRef , useState } from 'react' ;
2- import { StyleSheet , Switch , Text , View } from 'react-native' ;
2+ import { ScrollView , StyleSheet , Switch , Text , View } from 'react-native' ;
33import { GestureDetector , usePanGesture } from 'react-native-gesture-handler' ;
44import {
55 COLORS ,
@@ -8,9 +8,48 @@ import {
88 commonStyles ,
99} from '../../../common' ;
1010
11- const MAX_EVENTS = 8 ;
11+ const SECTION_MIN_HEIGHT = 640 ;
1212
1313export default function RNResponderCancellationExample ( ) {
14+ return (
15+ < ScrollView
16+ style = { scrollStyles . scroll }
17+ contentContainerStyle = { scrollStyles . content }
18+ showsVerticalScrollIndicator >
19+ < View style = { scrollStyles . section } >
20+ < SingleHandlerExample />
21+ </ View >
22+ < View style = { scrollStyles . divider } />
23+ < View style = { scrollStyles . section } >
24+ < MultiHandlerExample />
25+ </ View >
26+ </ ScrollView >
27+ ) ;
28+ }
29+
30+ const scrollStyles = StyleSheet . create ( {
31+ scroll : {
32+ flex : 1 ,
33+ } ,
34+ content : {
35+ flexGrow : 1 ,
36+ } ,
37+ section : {
38+ minHeight : SECTION_MIN_HEIGHT ,
39+ } ,
40+ divider : {
41+ height : 2 ,
42+ marginHorizontal : 16 ,
43+ marginVertical : 4 ,
44+ backgroundColor : '#b6bfd0' ,
45+ } ,
46+ } ) ;
47+
48+ // ---------- Single handler --------------------------------------------------
49+
50+ const SINGLE_MAX_EVENTS = 8 ;
51+
52+ function SingleHandlerExample ( ) {
1453 const feedbackRef = useRef < FeedbackHandle > ( null ) ;
1554 const sequenceRef = useRef ( 0 ) ;
1655 const [ events , setEvents ] = useState < string [ ] > ( [ ] ) ;
@@ -22,7 +61,7 @@ export default function RNResponderCancellationExample() {
2261
2362 console . log ( event ) ;
2463 feedbackRef . current ?. showMessage ( label ) ;
25- setEvents ( ( prev ) => [ event , ...prev ] . slice ( 0 , MAX_EVENTS ) ) ;
64+ setEvents ( ( prev ) => [ event , ...prev ] . slice ( 0 , SINGLE_MAX_EVENTS ) ) ;
2665 } , [ ] ) ;
2766
2867 const panGesture = usePanGesture ( {
@@ -38,13 +77,13 @@ export default function RNResponderCancellationExample() {
3877 } ) ;
3978
4079 return (
41- < View style = { styles . container } >
80+ < View style = { singleStyles . container } >
4281 < Text style = { commonStyles . header } > RN responder cancellation</ Text >
4382 < Text style = { commonStyles . instructions } >
4483 Toggle preventRecognizers and drag inside the box to compare behavior.
4584 </ Text >
46- < View style = { styles . settingsRow } >
47- < Text style = { styles . settingsLabel } > preventRecognizers</ Text >
85+ < View style = { singleStyles . settingsRow } >
86+ < Text style = { singleStyles . settingsLabel } > preventRecognizers</ Text >
4887 < Switch
4988 value = { preventRecognizers }
5089 onValueChange = { setPreventRecognizers }
@@ -53,7 +92,7 @@ export default function RNResponderCancellationExample() {
5392
5493 < GestureDetector gesture = { panGesture } >
5594 < View
56- style = { styles . touchArea }
95+ style = { singleStyles . touchArea }
5796 onStartShouldSetResponder = { ( ) => {
5897 pushEvent ( 'RN onStartShouldSetResponder -> true' ) ;
5998 return true ;
@@ -78,18 +117,18 @@ export default function RNResponderCancellationExample() {
78117 pushEvent ( 'RN onResponderTerminationRequest -> true' ) ;
79118 return true ;
80119 } } >
81- < Text style = { styles . touchAreaLabel } > Drag me</ Text >
120+ < Text style = { singleStyles . touchAreaLabel } > Drag me</ Text >
82121 </ View >
83122 </ GestureDetector >
84123
85124 < Feedback ref = { feedbackRef } duration = { 1300 } />
86- < View style = { styles . logContainer } >
125+ < View style = { singleStyles . logContainer } >
87126 { events . map ( ( item ) => (
88127 < Text
89128 key = { item }
90129 style = { [
91- styles . logLine ,
92- item . includes ( 'GH pan ACTIVE' ) && styles . logLineActive ,
130+ singleStyles . logLine ,
131+ item . includes ( 'GH pan ACTIVE' ) && singleStyles . logLineActive ,
93132 ] } >
94133 { item }
95134 </ Text >
@@ -99,7 +138,7 @@ export default function RNResponderCancellationExample() {
99138 ) ;
100139}
101140
102- const styles = StyleSheet . create ( {
141+ const singleStyles = StyleSheet . create ( {
103142 container : {
104143 flex : 1 ,
105144 paddingHorizontal : 16 ,
@@ -157,3 +196,238 @@ const styles = StyleSheet.create({
157196 fontWeight : 'bold' ,
158197 } ,
159198} ) ;
199+
200+ // ---------- Multi handler ---------------------------------------------------
201+ // Validates that when two Gesture Handler recognizers are active at the same
202+ // time, both with preventRecognizers set to true, finishing ONE of them does
203+ // NOT unblock React Native JS responders — the block must stay in place until
204+ // the LAST preventing recognizer finishes.
205+ //
206+ // Expected interaction to reproduce:
207+ // 1. Finger 1: drag inside "Pan A" → GH_A ACTIVE (RN blocked)
208+ // 2. Finger 2: drag inside "Pan B" → GH_B ACTIVE
209+ // 3. Finger 3: tap the "RN responder zone" → grant must NOT fire
210+ // 4. Release finger 1 → GH_A finalize
211+ // 5. Finger 3: tap the "RN responder zone" → grant must STILL NOT fire
212+ // (GH_B is still active)
213+ // 6. Release finger 2 → GH_B finalize (released)
214+ // 7. Finger 3: tap the "RN responder zone" → grant SHOULD now fire
215+ //
216+ // If step 5 logs "RN zone onResponderGrant" the invariant is broken.
217+
218+ const MULTI_MAX_EVENTS = 14 ;
219+
220+ function MultiHandlerExample ( ) {
221+ const feedbackRef = useRef < FeedbackHandle > ( null ) ;
222+ const sequenceRef = useRef ( 0 ) ;
223+ const [ events , setEvents ] = useState < string [ ] > ( [ ] ) ;
224+ const [ preventRecognizers , setPreventRecognizers ] = useState ( true ) ;
225+
226+ const pushEvent = useCallback ( ( label : string ) => {
227+ sequenceRef . current += 1 ;
228+ const event = `${ sequenceRef . current } . ${ label } ` ;
229+
230+ console . log ( event ) ;
231+ feedbackRef . current ?. showMessage ( label ) ;
232+ setEvents ( ( prev ) => [ event , ...prev ] . slice ( 0 , MULTI_MAX_EVENTS ) ) ;
233+ } , [ ] ) ;
234+
235+ const panA = usePanGesture ( {
236+ minDistance : 8 ,
237+ runOnJS : true ,
238+ preventRecognizers,
239+ onActivate : ( ) => pushEvent ( 'GH_A ACTIVE' ) ,
240+ onFinalize : ( _e , success ) =>
241+ pushEvent ( `GH_A finalize (${ success ? 'success' : 'cancel/fail' } )` ) ,
242+ } ) ;
243+
244+ const panB = usePanGesture ( {
245+ minDistance : 8 ,
246+ runOnJS : true ,
247+ // preventRecognizers,
248+ onActivate : ( ) => pushEvent ( 'GH_B ACTIVE' ) ,
249+ onFinalize : ( _e , success ) =>
250+ pushEvent ( `GH_B finalize (${ success ? 'success' : 'cancel/fail' } )` ) ,
251+ } ) ;
252+
253+ const clearLog = useCallback ( ( ) => {
254+ sequenceRef . current = 0 ;
255+ setEvents ( [ ] ) ;
256+ } , [ ] ) ;
257+
258+ return (
259+ < View style = { multiStyles . container } >
260+ < Text style = { commonStyles . header } > preventRecognizers — multi</ Text >
261+ < Text style = { commonStyles . instructions } >
262+ Drag A and B with two fingers simultaneously, then tap the RN zone with
263+ a third finger. Release one finger at a time and re-tap.
264+ </ Text >
265+
266+ < View style = { multiStyles . settingsRow } >
267+ < Text style = { multiStyles . settingsLabel } > preventRecognizers</ Text >
268+ < Switch
269+ value = { preventRecognizers }
270+ onValueChange = { setPreventRecognizers }
271+ />
272+ < Text onPress = { clearLog } style = { multiStyles . clearButton } >
273+ clear
274+ </ Text >
275+ </ View >
276+
277+ < View style = { multiStyles . boxesRow } >
278+ < GestureDetector gesture = { panA } >
279+ < View style = { [ multiStyles . panBox , multiStyles . panBoxA ] } >
280+ < Text style = { multiStyles . panLabel } > Pan A</ Text >
281+ </ View >
282+ </ GestureDetector >
283+ < GestureDetector gesture = { panB } >
284+ < View style = { [ multiStyles . panBox , multiStyles . panBoxB ] } >
285+ < Text style = { multiStyles . panLabel } > Pan B</ Text >
286+ </ View >
287+ </ GestureDetector >
288+ </ View >
289+
290+ < View
291+ style = { multiStyles . rnZone }
292+ onStartShouldSetResponder = { ( ) => {
293+ pushEvent ( 'RN zone onStartShouldSetResponder -> true' ) ;
294+ return true ;
295+ } }
296+ onResponderGrant = { ( ) => {
297+ pushEvent (
298+ 'RN zone onResponderGrant <-- NOT expected while GH active'
299+ ) ;
300+ } }
301+ onResponderRelease = { ( ) => pushEvent ( 'RN zone onResponderRelease' ) }
302+ onResponderTerminate = { ( ) =>
303+ pushEvent ( 'RN zone onResponderTerminate <-- cancelled by GH' )
304+ }
305+ onResponderTerminationRequest = { ( ) => {
306+ pushEvent ( 'RN zone onResponderTerminationRequest -> true' ) ;
307+ return true ;
308+ } } >
309+ < Text style = { multiStyles . rnZoneLabel } > RN responder zone (tap me)</ Text >
310+ </ View >
311+
312+ < View style = { multiStyles . feedbackSlot } >
313+ < Feedback ref = { feedbackRef } duration = { 1200 } />
314+ </ View >
315+ < View style = { multiStyles . logContainer } >
316+ { events . map ( ( item ) => (
317+ < Text
318+ key = { item }
319+ style = { [
320+ multiStyles . logLine ,
321+ item . includes ( 'ACTIVE' ) && multiStyles . logLineActive ,
322+ item . includes ( 'onResponderGrant' ) && multiStyles . logLineBad ,
323+ item . includes ( 'Terminate' ) && multiStyles . logLineCancel ,
324+ ] } >
325+ { item }
326+ </ Text >
327+ ) ) }
328+ </ View >
329+ </ View >
330+ ) ;
331+ }
332+
333+ const multiStyles = StyleSheet . create ( {
334+ container : {
335+ flex : 1 ,
336+ paddingHorizontal : 16 ,
337+ paddingVertical : 16 ,
338+ gap : 10 ,
339+ alignItems : 'center' ,
340+ backgroundColor : COLORS . offWhite ,
341+ } ,
342+ settingsRow : {
343+ width : '100%' ,
344+ maxWidth : 380 ,
345+ flexDirection : 'row' ,
346+ alignItems : 'center' ,
347+ justifyContent : 'space-between' ,
348+ } ,
349+ settingsLabel : {
350+ color : COLORS . NAVY ,
351+ fontSize : 14 ,
352+ fontWeight : '600' ,
353+ } ,
354+ clearButton : {
355+ color : COLORS . NAVY ,
356+ fontSize : 13 ,
357+ fontWeight : '600' ,
358+ paddingHorizontal : 10 ,
359+ paddingVertical : 4 ,
360+ borderRadius : 6 ,
361+ borderWidth : 1 ,
362+ borderColor : COLORS . NAVY ,
363+ } ,
364+ boxesRow : {
365+ width : '100%' ,
366+ maxWidth : 380 ,
367+ flexDirection : 'row' ,
368+ justifyContent : 'space-between' ,
369+ gap : 10 ,
370+ } ,
371+ panBox : {
372+ flex : 1 ,
373+ minHeight : 140 ,
374+ borderRadius : 16 ,
375+ borderWidth : 2 ,
376+ borderColor : COLORS . NAVY ,
377+ justifyContent : 'center' ,
378+ alignItems : 'center' ,
379+ } ,
380+ panBoxA : { backgroundColor : '#d8ebff' } ,
381+ panBoxB : { backgroundColor : '#ffe0d8' } ,
382+ panLabel : {
383+ color : COLORS . NAVY ,
384+ fontWeight : '700' ,
385+ fontSize : 16 ,
386+ } ,
387+ rnZone : {
388+ width : '100%' ,
389+ maxWidth : 380 ,
390+ minHeight : 80 ,
391+ borderRadius : 14 ,
392+ borderWidth : 2 ,
393+ borderStyle : 'dashed' ,
394+ borderColor : '#7a4dff' ,
395+ backgroundColor : '#ece2ff' ,
396+ justifyContent : 'center' ,
397+ alignItems : 'center' ,
398+ } ,
399+ rnZoneLabel : {
400+ color : '#3a1f9c' ,
401+ fontWeight : '700' ,
402+ fontSize : 15 ,
403+ } ,
404+ feedbackSlot : {
405+ width : '100%' ,
406+ maxWidth : 420 ,
407+ height : 84 ,
408+ paddingHorizontal : 8 ,
409+ justifyContent : 'center' ,
410+ alignItems : 'center' ,
411+ overflow : 'hidden' ,
412+ } ,
413+ logContainer : {
414+ width : '100%' ,
415+ maxWidth : 420 ,
416+ height : 260 ,
417+ borderRadius : 12 ,
418+ padding : 10 ,
419+ backgroundColor : '#ffffff' ,
420+ borderWidth : 1 ,
421+ borderColor : '#d5dbe6' ,
422+ gap : 2 ,
423+ overflow : 'hidden' ,
424+ } ,
425+ logLine : {
426+ fontSize : 12 ,
427+ color : '#2c3a4f' ,
428+ fontFamily : 'Courier' ,
429+ } ,
430+ logLineActive : { color : '#1565c0' , fontWeight : 'bold' } ,
431+ logLineBad : { color : '#b71c1c' , fontWeight : 'bold' } ,
432+ logLineCancel : { color : '#6a1b9a' } ,
433+ } ) ;
0 commit comments