Skip to content

Commit 18978a3

Browse files
committed
Other approach
1 parent 720e753 commit 18978a3

3 files changed

Lines changed: 121 additions & 119 deletions

File tree

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

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -175,14 +175,10 @@ describe('[API v3] Components', () => {
175175
const nativeDetector = getNativeDetector(UNSAFE_getAllByType);
176176
const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType);
177177

178-
expect(scrollViewResponder).toBeDefined();
179-
expect(
180-
scrollViewResponder?.props.onStartShouldSetResponderCapture()
181-
).toBe(false);
178+
// Outside of 'handled' mode the logical responder view is not rendered
179+
// at all — the responder event can never be claimed on behalf of RNGH.
180+
expect(scrollViewResponder).toBeUndefined();
182181
expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false);
183-
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
184-
false
185-
);
186182
});
187183

188184
test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => {

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

Lines changed: 27 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,7 @@ import { GestureDetectorType } from '../detectors';
2121
import type { NativeGesture } from '../hooks/gestures/native/NativeTypes';
2222
import { NativeWrapperProps } from '../hooks/utils';
2323
import type { NativeWrapperProperties } from '../types/NativeWrapperType';
24-
import ScrollViewResponderInterceptor, {
25-
interceptScrollViewChildren,
26-
ScrollViewResponderProvider,
27-
} from './ScrollViewResponderInterceptor';
24+
import { ScrollViewResponderProvider } from './ScrollViewResponderInterceptor';
2825

2926
export const RefreshControl: React.ComponentType<
3027
RNRefreshControlProps &
@@ -81,56 +78,35 @@ export const ScrollView = (
8178
});
8279
};
8380

84-
// ScrollView resolves `stickyHeaderIndices` against its direct children, so
85-
// the single responder interceptor would collapse the
86-
// content into one child and pin all of it when index 0 is sticky.
87-
// ScrollViews using sticky headers move the responder context above the
88-
// ScrollView and leave the child count intact — with one logical responder
89-
// per child in 'handled' mode.
90-
const hasStickyHeaders =
91-
Array.isArray(rest.stickyHeaderIndices) &&
92-
rest.stickyHeaderIndices.length > 0;
93-
94-
const scrollView = (
95-
<GHScrollView
96-
{...rest}
97-
ref={props.ref}
98-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
99-
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
100-
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
101-
refreshControl={
102-
refreshControl
103-
? React.cloneElement(
104-
refreshControl,
105-
// @ts-ignore block exists (on our RefreshControl)
106-
scrollGesture ? { block: scrollGesture } : {}
107-
)
108-
: undefined
109-
}>
110-
{!hasStickyHeaders ? (
111-
<ScrollViewResponderInterceptor
112-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
113-
{children}
114-
</ScrollViewResponderInterceptor>
115-
) : keyboardShouldPersistTaps === 'handled' ? (
116-
interceptScrollViewChildren(children)
117-
) : (
118-
children
119-
)}
120-
</GHScrollView>
121-
);
122-
123-
// The provider is required even when no logical responders are rendered —
124-
// Pressable and Touchable read its context (and its keyboard-visibility
125-
// tracking) to suppress presses on keyboard-dismissing taps in 'never'
126-
// mode. Only the responder wrappers are gated on 'handled'.
127-
return hasStickyHeaders ? (
81+
return (
12882
<ScrollViewResponderProvider
12983
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
130-
{scrollView}
84+
<GHScrollView
85+
{...rest}
86+
ref={props.ref}
87+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
88+
// In 'handled' mode the provider above owns the keyboard dismissal, so
89+
// RN's own responder claim is disabled to let unclaimed taps bubble up
90+
// to it. Children stay untouched for `stickyHeaderIndices` (#4328).
91+
disableScrollViewPanResponder={
92+
keyboardShouldPersistTaps === 'handled'
93+
? true
94+
: rest.disableScrollViewPanResponder
95+
}
96+
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
97+
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
98+
refreshControl={
99+
refreshControl
100+
? React.cloneElement(
101+
refreshControl,
102+
// @ts-ignore block exists (on our RefreshControl)
103+
scrollGesture ? { block: scrollGesture } : {}
104+
)
105+
: undefined
106+
}>
107+
{children}
108+
</GHScrollView>
131109
</ScrollViewResponderProvider>
132-
) : (
133-
scrollView
134110
);
135111
};
136112

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

Lines changed: 91 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { PropsWithChildren } from 'react';
2-
import React, { use, useCallback, useEffect, useMemo, useRef } from 'react';
2+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
33
import 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

1011
type 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
98125
export 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-
181213
const styles = StyleSheet.create({
182214
logicalResponder: {
183215
display: 'contents',
184216
},
185217
});
186-
187-
export default ScrollViewResponderInterceptor;

0 commit comments

Comments
 (0)