Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 159 additions & 3 deletions packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@
import { fireGestureHandler, getByGestureTestId } from '../jestUtils';
import { State } from '../State';
import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components';
import { GestureDetector } from '../v3/detectors';
import {
GestureDetector,
InterceptingGestureDetector,
VirtualGestureDetector,
} from '../v3/detectors';
import { useSimultaneousGestures } from '../v3/hooks';
import {
useLongPressGesture,
useNativeGesture,
usePanGesture,
useTapGesture,
} from '../v3/hooks/gestures';
Expand Down Expand Up @@ -59,8 +64,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onBegin: (e) => onBegin(e),

Check warning on line 67 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onActivate: (e) => onStart(e),

Check warning on line 68 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand Down Expand Up @@ -88,8 +93,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onUpdate: (e) => onUpdate(e),

Check warning on line 96 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onTouchesUp: (e) => onTouchesUp(e),

Check warning on line 97 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand Down Expand Up @@ -189,14 +194,24 @@
);
};

const getNativeDetector = (
// Detector host views in document order (outer first).
const getNativeDetectors = (
views: ReturnType<typeof render>['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<typeof render>['UNSAFE_getAllByType']
) => {
const detectors = getNativeDetectors(views);
return detectors[detectors.length - 1];
};

const TapGestureDetectorExample = () => {
const tap = useTapGesture({ disableReanimated: true });

Expand All @@ -217,6 +232,46 @@
);
};

const VirtualTapDetectorExample = () => {
const tap = useTapGesture({ disableReanimated: true });

return (
<VirtualGestureDetector gesture={tap}>
<View testID="virtual-child" />
</VirtualGestureDetector>
);
};

const VirtualPanDetectorExample = () => {
const pan = usePanGesture({ disableReanimated: true });

return (
<VirtualGestureDetector gesture={pan}>
<View testID="virtual-child" />
</VirtualGestureDetector>
);
};

const InterceptingTapDetectorExample = () => {
const tap = useTapGesture({ disableReanimated: true });

return (
<InterceptingGestureDetector gesture={tap}>
<View />
</InterceptingGestureDetector>
);
};

const InterceptingNativeDetectorExample = () => {
const native = useNativeGesture({ disableReanimated: true });

return (
<InterceptingGestureDetector gesture={native}>
<View />
</InterceptingGestureDetector>
);
};

const SimultaneousGestureDetectorExample = () => {
const tap = useTapGesture({ disableReanimated: true });
const pan = usePanGesture({ disableReanimated: true });
Expand Down Expand Up @@ -336,6 +391,107 @@
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true);
});

test('handles responder event passed through VirtualDetector for supported gestures', async () => {
const { UNSAFE_getAllByType } = render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
<VirtualTapDetectorExample />
</ScrollView>
</GestureHandlerRootView>
);

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.onStartShouldSetResponder()).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(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
<VirtualPanDetectorExample />
</ScrollView>
</GestureHandlerRootView>
);

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.onStartShouldSetResponder()).toBe(false);
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
false
);
});

test('handles responder event passed through InterceptingGestureDetector for supported gestures', async () => {
const { UNSAFE_getAllByType } = render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
<InterceptingTapDetectorExample />
</ScrollView>
</GestureHandlerRootView>
);

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(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
<InterceptingNativeDetectorExample />
</ScrollView>
</GestureHandlerRootView>
);

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 NativeDetector for unsupported gestures', async () => {
const { UNSAFE_getAllByType } = render(
<GestureHandlerRootView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +56,10 @@ export function InterceptingGestureDetector<
TExtendedHandlerData
>) {
useEnsureGestureHandlerRootView();
const { handleStartShouldSetResponder } = useJSResponderHandler(
gesture,
false
);

const [virtualChildren, setVirtualChildren] = useState<Set<VirtualChild>>(
() => new Set()
Expand Down Expand Up @@ -299,6 +304,7 @@ export function InterceptingGestureDetector<
: undefined
}
handlerTags={handlerTags}
onStartShouldSetResponder={handleStartShouldSetResponder}
style={nativeDetectorStyles.detector}
virtualChildren={strippedVirtualChildren}
moduleId={getModuleId()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
Expand Down Expand Up @@ -37,6 +38,9 @@ export function VirtualDetector<

const { register, unregister, setMode } =
useRequiredInterceptingDetectorContext();
const { handleStartShouldSetResponder } = useJSResponderHandler(
props.gesture
);

const viewRef = useRef(null);
const [viewTag, setViewTag] = useState<number>(-1);
Expand Down Expand Up @@ -114,5 +118,11 @@ export function VirtualDetector<

useGestureRelationsUpdater(props.gesture);

return <Wrap ref={handleRef}>{props.children}</Wrap>;
return (
<Wrap
ref={handleRef}
onStartShouldSetResponder={handleStartShouldSetResponder}>
{props.children}
</Wrap>
);
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
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';
import { tagMessage } from '../../../utils';

export type { WrapRef };

export type WrapProps = PropsWithChildren<{ ref?: WrapRef }>;
export type WrapProps = PropsWithChildren<{
ref?: WrapRef;
onStartShouldSetResponder?: (event: GestureResponderEvent) => boolean;
}>;

export const Wrap: React.FunctionComponent<WrapProps> = ({ ref, children }) => {
export const Wrap: React.FunctionComponent<WrapProps> = ({
ref,
children,
onStartShouldSetResponder,
}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let child: any;
try {
Expand Down Expand Up @@ -53,9 +61,29 @@ export const Wrap: React.FunctionComponent<WrapProps> = ({ ref, children }) => {
[childRef, ref]
);

const childOnStartShouldSetResponder =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
child.props.onStartShouldSetResponder as
| ((event: GestureResponderEvent) => boolean)
| undefined;

// Marks the responder event for the scroll view interop, then defers to the
// child's own handler.
const handleStartShouldSetResponder = useCallback(
(event: GestureResponderEvent) => {
onStartShouldSetResponder?.(event);
return childOnStartShouldSetResponder?.(event) ?? false;
},
[onStartShouldSetResponder, childOnStartShouldSetResponder]
);

return React.cloneElement(
child,
{ collapsable: false, ref: attachRef },
{
collapsable: false,
ref: attachRef,
onStartShouldSetResponder: handleStartShouldSetResponder,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
child.props.children
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,18 +19,28 @@ function isSupportedGesture<
TConfig,
THandlerData,
TExtendedHandlerData extends THandlerData,
>(gesture: Gesture<TConfig, THandlerData, TExtendedHandlerData>): boolean {
>(
gesture: Gesture<TConfig, THandlerData, TExtendedHandlerData>,
allowNative: boolean
): boolean {
if (isComposedGesture(gesture)) {
return gesture.gestures.some(isSupportedGesture);
return gesture.gestures.some((child) =>
isSupportedGesture(child, allowNative)
);
}

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 allowNative;
default:
return false;
}
Expand All @@ -40,7 +50,14 @@ export function useJSResponderHandler<
TConfig,
THandlerData,
TExtendedHandlerData extends THandlerData,
>(gesture: Gesture<TConfig, THandlerData, TExtendedHandlerData>) {
>(
gesture: Gesture<TConfig, THandlerData, TExtendedHandlerData> | undefined,
// Whether a Native gesture counts as handling the tap. True for buttons and
// other wrapped controls. InterceptingGestureDetector passes false, since
// ScrollView and FlatList are intercepting detectors with a Native gesture
// and a touch on a scroller should not keep the keyboard open.
allowNative = true
) {
const jsResponderContext = use(JSResponderContext);
const [enabledSharedValueRevision, setEnabledSharedValueRevision] =
useState(0);
Expand All @@ -51,6 +68,10 @@ export function useJSResponderHandler<
}

useEffect(() => {
if (gesture === undefined) {
return;
}

const enabledSharedValues = getEnabledSharedValues(gesture);

if (Worklets === undefined || enabledSharedValues.length === 0) {
Expand Down Expand Up @@ -107,8 +128,8 @@ export function useJSResponderHandler<

const shouldHandleJSResponderEvent = useCallback(() => {
void enabledSharedValueRevision;
return isGestureEnabled(gesture) && isSupportedGesture(gesture);
}, [enabledSharedValueRevision, gesture]);
return gesture !== undefined && isSupportedGesture(gesture, allowNative);
}, [enabledSharedValueRevision, gesture, allowNative]);

const handleStartShouldSetResponder = useCallback(() => {
if (shouldHandleJSResponderEvent()) {
Expand Down
Loading