Skip to content

Commit 5e88e70

Browse files
m-bertCopilot
andauthored
Move Interceptor on ScrollView, not its content (#4331)
## Description #4158 introduced `ScrollViewResponderInterceptor`, which wraps all `ScrollView` children in a single logical-responder `View`. RN's `ScrollView.render()` resolves `stickyHeaderIndices` against `React.Children.toArray(this.props.children)` and wraps the child at each sticky index in `ScrollViewStickyHeader`, which counter-translates that child against the scroll offset. With the content collapsed into one child, index `0` is the entire content — so everything gets pinned. Indices > 0 were a silent no-op (they pointed past the single child). To fix this, we now move the wrapper outside of `ScrollView` and handle keyboard dismissal manually. Fixes #4328 ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { FlatList, LegacyFlatList } from 'react-native-gesture-handler'; // Repro for #4328 // v3 FlatList with stickyHeaderIndices: scroll indicator moves, content doesn't. // LegacyFlatList behaves correctly. const items = Array.from({ length: 50 }, (_, i) => ({ id: i.toString(), text: `Text content for ${i}`, })); export default function EmptyExample() { const renderItem = ({ item }: { item: (typeof items)[0] }) => ( <Text style={styles.item}>{item.text}</Text> ); return ( <View style={styles.container}> <View style={styles.flex1}> <FlatList data={items} keyExtractor={(item) => item.id} contentContainerStyle={styles.contentContainer} stickyHeaderHiddenOnScroll={false} ListHeaderComponentStyle={styles.headerContainer} stickyHeaderIndices={[0]} ListHeaderComponent={<Text>FlatList (v3) Header</Text>} renderItem={renderItem} /> </View> <View style={styles.flex1}> <LegacyFlatList data={items} keyExtractor={(item) => item.id} contentContainerStyle={styles.contentContainer} stickyHeaderHiddenOnScroll={false} ListHeaderComponentStyle={styles.headerContainer} stickyHeaderIndices={[0]} ListHeaderComponent={<Text>LegacyFlatList Header</Text>} renderItem={renderItem} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, }, flex1: { flex: 1, }, headerContainer: { paddingTop: 24, paddingBottom: 12, backgroundColor: '#f6f6f6', }, contentContainer: { paddingHorizontal: 24, }, item: { paddingVertical: 4, }, }); ``` </details> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent c4064f9 commit 5e88e70

3 files changed

Lines changed: 114 additions & 46 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
@@ -284,14 +284,10 @@ describe('[API v3] Components', () => {
284284
const nativeDetector = getNativeDetector(UNSAFE_getAllByType);
285285
const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType);
286286

287-
expect(scrollViewResponder).toBeDefined();
288-
expect(
289-
scrollViewResponder?.props.onStartShouldSetResponderCapture()
290-
).toBe(false);
287+
// Outside of 'handled' mode the logical responder view is not rendered
288+
// at all — the responder event can never be claimed on behalf of RNGH.
289+
expect(scrollViewResponder).toBeUndefined();
291290
expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false);
292-
expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(
293-
false
294-
);
295291
});
296292

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

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

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +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 from './ScrollViewResponderInterceptor';
24+
import { ScrollViewResponderProvider } from './ScrollViewResponderInterceptor';
2525

2626
export const RefreshControl: React.ComponentType<
2727
RNRefreshControlProps &
@@ -79,26 +79,34 @@ export const ScrollView = (
7979
};
8080

8181
return (
82-
<GHScrollView
83-
{...rest}
84-
ref={props.ref}
85-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
86-
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
87-
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
88-
refreshControl={
89-
refreshControl
90-
? React.cloneElement(
91-
refreshControl,
92-
// @ts-ignore block exists (on our RefreshControl)
93-
scrollGesture ? { block: scrollGesture } : {}
94-
)
95-
: undefined
96-
}>
97-
<ScrollViewResponderInterceptor
98-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
82+
<ScrollViewResponderProvider
83+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
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+
}>
99107
{children}
100-
</ScrollViewResponderInterceptor>
101-
</GHScrollView>
108+
</GHScrollView>
109+
</ScrollViewResponderProvider>
102110
);
103111
};
104112

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

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import type { PropsWithChildren } from 'react';
22
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

98-
const ScrollViewResponderInterceptor = ({
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
125+
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]
@@ -115,29 +143,67 @@ const ScrollViewResponderInterceptor = ({
115143
return false;
116144
}, []);
117145

118-
const handleStartShouldSetResponder = useCallback(() => {
119-
const shouldHandleRNGHEvent =
120-
keyboardShouldPersistTaps === 'handled' && 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+
);
121166

122-
isRNGHResponderEvent.current = 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+
}, []);
123182

124-
return shouldHandleRNGHEvent;
125-
}, [keyboardShouldPersistTaps]);
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+
}, []);
188+
189+
const isHandledMode = keyboardShouldPersistTaps === 'handled';
126190

127-
// RNGH tap responders need to let RN components higher in the tree handle
128-
// the JS responder event first. If no RN component claims it, this logical
129-
// ScrollView child consumes the marked event before ScrollView's own
130-
// keyboardShouldPersistTaps='handled' responder logic handles it.
131-
// For more information check this comment:
132-
// https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
133191
return (
134192
<JSResponderContext value={contextValue}>
135193
<View
136194
collapsable={false}
137-
onStartShouldSetResponderCapture={resetRNGHResponderEvent}
138-
onStartShouldSetResponder={handleStartShouldSetResponder}
139195
pointerEvents="box-none"
140-
style={styles.logicalResponder}>
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+
}>
141207
{children}
142208
</View>
143209
</JSResponderContext>
@@ -149,5 +215,3 @@ const styles = StyleSheet.create({
149215
display: 'contents',
150216
},
151217
});
152-
153-
export default ScrollViewResponderInterceptor;

0 commit comments

Comments
 (0)