Skip to content

Commit 33c4f28

Browse files
authored
Fix dead presses in "never" mode when the keyboard belongs to a native field (#4439)
## Description With a Gesture Handler `ScrollView` in the default (`never`) `keyboardShouldPersistTaps` mode, a keyboard opened by a native field (e.g. a native-stack `headerSearchBarOptions` search bar) made every RNGH `Pressable`/`Touchable` inside dead, with no way to dismiss the keyboard by tapping. The keyboard-dismissing tap drop (#992) checks only keyboard visibility, but the dismissal blurs `TextInput.State.currentlyFocusedInput()`, which is `null` for native fields - the tap was consumed while nothing could be dismissed. Now the tap is dropped only when an RN `TextInput` is focused, mirroring RN ScrollView's `_keyboardIsDismissible`. Focus is snapshotted when the keyboard shows, since the dismissal blurs the input at touch-down, before the press events are checked; a live check is OR-ed in for focus moving to an RN input while the keyboard is already up. With a native-field keyboard, presses now behave like RN's `Pressable`: they fire and the keyboard stays. ## Test plan - `yarn test` — added cases: no drop when the keyboard is up without a focused RN input; the drop verdict survives the input being blurred mid-tap; existing `never`-drop tests updated to mock a focused input. - iOS simulator and Android emulator, repro below with RNGH and RN `Pressable` side by side: - search bar active: both fire, keyboard stays (previously the RNGH one was dead) - RN `TextInput` focused: both are dropped and the tap dismisses the keyboard (#992 behavior unchanged) <details> <summary>Repro</summary> ```tsx import React, { useState } from 'react'; import { Pressable as RNPressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { NavigationContainer, NavigationIndependentTree } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { Pressable, ScrollView } from 'react-native-gesture-handler'; const ROUTES = ['12', '17', '25', '42', '58', '73', '81', '99']; function RoutesScreen() { const [pressCount, setPressCount] = useState(0); const [lastPressed, setLastPressed] = useState<string | null>(null); const press = (label: string) => () => { setPressCount((c) => c + 1); setLastPressed(label); }; return ( <ScrollView contentInsetAdjustmentBehavior="automatic"> <TextInput placeholder="RN TextInput (control)" style={styles.input} /> <Text style={styles.status}> {`onPress count: ${pressCount}` + (lastPressed ? ` (last: ${lastPressed})` : '')} </Text> {ROUTES.map((route) => ( <View key={route} style={styles.routeRow}> <Pressable style={styles.row} onPress={press(`GH ${route}`)}> <Text>{`GH ${route}`}</Text> </Pressable> <RNPressable style={styles.row} onPress={press(`RN ${route}`)}> <Text>{`RN ${route}`}</Text> </RNPressable> </View> ))} </ScrollView> ); } const Stack = createNativeStackNavigator(); export default function Example() { return ( <NavigationIndependentTree> <NavigationContainer> <Stack.Navigator> <Stack.Screen name="Routes" component={RoutesScreen} options={{ headerSearchBarOptions: { placeholder: 'Search routes', hideWhenScrolling: false }, }} /> </Stack.Navigator> </NavigationContainer> </NavigationIndependentTree> ); } const styles = StyleSheet.create({ status: { fontSize: 16, fontWeight: 'bold', padding: 16 }, input: { borderColor: '#ccd', borderRadius: 8, borderWidth: 1, margin: 16, padding: 12 }, routeRow: { flexDirection: 'row', gap: 8, marginHorizontal: 16, marginVertical: 4 }, row: { backgroundColor: '#eef', borderRadius: 8, flex: 1, padding: 16 }, }); ``` </details>
1 parent d29545a commit 33c4f28

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
screen,
66
} from '@testing-library/react-native';
77
import { act } from 'react';
8-
import { Keyboard, View } from 'react-native';
8+
import { Keyboard, TextInput, View } from 'react-native';
99

1010
import GestureHandlerRootView from '../components/GestureHandlerRootView';
1111
import { fireGestureHandler, getByGestureTestId } from '../jestUtils';
@@ -454,8 +454,17 @@ describe('[API v3] Components', () => {
454454
keyboardShouldPersistTaps,
455455
});
456456

457+
// The drop requires a focused RN TextInput to blur.
458+
const focusInput = () =>
459+
jest
460+
.spyOn(TextInput.State, 'currentlyFocusedInput')
461+
.mockReturnValue(
462+
{} as ReturnType<typeof TextInput.State.currentlyFocusedInput>
463+
);
464+
457465
test('isKeyboardDismissingTap is true only in never mode while the keyboard is visible', async () => {
458466
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');
467+
const focusSpy = focusInput();
459468

460469
render(
461470
<GestureHandlerRootView>
@@ -477,7 +486,38 @@ describe('[API v3] Components', () => {
477486
// Outside an RNGH ScrollView there is no context, so nothing is dropped.
478487
expect(isKeyboardDismissingTap(null)).toBe(false);
479488

489+
// The verdict must survive the dismissal blurring the input mid-tap.
490+
focusSpy.mockReturnValue(undefined);
491+
expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true);
492+
480493
addListenerSpy.mockRestore();
494+
focusSpy.mockRestore();
495+
});
496+
497+
test('isKeyboardDismissingTap is false when no RN TextInput is focused (native field keyboard)', async () => {
498+
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');
499+
500+
render(
501+
<GestureHandlerRootView>
502+
<ScrollView keyboardShouldPersistTaps="never" />
503+
</GestureHandlerRootView>
504+
);
505+
await act(flushImmediate);
506+
507+
// Keyboard up for a native field (e.g. a native-stack search bar) -
508+
// no RN TextInput to blur, so the tap must not be dropped.
509+
showKeyboard(addListenerSpy);
510+
511+
expect(TextInput.State.currentlyFocusedInput()).toBeNull();
512+
expect(isKeyboardDismissingTap(makeContext('never'))).toBe(false);
513+
514+
// Focus moving to an RN input while the keyboard stays up makes the
515+
// tap dismissible again.
516+
const focusSpy = focusInput();
517+
expect(isKeyboardDismissingTap(makeContext('never'))).toBe(true);
518+
519+
addListenerSpy.mockRestore();
520+
focusSpy.mockRestore();
481521
});
482522

483523
test('isKeyboardDismissingTap is false for a detached (height 0) keyboard', async () => {
@@ -500,6 +540,7 @@ describe('[API v3] Components', () => {
500540

501541
test('Touchable does NOT fire any press callback on the keyboard-dismissing tap (never)', async () => {
502542
const addListenerSpy = jest.spyOn(Keyboard, 'addListener');
543+
const focusSpy = focusInput();
503544
const onPress = jest.fn();
504545
const onPressIn = jest.fn();
505546
const onPressOut = jest.fn();
@@ -519,6 +560,10 @@ describe('[API v3] Components', () => {
519560
await act(flushImmediate);
520561
showKeyboard(addListenerSpy);
521562

563+
// The 'never' responder blurs the input at touch-down, before the
564+
// press events arrive - mirror that ordering.
565+
focusSpy.mockReturnValue(undefined);
566+
522567
// Includes a re-entry PressIn (finger dragged out and back in) so the
523568
// capture-once verdict path is exercised too.
524569
const button = screen.getByTestId('touchable');
@@ -535,6 +580,7 @@ describe('[API v3] Components', () => {
535580
expect(onPressIn).not.toHaveBeenCalled();
536581
expect(onPressOut).not.toHaveBeenCalled();
537582
addListenerSpy.mockRestore();
583+
focusSpy.mockRestore();
538584
});
539585

540586
test('Touchable fires onPress in never mode when the keyboard is not visible', async () => {

packages/react-native-gesture-handler/src/v3/scrollViewInterop.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import { TextInput } from 'react-native';
23

34
export type KeyboardShouldPersistTaps =
45
| boolean
@@ -27,9 +28,15 @@ export function updateResponderEventValue(
2728
}
2829

2930
let isKeyboardVisible = false;
31+
let keyboardOpenedForRNInput = false;
3032

3133
export function setKeyboardVisibility(visible: boolean) {
3234
isKeyboardVisible = visible;
35+
36+
// Snapshotted at show-time: the dismissal blurs the input at touch-down,
37+
// before the press events get checked
38+
keyboardOpenedForRNInput =
39+
visible && TextInput.State.currentlyFocusedInput?.() != null;
3340
}
3441

3542
export function isKeyboardDismissingTap(
@@ -42,5 +49,14 @@ export function isKeyboardDismissingTap(
4249
const mode = jsResponderContext.keyboardShouldPersistTaps;
4350
const keyboardNeverPersistTaps = !mode || mode === 'never';
4451

45-
return keyboardNeverPersistTaps && isKeyboardVisible;
52+
// Drop only taps that can dismiss the keyboard, i.e. an RN TextInput is (or
53+
// was at show-time) focused - mirrors RN ScrollView's `_keyboardIsDismissible`.
54+
// A native field's keyboard (e.g. a native-stack search bar) can't be
55+
// blurred, so dropping there would leave presses permanently dead
56+
return (
57+
keyboardNeverPersistTaps &&
58+
isKeyboardVisible &&
59+
(keyboardOpenedForRNInput ||
60+
TextInput.State.currentlyFocusedInput?.() != null)
61+
);
4662
}

0 commit comments

Comments
 (0)