From b90ef5d59786f34c95d1707c10e93aa0e2e1977e Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 23 Apr 2026 15:53:45 +0200 Subject: [PATCH 01/16] capturing touch event in GH Pressable --- apps/common-app/src/new_api/index.tsx | 5 + .../tests/keyboardShouldPersistTaps/index.tsx | 264 ++++++++++++++++++ .../src/v3/components/Pressable.tsx | 12 + 3 files changed, 281 insertions(+) create mode 100644 apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 9327721c0d..7f91dce4bd 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -44,6 +44,7 @@ import ReattachingExample from './tests/reattaching'; import NestedRootViewExample from './tests/nestedRootView'; import NestedPressablesExample from './tests/nestedPressables'; import PressableExample from './tests/pressable'; +import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; import { ExamplesSection } from '../common'; import EmptyExample from '../empty'; @@ -131,6 +132,10 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ { name: 'Modal with Nested Root View', component: NestedRootViewExample }, { name: 'Nested pressables', component: NestedPressablesExample }, { name: 'Pressable', component: PressableExample }, + { + name: 'Keyboard Should Persist Taps', + component: KeyboardShouldPersistTapsExample, + }, ], }, ]; diff --git a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx new file mode 100644 index 0000000000..2add0393d0 --- /dev/null +++ b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx @@ -0,0 +1,264 @@ +import React, { useRef, useState } from 'react'; + +import { + Pressable as RNPressable, + ScrollView as RNScrollView, + TextInput as RNTextInput, + Keyboard, + StyleSheet, + Text, + View, +} from 'react-native'; +import { + Pressable as RNGHPressable, + ScrollView as RNGHScrollView, + TextInput as RNGHTextInput, +} from 'react-native-gesture-handler'; + +import { COLORS, Feedback, FeedbackHandle, InfoSection } from '../../../common'; + +type Mode = 'never' | 'handled' | 'always'; + +const MODES: Mode[] = ['never', 'handled', 'always']; + +const MODE_DESCRIPTIONS: Record = { + never: + "RN: first tap outside the input dismisses the keyboard AND is swallowed — press doesn't fire. GH: keyboard still dismisses (ScrollView captures the responder normally), but the press ALSO fires because GH's native recognizer runs in parallel to the JS responder system.", + handled: + 'Keyboard stays up if a child claims the tap. Tap an input to raise the keyboard, then tap a button — press fires and keyboard stays. RN and GH match here.', + always: + "Keyboard never auto-dismisses on tap; children always receive taps. You'd have to call Keyboard.dismiss() yourself. RN and GH match here.", +}; + +export default function KeyboardShouldPersistTapsExample() { + const [mode, setMode] = useState('handled'); + const feedbackRef = useRef(null); + + const report = (message: string) => { + feedbackRef.current?.showMessage(message); + }; + + return ( + + + + Keyboard.dismiss()}> + Dismiss KB + + + + + + + [ + styles.button, + { + backgroundColor: pressed + ? COLORS.KINDA_BLUE + : COLORS.LIGHT_BLUE, + }, + ]} + onPress={() => report('RN Pressable onPress')}> + Press me + + + + + + [ + styles.button, + { + backgroundColor: pressed ? COLORS.KINDA_GREEN : COLORS.GREEN, + }, + ]} + onPress={() => report('GH Pressable onPress')}> + Press me + + + + + + + + + + + ); +} + +type ModeSelectorProps = { + value: Mode; + onChange: (next: Mode) => void; +}; + +function ModeSelector({ value, onChange }: ModeSelectorProps) { + return ( + + {MODES.map((m) => { + const active = m === value; + return ( + onChange(m)} + style={[styles.modeChip, active && styles.modeChipActive]}> + + {m} + + + ); + })} + + ); +} + +type PanelProps = { + title: string; + accent: string; + mode: Mode; + ScrollViewComponent: React.ComponentType< + React.ComponentProps + >; + children: React.ReactNode; +}; + +function Panel({ + title, + accent, + mode, + ScrollViewComponent, + children, +}: PanelProps) { + return ( + + + {title} + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 12, + gap: 10, + backgroundColor: COLORS.offWhite, + }, + topBar: { + flexDirection: 'row', + alignItems: 'stretch', + gap: 8, + }, + modeRow: { + flex: 1, + flexDirection: 'row', + gap: 6, + padding: 4, + borderRadius: 10, + backgroundColor: COLORS.headerSeparator, + }, + modeChip: { + flex: 1, + paddingVertical: 8, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + }, + modeChipActive: { + backgroundColor: COLORS.NAVY, + }, + modeLabel: { + fontSize: 13, + fontWeight: '600', + color: COLORS.NAVY, + fontFamily: 'Menlo', + }, + modeLabelActive: { + color: '#ffffff', + }, + dismiss: { + paddingHorizontal: 12, + justifyContent: 'center', + borderRadius: 10, + backgroundColor: COLORS.PURPLE, + }, + dismissText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 12, + }, + panelRow: { + flexDirection: 'row', + gap: 10, + height: 200, + }, + panel: { + flex: 1, + borderWidth: 2, + borderRadius: 10, + overflow: 'hidden', + backgroundColor: '#ffffff', + }, + panelHeader: { + paddingHorizontal: 10, + paddingVertical: 6, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + panelTitle: { + color: '#ffffff', + fontSize: 13, + fontWeight: '700', + }, + panelBody: { + padding: 10, + gap: 10, + }, + input: { + borderWidth: 1, + borderColor: COLORS.GRAY, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + fontSize: 14, + backgroundColor: '#ffffff', + }, + button: { + borderRadius: 8, + paddingVertical: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 14, + }, + feedbackArea: { + alignItems: 'center', + minHeight: 30, + }, +}); diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 3d05214f5d..af0372c8fd 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -364,6 +364,17 @@ const Pressable = (props: PressableProps) => { [onLayout] ); + // Claim the JS responder in the bubble phase so an ancestor RN ScrollView + // with keyboardShouldPersistTaps='handled' or 'always' cannot run its + // release-time TextInput blur. The GH tap still fires via the native + // recognizer; when it does, UIKit cancels touches. + const handleStartShouldSetResponder = useCallback(() => { + if (__DEV__) { + console.log('[GH Pressable] onStartShouldSetResponder -> true'); + } + return true; + }, []); + return ( { rippleColor={rippleColor} rippleRadius={android_ripple?.radius ?? undefined} style={[pointerStyle, styleProp]} + onStartShouldSetResponder={handleStartShouldSetResponder} testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} From abd43f194995496cb6da77f3f233c150df3e8713 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 24 Apr 2026 10:40:44 +0200 Subject: [PATCH 02/16] move onStartShouldSetResponder --- .../src/v3/components/Pressable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index af0372c8fd..c77d7d5165 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -378,6 +378,7 @@ const Pressable = (props: PressableProps) => { return ( { rippleColor={rippleColor} rippleRadius={android_ripple?.radius ?? undefined} style={[pointerStyle, styleProp]} - onStartShouldSetResponder={handleStartShouldSetResponder} testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} From 6ed0aa7c924a7a0311e850a6b52aa8c9a0e9d356 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Thu, 7 May 2026 16:54:34 +0200 Subject: [PATCH 03/16] add to NativeDetector --- .../src/v3/detectors/NativeDetector.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 37614db708..781c901f91 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import HostGestureDetector from './HostGestureDetector'; import { configureRelations, ensureNativeDetectorComponent } from './utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; @@ -9,6 +9,7 @@ import { } from './common'; import { ReanimatedNativeDetector } from './ReanimatedNativeDetector'; import { Platform } from 'react-native'; +import { SingleGestureName } from '../types'; export function NativeDetector< TConfig, @@ -57,8 +58,20 @@ export function NativeDetector< gesture.detectorCallbacks.reanimatedEventHandler, }; + const isTapGesture = + !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; + + const handleStartShouldSetResponder = useCallback(() => { + if (__DEV__ && isTapGesture) { + console.log('[GH NativeDetector] onStartShouldSetResponder -> true'); + } + + return isTapGesture; + }, [isTapGesture]); + return ( Date: Fri, 8 May 2026 10:53:55 +0200 Subject: [PATCH 04/16] better example --- .../tests/keyboardShouldPersistTaps/index.tsx | 135 ++++++++++++++++-- 1 file changed, 120 insertions(+), 15 deletions(-) diff --git a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx index 2add0393d0..9fa8e0c1d5 100644 --- a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx +++ b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx @@ -1,25 +1,34 @@ import React, { useRef, useState } from 'react'; - import { + Keyboard, Pressable as RNPressable, ScrollView as RNScrollView, - TextInput as RNTextInput, - Keyboard, StyleSheet, Text, + TextInput as RNTextInput, View, } from 'react-native'; import { + GestureDetector, Pressable as RNGHPressable, ScrollView as RNGHScrollView, TextInput as RNGHTextInput, + useTapGesture, } from 'react-native-gesture-handler'; -import { COLORS, Feedback, FeedbackHandle, InfoSection } from '../../../common'; +import type { FeedbackHandle } from '../../../common'; +import { COLORS, Feedback, InfoSection } from '../../../common'; type Mode = 'never' | 'handled' | 'always'; +type Example = 'pressable' | 'tap'; const MODES: Mode[] = ['never', 'handled', 'always']; +const EXAMPLES: Example[] = ['pressable', 'tap']; + +const EXAMPLE_LABELS: Record = { + pressable: 'GH Pressable', + tap: 'useTapGesture', +}; const MODE_DESCRIPTIONS: Record = { never: @@ -32,6 +41,7 @@ const MODE_DESCRIPTIONS: Record = { export default function KeyboardShouldPersistTapsExample() { const [mode, setMode] = useState('handled'); + const [example, setExample] = useState('pressable'); const feedbackRef = useRef(null); const report = (message: string) => { @@ -47,6 +57,8 @@ export default function KeyboardShouldPersistTapsExample() { + + @@ -82,16 +94,22 @@ export default function KeyboardShouldPersistTapsExample() { placeholder="GH input" placeholderTextColor={COLORS.GRAY} /> - [ - styles.button, - { - backgroundColor: pressed ? COLORS.KINDA_GREEN : COLORS.GREEN, - }, - ]} - onPress={() => report('GH Pressable onPress')}> - Press me - + {example === 'pressable' ? ( + [ + styles.button, + { + backgroundColor: pressed ? COLORS.KINDA_GREEN : COLORS.GREEN, + }, + ]} + onPress={() => report('GH Pressable onPress')}> + Press me + + ) : ( + report('useTapGesture onActivate')} + /> + )} @@ -104,6 +122,69 @@ export default function KeyboardShouldPersistTapsExample() { ); } +type ExampleSelectorProps = { + value: Example; + onChange: (next: Example) => void; +}; + +function ExampleSelector({ value, onChange }: ExampleSelectorProps) { + return ( + + {EXAMPLES.map((example) => { + const active = example === value; + return ( + onChange(example)} + style={[styles.exampleTab, active && styles.exampleTabActive]}> + + {EXAMPLE_LABELS[example]} + + + ); + })} + + ); +} + +type GestureTapButtonProps = { + onTap: () => void; +}; + +function GestureTapButton({ onTap }: GestureTapButtonProps) { + const [pressed, setPressed] = useState(false); + + const tap = useTapGesture({ + disableReanimated: true, + onBegin: () => { + setPressed(true); + }, + onActivate: onTap, + onFinalize: () => { + setPressed(false); + }, + }); + + return ( + + + Tap me + + + ); +} + type ModeSelectorProps = { value: Mode; onChange: (next: Mode) => void; @@ -210,6 +291,30 @@ const styles = StyleSheet.create({ fontWeight: '700', fontSize: 12, }, + exampleRow: { + flexDirection: 'row', + gap: 8, + }, + exampleTab: { + flex: 1, + paddingVertical: 10, + borderRadius: 10, + borderWidth: 2, + borderColor: COLORS.DARK_GREEN, + alignItems: 'center', + backgroundColor: 'transparent', + }, + exampleTabActive: { + backgroundColor: COLORS.DARK_GREEN, + }, + exampleTabLabel: { + color: COLORS.DARK_GREEN, + fontWeight: '700', + fontSize: 14, + }, + exampleTabLabelActive: { + color: '#ffffff', + }, panelRow: { flexDirection: 'row', gap: 10, From 094607c0a97439480d000549ffc074a92834b007 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 8 May 2026 13:22:15 +0200 Subject: [PATCH 05/16] check if disabled --- .../src/v3/components/Pressable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 0a9d21ae41..b8bf3ecf1f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -373,8 +373,8 @@ const Pressable = (props: PressableProps) => { if (__DEV__) { console.log('[GH Pressable] onStartShouldSetResponder -> true'); } - return true; - }, []); + return !disabled; + }, [disabled]); return ( From d1e77957967a0e9648f88064ddcd1edbbc58179c Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 8 May 2026 13:24:51 +0200 Subject: [PATCH 06/16] remove logs --- .../src/v3/components/Pressable.tsx | 3 --- .../src/v3/detectors/NativeDetector.tsx | 4 ---- 2 files changed, 7 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index b8bf3ecf1f..2a6e37f199 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -370,9 +370,6 @@ const Pressable = (props: PressableProps) => { // release-time TextInput blur. The GH tap still fires via the native // recognizer; when it does, UIKit cancels touches. const handleStartShouldSetResponder = useCallback(() => { - if (__DEV__) { - console.log('[GH Pressable] onStartShouldSetResponder -> true'); - } return !disabled; }, [disabled]); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 098998a795..ecdd539109 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -60,10 +60,6 @@ export function NativeDetector< !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; const handleStartShouldSetResponder = useCallback(() => { - if (__DEV__ && isTapGesture) { - console.log('[GH NativeDetector] onStartShouldSetResponder -> true'); - } - return isTapGesture; }, [isTapGesture]); From fdbd840f30214c2508bdce2d799587e41c25e6de Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 8 May 2026 13:44:28 +0200 Subject: [PATCH 07/16] check if gesture is enabled --- .../src/v3/detectors/NativeDetector.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index ecdd539109..00ed24244b 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; +import { maybeUnpackValue } from '../hooks/utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; +import type { SingleGesture } from '../types'; import { SingleGestureName } from '../types'; import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; @@ -9,6 +11,14 @@ import HostGestureDetector from './HostGestureDetector'; import { ReanimatedNativeDetector } from './ReanimatedNativeDetector'; import { configureRelations, ensureNativeDetectorComponent } from './utils'; +function isGestureEnabled< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>(gesture: SingleGesture) { + return maybeUnpackValue(gesture.config.enabled) !== false; +} + export function NativeDetector< TConfig, THandlerData, @@ -56,12 +66,14 @@ export function NativeDetector< gesture.detectorCallbacks.reanimatedEventHandler, }; - const isTapGesture = - !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; - const handleStartShouldSetResponder = useCallback(() => { - return isTapGesture; - }, [isTapGesture]); + const isTapGesture = + !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; + + return ( + isTapGesture && !isComposedGesture(gesture) && isGestureEnabled(gesture) + ); + }, [gesture]); return ( Date: Fri, 8 May 2026 11:49:53 +0000 Subject: [PATCH 08/16] chore: polish responder notes and detector condition Agent-Logs-Url: https://github.com/software-mansion/react-native-gesture-handler/sessions/f3bb6a6a-3758-4ef9-a7c4-13748ce96de5 --- .../src/v3/components/Pressable.tsx | 2 +- .../src/v3/detectors/NativeDetector.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 2a6e37f199..74cac3beb2 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -368,7 +368,7 @@ const Pressable = (props: PressableProps) => { // Claim the JS responder in the bubble phase so an ancestor RN ScrollView // with keyboardShouldPersistTaps='handled' or 'always' cannot run its // release-time TextInput blur. The GH tap still fires via the native - // recognizer; when it does, UIKit cancels touches. + // recognizer when activated. const handleStartShouldSetResponder = useCallback(() => { return !disabled; }, [disabled]); diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 00ed24244b..d3ff7b45a6 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -70,9 +70,7 @@ export function NativeDetector< const isTapGesture = !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; - return ( - isTapGesture && !isComposedGesture(gesture) && isGestureEnabled(gesture) - ); + return isTapGesture && isGestureEnabled(gesture); }, [gesture]); return ( From 4b905a59bd39754b4cfb6023915c936e6e7322af Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 8 May 2026 14:46:33 +0200 Subject: [PATCH 09/16] register only if gesture is tap gesture --- .../src/v3/detectors/NativeDetector.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index d3ff7b45a6..e0f56b8384 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -66,16 +66,18 @@ export function NativeDetector< gesture.detectorCallbacks.reanimatedEventHandler, }; - const handleStartShouldSetResponder = useCallback(() => { - const isTapGesture = - !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; + const isTapGesture = + !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; - return isTapGesture && isGestureEnabled(gesture); + const handleStartShouldSetResponder = useCallback(() => { + return !isComposedGesture(gesture) && isGestureEnabled(gesture); }, [gesture]); return ( Date: Tue, 12 May 2026 16:05:01 +0200 Subject: [PATCH 10/16] Catch JSResponder event right before the ScrollView --- .../src/v3/JSResponderContext.ts | 8 +++ .../src/v3/components/GestureComponents.tsx | 66 ++++++++++++++++++- .../src/v3/components/Pressable.tsx | 22 +++++-- .../src/v3/detectors/NativeDetector.tsx | 20 +++++- 4 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/v3/JSResponderContext.ts diff --git a/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts b/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts new file mode 100644 index 0000000000..7b8f430589 --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts @@ -0,0 +1,8 @@ +import React from 'react'; + +export type JSResponderContextValue = { + isRNGHResponderEvent: React.MutableRefObject; +}; + +export const JSResponderContext = + React.createContext(null); diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index d0c01952a5..251cd5c713 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren, ReactElement } from 'react'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import type { FlatListProps as RNFlatListProps, RefreshControlProps as RNRefreshControlProps, @@ -11,8 +11,10 @@ import { FlatList as RNFlatList, RefreshControl as RNRefreshControl, ScrollView as RNScrollView, + StyleSheet, Switch as RNSwitch, TextInput as RNTextInput, + View, } from 'react-native'; import { ghQueueMicrotask } from '../../ghQueueMicrotask'; @@ -20,6 +22,7 @@ import createNativeWrapper from '../createNativeWrapper'; import { GestureDetectorType } from '../detectors'; import type { NativeGesture } from '../hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from '../hooks/utils'; +import { JSResponderContext } from '../JSResponderContext'; import type { NativeWrapperProperties } from '../types/NativeWrapperType'; export const RefreshControl = createNativeWrapper< @@ -49,12 +52,57 @@ const GHScrollView = createNativeWrapper< GestureDetectorType.Intercepting ); +type GHScrollViewResponderProps = PropsWithChildren<{ + keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; +}>; + +const GHScrollViewResponder = ({ + children, + keyboardShouldPersistTaps, +}: GHScrollViewResponderProps) => { + const isRNGHResponderEvent = useRef(false); + const contextValue = useMemo( + () => ({ isRNGHResponderEvent }), + [isRNGHResponderEvent] + ); + + const resetRNGHResponderEvent = useCallback(() => { + isRNGHResponderEvent.current = false; + return false; + }, []); + + const handleStartShouldSetResponder = useCallback(() => { + const shouldHandleRNGHEvent = + keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; + + isRNGHResponderEvent.current = false; + + return shouldHandleRNGHEvent; + }, [keyboardShouldPersistTaps]); + + return ( + + + {children} + + + ); +}; + export const ScrollView = ( props: RNScrollViewProps & NativeWrapperProperties ) => { const { + children, refreshControl, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER, + horizontal, + keyboardShouldPersistTaps, ...rest } = props; @@ -75,6 +123,8 @@ export const ScrollView = ( + }> + + {children} + + ); }; // eslint-disable-next-line @typescript-eslint/no-redeclare export type ScrollView = typeof ScrollView & RNScrollView; +const styles = StyleSheet.create({ + logicalResponder: { + display: 'contents', + }, +}); + export const Switch = createNativeWrapper(RNSwitch, { shouldCancelWhenOutside: false, shouldActivateOnStart: true, diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 74cac3beb2..4904c3d3d5 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -1,4 +1,5 @@ import React, { + use, useCallback, useEffect, useMemo, @@ -40,6 +41,7 @@ import { useNativeGesture, useSimultaneousGestures, } from '../hooks'; +import { JSResponderContext } from '../JSResponderContext'; import { PureNativeButton } from './GestureButtons'; const DEFAULT_LONG_PRESS_DURATION = 500; @@ -78,6 +80,7 @@ const Pressable = (props: PressableProps) => { const longPressTimeoutRef = useRef(null); const pressDelayTimeoutRef = useRef(null); const isOnPressAllowed = useRef(true); + const jsResponderContext = use(JSResponderContext); const isCurrentlyPressed = useRef(false); const dimensions = useRef({ width: 0, @@ -365,13 +368,20 @@ const Pressable = (props: PressableProps) => { [onLayout] ); - // Claim the JS responder in the bubble phase so an ancestor RN ScrollView - // with keyboardShouldPersistTaps='handled' or 'always' cannot run its - // release-time TextInput blur. The GH tap still fires via the native - // recognizer when activated. + // Let RN components higher in the tree handle JS responder negotiation. + // RNGH ScrollView uses this marker to preserve keyboardShouldPersistTaps='handled' + // when there are no RN responder components between it and this Pressable. const handleStartShouldSetResponder = useCallback(() => { - return !disabled; - }, [disabled]); + if (!disabled) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [disabled, jsResponderContext]); return ( diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index e0f56b8384..cdc45f43db 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,8 +1,9 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { use, useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; import { maybeUnpackValue } from '../hooks/utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; +import { JSResponderContext } from '../JSResponderContext'; import type { SingleGesture } from '../types'; import { SingleGestureName } from '../types'; import type { NativeDetectorProps } from './common'; @@ -30,6 +31,8 @@ export function NativeDetector< userSelect, enableContextMenu, }: NativeDetectorProps) { + const jsResponderContext = use(JSResponderContext); + const NativeDetectorComponent = gesture.config.dispatchesAnimatedEvents ? AnimatedNativeDetector : gesture.config.shouldUseReanimatedDetector @@ -70,8 +73,19 @@ export function NativeDetector< !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; const handleStartShouldSetResponder = useCallback(() => { - return !isComposedGesture(gesture) && isGestureEnabled(gesture); - }, [gesture]); + const shouldHandleResponderEvent = + !isComposedGesture(gesture) && isGestureEnabled(gesture); + + if (shouldHandleResponderEvent) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [gesture, jsResponderContext]); return ( Date: Tue, 12 May 2026 17:03:45 +0200 Subject: [PATCH 11/16] tests --- .../src/__tests__/api_v3.test.tsx | 114 +++++++++++++++++- 1 file changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index aba80c8a8f..27a4bfa59c 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -1,13 +1,20 @@ import { render, renderHook } from '@testing-library/react-native'; import { act } from 'react'; +import { View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; -import { RectButton, Touchable } from '../v3/components'; -import { usePanGesture } from '../v3/hooks/gestures'; +import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; +import { GestureDetector } from '../v3/detectors'; +import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; import type { SingleGesture } from '../v3/types'; +const flushImmediate = () => + new Promise((resolve) => { + setImmediate(() => resolve(undefined)); + }); + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -34,6 +41,35 @@ describe('[API v3] Hooks', () => { }); describe('[API v3] Components', () => { + const getScrollViewResponder = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + return views(View).find( + ({ props }) => + props.collapsable === false && + props.onStartShouldSetResponderCapture && + props.onStartShouldSetResponder + ); + }; + + const getNativeDetector = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + return views(View).find( + ({ props }) => props.handlerTags && props.onStartShouldSetResponder + ); + }; + + const TapGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + test('Rect Button', () => { const pressFn = jest.fn(); @@ -60,6 +96,80 @@ describe('[API v3] Components', () => { expect(pressFn).toHaveBeenCalledTimes(1); }); + describe('ScrollView', () => { + test('handles responder event passed through Pressable for keyboardShouldPersistTaps handled', async () => { + const { getByTestId, UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const pressable = getByTestId('pressable'); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('does not handle responder event passed through Pressable without keyboardShouldPersistTaps handled', async () => { + const { getByTestId, UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const pressable = getByTestId('pressable'); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + }); + describe('Touchable', () => { test('calls onPress on successful press', () => { const pressFn = jest.fn(); From 5d8f5933e02e4cc0a54e250461eff08fca274a73 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 12:24:03 +0200 Subject: [PATCH 12/16] fix lint --- .../src/v3/components/GestureComponents.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index 251cd5c713..54fd80d698 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -81,7 +81,7 @@ const GHScrollViewResponder = ({ }, [keyboardShouldPersistTaps]); return ( - + {children} - + ); }; From df0a998668d0b094b0d4c5fff4eab690498b7f8f Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 12:41:27 +0200 Subject: [PATCH 13/16] change name and add comment --- .../src/v3/components/GestureComponents.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index 54fd80d698..24fc96d214 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -52,14 +52,14 @@ const GHScrollView = createNativeWrapper< GestureDetectorType.Intercepting ); -type GHScrollViewResponderProps = PropsWithChildren<{ +type GHScrollViewResponderInterceptorProps = PropsWithChildren<{ keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; }>; -const GHScrollViewResponder = ({ +const GHScrollViewResponderInterceptor = ({ children, keyboardShouldPersistTaps, -}: GHScrollViewResponderProps) => { +}: GHScrollViewResponderInterceptorProps) => { const isRNGHResponderEvent = useRef(false); const contextValue = useMemo( () => ({ isRNGHResponderEvent }), @@ -80,6 +80,12 @@ const GHScrollViewResponder = ({ return shouldHandleRNGHEvent; }, [keyboardShouldPersistTaps]); + // RNGH tap responders need to let RN components higher in the tree handle + // the JS responder event first. If no RN component claims it, this logical + // ScrollView child consumes the marked event before ScrollView's own + // keyboardShouldPersistTaps='handled' responder logic handles it. + // For more information check this comment: + // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 return ( - {children} - + ); }; From 93519a5f024e8a4aaf2ba6f270f7deee86f06199 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 13:27:19 +0200 Subject: [PATCH 14/16] support any gesture --- .../src/__tests__/api_v3.test.tsx | 106 ++++++++++++++++++ .../src/v3/detectors/NativeDetector.tsx | 26 ++--- 2 files changed, 119 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index 27a4bfa59c..f166f7befa 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -7,6 +7,7 @@ import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; import { GestureDetector } from '../v3/detectors'; +import { useSimultaneousGestures } from '../v3/hooks'; import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; import type { SingleGesture } from '../v3/types'; @@ -70,6 +71,40 @@ describe('[API v3] Components', () => { ); }; + const PanGestureDetectorExample = () => { + const pan = usePanGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const SimultaneousGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + const pan = usePanGesture({ disableReanimated: true }); + const simultaneous = useSimultaneousGestures(tap, pan); + + return ( + + + + ); + }; + + const DisabledSimultaneousGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true, enabled: false }); + const pan = usePanGesture({ disableReanimated: true, enabled: false }); + const simultaneous = useSimultaneousGestures(tap, pan); + + return ( + + + + ); + }; + test('Rect Button', () => { const pressFn = jest.fn(); @@ -168,6 +203,77 @@ describe('[API v3] Components', () => { expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); + + test('handles responder event passed through NativeDetector for non-tap gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('handles responder event passed through NativeDetector for composed gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through NativeDetector for disabled composed gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); }); describe('Touchable', () => { diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index cdc45f43db..7c32fedeeb 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -4,8 +4,7 @@ import { Platform } from 'react-native'; import { maybeUnpackValue } from '../hooks/utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; import { JSResponderContext } from '../JSResponderContext'; -import type { SingleGesture } from '../types'; -import { SingleGestureName } from '../types'; +import type { Gesture } from '../types'; import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; import HostGestureDetector from './HostGestureDetector'; @@ -16,7 +15,12 @@ function isGestureEnabled< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(gesture: SingleGesture) { +>(gesture: Gesture): boolean { + if (isComposedGesture(gesture)) { + // For composed gestures, we need to check if at least one of the composed gestures is enabled + return gesture.gestures.some(isGestureEnabled); + } + return maybeUnpackValue(gesture.config.enabled) !== false; } @@ -69,14 +73,12 @@ export function NativeDetector< gesture.detectorCallbacks.reanimatedEventHandler, }; - const isTapGesture = - !isComposedGesture(gesture) && gesture.type === SingleGestureName.Tap; + const shouldHandleJSResponderEvent = useCallback(() => { + return isGestureEnabled(gesture); + }, [gesture]); const handleStartShouldSetResponder = useCallback(() => { - const shouldHandleResponderEvent = - !isComposedGesture(gesture) && isGestureEnabled(gesture); - - if (shouldHandleResponderEvent) { + if (shouldHandleJSResponderEvent()) { const responderEventRef = jsResponderContext?.isRNGHResponderEvent; if (responderEventRef) { @@ -85,13 +87,11 @@ export function NativeDetector< } return false; - }, [gesture, jsResponderContext]); + }, [jsResponderContext, shouldHandleJSResponderEvent]); return ( Date: Thu, 14 May 2026 17:29:36 +0200 Subject: [PATCH 15/16] move into single file, rename --- .../src/v3/JSResponderContext.ts | 8 --- .../src/v3/components/GestureComponents.tsx | 64 ++---------------- .../src/v3/components/Pressable.tsx | 2 +- .../ScrollViewResponderInterceptor.tsx | 67 +++++++++++++++++++ .../src/v3/detectors/NativeDetector.tsx | 2 +- 5 files changed, 73 insertions(+), 70 deletions(-) delete mode 100644 packages/react-native-gesture-handler/src/v3/JSResponderContext.ts create mode 100644 packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx diff --git a/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts b/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts deleted file mode 100644 index 7b8f430589..0000000000 --- a/packages/react-native-gesture-handler/src/v3/JSResponderContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import React from 'react'; - -export type JSResponderContextValue = { - isRNGHResponderEvent: React.MutableRefObject; -}; - -export const JSResponderContext = - React.createContext(null); diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index 24fc96d214..e41a749dae 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren, ReactElement } from 'react'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useState } from 'react'; import type { FlatListProps as RNFlatListProps, RefreshControlProps as RNRefreshControlProps, @@ -11,10 +11,8 @@ import { FlatList as RNFlatList, RefreshControl as RNRefreshControl, ScrollView as RNScrollView, - StyleSheet, Switch as RNSwitch, TextInput as RNTextInput, - View, } from 'react-native'; import { ghQueueMicrotask } from '../../ghQueueMicrotask'; @@ -22,8 +20,8 @@ import createNativeWrapper from '../createNativeWrapper'; import { GestureDetectorType } from '../detectors'; import type { NativeGesture } from '../hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from '../hooks/utils'; -import { JSResponderContext } from '../JSResponderContext'; import type { NativeWrapperProperties } from '../types/NativeWrapperType'; +import ScrollViewResponderInterceptor from './ScrollViewResponderInterceptor'; export const RefreshControl = createNativeWrapper< RNRefreshControl, @@ -52,54 +50,6 @@ const GHScrollView = createNativeWrapper< GestureDetectorType.Intercepting ); -type GHScrollViewResponderInterceptorProps = PropsWithChildren<{ - keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; -}>; - -const GHScrollViewResponderInterceptor = ({ - children, - keyboardShouldPersistTaps, -}: GHScrollViewResponderInterceptorProps) => { - const isRNGHResponderEvent = useRef(false); - const contextValue = useMemo( - () => ({ isRNGHResponderEvent }), - [isRNGHResponderEvent] - ); - - const resetRNGHResponderEvent = useCallback(() => { - isRNGHResponderEvent.current = false; - return false; - }, []); - - const handleStartShouldSetResponder = useCallback(() => { - const shouldHandleRNGHEvent = - keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; - - isRNGHResponderEvent.current = false; - - return shouldHandleRNGHEvent; - }, [keyboardShouldPersistTaps]); - - // RNGH tap responders need to let RN components higher in the tree handle - // the JS responder event first. If no RN component claims it, this logical - // ScrollView child consumes the marked event before ScrollView's own - // keyboardShouldPersistTaps='handled' responder logic handles it. - // For more information check this comment: - // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 - return ( - - - {children} - - - ); -}; - export const ScrollView = ( props: RNScrollViewProps & NativeWrapperProperties ) => { @@ -142,10 +92,10 @@ export const ScrollView = ( ) : undefined }> - {children} - + ); }; @@ -153,12 +103,6 @@ export const ScrollView = ( // eslint-disable-next-line @typescript-eslint/no-redeclare export type ScrollView = typeof ScrollView & RNScrollView; -const styles = StyleSheet.create({ - logicalResponder: { - display: 'contents', - }, -}); - export const Switch = createNativeWrapper(RNSwitch, { shouldCancelWhenOutside: false, shouldActivateOnStart: true, diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index 4904c3d3d5..f475019f7c 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -41,8 +41,8 @@ import { useNativeGesture, useSimultaneousGestures, } from '../hooks'; -import { JSResponderContext } from '../JSResponderContext'; import { PureNativeButton } from './GestureButtons'; +import { JSResponderContext } from './ScrollViewResponderInterceptor'; const DEFAULT_LONG_PRESS_DURATION = 500; const IS_TEST_ENV = isTestEnv(); diff --git a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx new file mode 100644 index 0000000000..89498d78af --- /dev/null +++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx @@ -0,0 +1,67 @@ +import type { PropsWithChildren } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import type { ScrollViewProps as RNScrollViewProps } from 'react-native'; +import { StyleSheet, View } from 'react-native'; + +export type JSResponderContextValue = { + isRNGHResponderEvent: React.MutableRefObject; +}; + +export const JSResponderContext = + React.createContext(null); + +type ScrollViewResponderInterceptorProps = PropsWithChildren<{ + keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; +}>; + +const ScrollViewResponderInterceptor = ({ + children, + keyboardShouldPersistTaps, +}: ScrollViewResponderInterceptorProps) => { + const isRNGHResponderEvent = useRef(false); + const contextValue = useMemo( + () => ({ isRNGHResponderEvent }), + [isRNGHResponderEvent] + ); + + const resetRNGHResponderEvent = useCallback(() => { + isRNGHResponderEvent.current = false; + return false; + }, []); + + const handleStartShouldSetResponder = useCallback(() => { + const shouldHandleRNGHEvent = + keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; + + isRNGHResponderEvent.current = false; + + return shouldHandleRNGHEvent; + }, [keyboardShouldPersistTaps]); + + // RNGH tap responders need to let RN components higher in the tree handle + // the JS responder event first. If no RN component claims it, this logical + // ScrollView child consumes the marked event before ScrollView's own + // keyboardShouldPersistTaps='handled' responder logic handles it. + // For more information check this comment: + // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 + return ( + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + logicalResponder: { + display: 'contents', + }, +}); + +export default ScrollViewResponderInterceptor; diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 7c32fedeeb..7606ff2776 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,9 +1,9 @@ import React, { use, useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; +import { JSResponderContext } from '../components/ScrollViewResponderInterceptor'; import { maybeUnpackValue } from '../hooks/utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; -import { JSResponderContext } from '../JSResponderContext'; import type { Gesture } from '../types'; import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; From 186fb26461819cdcbed49d89898c6116d5427f82 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 15 May 2026 15:58:37 +0200 Subject: [PATCH 16/16] subscribe to handlers --- .../apple/RNGestureHandlerDetector.mm | 177 ++++++++++-------- .../apple/RNGestureHandlerModule.mm | 9 +- .../apple/RNGestureHandlerRegistry.h | 14 ++ .../apple/RNGestureHandlerRegistry.m | 60 ++++++ .../src/specs/NativeRNGestureHandlerModule.ts | 4 +- .../src/v3/NativeProxy.ts | 12 +- .../src/v3/hooks/useGesture.ts | 17 +- 7 files changed, 189 insertions(+), 104 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm index 8aef908a1a..a41bda0b04 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm @@ -16,8 +16,9 @@ @interface RNGestureHandlerDetector () @property (nonatomic, nonnull) NSMutableSet *nativeHandlers; +@property (nonatomic, nonnull) NSMutableSet *subscribedHandlers; @property (nonatomic, nonnull) NSMutableSet *attachedHandlers; -@property (nonatomic) std::unordered_map attachedVirtualHandlers; +@property (nonatomic) std::unordered_map subscribedVirtualHandlers; @end @@ -49,25 +50,23 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); - const auto &props = *std::static_pointer_cast(_props); + [handlerManager.registry cancelAllObservationsForOwner:self]; - for (const auto handler : props.handlerTags) { - NSNumber *handlerTag = [NSNumber numberWithInt:handler]; + for (NSNumber *handlerTag in _attachedHandlers) { [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; } - for (const auto &child : _attachedVirtualHandlers) { - for (id handlerTag : child.second) { - [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; - } - } - _attachedVirtualHandlers.clear(); - _attachedHandlers = [NSMutableSet set]; + + [_attachedHandlers removeAllObjects]; + _subscribedVirtualHandlers.clear(); + [_subscribedHandlers removeAllObjects]; + [_nativeHandlers removeAllObjects]; } else { const auto &props = *std::static_pointer_cast(_props); [self attachHandlers:props.handlerTags - actionType:RNGestureHandlerActionTypeNativeDetector - viewTag:-1 - attachedHandlers:_attachedHandlers]; + actionType:RNGestureHandlerActionTypeNativeDetector + viewTag:-1 + subscribedHandlers:_subscribedHandlers]; + [self updateVirtualChildren:props.virtualChildren]; } } @@ -77,6 +76,7 @@ - (void)setDefaultProps _props = defaultProps; _moduleId = -1; _nativeHandlers = [NSMutableSet set]; + _subscribedHandlers = [NSMutableSet set]; _attachedHandlers = [NSMutableSet set]; } @@ -140,13 +140,6 @@ - (void)dispatchReanimatedTouchEvent:(RNGestureHandlerDetectorEventEmitter::OnGe } } -- (BOOL)shouldAttachGestureToSubview:(NSNumber *)handlerTag -{ - RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; - - return [[[handlerManager registry] handlerWithTag:handlerTag] wantsToAttachDirectlyToView]; -} - - (void)prepareForRecycle { [super prepareForRecycle]; @@ -194,62 +187,85 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics - (void)attachHandlers:(const std::vector &)handlerTags actionType:(RNGestureHandlerActionType)actionType viewTag:(const int)viewTag - attachedHandlers:(NSMutableSet *)attachedHandlers + subscribedHandlers:(NSMutableSet *)subscribedHandlers { RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); - NSMutableSet *handlersToDetach = [attachedHandlers mutableCopy]; + NSMutableSet *handlersToDetach = [subscribedHandlers mutableCopy]; + + __weak __typeof(self) weakSelf = self; + const int capturedViewTag = viewTag; + const RNGestureHandlerActionType capturedActionType = actionType; for (const int tag : handlerTags) { [handlersToDetach removeObject:@(tag)]; - if (![attachedHandlers containsObject:@(tag)]) { - if ([self shouldAttachGestureToSubview:@(tag)] && actionType == RNGestureHandlerActionTypeNativeDetector) { - // It might happen that `attachHandlers` will be called before children are added into view hierarchy. In that - // case we cannot attach `NativeViewGestureHandlers` here and we have to do it in `didAddSubview` method. - react_native_assert( - self.subviews.count <= 1 && - "Cannot attach native gesture handlers when the detector has multiple children"); - [_nativeHandlers addObject:@(tag)]; - } else { - if (actionType == RNGestureHandlerActionTypeVirtualDetector) { - RNGHUIView *targetView = [handlerManager viewForReactTag:@(viewTag)]; - - if (targetView != nil) { - [handlerManager attachGestureHandler:@(tag) - toViewWithTag:@(viewTag) - withActionType:actionType - withHostDetector:self]; - } else { - // Let's assume that if the native view for the virtual detector hasn't been found, the hierarchy was folded - // into a single UIView. - [handlerManager.registry attachHandlerWithTag:@(tag) - toView:self - withActionType:actionType - withHostDetector:self]; - [[handlerManager registry] handlerWithTag:@(tag)].virtualViewTag = @(viewTag); - } - } else { - [handlerManager.registry attachHandlerWithTag:@(tag) - toView:self - withActionType:actionType - withHostDetector:self]; - } - [attachedHandlers addObject:@(tag)]; - } + if ([subscribedHandlers containsObject:@(tag)]) { + continue; } + [handlerManager.registry observeHandlerWithTag:@(tag) + owner:self + usingBlock:^(RNGestureHandler *handler) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + [strongSelf attachReadyHandler:handler + actionType:capturedActionType + viewTag:capturedViewTag]; + }]; + [subscribedHandlers addObject:@(tag)]; } for (const id tag : handlersToDetach) { - [handlerManager.registry detachHandlerWithTag:tag fromHostDetector:self]; - [attachedHandlers removeObject:tag]; + [handlerManager.registry cancelObservationForTag:tag owner:self]; + if ([_attachedHandlers containsObject:tag]) { + [handlerManager.registry detachHandlerWithTag:tag fromHostDetector:self]; + [_attachedHandlers removeObject:tag]; + } + [subscribedHandlers removeObject:tag]; [_nativeHandlers removeObject:tag]; } +} + +// Invoked from the registry's `observeHandlerWithTag:` block once the handler is known to exist. +// Branches on handler kind + actionType to pick the right binding flow. May be called multiple +// times for the same tag (handler re-registration), so each branch must be idempotent. +- (void)attachReadyHandler:(RNGestureHandler *)handler + actionType:(RNGestureHandlerActionType)actionType + viewTag:(int)viewTag +{ + RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + react_native_assert(manager != nullptr && "Tried to access a non-existent handler manager"); - // This covers the case where `NativeViewGestureHandlers` are attached after child views were created. - if (self.subviews.count != 0) { - [self tryAttachNativeHandlersToChildView]; + if ([handler wantsToAttachDirectlyToView] && actionType == RNGestureHandlerActionTypeNativeDetector) { + react_native_assert( + self.subviews.count <= 1 && "Cannot attach native gesture handlers when the detector has multiple children"); + [_nativeHandlers addObject:handler.tag]; + if (self.subviews.count != 0) { + [self tryAttachNativeHandlersToChildView]; + } + return; } + + if (actionType == RNGestureHandlerActionTypeVirtualDetector) { + RNGHUIView *targetView = [manager viewForReactTag:@(viewTag)]; + if (targetView != nil) { + [manager attachGestureHandler:handler.tag + toViewWithTag:@(viewTag) + withActionType:actionType + withHostDetector:self]; + } else { + // Hierarchy was folded into a single UIView. + [manager.registry attachHandlerWithTag:handler.tag toView:self withActionType:actionType withHostDetector:self]; + handler.virtualViewTag = @(viewTag); + } + [_attachedHandlers addObject:handler.tag]; + return; + } + + [manager.registry attachHandlerWithTag:handler.tag toView:self withActionType:actionType withHostDetector:self]; + [_attachedHandlers addObject:handler.tag]; } - (void)updateProps:(const Props::Shared &)propsBase oldProps:(const Props::Shared &)oldPropsBase @@ -258,9 +274,9 @@ - (void)updateProps:(const Props::Shared &)propsBase oldProps:(const Props::Shar _moduleId = newProps.moduleId; [self attachHandlers:newProps.handlerTags - actionType:RNGestureHandlerActionTypeNativeDetector - viewTag:-1 - attachedHandlers:_attachedHandlers]; + actionType:RNGestureHandlerActionTypeNativeDetector + viewTag:-1 + subscribedHandlers:_subscribedHandlers]; [super updateProps:propsBase oldProps:oldPropsBase]; [self updateVirtualChildren:newProps.virtualChildren]; @@ -275,7 +291,7 @@ - (void)updateVirtualChildren:(const std::vector *handlers; @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m index 0c52243e35..34c6e6aa48 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m @@ -12,12 +12,14 @@ @implementation RNGestureHandlerRegistry { NSMutableDictionary *_handlers; + NSMutableDictionary *> *_observers; } - (instancetype)init { if ((self = [super init])) { _handlers = [NSMutableDictionary new]; + _observers = [NSMutableDictionary new]; } return self; } @@ -36,8 +38,66 @@ - (RNGestureHandler *)handlerWithTag:(NSNumber *)handlerTag - (void)registerGestureHandler:(RNGestureHandler *)gestureHandler { + NSArray *observers = nil; + @synchronized(_handlers) { _handlers[gestureHandler.tag] = gestureHandler; + + NSMapTable *table = _observers[gestureHandler.tag]; + if (table != nil) { + observers = [[table objectEnumerator] allObjects]; + } + } + + for (RNGestureHandlerReadyBlock block in observers) { + block(gestureHandler); + } +} + +- (void)observeHandlerWithTag:(NSNumber *)handlerTag owner:(id)owner usingBlock:(RNGestureHandlerReadyBlock)block +{ + RNGestureHandler *existing = nil; + + @synchronized(_handlers) { + NSMapTable *table = _observers[handlerTag]; + if (table == nil) { + table = + [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsWeakMemory | NSPointerFunctionsObjectPointerPersonality + valueOptions:NSPointerFunctionsStrongMemory]; + _observers[handlerTag] = table; + } + [table setObject:[block copy] forKey:owner]; + + existing = _handlers[handlerTag]; + } + + if (existing != nil) { + block(existing); + } +} + +- (void)cancelObservationForTag:(NSNumber *)handlerTag owner:(id)owner +{ + @synchronized(_handlers) { + NSMapTable *table = _observers[handlerTag]; + [table removeObjectForKey:owner]; + if (table.count == 0) { + [_observers removeObjectForKey:handlerTag]; + } + } +} + +- (void)cancelAllObservationsForOwner:(id)owner +{ + @synchronized(_handlers) { + NSArray *tags = [_observers allKeys]; + for (NSNumber *tag in tags) { + NSMapTable *table = _observers[tag]; + [table removeObjectForKey:owner]; + if (table.count == 0) { + [_observers removeObjectForKey:tag]; + } + } } } diff --git a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts index ebc021b447..71416a6165 100644 --- a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts +++ b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts @@ -3,15 +3,13 @@ import { TurboModuleRegistry } from 'react-native'; import type { Double } from 'react-native/Libraries/Types/CodegenTypes'; export interface Spec extends TurboModule { - // This method returns a boolean only to force the codegen to generate - // a synchronous method. The returned value doesn't have any meaning. createGestureHandler: ( handlerName: string, handlerTag: Double, // Record<> is not supported by codegen // eslint-disable-next-line @typescript-eslint/ban-types config: Object - ) => boolean; + ) => void; attachGestureHandler: ( handlerTag: Double, newView: Double, diff --git a/packages/react-native-gesture-handler/src/v3/NativeProxy.ts b/packages/react-native-gesture-handler/src/v3/NativeProxy.ts index ac135334cf..f34ba72c8f 100644 --- a/packages/react-native-gesture-handler/src/v3/NativeProxy.ts +++ b/packages/react-native-gesture-handler/src/v3/NativeProxy.ts @@ -16,11 +16,13 @@ export const NativeProxy = { handlerTag: number, config?: T ) => { - RNGestureHandlerModule.createGestureHandler( - handlerName, - handlerTag, - config || {} - ); + scheduleOperationToBeFlushed(() => { + RNGestureHandlerModule.createGestureHandler( + handlerName, + handlerTag, + config || {} + ); + }); }, setGestureHandlerConfig: < TConfig, diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts index 6548575b25..380161314a 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { getNextHandlerTag } from '../../handlers/getNextHandlerTag'; import { @@ -61,15 +61,6 @@ export function useGesture< [handlerTag, config.simultaneousWith, config.requireToFail, config.block] ); - const currentGestureRef = useRef({ type: '', handlerTag: -1 }); - if ( - currentGestureRef.current.handlerTag !== handlerTag || - currentGestureRef.current.type !== (type as string) - ) { - currentGestureRef.current = { type, handlerTag }; - NativeProxy.createGestureHandler(type, handlerTag, {}); - } - const gesture = useMemo( () => ({ handlerTag, @@ -94,11 +85,9 @@ export function useGesture< ); useEffect(() => { + NativeProxy.createGestureHandler(type, handlerTag, {}); + scheduleFlushOperations(); return () => { - if (currentGestureRef.current.handlerTag === handlerTag) { - currentGestureRef.current = { type: '', handlerTag: -1 }; - } - NativeProxy.dropGestureHandler(handlerTag); scheduleFlushOperations(); };