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 508b2fc194..cf9a3b8d24 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 @@ -10,11 +10,22 @@ import { Keyboard, TextInput, View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; -import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; -import { GestureDetector } from '../v3/detectors'; +import { + Pressable, + RectButton, + RefreshControl, + ScrollView, + Touchable, +} from '../v3/components'; +import { + GestureDetector, + InterceptingGestureDetector, + VirtualGestureDetector, +} from '../v3/detectors'; import { useSimultaneousGestures } from '../v3/hooks'; import { useLongPressGesture, + useNativeGesture, usePanGesture, useTapGesture, } from '../v3/hooks/gestures'; @@ -189,14 +200,24 @@ describe('[API v3] Components', () => { ); }; - const getNativeDetector = ( + // Detector host views in document order (outer first). + const getNativeDetectors = ( views: ReturnType['UNSAFE_getAllByType'] ) => { - return views(View).find( + return views(View).filter( ({ props }) => props.handlerTags && props.onStartShouldSetResponder ); }; + // The innermost detector: the GH ScrollView is a detector too, and it is + // rendered above the example's one. + const getNativeDetector = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + const detectors = getNativeDetectors(views); + return detectors[detectors.length - 1]; + }; + const TapGestureDetectorExample = () => { const tap = useTapGesture({ disableReanimated: true }); @@ -217,6 +238,56 @@ describe('[API v3] Components', () => { ); }; + const VirtualTapDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const VirtualPanDetectorExample = () => { + const pan = usePanGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const VirtualNativeDetectorExample = () => { + const native = useNativeGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const InterceptingTapDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const InterceptingNativeDetectorExample = () => { + const native = useNativeGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + const SimultaneousGestureDetectorExample = () => { const tap = useTapGesture({ disableReanimated: true }); const pan = usePanGesture({ disableReanimated: true }); @@ -370,6 +441,149 @@ describe('[API v3] Components', () => { expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); }); + test('handles responder event passed through VirtualDetector for supported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('handles responder event passed through VirtualDetector for native gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through VirtualDetector for unsupported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const virtualChild = screen.getByTestId('virtual-child'); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(virtualChild.props.onStartShouldSetResponderCapture()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('handles responder event passed through InterceptingGestureDetector for supported gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const detectors = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + // The GH ScrollView is the outer intercepting detector, the example is the inner one. + const interceptingDetector = detectors[detectors.length - 1]; + + expect(interceptingDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through InterceptingGestureDetector for native gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const detectors = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + const interceptingDetector = detectors[detectors.length - 1]; + + expect(interceptingDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(interceptingDetector?.props.onStartShouldSetResponder()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('does not handle responder event passed through the native gesture of ScrollView itself', async () => { + const { UNSAFE_getAllByType } = render( + + + + ); + + await act(flushImmediate); + + const [scrollViewDetector] = getNativeDetectors(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect(scrollViewDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + test('does not handle responder event passed through NativeDetector for unsupported gestures', async () => { const { UNSAFE_getAllByType } = render( @@ -489,6 +703,43 @@ describe('[API v3] Components', () => { false ); }); + + test('does not handle responder event passed through RefreshControl', async () => { + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + const { UNSAFE_root, UNSAFE_getAllByType } = render( + + } + /> + + ); + + await act(flushImmediate); + + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + // The native mock drops the props, so look at the element RN renders + const refreshControl = UNSAFE_root.find( + ({ props }) => + props.refreshing !== undefined && + props.onStartShouldSetResponderCapture + ); + + expect(scrollViewResponder).toBeDefined(); + expect(refreshControl.props.onStartShouldSetResponderCapture()).toBe( + false + ); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + // The opt-out is an internal config prop, it must not reach the native side + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('countNativeAsHandled') + ); + warn.mockRestore(); + }); }); describe('keyboardShouldPersistTaps="never" drop', () => { 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 bce5014531..1193a77d45 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -35,7 +35,9 @@ export const RefreshControl: React.ComponentType< disallowInterruption: true, shouldCancelWhenOutside: false, }, - GestureDetectorType.Virtual + GestureDetectorType.Virtual, + // On Android it is the parent of the whole list, so it must not mark taps + false ); // eslint-disable-next-line @typescript-eslint/no-redeclare diff --git a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx index 60ba4641be..d7da826ad9 100644 --- a/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx +++ b/packages/react-native-gesture-handler/src/v3/createNativeWrapper.tsx @@ -6,6 +6,7 @@ import { VirtualDetector } from './detectors/VirtualDetector/VirtualDetector'; import { useNativeGesture } from './hooks/gestures'; import type { NativeGestureConfig } from './hooks/gestures/native/NativeTypes'; import { NativeWrapperProps } from './hooks/utils'; +import type { InternalConfigProps } from './types'; import type { NativeWrapperProperties, WrapperSpecificProperties, @@ -16,7 +17,10 @@ export default function createNativeWrapper( config: Readonly< Omit, keyof WrapperSpecificProperties> > = {}, - detectorType: GestureDetectorType = GestureDetectorType.Native + detectorType: GestureDetectorType = GestureDetectorType.Native, + // Whether a tap on the wrapped component counts as handled, see + // `useJSResponderHandler` + countNativeAsHandled = true ) { const ComponentWrapper = ( props: TProps & NativeWrapperProperties @@ -51,6 +55,12 @@ export default function createNativeWrapper( // detector, whatever the caller passed. gestureHandlerProps.disableReanimated = true; + if (!countNativeAsHandled) { + ( + gestureHandlerProps as InternalConfigProps + ).countNativeAsHandled = false; + } + const native = useNativeGesture(gestureHandlerProps); useEffect(() => { diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx index ab58852eaa..eb8ecefa00 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/InterceptingGestureDetector.tsx @@ -8,6 +8,7 @@ import type { import { Reanimated } from '../../../handlers/gestures/reanimatedWrapper'; import { getModuleId } from '../../../moduleId'; import { tagMessage } from '../../../utils'; +import { useJSResponderHandler } from '../../hooks/useJSResponderHandler'; import { isComposedGesture } from '../../hooks/utils/relationUtils'; import type { DetectorCallbacks, @@ -55,6 +56,12 @@ export function InterceptingGestureDetector< TExtendedHandlerData >) { useEnsureGestureHandlerRootView(); + // A Native gesture here belongs to the container hosting virtual children + // (a scroller, a pager), not to a tap target + const { handleStartShouldSetResponder } = useJSResponderHandler( + gesture, + false + ); const [virtualChildren, setVirtualChildren] = useState>( () => new Set() @@ -299,6 +306,7 @@ export function InterceptingGestureDetector< : undefined } handlerTags={handlerTags} + onStartShouldSetResponder={handleStartShouldSetResponder} style={nativeDetectorStyles.detector} virtualChildren={strippedVirtualChildren} moduleId={getModuleId()}> diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx index 008ddef80d..b6628a3592 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/VirtualDetector.tsx @@ -10,6 +10,7 @@ import { import { findNodeHandle, Platform } from 'react-native'; import { tagMessage } from '../../../utils'; +import { useJSResponderHandler } from '../../hooks/useJSResponderHandler'; import { isComposedGesture } from '../../hooks/utils/relationUtils'; import type { DetectorCallbacks, VirtualChild } from '../../types'; import type { VirtualDetectorProps } from '../common'; @@ -56,6 +57,9 @@ export function VirtualDetector< const { register, unregister, setMode } = useRequiredInterceptingDetectorContext(); + const { handleStartShouldSetResponder } = useJSResponderHandler( + props.gesture + ); const viewRef = useRef(null); const [viewTag, setViewTag] = useState(-1); @@ -151,5 +155,11 @@ export function VirtualDetector< useGestureRelationsUpdater(props.gesture); - return {props.children}; + return ( + + {props.children} + + ); } diff --git a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx index 14d0313ee8..e80f252dff 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/VirtualDetector/Wrap.tsx @@ -1,5 +1,6 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; +import type { GestureResponderEvent } from 'react-native'; import type { WrapRef } from '../../../hostInstance'; import { assignRef, preferHostInstance } from '../../../hostInstance'; @@ -7,9 +8,16 @@ import { tagMessage } from '../../../utils'; export type { WrapRef }; -export type WrapProps = PropsWithChildren<{ ref?: WrapRef }>; +export type WrapProps = PropsWithChildren<{ + ref?: WrapRef; + onStartShouldSetResponderCapture?: (event: GestureResponderEvent) => boolean; +}>; -export const Wrap: React.FunctionComponent = ({ ref, children }) => { +export const Wrap: React.FunctionComponent = ({ + ref, + children, + onStartShouldSetResponderCapture, +}) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let child: any; try { @@ -53,9 +61,29 @@ export const Wrap: React.FunctionComponent = ({ ref, children }) => { [childRef, ref] ); + const childOnStartShouldSetResponderCapture = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + child.props.onStartShouldSetResponderCapture as + | ((event: GestureResponderEvent) => boolean) + | undefined; + + // Marks in the capture phase: RN Text replaces its own press handling with + // any passed `onStartShouldSetResponder`, which would kill its `onPress`. + const handleStartShouldSetResponderCapture = useCallback( + (event: GestureResponderEvent) => { + onStartShouldSetResponderCapture?.(event); + return childOnStartShouldSetResponderCapture?.(event) ?? false; + }, + [onStartShouldSetResponderCapture, childOnStartShouldSetResponderCapture] + ); + return React.cloneElement( child, - { collapsable: false, ref: attachRef }, + { + collapsable: false, + ref: attachRef, + onStartShouldSetResponderCapture: handleStartShouldSetResponderCapture, + }, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access child.props.children ); diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts index 204b2d1dc7..223bdeb4e7 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts @@ -6,7 +6,7 @@ import { updateResponderEventValue, } from '../scrollViewInterop'; import { type Gesture, type SharedValue, SingleGestureName } from '../types'; -import { isComposedGesture, isGestureEnabled } from './utils'; +import { isComposedGesture, maybeUnpackValue } from './utils'; import { getEnabledSharedValues, SHARED_VALUE_OFFSET, @@ -19,18 +19,30 @@ function isSupportedGesture< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(gesture: Gesture): boolean { +>( + gesture: Gesture, + countNativeAsHandled: boolean +): boolean { if (isComposedGesture(gesture)) { - return gesture.gestures.some(isSupportedGesture); + return gesture.gestures.some((child) => + isSupportedGesture(child, countNativeAsHandled) + ); + } + + if (maybeUnpackValue(gesture.config.enabled) === false) { + return false; } switch (gesture.type) { case SingleGestureName.Tap: case SingleGestureName.LongPress: case SingleGestureName.Fling: - case SingleGestureName.Native: case SingleGestureName.Hover: return true; + case SingleGestureName.Native: + return ( + countNativeAsHandled && gesture.config.countNativeAsHandled !== false + ); default: return false; } @@ -40,7 +52,12 @@ export function useJSResponderHandler< TConfig, THandlerData, TExtendedHandlerData extends THandlerData, ->(gesture: Gesture) { +>( + gesture: Gesture | undefined, + // Whether a tap on a Native gesture counts as handled. Scrollers and other + // non-tap-targets turn it off so touching them doesn't keep the keyboard open. + countNativeAsHandled = true +) { const jsResponderContext = use(JSResponderContext); const [enabledSharedValueRevision, setEnabledSharedValueRevision] = useState(0); @@ -51,6 +68,10 @@ export function useJSResponderHandler< } useEffect(() => { + if (gesture === undefined) { + return; + } + const enabledSharedValues = getEnabledSharedValues(gesture); if (Worklets === undefined || enabledSharedValues.length === 0) { @@ -107,8 +128,10 @@ export function useJSResponderHandler< const shouldHandleJSResponderEvent = useCallback(() => { void enabledSharedValueRevision; - return isGestureEnabled(gesture) && isSupportedGesture(gesture); - }, [enabledSharedValueRevision, gesture]); + return ( + gesture !== undefined && isSupportedGesture(gesture, countNativeAsHandled) + ); + }, [enabledSharedValueRevision, gesture, countNativeAsHandled]); const handleStartShouldSetResponder = useCallback(() => { if (shouldHandleJSResponderEvent()) { diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts index 3f54c74114..425fcd272b 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/configUtils.ts @@ -7,7 +7,6 @@ import { isTestEnv, tagMessage } from '../../../utils'; import type { BaseGestureConfig, ExcludeInternalConfigProps, - Gesture, SingleGestureName, } from '../../types'; import { isNativeAnimatedEvent, shouldHandleTouchEvents } from './eventUtils'; @@ -19,20 +18,6 @@ import { PropsWhiteLists, } from './propsWhiteList'; import { hasWorkletEventHandlers, maybeUnpackValue } from './reanimatedUtils'; -import { isComposedGesture } from './relationUtils'; - -export function isGestureEnabled< - TConfig, - THandlerData, - TExtendedHandlerData extends THandlerData, ->(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; -} export function resolveInternalConfigProps< TConfig extends object, diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts index e3abff4319..1a40919f6f 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/index.ts @@ -1,5 +1,4 @@ export { - isGestureEnabled, prepareConfigForNativeSide, useClonedAndRemappedConfig, } from './configUtils'; diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts index ad3c07a131..6ffa590628 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/propsWhiteList.ts @@ -71,6 +71,7 @@ export const PropsToFilter = new Set< 'changeEventCalculator', 'disableReanimated', 'shouldUseReanimatedDetector', + 'countNativeAsHandled', 'useAnimated', 'runOnJS', diff --git a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts index 6415743183..28fa9d5be5 100644 --- a/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts +++ b/packages/react-native-gesture-handler/src/v3/types/ConfigTypes.ts @@ -63,6 +63,9 @@ export type InternalConfigProps = { fillInDefaultValues?: | ((event: GestureEvent) => void) | undefined; + // Set to false by native wrappers of components that are not tap targets, + // see `useJSResponderHandler` + countNativeAsHandled?: boolean | undefined; }; export type CommonGestureConfig = {