Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onBegin: (e) => onBegin(e),

Check warning on line 31 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onActivate: (e) => onStart(e),

Check warning on line 32 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand Down Expand Up @@ -57,8 +57,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onUpdate: (e) => onUpdate(e),

Check warning on line 60 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onTouchesUp: (e) => onTouchesUp(e),

Check warning on line 61 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand All @@ -75,7 +75,7 @@

expect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
jsEventHandler?.(malformedTouchEvent as any);

Check warning on line 78 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe argument of type `any` assigned to a parameter of type `GestureHandlerEventWithHandlerData<PanHandlerData, PanExtendedHandlerData>`
}).not.toThrow();

expect(onUpdate).not.toHaveBeenCalled();
Expand All @@ -88,7 +88,7 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onUpdate: (e) => onUpdate(e),

Check warning on line 91 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand Down Expand Up @@ -257,14 +257,10 @@
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ScrollViewResponderInterceptor from './ScrollViewResponderInterceptor';
import { ScrollViewResponderProvider } from './ScrollViewResponderInterceptor';

export const RefreshControl: React.ComponentType<
RNRefreshControlProps &
Expand Down Expand Up @@ -79,26 +79,34 @@ export const ScrollView = (
};

return (
<GHScrollView
{...rest}
ref={props.ref}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
refreshControl={
refreshControl
? React.cloneElement(
refreshControl,
// @ts-ignore block exists (on our RefreshControl)
scrollGesture ? { block: scrollGesture } : {}
)
: undefined
}>
<ScrollViewResponderInterceptor
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
<ScrollViewResponderProvider
keyboardShouldPersistTaps={keyboardShouldPersistTaps}>
<GHScrollView
{...rest}
ref={props.ref}
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
// In 'handled' mode the provider above owns the keyboard dismissal, so
// RN's own responder claim is disabled to let unclaimed taps bubble up
// to it. Children stay untouched for `stickyHeaderIndices` (#4328).
disableScrollViewPanResponder={
keyboardShouldPersistTaps === 'handled'
? true
: rest.disableScrollViewPanResponder
}
onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER={updateGesture}
// @ts-ignore we don't pass `refreshing` prop as we only want to override the ref
refreshControl={
refreshControl
? React.cloneElement(
refreshControl,
// @ts-ignore block exists (on our RefreshControl)
scrollGesture ? { block: scrollGesture } : {}
)
: undefined
}>
{children}
</ScrollViewResponderInterceptor>
</GHScrollView>
</GHScrollView>
</ScrollViewResponderProvider>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { PropsWithChildren } 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'];

Expand Down Expand Up @@ -95,11 +96,38 @@ type ScrollViewResponderInterceptorProps = PropsWithChildren<{
keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps'];
}>;

const ScrollViewResponderInterceptor = ({
// 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]
Expand All @@ -115,29 +143,67 @@ const ScrollViewResponderInterceptor = ({
return false;
}, []);

const handleStartShouldSetResponder = useCallback(() => {
const shouldHandleRNGHEvent =
keyboardShouldPersistTaps === 'handled' && 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;
},
[]
);
Comment thread
m-bert marked this conversation as resolved.

isRNGHResponderEvent.current = 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;
}, [keyboardShouldPersistTaps]);
// A native scroll taking over terminates the JS responder — mirror RN's
// `_observedScrollSinceBecomingResponder` guard by skipping the dismissal.
const handleResponderTerminate = useCallback(() => {
claimedForKeyboardDismissal.current = false;
}, []);

const isHandledMode = keyboardShouldPersistTaps === 'handled';

// 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
return (
<JSResponderContext value={contextValue}>
<View
collapsable={false}
onStartShouldSetResponderCapture={resetRNGHResponderEvent}
onStartShouldSetResponder={handleStartShouldSetResponder}
pointerEvents="box-none"
style={styles.logicalResponder}>
style={styles.logicalResponder}
onStartShouldSetResponderCapture={
isHandledMode ? resetRNGHResponderEvent : undefined
}
onStartShouldSetResponder={
isHandledMode ? handleStartShouldSetResponder : undefined
}
onResponderRelease={isHandledMode ? handleResponderRelease : undefined}
onResponderTerminate={
isHandledMode ? handleResponderTerminate : undefined
}>
{children}
</View>
</JSResponderContext>
Expand All @@ -149,5 +215,3 @@ const styles = StyleSheet.create({
display: 'contents',
},
});

export default ScrollViewResponderInterceptor;
Loading