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 d040132620..f44ca049aa 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
@@ -257,14 +257,10 @@ describe('[API v3] Components', () => {
const nativeDetector = getNativeDetector(UNSAFE_getAllByType);
const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType);
- expect(scrollViewResponder).toBeDefined();
- expect(
- scrollViewResponder?.props.onStartShouldSetResponderCapture()
- ).toBe(false);
+ // Outside of 'handled' mode the logical responder view is not rendered
+ // at all — the responder event can never be claimed on behalf of RNGH.
+ expect(scrollViewResponder).toBeUndefined();
expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false);
- expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
- false
- );
});
test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => {
diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx
index ffbdb846b3..bce5014531 100644
--- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx
+++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx
@@ -21,7 +21,7 @@ import { GestureDetectorType } from '../detectors';
import type { NativeGesture } from '../hooks/gestures/native/NativeTypes';
import { NativeWrapperProps } from '../hooks/utils';
import type { NativeWrapperProperties } from '../types/NativeWrapperType';
-import ScrollViewResponderInterceptor from './ScrollViewResponderInterceptor';
+import { ScrollViewResponderProvider } from './ScrollViewResponderInterceptor';
export const RefreshControl: React.ComponentType<
RNRefreshControlProps &
@@ -79,26 +79,34 @@ export const ScrollView = (
};
return (
-
-
+
+
{children}
-
-
+
+
);
};
diff --git a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx
index 11465ac7d6..e5269affc6 100644
--- a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx
+++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx
@@ -2,10 +2,11 @@ import type { PropsWithChildren } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import type {
EmitterSubscription,
+ GestureResponderEvent,
KeyboardEvent,
ScrollViewProps as RNScrollViewProps,
} from 'react-native';
-import { Keyboard, StyleSheet, View } from 'react-native';
+import { Keyboard, Platform, StyleSheet, TextInput, View } from 'react-native';
type KeyboardShouldPersistTaps = RNScrollViewProps['keyboardShouldPersistTaps'];
@@ -95,11 +96,38 @@ type ScrollViewResponderInterceptorProps = PropsWithChildren<{
keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps'];
}>;
-const ScrollViewResponderInterceptor = ({
+// Mirrors ScrollView's `_keyboardIsDismissible` + `_softKeyboardIsDetached`
+// (react-native/Libraries/Components/ScrollView/ScrollView.js) using only
+// public API.
+function keyboardIsDismissible(): boolean {
+ const currentlyFocusedInput = TextInput.State.currentlyFocusedInput();
+ if (currentlyFocusedInput == null) {
+ return false;
+ }
+
+ const metrics = Keyboard.metrics?.();
+
+ const softKeyboardMayBeOpen =
+ metrics != null ||
+ (Platform.OS === 'android' && Number(Platform.Version) < 30);
+
+ const softKeyboardIsDetached = metrics != null && metrics.height === 0;
+
+ return softKeyboardMayBeOpen && !softKeyboardIsDetached;
+}
+
+// In 'handled' mode, consumes RNGH-marked responder events so handled taps
+// don't dismiss the keyboard (see the PR #4158 discussion), and reimplements
+// RN ScrollView's own 'handled' dismissal, which GH ScrollView turns off via
+// `disableScrollViewPanResponder`. The wrapper sits ABOVE the ScrollView so
+// its children stay untouched for `stickyHeaderIndices` (#4328).
+// https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
+export const ScrollViewResponderProvider = ({
children,
keyboardShouldPersistTaps,
}: ScrollViewResponderInterceptorProps) => {
const isRNGHResponderEvent = useRef(false);
+ const claimedForKeyboardDismissal = useRef(false);
const contextValue = useMemo(
() => ({ isRNGHResponderEvent, keyboardShouldPersistTaps }),
[isRNGHResponderEvent, keyboardShouldPersistTaps]
@@ -115,29 +143,67 @@ const ScrollViewResponderInterceptor = ({
return false;
}, []);
- const handleStartShouldSetResponder = useCallback(() => {
- const shouldHandleRNGHEvent =
- keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current;
+ const handleStartShouldSetResponder = useCallback(
+ (event: GestureResponderEvent) => {
+ if (isRNGHResponderEvent.current) {
+ // Claim marked events so outer keyboard-dismissing responders can't —
+ // release does nothing and the keyboard stays open.
+ isRNGHResponderEvent.current = false;
+ claimedForKeyboardDismissal.current = false;
+ return true;
+ }
+
+ // Unhandled tap — claim to dismiss the keyboard on release, like RN
+ // ScrollView's 'handled' claim would.
+ const shouldClaim =
+ keyboardIsDismissible() &&
+ event.target !== TextInput.State.currentlyFocusedInput();
+ claimedForKeyboardDismissal.current = shouldClaim;
+ return shouldClaim;
+ },
+ []
+ );
- isRNGHResponderEvent.current = false;
+ const handleResponderRelease = useCallback((event: GestureResponderEvent) => {
+ if (!claimedForKeyboardDismissal.current) {
+ return;
+ }
+ claimedForKeyboardDismissal.current = false;
+
+ const currentlyFocusedInput = TextInput.State.currentlyFocusedInput();
+ if (
+ currentlyFocusedInput != null &&
+ keyboardIsDismissible() &&
+ event.target !== currentlyFocusedInput
+ ) {
+ TextInput.State.blurTextInput(currentlyFocusedInput);
+ }
+ }, []);
- return shouldHandleRNGHEvent;
- }, [keyboardShouldPersistTaps]);
+ // A native scroll taking over terminates the JS responder — mirror RN's
+ // `_observedScrollSinceBecomingResponder` guard by skipping the dismissal.
+ const handleResponderTerminate = useCallback(() => {
+ claimedForKeyboardDismissal.current = false;
+ }, []);
+
+ const isHandledMode = keyboardShouldPersistTaps === 'handled';
- // RNGH tap responders need to let RN components higher in the tree handle
- // the JS responder event first. If no RN component claims it, this logical
- // ScrollView child consumes the marked event before ScrollView's own
- // keyboardShouldPersistTaps='handled' responder logic handles it.
- // For more information check this comment:
- // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
return (
+ style={styles.logicalResponder}
+ onStartShouldSetResponderCapture={
+ isHandledMode ? resetRNGHResponderEvent : undefined
+ }
+ onStartShouldSetResponder={
+ isHandledMode ? handleStartShouldSetResponder : undefined
+ }
+ onResponderRelease={isHandledMode ? handleResponderRelease : undefined}
+ onResponderTerminate={
+ isHandledMode ? handleResponderTerminate : undefined
+ }>
{children}
@@ -149,5 +215,3 @@ const styles = StyleSheet.create({
display: 'contents',
},
});
-
-export default ScrollViewResponderInterceptor;