11import type { PropsWithChildren } from 'react' ;
2- import React , { use , useCallback , useEffect , useMemo , useRef } from 'react' ;
2+ import React , { useCallback , useEffect , useMemo , useRef } from 'react' ;
33import type {
44 EmitterSubscription ,
5+ GestureResponderEvent ,
56 KeyboardEvent ,
67 ScrollViewProps as RNScrollViewProps ,
78} from 'react-native' ;
8- import { Keyboard , StyleSheet , View } from 'react-native' ;
9+ import { Keyboard , Platform , StyleSheet , TextInput , View } from 'react-native' ;
910
1011type KeyboardShouldPersistTaps = RNScrollViewProps [ 'keyboardShouldPersistTaps' ] ;
1112
@@ -95,11 +96,38 @@ type ScrollViewResponderInterceptorProps = PropsWithChildren<{
9596 keyboardShouldPersistTaps ?: RNScrollViewProps [ 'keyboardShouldPersistTaps' ] ;
9697} > ;
9798
99+ // Mirrors ScrollView's `_keyboardIsDismissible` + `_softKeyboardIsDetached`
100+ // (react-native/Libraries/Components/ScrollView/ScrollView.js) using only
101+ // public API.
102+ function keyboardIsDismissible ( ) : boolean {
103+ const currentlyFocusedInput = TextInput . State . currentlyFocusedInput ( ) ;
104+ if ( currentlyFocusedInput == null ) {
105+ return false ;
106+ }
107+
108+ const metrics = Keyboard . metrics ?.( ) ;
109+
110+ const softKeyboardMayBeOpen =
111+ metrics != null ||
112+ ( Platform . OS === 'android' && Number ( Platform . Version ) < 30 ) ;
113+
114+ const softKeyboardIsDetached = metrics != null && metrics . height === 0 ;
115+
116+ return softKeyboardMayBeOpen && ! softKeyboardIsDetached ;
117+ }
118+
119+ // In 'handled' mode, consumes RNGH-marked responder events so handled taps
120+ // don't dismiss the keyboard (see the PR #4158 discussion), and reimplements
121+ // RN ScrollView's own 'handled' dismissal, which GH ScrollView turns off via
122+ // `disableScrollViewPanResponder`. The wrapper sits ABOVE the ScrollView so
123+ // its children stay untouched for `stickyHeaderIndices` (#4328).
124+ // https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
98125export const ScrollViewResponderProvider = ( {
99126 children,
100127 keyboardShouldPersistTaps,
101128} : ScrollViewResponderInterceptorProps ) => {
102129 const isRNGHResponderEvent = useRef ( false ) ;
130+ const claimedForKeyboardDismissal = useRef ( false ) ;
103131 const contextValue = useMemo (
104132 ( ) => ( { isRNGHResponderEvent, keyboardShouldPersistTaps } ) ,
105133 [ isRNGHResponderEvent , keyboardShouldPersistTaps ]
@@ -110,78 +138,80 @@ export const ScrollViewResponderProvider = ({
110138 return ( ) => unsubscribeFromKeyboardVisibility ( ) ;
111139 } , [ ] ) ;
112140
113- return (
114- < JSResponderContext value = { contextValue } > { children } </ JSResponderContext >
115- ) ;
116- } ;
117-
118- // RNGH tap responders need to let RN components higher in the tree handle the JS
119- // responder event first. If no RN component claims it, this logical ScrollView
120- // child consumes the marked event before ScrollView's own
121- // keyboardShouldPersistTaps='handled' responder logic handles it.
122- // For more context: https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
123- const LogicalResponder = ( { children } : PropsWithChildren ) => {
124- const jsResponderContext = use ( JSResponderContext ) ;
125-
126141 const resetRNGHResponderEvent = useCallback ( ( ) => {
127- updateResponderEventValue ( jsResponderContext , false ) ;
142+ isRNGHResponderEvent . current = false ;
128143 return false ;
129- } , [ jsResponderContext ] ) ;
144+ } , [ ] ) ;
130145
131- const handleStartShouldSetResponder = useCallback ( ( ) => {
132- const shouldHandleRNGHEvent =
133- jsResponderContext ?. keyboardShouldPersistTaps === 'handled' &&
134- jsResponderContext . isRNGHResponderEvent . current ;
146+ const handleStartShouldSetResponder = useCallback (
147+ ( event : GestureResponderEvent ) => {
148+ if ( isRNGHResponderEvent . current ) {
149+ // Claim marked events so outer keyboard-dismissing responders can't —
150+ // release does nothing and the keyboard stays open.
151+ isRNGHResponderEvent . current = false ;
152+ claimedForKeyboardDismissal . current = false ;
153+ return true ;
154+ }
155+
156+ // Unhandled tap — claim to dismiss the keyboard on release, like RN
157+ // ScrollView's 'handled' claim would.
158+ const shouldClaim =
159+ keyboardIsDismissible ( ) &&
160+ event . target !== TextInput . State . currentlyFocusedInput ( ) ;
161+ claimedForKeyboardDismissal . current = shouldClaim ;
162+ return shouldClaim ;
163+ } ,
164+ [ ]
165+ ) ;
135166
136- updateResponderEventValue ( jsResponderContext , false ) ;
167+ const handleResponderRelease = useCallback ( ( event : GestureResponderEvent ) => {
168+ if ( ! claimedForKeyboardDismissal . current ) {
169+ return ;
170+ }
171+ claimedForKeyboardDismissal . current = false ;
172+
173+ const currentlyFocusedInput = TextInput . State . currentlyFocusedInput ( ) ;
174+ if (
175+ currentlyFocusedInput != null &&
176+ keyboardIsDismissible ( ) &&
177+ event . target !== currentlyFocusedInput
178+ ) {
179+ TextInput . State . blurTextInput ( currentlyFocusedInput ) ;
180+ }
181+ } , [ ] ) ;
137182
138- return shouldHandleRNGHEvent ;
139- } , [ jsResponderContext ] ) ;
183+ // A native scroll taking over terminates the JS responder — mirror RN's
184+ // `_observedScrollSinceBecomingResponder` guard by skipping the dismissal.
185+ const handleResponderTerminate = useCallback ( ( ) => {
186+ claimedForKeyboardDismissal . current = false ;
187+ } , [ ] ) ;
140188
141- return (
142- < View
143- collapsable = { false }
144- onStartShouldSetResponderCapture = { resetRNGHResponderEvent }
145- onStartShouldSetResponder = { handleStartShouldSetResponder }
146- pointerEvents = "box-none"
147- style = { styles . logicalResponder } >
148- { children }
149- </ View >
150- ) ;
151- } ;
189+ const isHandledMode = keyboardShouldPersistTaps === 'handled' ;
152190
153- // Wraps the whole ScrollView content in a single logical responder
154- const ScrollViewResponderInterceptor = ( {
155- children,
156- keyboardShouldPersistTaps,
157- } : ScrollViewResponderInterceptorProps ) => {
158191 return (
159- < ScrollViewResponderProvider
160- keyboardShouldPersistTaps = { keyboardShouldPersistTaps } >
161- < LogicalResponder > { children } </ LogicalResponder >
162- </ ScrollViewResponderProvider >
192+ < JSResponderContext value = { contextValue } >
193+ < View
194+ collapsable = { false }
195+ pointerEvents = "box-none"
196+ style = { styles . logicalResponder }
197+ onStartShouldSetResponderCapture = {
198+ isHandledMode ? resetRNGHResponderEvent : undefined
199+ }
200+ onStartShouldSetResponder = {
201+ isHandledMode ? handleStartShouldSetResponder : undefined
202+ }
203+ onResponderRelease = { isHandledMode ? handleResponderRelease : undefined }
204+ onResponderTerminate = {
205+ isHandledMode ? handleResponderTerminate : undefined
206+ } >
207+ { children }
208+ </ View >
209+ </ JSResponderContext >
163210 ) ;
164211} ;
165212
166- // Wraps each ScrollView child in its own logical responder, keeping the child
167- // count intact. Requires a `ScrollViewResponderProvider` above the ScrollView
168- export function interceptScrollViewChildren ( children : React . ReactNode ) {
169- // Null and boolean children must be passed through untouched — ScrollView
170- // drops them in its own `React.Children.toArray` call, so wrapping them
171- // would shift the indices that `stickyHeaderIndices` refers to.
172- return React . Children . map ( children , ( child ) =>
173- child == null || typeof child === 'boolean' ? (
174- child
175- ) : (
176- < LogicalResponder > { child } </ LogicalResponder >
177- )
178- ) ;
179- }
180-
181213const styles = StyleSheet . create ( {
182214 logicalResponder : {
183215 display : 'contents' ,
184216 } ,
185217} ) ;
186-
187- export default ScrollViewResponderInterceptor ;
0 commit comments