Skip to content

Commit 720e753

Browse files
committed
Per-child interceptor only with sticky header
1 parent d8d2751 commit 720e753

2 files changed

Lines changed: 70 additions & 21 deletions

File tree

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

Lines changed: 48 additions & 19 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 {
24+
import ScrollViewResponderInterceptor, {
2525
interceptScrollViewChildren,
2626
ScrollViewResponderProvider,
2727
} from './ScrollViewResponderInterceptor';
@@ -81,27 +81,56 @@ export const ScrollView = (
8181
});
8282
};
8383

84-
return (
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 ? (
85128
<ScrollViewResponderProvider
86129
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
87-
<GHScrollView
88-
{...rest}
89-
ref={props.ref}
90-
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
91-
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
92-
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
93-
refreshControl={
94-
refreshControl
95-
? React.cloneElement(
96-
refreshControl,
97-
// @ts-ignore block exists (on our RefreshControl)
98-
scrollGesture ? { block: scrollGesture } : {}
99-
)
100-
: undefined
101-
}>
102-
{interceptScrollViewChildren(children)}
103-
</GHScrollView>
130+
{scrollView}
104131
</ScrollViewResponderProvider>
132+
) : (
133+
scrollView
105134
);
106135
};
107136

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

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export const ScrollViewResponderProvider = ({
120120
// child consumes the marked event before ScrollView's own
121121
// keyboardShouldPersistTaps='handled' responder logic handles it.
122122
// For more context: https://github.com/software-mansion/react-native-gesture-handler/pull/4158#issuecomment-4431632964
123-
const LogicalResponderChild = ({ children }: PropsWithChildren) => {
123+
const LogicalResponder = ({ children }: PropsWithChildren) => {
124124
const jsResponderContext = use(JSResponderContext);
125125

126126
const resetRNGHResponderEvent = useCallback(() => {
@@ -150,12 +150,30 @@ const LogicalResponderChild = ({ children }: PropsWithChildren) => {
150150
);
151151
};
152152

153+
// Wraps the whole ScrollView content in a single logical responder
154+
const ScrollViewResponderInterceptor = ({
155+
children,
156+
keyboardShouldPersistTaps,
157+
}: ScrollViewResponderInterceptorProps) => {
158+
return (
159+
<ScrollViewResponderProvider
160+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
161+
<LogicalResponder>{children}</LogicalResponder>
162+
</ScrollViewResponderProvider>
163+
);
164+
};
165+
166+
// Wraps each ScrollView child in its own logical responder, keeping the child
167+
// count intact. Requires a `ScrollViewResponderProvider` above the ScrollView
153168
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.
154172
return React.Children.map(children, (child) =>
155173
child == null || typeof child === 'boolean' ? (
156174
child
157175
) : (
158-
<LogicalResponderChild>{child}</LogicalResponderChild>
176+
<LogicalResponder>{child}</LogicalResponder>
159177
)
160178
);
161179
}
@@ -165,3 +183,5 @@ const styles = StyleSheet.create({
165183
display: 'contents',
166184
},
167185
});
186+
187+
export default ScrollViewResponderInterceptor;

0 commit comments

Comments
 (0)