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;