From d0849a10dc15e70f78492fbba8a6e1bb2d00f350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 23 Jul 2026 09:53:23 +0200 Subject: [PATCH 1/4] Wrap children --- .../src/v3/components/GestureComponents.tsx | 45 +++++++------- .../ScrollViewResponderInterceptor.tsx | 61 +++++++++++-------- 2 files changed, 59 insertions(+), 47 deletions(-) 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..e68f65d3b9 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,10 @@ 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 { + interceptScrollViewChildren, + ScrollViewResponderProvider, +} from './ScrollViewResponderInterceptor'; export const RefreshControl: React.ComponentType< RNRefreshControlProps & @@ -79,26 +82,26 @@ export const ScrollView = ( }; return ( - - - {children} - - + + + {interceptScrollViewChildren(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..a54ad1d9ca 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx @@ -1,5 +1,5 @@ import type { PropsWithChildren } from 'react'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { use, useCallback, useEffect, useMemo, useRef } from 'react'; import type { EmitterSubscription, KeyboardEvent, @@ -95,7 +95,7 @@ type ScrollViewResponderInterceptorProps = PropsWithChildren<{ keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; }>; -const ScrollViewResponderInterceptor = ({ +export const ScrollViewResponderProvider = ({ children, keyboardShouldPersistTaps, }: ScrollViewResponderInterceptorProps) => { @@ -110,44 +110,53 @@ const ScrollViewResponderInterceptor = ({ return () => unsubscribeFromKeyboardVisibility(); }, []); + return ( + {children} + ); +}; + +const LogicalResponderChild = ({ children }: PropsWithChildren) => { + const jsResponderContext = use(JSResponderContext); + const resetRNGHResponderEvent = useCallback(() => { - isRNGHResponderEvent.current = false; + updateResponderEventValue(jsResponderContext, false); return false; - }, []); + }, [jsResponderContext]); const handleStartShouldSetResponder = useCallback(() => { const shouldHandleRNGHEvent = - keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; + jsResponderContext?.keyboardShouldPersistTaps === 'handled' && + jsResponderContext.isRNGHResponderEvent.current; - isRNGHResponderEvent.current = false; + updateResponderEventValue(jsResponderContext, false); return shouldHandleRNGHEvent; - }, [keyboardShouldPersistTaps]); - - // 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 + }, [jsResponderContext]); + return ( - - - {children} - - + + {children} + ); }; +export function interceptScrollViewChildren(children: React.ReactNode) { + return React.Children.map(children, (child) => + child == null || typeof child === 'boolean' ? ( + child + ) : ( + {child} + ) + ); +} + const styles = StyleSheet.create({ logicalResponder: { display: 'contents', }, }); - -export default ScrollViewResponderInterceptor; From d8d2751f2751d2208786b7069bb1fe75c6736f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:11:15 +0200 Subject: [PATCH 2/4] Bring back comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/v3/components/ScrollViewResponderInterceptor.tsx | 5 +++++ 1 file changed, 5 insertions(+) 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 a54ad1d9ca..e421d6648f 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx @@ -115,6 +115,11 @@ export const ScrollViewResponderProvider = ({ ); }; +// 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 context: https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 const LogicalResponderChild = ({ children }: PropsWithChildren) => { const jsResponderContext = use(JSResponderContext); From 720e7536a4872cad5142797867091078c7b19892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 23 Jul 2026 14:47:49 +0200 Subject: [PATCH 3/4] Per-child interceptor only with sticky header --- .../src/v3/components/GestureComponents.tsx | 67 +++++++++++++------ .../ScrollViewResponderInterceptor.tsx | 24 ++++++- 2 files changed, 70 insertions(+), 21 deletions(-) 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 e68f65d3b9..cc55555283 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 { +import ScrollViewResponderInterceptor, { interceptScrollViewChildren, ScrollViewResponderProvider, } from './ScrollViewResponderInterceptor'; @@ -81,27 +81,56 @@ export const ScrollView = ( }); }; - return ( + // ScrollView resolves `stickyHeaderIndices` against its direct children, so + // the single responder interceptor would collapse the + // content into one child and pin all of it when index 0 is sticky. + // ScrollViews using sticky headers move the responder context above the + // ScrollView and leave the child count intact — with one logical responder + // per child in 'handled' mode. + const hasStickyHeaders = + Array.isArray(rest.stickyHeaderIndices) && + rest.stickyHeaderIndices.length > 0; + + const scrollView = ( + + {!hasStickyHeaders ? ( + + {children} + + ) : keyboardShouldPersistTaps === 'handled' ? ( + interceptScrollViewChildren(children) + ) : ( + children + )} + + ); + + // The provider is required even when no logical responders are rendered — + // Pressable and Touchable read its context (and its keyboard-visibility + // tracking) to suppress presses on keyboard-dismissing taps in 'never' + // mode. Only the responder wrappers are gated on 'handled'. + return hasStickyHeaders ? ( - - {interceptScrollViewChildren(children)} - + {scrollView} + ) : ( + scrollView ); }; 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 e421d6648f..71939a2854 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx @@ -120,7 +120,7 @@ export const ScrollViewResponderProvider = ({ // child consumes the marked event before ScrollView's own // keyboardShouldPersistTaps='handled' responder logic handles it. // For more context: https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 -const LogicalResponderChild = ({ children }: PropsWithChildren) => { +const LogicalResponder = ({ children }: PropsWithChildren) => { const jsResponderContext = use(JSResponderContext); const resetRNGHResponderEvent = useCallback(() => { @@ -150,12 +150,30 @@ const LogicalResponderChild = ({ children }: PropsWithChildren) => { ); }; +// Wraps the whole ScrollView content in a single logical responder +const ScrollViewResponderInterceptor = ({ + children, + keyboardShouldPersistTaps, +}: ScrollViewResponderInterceptorProps) => { + return ( + + {children} + + ); +}; + +// Wraps each ScrollView child in its own logical responder, keeping the child +// count intact. Requires a `ScrollViewResponderProvider` above the ScrollView export function interceptScrollViewChildren(children: React.ReactNode) { + // Null and boolean children must be passed through untouched — ScrollView + // drops them in its own `React.Children.toArray` call, so wrapping them + // would shift the indices that `stickyHeaderIndices` refers to. return React.Children.map(children, (child) => child == null || typeof child === 'boolean' ? ( child ) : ( - {child} + {child} ) ); } @@ -165,3 +183,5 @@ const styles = StyleSheet.create({ display: 'contents', }, }); + +export default ScrollViewResponderInterceptor; From 18978a3f22bcdb2406359689aa14d62301da3586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Fri, 24 Jul 2026 15:30:20 +0200 Subject: [PATCH 4/4] Other approach --- .../src/__tests__/api_v3.test.tsx | 10 +- .../src/v3/components/GestureComponents.tsx | 78 ++++----- .../ScrollViewResponderInterceptor.tsx | 152 +++++++++++------- 3 files changed, 121 insertions(+), 119 deletions(-) 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 35165035d3..7e1e397f05 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 @@ -175,14 +175,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 cc55555283..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,10 +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, { - interceptScrollViewChildren, - ScrollViewResponderProvider, -} from './ScrollViewResponderInterceptor'; +import { ScrollViewResponderProvider } from './ScrollViewResponderInterceptor'; export const RefreshControl: React.ComponentType< RNRefreshControlProps & @@ -81,56 +78,35 @@ export const ScrollView = ( }); }; - // ScrollView resolves `stickyHeaderIndices` against its direct children, so - // the single responder interceptor would collapse the - // content into one child and pin all of it when index 0 is sticky. - // ScrollViews using sticky headers move the responder context above the - // ScrollView and leave the child count intact — with one logical responder - // per child in 'handled' mode. - const hasStickyHeaders = - Array.isArray(rest.stickyHeaderIndices) && - rest.stickyHeaderIndices.length > 0; - - const scrollView = ( - - {!hasStickyHeaders ? ( - - {children} - - ) : keyboardShouldPersistTaps === 'handled' ? ( - interceptScrollViewChildren(children) - ) : ( - children - )} - - ); - - // The provider is required even when no logical responders are rendered — - // Pressable and Touchable read its context (and its keyboard-visibility - // tracking) to suppress presses on keyboard-dismissing taps in 'never' - // mode. Only the responder wrappers are gated on 'handled'. - return hasStickyHeaders ? ( + return ( - {scrollView} + + {children} + - ) : ( - scrollView ); }; 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 71939a2854..e5269affc6 100644 --- a/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/ScrollViewResponderInterceptor.tsx @@ -1,11 +1,12 @@ import type { PropsWithChildren } from 'react'; -import React, { use, useCallback, useEffect, useMemo, useRef } 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']; }>; +// 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] @@ -110,78 +138,80 @@ export const ScrollViewResponderProvider = ({ return () => unsubscribeFromKeyboardVisibility(); }, []); - return ( - {children} - ); -}; - -// 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 context: https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964 -const LogicalResponder = ({ children }: PropsWithChildren) => { - const jsResponderContext = use(JSResponderContext); - const resetRNGHResponderEvent = useCallback(() => { - updateResponderEventValue(jsResponderContext, false); + isRNGHResponderEvent.current = false; return false; - }, [jsResponderContext]); + }, []); - const handleStartShouldSetResponder = useCallback(() => { - const shouldHandleRNGHEvent = - jsResponderContext?.keyboardShouldPersistTaps === 'handled' && - jsResponderContext.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; + }, + [] + ); - updateResponderEventValue(jsResponderContext, 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; - }, [jsResponderContext]); + // A native scroll taking over terminates the JS responder — mirror RN's + // `_observedScrollSinceBecomingResponder` guard by skipping the dismissal. + const handleResponderTerminate = useCallback(() => { + claimedForKeyboardDismissal.current = false; + }, []); - return ( - - {children} - - ); -}; + const isHandledMode = keyboardShouldPersistTaps === 'handled'; -// Wraps the whole ScrollView content in a single logical responder -const ScrollViewResponderInterceptor = ({ - children, - keyboardShouldPersistTaps, -}: ScrollViewResponderInterceptorProps) => { return ( - - {children} - + + + {children} + + ); }; -// Wraps each ScrollView child in its own logical responder, keeping the child -// count intact. Requires a `ScrollViewResponderProvider` above the ScrollView -export function interceptScrollViewChildren(children: React.ReactNode) { - // Null and boolean children must be passed through untouched — ScrollView - // drops them in its own `React.Children.toArray` call, so wrapping them - // would shift the indices that `stickyHeaderIndices` refers to. - return React.Children.map(children, (child) => - child == null || typeof child === 'boolean' ? ( - child - ) : ( - {child} - ) - ); -} - const styles = StyleSheet.create({ logicalResponder: { display: 'contents', }, }); - -export default ScrollViewResponderInterceptor;