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 ccfd163fdb..31ce7493a1 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 @@ -5,7 +5,7 @@ import { screen, } from '@testing-library/react-native'; import { act } from 'react'; -import { Keyboard, View } from 'react-native'; +import { Keyboard, TextInput, View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; @@ -454,8 +454,17 @@ describe('[API v3] Components', () => { keyboardShouldPersistTaps, }); + // The drop requires a focused RN TextInput to blur. + const focusInput = () => + jest + .spyOn(TextInput.State, 'currentlyFocusedInput') + .mockReturnValue( + {} as ReturnType + ); + test('isKeyboardDismissingTap is true only in never mode while the keyboard is visible', async () => { const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + const focusSpy = focusInput(); render( @@ -477,7 +486,38 @@ describe('[API v3] Components', () => { // Outside an RNGH ScrollView there is no context, so nothing is dropped. expect(isKeyboardDismissingTap(null)).toBe(false); + // The verdict must survive the dismissal blurring the input mid-tap. + focusSpy.mockReturnValue(undefined); + expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); + addListenerSpy.mockRestore(); + focusSpy.mockRestore(); + }); + + test('isKeyboardDismissingTap is false when no RN TextInput is focused (native field keyboard)', async () => { + const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + + render( + + + + ); + await act(flushImmediate); + + // Keyboard up for a native field (e.g. a native-stack search bar) - + // no RN TextInput to blur, so the tap must not be dropped. + showKeyboard(addListenerSpy); + + expect(TextInput.State.currentlyFocusedInput()).toBeNull(); + expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false); + + // Focus moving to an RN input while the keyboard stays up makes the + // tap dismissible again. + const focusSpy = focusInput(); + expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true); + + addListenerSpy.mockRestore(); + focusSpy.mockRestore(); }); test('isKeyboardDismissingTap is false for a detached (height 0) keyboard', async () => { @@ -500,6 +540,7 @@ describe('[API v3] Components', () => { test('Touchable does NOT fire any press callback on the keyboard-dismissing tap (never)', async () => { const addListenerSpy = jest.spyOn(Keyboard, 'addListener'); + const focusSpy = focusInput(); const onPress = jest.fn(); const onPressIn = jest.fn(); const onPressOut = jest.fn(); @@ -519,6 +560,10 @@ describe('[API v3] Components', () => { await act(flushImmediate); showKeyboard(addListenerSpy); + // The 'never' responder blurs the input at touch-down, before the + // press events arrive - mirror that ordering. + focusSpy.mockReturnValue(undefined); + // Includes a re-entry PressIn (finger dragged out and back in) so the // capture-once verdict path is exercised too. const button = screen.getByTestId('touchable'); @@ -535,6 +580,7 @@ describe('[API v3] Components', () => { expect(onPressIn).not.toHaveBeenCalled(); expect(onPressOut).not.toHaveBeenCalled(); addListenerSpy.mockRestore(); + focusSpy.mockRestore(); }); test('Touchable fires onPress in never mode when the keyboard is not visible', async () => { diff --git a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts index 42a1d7f7aa..ee469e5621 100644 --- a/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts +++ b/packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import { TextInput } from 'react-native'; export type KeyboardShouldPersistTaps = | boolean @@ -27,9 +28,15 @@ export function updateResponderEventValue( } let isKeyboardVisible = false; +let keyboardOpenedForRNInput = false; export function setKeyboardVisibility(visible: boolean) { isKeyboardVisible = visible; + + // Snapshotted at show-time: the dismissal blurs the input at touch-down, + // before the press events get checked + keyboardOpenedForRNInput = + visible && TextInput.State.currentlyFocusedInput?.() != null; } export function isKeyboardDismissingTap( @@ -42,5 +49,14 @@ export function isKeyboardDismissingTap( const mode = jsResponderContext.keyboardShouldPersistTaps; const keyboardNeverPersistTaps = !mode || mode === 'never'; - return keyboardNeverPersistTaps && isKeyboardVisible; + // Drop only taps that can dismiss the keyboard, i.e. an RN TextInput is (or + // was at show-time) focused - mirrors RN ScrollView's `_keyboardIsDismissible`. + // A native field's keyboard (e.g. a native-stack search bar) can't be + // blurred, so dropping there would leave presses permanently dead + return ( + keyboardNeverPersistTaps && + isKeyboardVisible && + (keyboardOpenedForRNInput || + TextInput.State.currentlyFocusedInput?.() != null) + ); }