diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 95e6191211..e7bc1b6a79 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -33,6 +33,7 @@ import PanExample from './simple/pan'; import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; +import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; import NestedPressablesExample from './tests/nestedPressables'; import NestedRootViewExample from './tests/nestedRootView'; import NestedTouchablesExample from './tests/nestedTouchables'; @@ -132,6 +133,10 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ name: 'RN responder cancellation', component: RNResponderCancellationExample, }, + { + 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..9fa8e0c1d5 --- /dev/null +++ b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx @@ -0,0 +1,369 @@ +import React, { useRef, useState } from 'react'; +import { + Keyboard, + Pressable as RNPressable, + ScrollView as RNScrollView, + 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 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: + "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 [example, setExample] = useState('pressable'); + 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 + + + + + + {example === 'pressable' ? ( + [ + styles.button, + { + backgroundColor: pressed ? COLORS.KINDA_GREEN : COLORS.GREEN, + }, + ]} + onPress={() => report('GH Pressable onPress')}> + Press me + + ) : ( + report('useTapGesture onActivate')} + /> + )} + + + + + + + + + + ); +} + +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; +}; + +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, + }, + 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, + 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/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx b/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx index 787ca41035..e96abb6d6d 100644 --- a/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx +++ b/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx @@ -1,6 +1,7 @@ +import HomepageButton from '@site/src/components/HomepageButton'; import React from 'react'; + import styles from './styles.module.css'; -import HomepageButton from '@site/src/components/HomepageButton'; const StartScreen = () => { return ( 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..c7ce5958bf 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,21 @@ 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 { useSimultaneousGestures } from '../v3/hooks'; +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 +42,69 @@ 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 ( + + + + ); + }; + + 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(); @@ -60,6 +131,153 @@ 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); + }); + + test('does not handle responder event passed through NativeDetector for unsupported 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 + ); + }); + + 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', () => { test('calls onPress on successful press', () => { const pressFn = jest.fn(); diff --git a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx index 48c5f074da..e20766cea6 100644 --- a/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/mocks.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render } from '@testing-library/react-native'; -import React from 'react'; +import { render } from '@testing-library/react-native'; +import React, { act } from 'react'; import { Text } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; +import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { LegacyBaseButton, LegacyBorderlessButton, @@ -24,6 +25,7 @@ import { TouchableOpacity, TouchableWithoutFeedback, } from '../mocks/Touchables'; +import { State } from '../State'; import { Touchable } from '../v3/components'; describe('Jest mocks – legacy components render without crashing', () => { @@ -116,17 +118,25 @@ describe('Jest mocks – legacy components render without crashing', () => { }); }); -test('Trigger press by text', () => { +test('Trigger Touchable press', () => { const onPress = jest.fn(); - const { getByText } = render( + render( - + Press Me ); - fireEvent.press(getByText('Press Me')); + const gesture = getByGestureTestId('touchable'); + + act(() => { + fireGestureHandler(gesture, [ + { oldState: State.UNDETERMINED, state: State.BEGAN }, + { oldState: State.BEGAN, state: State.ACTIVE }, + { oldState: State.ACTIVE, state: State.END }, + ]); + }); expect(onPress).toHaveBeenCalled(); }); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts index 5708870405..f377329d49 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts @@ -74,6 +74,9 @@ let Reanimated: useComposedEventHandler( handlers: (((event: T) => void) | null)[] ): (event: T) => void; + runOnJS( + fn: (...args: A) => R + ): (...args: Parameters) => void; runOnUI( fn: (...args: A) => R ): (...args: Parameters) => void; 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..e41a749dae 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -21,6 +21,7 @@ import { GestureDetectorType } from '../detectors'; import type { NativeGesture } from '../hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from '../hooks/utils'; import type { NativeWrapperProperties } from '../types/NativeWrapperType'; +import ScrollViewResponderInterceptor from './ScrollViewResponderInterceptor'; export const RefreshControl = createNativeWrapper< RNRefreshControl, @@ -53,8 +54,11 @@ export const ScrollView = ( props: RNScrollViewProps & NativeWrapperProperties ) => { const { + children, refreshControl, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER, + horizontal, + keyboardShouldPersistTaps, ...rest } = props; @@ -75,6 +79,8 @@ export const ScrollView = ( + }> + + {children} + + ); }; 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 e1ccae5b0f..f475019f7c 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, @@ -41,6 +42,7 @@ import { useSimultaneousGestures, } from '../hooks'; import { PureNativeButton } from './GestureButtons'; +import { JSResponderContext } from './ScrollViewResponderInterceptor'; const DEFAULT_LONG_PRESS_DURATION = 500; const IS_TEST_ENV = isTestEnv(); @@ -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,9 +368,25 @@ const Pressable = (props: PressableProps) => { [onLayout] ); + // 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(() => { + if (!disabled) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [disabled, jsResponderContext]); + return ( ; +}; + +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 9a3220ddfd..7ce6113c8b 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import { Platform } from 'react-native'; +import { useJSResponderHandler } from '../hooks/useJSResponderHandler'; import { isComposedGesture } from '../hooks/utils/relationUtils'; import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; @@ -19,6 +20,8 @@ export function NativeDetector< userSelect, enableContextMenu, }: NativeDetectorProps) { + const { handleStartShouldSetResponder } = useJSResponderHandler(gesture); + const NativeDetectorComponent = gesture.config.dispatchesAnimatedEvents ? AnimatedNativeDetector : gesture.config.shouldUseReanimatedDetector @@ -57,6 +60,7 @@ export function NativeDetector< return ( (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; +} + +function isSupportedGesture< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>(gesture: Gesture): boolean { + if (isComposedGesture(gesture)) { + return gesture.gestures.some(isSupportedGesture); + } + + switch (gesture.type) { + case SingleGestureName.Tap: + case SingleGestureName.LongPress: + case SingleGestureName.Fling: + case SingleGestureName.Native: + case SingleGestureName.Hover: + return true; + default: + return false; + } +} + +function getEnabledSharedValues< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>( + gesture: Gesture +): SharedValue[] { + if (Reanimated === undefined) { + return []; + } + + if (isComposedGesture(gesture)) { + return gesture.gestures.flatMap(getEnabledSharedValues); + } + + const enabled = gesture.config.enabled; + return Reanimated.isSharedValue(enabled) ? [enabled] : []; +} + +export function useJSResponderHandler< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>(gesture: Gesture) { + const jsResponderContext = use(JSResponderContext); + const [enabledSharedValueRevision, setEnabledSharedValueRevision] = + useState(0); + const listenerIdRef = useRef(null); + + if (listenerIdRef.current === null) { + listenerIdRef.current = nextJSResponderContextListenerId++; + } + + useEffect(() => { + const reanimated = Reanimated; + const enabledSharedValues = getEnabledSharedValues(gesture); + + if (reanimated === undefined || enabledSharedValues.length === 0) { + return; + } + + const listenerId = listenerIdRef.current; + if (listenerId === null) { + return; + } + + const notifyEnabledChanged = reanimated.runOnJS(() => { + setEnabledSharedValueRevision((revision) => revision + 1); + }); + + const attachListeners = ( + sharedValues: SharedValue[], + id: number, + listener: () => void + ) => { + 'worklet'; + for (const sharedValue of sharedValues) { + sharedValue.addListener(id, listener); + } + }; + + const detachListeners = ( + sharedValues: SharedValue[], + id: number + ) => { + 'worklet'; + for (const sharedValue of sharedValues) { + sharedValue.removeListener(id); + } + }; + + reanimated.runOnUI(attachListeners)( + enabledSharedValues, + listenerId, + notifyEnabledChanged + ); + + return () => { + reanimated.runOnUI(detachListeners)(enabledSharedValues, listenerId); + }; + }, [gesture]); + + const shouldHandleJSResponderEvent = useCallback(() => { + void enabledSharedValueRevision; + return isGestureEnabled(gesture) && isSupportedGesture(gesture); + }, [enabledSharedValueRevision, gesture]); + + const handleStartShouldSetResponder = useCallback(() => { + if (shouldHandleJSResponderEvent()) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [jsResponderContext, shouldHandleJSResponderEvent]); + + return { + handleStartShouldSetResponder: + jsResponderContext == null ? () => false : handleStartShouldSetResponder, + }; +}