Skip to content

Commit 4c30d86

Browse files
authored
Extract the RN-free half of ScrollView interop from ScrollViewResponderInterceptor (#4376)
## Description `useJSResponderHandler` and the v3 press components imported `JSResponderContext`, `updateResponderEventValue`, and `isKeyboardDismissingTap` from `ScrollViewResponderInterceptor` — a module coupled to react-native (`Keyboard`, `TextInput`, `Platform`, `View`). This PR splits the module along that line. The react-native-free half of the contract between the GH `ScrollView` and the pressables/detectors inside it moves to a new `v3/scrollViewInterop.ts` leaf: the `JSResponderContext` and its value type, `updateResponderEventValue`, the keyboard-visibility state, and the `isKeyboardDismissingTap` policy. Its only dependency is `react`. The interceptor keeps all platform plumbing and feeds keyboard visibility into the leaf through a new `setKeyboardVisibility()` seam from its `Keyboard` listeners, so consumers of the policy never touch the platform API. `KeyboardShouldPersistTaps` becomes a structural copy of RN's union (including the deprecated boolean form) so the leaf needs no react-native type import. No behavior change; no re-export stubs kept — the module is internal and has no external consumers (checked GitHub code search for the old path). ## Test plan - `yarn ts-check` clean; `yarn test` 103/103; `yarn lint:js` 0 errors - The keyboard suites in `api_v3.test.tsx` cover the seam end-to-end in jest: they emit `Keyboard` events through the interceptor's listeners and assert dismissal/persist behavior via the moved logic - Verified on iOS simulator and Android emulator (expo-example, `keyboardShouldPersistTaps` test screen, real software keyboard): `never` mode — keyboard-dismissing tap on GH Pressable is swallowed and dismisses the keyboard, follow-up tap fires normally; `handled` mode — press fires and the keyboard stays up (responder marking through the moved context)
1 parent fbcb989 commit 4c30d86

6 files changed

Lines changed: 62 additions & 46 deletions

File tree

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import GestureHandlerRootView from '../components/GestureHandlerRootView';
1111
import { fireGestureHandler, getByGestureTestId } from '../jestUtils';
1212
import { State } from '../State';
1313
import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components';
14-
import {
15-
isKeyboardDismissingTap,
16-
type JSResponderContextValue,
17-
} from '../v3/components/ScrollViewResponderInterceptor';
1814
import { GestureDetector } from '../v3/detectors';
1915
import { useSimultaneousGestures } from '../v3/hooks';
2016
import { usePanGesture, useTapGesture } from '../v3/hooks/gestures';
17+
import {
18+
isKeyboardDismissingTap,
19+
type JSResponderContextValue,
20+
} from '../v3/scrollViewInterop';
2121

2222
const flushImmediate = () =>
2323
new Promise((resolve) => {

‎packages/react-native-gesture-handler/src/v3/components/Pressable.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,11 @@ import {
4343
useNativeGesture,
4444
useSimultaneousGestures,
4545
} from '../hooks';
46-
import { PureNativeButton } from './GestureButtons';
4746
import {
4847
isKeyboardDismissingTap,
4948
JSResponderContext,
50-
} from './ScrollViewResponderInterceptor';
49+
} from '../scrollViewInterop';
50+
import { PureNativeButton } from './GestureButtons';
5151

5252
const DEFAULT_LONG_PRESS_DURATION = 500;
5353
const IS_TEST_ENV = isTestEnv();

‎packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx‎

Lines changed: 8 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ import type {
88
} from 'react-native';
99
import { Keyboard, Platform, StyleSheet, TextInput, View } from 'react-native';
1010

11-
type KeyboardShouldPersistTaps = RNScrollViewProps['keyboardShouldPersistTaps'];
11+
import {
12+
JSResponderContext,
13+
setKeyboardVisibility,
14+
} from '../scrollViewInterop';
1215

1316
// The listeners are shared by every mounted ScrollView, so attach/detach is
1417
// reference-counted: we subscribe on the first interceptor and tear down only
1518
// once the last one unmounts.
1619
let keyboardTrackerRefCount = 0;
1720
let keyboardTrackerSubscriptions: EmitterSubscription[] = [];
18-
let isKeyboardVisible = false;
1921

2022
function keyboardIsOpen(height: number | undefined): boolean {
2123
return height != null && height > 0;
@@ -29,15 +31,15 @@ function subscribeToKeyboardVisibility() {
2931
}
3032

3133
const setVisible = (event: KeyboardEvent) => {
32-
isKeyboardVisible = keyboardIsOpen(event.endCoordinates?.height);
34+
setKeyboardVisibility(keyboardIsOpen(event.endCoordinates?.height));
3335
};
3436
const setHidden = () => {
35-
isKeyboardVisible = false;
37+
setKeyboardVisibility(false);
3638
};
3739

3840
// Seed from the current keyboard metrics in case it is already open when the
3941
// first ScrollView mounts (mirrors ScrollView seeding from Keyboard.metrics()).
40-
isKeyboardVisible = keyboardIsOpen(Keyboard.metrics?.()?.height);
42+
setKeyboardVisibility(keyboardIsOpen(Keyboard.metrics?.()?.height));
4143

4244
keyboardTrackerSubscriptions = [
4345
Keyboard.addListener('keyboardDidShow', setVisible),
@@ -57,39 +59,7 @@ function unsubscribeFromKeyboardVisibility() {
5759
subscription.remove();
5860
}
5961
keyboardTrackerSubscriptions = [];
60-
isKeyboardVisible = false;
61-
}
62-
63-
export type JSResponderContextValue = {
64-
isRNGHResponderEvent: React.MutableRefObject<boolean>;
65-
keyboardShouldPersistTaps: KeyboardShouldPersistTaps;
66-
};
67-
68-
export const JSResponderContext =
69-
React.createContext<JSResponderContextValue | null>(null);
70-
71-
export function updateResponderEventValue(
72-
jsResponderContext: JSResponderContextValue | null | undefined,
73-
value: boolean
74-
) {
75-
const responderEventRef = jsResponderContext?.isRNGHResponderEvent;
76-
77-
if (responderEventRef) {
78-
responderEventRef.current = value;
79-
}
80-
}
81-
82-
export function isKeyboardDismissingTap(
83-
jsResponderContext: JSResponderContextValue | null | undefined
84-
): boolean {
85-
if (jsResponderContext == null) {
86-
return false;
87-
}
88-
89-
const mode = jsResponderContext.keyboardShouldPersistTaps;
90-
const keyboardNeverPersistTaps = !mode || mode === 'never';
91-
92-
return keyboardNeverPersistTaps && isKeyboardVisible;
62+
setKeyboardVisibility(false);
9363
}
9464

9565
type ScrollViewResponderInterceptorProps = PropsWithChildren<{

‎packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { getNextHandlerTag } from '../../../handlers/getNextHandlerTag';
1010
import {
1111
isKeyboardDismissingTap,
1212
JSResponderContext,
13-
} from '../ScrollViewResponderInterceptor';
13+
} from '../../scrollViewInterop';
1414
import type { AnimationDuration, TouchableProps } from './TouchableProps';
1515

1616
const isAndroid = Platform.OS === 'android';

‎packages/react-native-gesture-handler/src/v3/hooks/useJSResponderHandler.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Reanimated } from '../../handlers/gestures/reanimatedWrapper';
44
import {
55
JSResponderContext,
66
updateResponderEventValue,
7-
} from '../components/ScrollViewResponderInterceptor';
7+
} from '../scrollViewInterop';
88
import { type Gesture, type SharedValue, SingleGestureName } from '../types';
99
import { isComposedGesture, isGestureEnabled } from './utils';
1010
import {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as React from 'react';
2+
3+
export type KeyboardShouldPersistTaps =
4+
| boolean
5+
| 'always'
6+
| 'never'
7+
| 'handled'
8+
| undefined;
9+
10+
export type JSResponderContextValue = {
11+
isRNGHResponderEvent: React.MutableRefObject<boolean>;
12+
keyboardShouldPersistTaps: KeyboardShouldPersistTaps;
13+
};
14+
15+
export const JSResponderContext =
16+
React.createContext<JSResponderContextValue | null>(null);
17+
18+
export function updateResponderEventValue(
19+
jsResponderContext: JSResponderContextValue | null | undefined,
20+
value: boolean
21+
) {
22+
const responderEventRef = jsResponderContext?.isRNGHResponderEvent;
23+
24+
if (responderEventRef) {
25+
responderEventRef.current = value;
26+
}
27+
}
28+
29+
let isKeyboardVisible = false;
30+
31+
export function setKeyboardVisibility(visible: boolean) {
32+
isKeyboardVisible = visible;
33+
}
34+
35+
export function isKeyboardDismissingTap(
36+
jsResponderContext: JSResponderContextValue | null | undefined
37+
): boolean {
38+
if (jsResponderContext == null) {
39+
return false;
40+
}
41+
42+
const mode = jsResponderContext.keyboardShouldPersistTaps;
43+
const keyboardNeverPersistTaps = !mode || mode === 'never';
44+
45+
return keyboardNeverPersistTaps && isKeyboardVisible;
46+
}

0 commit comments

Comments
 (0)