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
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,22 @@ import {
Pressable as RNGHPressable,
ScrollView as RNGHScrollView,
TextInput as RNGHTextInput,
Touchable as RNGHTouchable,
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';
type Example = 'pressable' | 'touchable' | 'tap';

const MODES: Mode[] = ['never', 'handled', 'always'];
const EXAMPLES: Example[] = ['pressable', 'tap'];
const EXAMPLES: Example[] = ['pressable', 'touchable', 'tap'];

const EXAMPLE_LABELS: Record<Example, string> = {
pressable: 'GH Pressable',
touchable: 'GH Touchable',
tap: 'useTapGesture',
};

Expand Down Expand Up @@ -105,6 +107,12 @@ export default function KeyboardShouldPersistTapsExample() {
onPress={() => report('GH Pressable onPress')}>
<Text style={styles.buttonText}>Press me</Text>
</RNGHPressable>
) : example === 'touchable' ? (
<RNGHTouchable
style={[styles.button, { backgroundColor: COLORS.GREEN }]}
onPress={() => report('GH Touchable onPress')}>
<Text style={styles.buttonText}>Press me</Text>
</RNGHTouchable>
) : (
<GestureTapButton
onTap={() => report('useTapGesture onActivate')}
Expand Down
134 changes: 127 additions & 7 deletions packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { render, renderHook } from '@testing-library/react-native';
import { act } from 'react';
import { View } from 'react-native';
import { Keyboard, 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 {
isKeyboardDismissingTap,
type JSResponderContextValue,
} from '../v3/components/ScrollViewResponderInterceptor';
import { GestureDetector } from '../v3/detectors';
import { useSimultaneousGestures } from '../v3/hooks';
import { usePanGesture, useTapGesture } from '../v3/hooks/gestures';
Expand All @@ -24,8 +28,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onBegin: (e) => onBegin(e),

Check warning on line 31 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 32 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 @@ -133,7 +137,7 @@

describe('ScrollView', () => {
test('handles responder event passed through Pressable for keyboardShouldPersistTaps handled', async () => {
const { getByTestId, UNSAFE_getAllByType } = render(
const { UNSAFE_getAllByType } = render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="handled">
<Pressable testID="pressable" />
Expand All @@ -143,22 +147,22 @@

await act(flushImmediate);

const pressable = getByTestId('pressable');
const nativeDetector = getNativeDetector(UNSAFE_getAllByType);
const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType);

expect(scrollViewResponder).toBeDefined();
expect(
scrollViewResponder?.props.onStartShouldSetResponderCapture()

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

View workflow job for this annotation

GitHub Actions / check

Unsafe call of an `any` typed value
).toBe(false);
expect(pressable.props.onStartShouldSetResponder()).toBe(false);
expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false);

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

View workflow job for this annotation

GitHub Actions / check

Unsafe call of an `any` typed value
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true);

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

View workflow job for this annotation

GitHub Actions / check

Unsafe call of an `any` typed value
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
false
);
});

test('does not handle responder event passed through Pressable without keyboardShouldPersistTaps handled', async () => {
const { getByTestId, UNSAFE_getAllByType } = render(
const { UNSAFE_getAllByType } = render(
<GestureHandlerRootView>
<ScrollView>
<Pressable testID="pressable" />
Expand All @@ -168,14 +172,14 @@

await act(flushImmediate);

const pressable = getByTestId('pressable');
const nativeDetector = getNativeDetector(UNSAFE_getAllByType);
const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType);

expect(scrollViewResponder).toBeDefined();
expect(
scrollViewResponder?.props.onStartShouldSetResponderCapture()
).toBe(false);
expect(pressable.props.onStartShouldSetResponder()).toBe(false);
expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false);
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
false
);
Expand Down Expand Up @@ -278,6 +282,122 @@
});
});

describe('keyboardShouldPersistTaps="never" drop', () => {
// The keyboard-visibility tracker subscribes via Keyboard.addListener when a
// ScrollView mounts. We spy on it to grab the captured `keyboardDidShow`
// handler and invoke it, simulating the soft keyboard being open.
const showSoftKeyboard = (addListenerSpy: jest.SpyInstance) => {
const showCall = addListenerSpy.mock.calls.find(
(call) => call[0] === 'keyboardDidShow'
);
act(() => {
showCall?.[1]?.({ endCoordinates: { height: 300 } });
});
};

const makeContext = (
keyboardShouldPersistTaps: JSResponderContextValue['keyboardShouldPersistTaps']
): JSResponderContextValue => ({
isRNGHResponderEvent: { current: false },
keyboardShouldPersistTaps,
});

const fireTap = (testID: string) =>
act(() => {
fireGestureHandler(getByGestureTestId(testID), [
{ state: State.BEGAN },
{ state: State.ACTIVE },
{ state: State.END },
]);
});

test('isKeyboardDismissingTap is true only in never mode while the soft keyboard is visible', async () => {
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');

render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="never" />
</GestureHandlerRootView>
);
await act(flushImmediate);

// Keyboard not shown yet -> nothing to dismiss.
expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false);

showSoftKeyboard(addListenerSpy);

// `never` (and its default, undefined) drops the tap; the others never do.
expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true);
expect(isKeyboardDismissingTap(makeContext(undefined))).toBe(true);
expect(isKeyboardDismissingTap(makeContext('handled'))).toBe(false);
expect(isKeyboardDismissingTap(makeContext('always'))).toBe(false);
// Outside an RNGH ScrollView there is no context, so nothing is dropped.
expect(isKeyboardDismissingTap(null)).toBe(false);

addListenerSpy.mockRestore();
});

test('Touchable does NOT fire onPress on the keyboard-dismissing tap (never)', async () => {
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');
const onPress = jest.fn();

render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="never">
<Touchable testID="touchable" onPress={onPress} />
</ScrollView>
</GestureHandlerRootView>
);
await act(flushImmediate);
showSoftKeyboard(addListenerSpy);

fireTap('touchable');

expect(onPress).not.toHaveBeenCalled();
addListenerSpy.mockRestore();
});

test('Touchable fires onPress in never mode when the keyboard is not visible', async () => {
const onPress = jest.fn();

render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps="never">
<Touchable testID="touchable" onPress={onPress} />
</ScrollView>
</GestureHandlerRootView>
);
await act(flushImmediate);

fireTap('touchable');

expect(onPress).toHaveBeenCalledTimes(1);
});

test.each(['handled', 'always'] as const)(
'Touchable fires onPress in %s mode even while the keyboard is visible',
async (mode) => {
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');
const onPress = jest.fn();

render(
<GestureHandlerRootView>
<ScrollView keyboardShouldPersistTaps={mode}>
<Touchable testID="touchable" onPress={onPress} />
</ScrollView>
</GestureHandlerRootView>
);
await act(flushImmediate);
showSoftKeyboard(addListenerSpy);

fireTap('touchable');

expect(onPress).toHaveBeenCalledTimes(1);
addListenerSpy.mockRestore();
}
);
});

describe('Touchable', () => {
test('calls onPress on successful press', () => {
const pressFn = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ import {
} from '../hooks';
import { PureNativeButton } from './GestureButtons';
import {
isKeyboardDismissingTap,
JSResponderContext,
updateResponderEventValue,
} from './ScrollViewResponderInterceptor';

const DEFAULT_LONG_PRESS_DURATION = 500;
Expand Down Expand Up @@ -92,6 +92,11 @@ const Pressable = (props: PressableProps) => {
height: 0,
});

// When the touch that begins a press is the one dismissing the keyboard
// (keyboardShouldPersistTaps="never"), the press is swallowed to match RN's
// touchables.
const dropKeyboardTapRef = useRef<boolean | null>(null);

const normalizedHitSlop: Insets = useMemo(
() =>
typeof hitSlop === 'number'
Expand Down Expand Up @@ -153,11 +158,16 @@ const Pressable = (props: PressableProps) => {

const handleFinalize = useCallback(() => {
isCurrentlyPressed.current = false;
dropKeyboardTapRef.current = null;
cancelLongPress();
cancelDelayedPress();
setPressedState(false);
}, [cancelDelayedPress, cancelLongPress]);

const captureKeyboardDismiss = useCallback(() => {
dropKeyboardTapRef.current ??= isKeyboardDismissingTap(jsResponderContext);
}, [jsResponderContext]);

const handlePressIn = useCallback(
(event: PressableEvent, skipBoundsCheck = false) => {
if (
Expand Down Expand Up @@ -265,6 +275,12 @@ const Pressable = (props: PressableProps) => {
maxDistance: INT32_MAX, // Stops long press from cancelling on touch move
cancelsTouchesInView: false,
onTouchesDown: (event) => {
captureKeyboardDismiss();

if (dropKeyboardTapRef.current) {
return;
}

const pressableEvent = gestureTouchToPressableEvent(event);
stateMachine.handleEvent(
StateMachineEvent.LONG_PRESS_TOUCHES_DOWN,
Expand Down Expand Up @@ -314,6 +330,12 @@ const Pressable = (props: PressableProps) => {
}
},
onBegin: () => {
captureKeyboardDismiss();

if (dropKeyboardTapRef.current) {
return;
}

if (Platform.isTV) {
// tvOS drives this native gesture from the focus-engine Select press.
// The press state machine is touch-based and never
Expand Down Expand Up @@ -398,23 +420,11 @@ 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) {
updateResponderEventValue(jsResponderContext, true);
}

return false;
}, [disabled, jsResponderContext]);

const tvProps = getTVProps(remainingProps);

return (
<GestureDetector gesture={gesture}>
<PureNativeButton
onStartShouldSetResponder={handleStartShouldSetResponder}
{...remainingProps}
{...tvProps}
onLayout={setDimensions}
Expand Down
Loading
Loading