diff --git a/apps/common-app/src/new_api/index.tsx b/apps/common-app/src/new_api/index.tsx index 95e6191211..e7bc1b6a79 100644 --- a/apps/common-app/src/new_api/index.tsx +++ b/apps/common-app/src/new_api/index.tsx @@ -33,6 +33,7 @@ import PanExample from './simple/pan'; import PinchExample from './simple/pinch'; import RotationExample from './simple/rotation'; import TapExample from './simple/tap'; +import KeyboardShouldPersistTapsExample from './tests/keyboardShouldPersistTaps'; import NestedPressablesExample from './tests/nestedPressables'; import NestedRootViewExample from './tests/nestedRootView'; import NestedTouchablesExample from './tests/nestedTouchables'; @@ -132,6 +133,10 @@ export const NEW_EXAMPLES: ExamplesSection[] = [ name: 'RN responder cancellation', component: RNResponderCancellationExample, }, + { + name: 'Keyboard Should Persist Taps', + component: KeyboardShouldPersistTapsExample, + }, ], }, ]; diff --git a/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx new file mode 100644 index 0000000000..9fa8e0c1d5 --- /dev/null +++ b/apps/common-app/src/new_api/tests/keyboardShouldPersistTaps/index.tsx @@ -0,0 +1,369 @@ +import React, { useRef, useState } from 'react'; +import { + Keyboard, + Pressable as RNPressable, + ScrollView as RNScrollView, + StyleSheet, + Text, + TextInput as RNTextInput, + View, +} from 'react-native'; +import { + GestureDetector, + Pressable as RNGHPressable, + ScrollView as RNGHScrollView, + TextInput as RNGHTextInput, + useTapGesture, +} from 'react-native-gesture-handler'; + +import type { FeedbackHandle } from '../../../common'; +import { COLORS, Feedback, InfoSection } from '../../../common'; + +type Mode = 'never' | 'handled' | 'always'; +type Example = 'pressable' | 'tap'; + +const MODES: Mode[] = ['never', 'handled', 'always']; +const EXAMPLES: Example[] = ['pressable', 'tap']; + +const EXAMPLE_LABELS: Record = { + pressable: 'GH Pressable', + tap: 'useTapGesture', +}; + +const MODE_DESCRIPTIONS: Record = { + never: + "RN: first tap outside the input dismisses the keyboard AND is swallowed — press doesn't fire. GH: keyboard still dismisses (ScrollView captures the responder normally), but the press ALSO fires because GH's native recognizer runs in parallel to the JS responder system.", + handled: + 'Keyboard stays up if a child claims the tap. Tap an input to raise the keyboard, then tap a button — press fires and keyboard stays. RN and GH match here.', + always: + "Keyboard never auto-dismisses on tap; children always receive taps. You'd have to call Keyboard.dismiss() yourself. RN and GH match here.", +}; + +export default function KeyboardShouldPersistTapsExample() { + const [mode, setMode] = useState('handled'); + const [example, setExample] = useState('pressable'); + const feedbackRef = useRef(null); + + const report = (message: string) => { + feedbackRef.current?.showMessage(message); + }; + + return ( + + + + Keyboard.dismiss()}> + Dismiss KB + + + + + + + + + [ + styles.button, + { + backgroundColor: pressed + ? COLORS.KINDA_BLUE + : COLORS.LIGHT_BLUE, + }, + ]} + onPress={() => report('RN Pressable onPress')}> + Press me + + + + + + {example === 'pressable' ? ( + [ + styles.button, + { + backgroundColor: pressed ? COLORS.KINDA_GREEN : COLORS.GREEN, + }, + ]} + onPress={() => report('GH Pressable onPress')}> + Press me + + ) : ( + report('useTapGesture onActivate')} + /> + )} + + + + + + + + + + ); +} + +type ExampleSelectorProps = { + value: Example; + onChange: (next: Example) => void; +}; + +function ExampleSelector({ value, onChange }: ExampleSelectorProps) { + return ( + + {EXAMPLES.map((example) => { + const active = example === value; + return ( + onChange(example)} + style={[styles.exampleTab, active && styles.exampleTabActive]}> + + {EXAMPLE_LABELS[example]} + + + ); + })} + + ); +} + +type GestureTapButtonProps = { + onTap: () => void; +}; + +function GestureTapButton({ onTap }: GestureTapButtonProps) { + const [pressed, setPressed] = useState(false); + + const tap = useTapGesture({ + disableReanimated: true, + onBegin: () => { + setPressed(true); + }, + onActivate: onTap, + onFinalize: () => { + setPressed(false); + }, + }); + + return ( + + + Tap me + + + ); +} + +type ModeSelectorProps = { + value: Mode; + onChange: (next: Mode) => void; +}; + +function ModeSelector({ value, onChange }: ModeSelectorProps) { + return ( + + {MODES.map((m) => { + const active = m === value; + return ( + onChange(m)} + style={[styles.modeChip, active && styles.modeChipActive]}> + + {m} + + + ); + })} + + ); +} + +type PanelProps = { + title: string; + accent: string; + mode: Mode; + ScrollViewComponent: React.ComponentType< + React.ComponentProps + >; + children: React.ReactNode; +}; + +function Panel({ + title, + accent, + mode, + ScrollViewComponent, + children, +}: PanelProps) { + return ( + + + {title} + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 12, + gap: 10, + backgroundColor: COLORS.offWhite, + }, + topBar: { + flexDirection: 'row', + alignItems: 'stretch', + gap: 8, + }, + modeRow: { + flex: 1, + flexDirection: 'row', + gap: 6, + padding: 4, + borderRadius: 10, + backgroundColor: COLORS.headerSeparator, + }, + modeChip: { + flex: 1, + paddingVertical: 8, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + }, + modeChipActive: { + backgroundColor: COLORS.NAVY, + }, + modeLabel: { + fontSize: 13, + fontWeight: '600', + color: COLORS.NAVY, + fontFamily: 'Menlo', + }, + modeLabelActive: { + color: '#ffffff', + }, + dismiss: { + paddingHorizontal: 12, + justifyContent: 'center', + borderRadius: 10, + backgroundColor: COLORS.PURPLE, + }, + dismissText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 12, + }, + exampleRow: { + flexDirection: 'row', + gap: 8, + }, + exampleTab: { + flex: 1, + paddingVertical: 10, + borderRadius: 10, + borderWidth: 2, + borderColor: COLORS.DARK_GREEN, + alignItems: 'center', + backgroundColor: 'transparent', + }, + exampleTabActive: { + backgroundColor: COLORS.DARK_GREEN, + }, + exampleTabLabel: { + color: COLORS.DARK_GREEN, + fontWeight: '700', + fontSize: 14, + }, + exampleTabLabelActive: { + color: '#ffffff', + }, + panelRow: { + flexDirection: 'row', + gap: 10, + height: 200, + }, + panel: { + flex: 1, + borderWidth: 2, + borderRadius: 10, + overflow: 'hidden', + backgroundColor: '#ffffff', + }, + panelHeader: { + paddingHorizontal: 10, + paddingVertical: 6, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + panelTitle: { + color: '#ffffff', + fontSize: 13, + fontWeight: '700', + }, + panelBody: { + padding: 10, + gap: 10, + }, + input: { + borderWidth: 1, + borderColor: COLORS.GRAY, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + fontSize: 14, + backgroundColor: '#ffffff', + }, + button: { + borderRadius: 8, + paddingVertical: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontWeight: '700', + fontSize: 14, + }, + feedbackArea: { + alignItems: 'center', + minHeight: 30, + }, +}); diff --git a/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx b/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx index 787ca41035..e96abb6d6d 100644 --- a/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx +++ b/packages/docs-gesture-handler/src/components/Hero/StartScreen/index.tsx @@ -1,6 +1,7 @@ +import HomepageButton from '@site/src/components/HomepageButton'; import React from 'react'; + import styles from './styles.module.css'; -import HomepageButton from '@site/src/components/HomepageButton'; const StartScreen = () => { return ( diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm index 8aef908a1a..a41bda0b04 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm @@ -16,8 +16,9 @@ @interface RNGestureHandlerDetector () @property (nonatomic, nonnull) NSMutableSet *nativeHandlers; +@property (nonatomic, nonnull) NSMutableSet *subscribedHandlers; @property (nonatomic, nonnull) NSMutableSet *attachedHandlers; -@property (nonatomic) std::unordered_map attachedVirtualHandlers; +@property (nonatomic) std::unordered_map subscribedVirtualHandlers; @end @@ -49,25 +50,23 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); - const auto &props = *std::static_pointer_cast(_props); + [handlerManager.registry cancelAllObservationsForOwner:self]; - for (const auto handler : props.handlerTags) { - NSNumber *handlerTag = [NSNumber numberWithInt:handler]; + for (NSNumber *handlerTag in _attachedHandlers) { [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; } - for (const auto &child : _attachedVirtualHandlers) { - for (id handlerTag : child.second) { - [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; - } - } - _attachedVirtualHandlers.clear(); - _attachedHandlers = [NSMutableSet set]; + + [_attachedHandlers removeAllObjects]; + _subscribedVirtualHandlers.clear(); + [_subscribedHandlers removeAllObjects]; + [_nativeHandlers removeAllObjects]; } else { const auto &props = *std::static_pointer_cast(_props); [self attachHandlers:props.handlerTags - actionType:RNGestureHandlerActionTypeNativeDetector - viewTag:-1 - attachedHandlers:_attachedHandlers]; + actionType:RNGestureHandlerActionTypeNativeDetector + viewTag:-1 + subscribedHandlers:_subscribedHandlers]; + [self updateVirtualChildren:props.virtualChildren]; } } @@ -77,6 +76,7 @@ - (void)setDefaultProps _props = defaultProps; _moduleId = -1; _nativeHandlers = [NSMutableSet set]; + _subscribedHandlers = [NSMutableSet set]; _attachedHandlers = [NSMutableSet set]; } @@ -140,13 +140,6 @@ - (void)dispatchReanimatedTouchEvent:(RNGestureHandlerDetectorEventEmitter::OnGe } } -- (BOOL)shouldAttachGestureToSubview:(NSNumber *)handlerTag -{ - RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; - - return [[[handlerManager registry] handlerWithTag:handlerTag] wantsToAttachDirectlyToView]; -} - - (void)prepareForRecycle { [super prepareForRecycle]; @@ -194,62 +187,85 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics - (void)attachHandlers:(const std::vector &)handlerTags actionType:(RNGestureHandlerActionType)actionType viewTag:(const int)viewTag - attachedHandlers:(NSMutableSet *)attachedHandlers + subscribedHandlers:(NSMutableSet *)subscribedHandlers { RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); - NSMutableSet *handlersToDetach = [attachedHandlers mutableCopy]; + NSMutableSet *handlersToDetach = [subscribedHandlers mutableCopy]; + + __weak __typeof(self) weakSelf = self; + const int capturedViewTag = viewTag; + const RNGestureHandlerActionType capturedActionType = actionType; for (const int tag : handlerTags) { [handlersToDetach removeObject:@(tag)]; - if (![attachedHandlers containsObject:@(tag)]) { - if ([self shouldAttachGestureToSubview:@(tag)] && actionType == RNGestureHandlerActionTypeNativeDetector) { - // It might happen that `attachHandlers` will be called before children are added into view hierarchy. In that - // case we cannot attach `NativeViewGestureHandlers` here and we have to do it in `didAddSubview` method. - react_native_assert( - self.subviews.count <= 1 && - "Cannot attach native gesture handlers when the detector has multiple children"); - [_nativeHandlers addObject:@(tag)]; - } else { - if (actionType == RNGestureHandlerActionTypeVirtualDetector) { - RNGHUIView *targetView = [handlerManager viewForReactTag:@(viewTag)]; - - if (targetView != nil) { - [handlerManager attachGestureHandler:@(tag) - toViewWithTag:@(viewTag) - withActionType:actionType - withHostDetector:self]; - } else { - // Let's assume that if the native view for the virtual detector hasn't been found, the hierarchy was folded - // into a single UIView. - [handlerManager.registry attachHandlerWithTag:@(tag) - toView:self - withActionType:actionType - withHostDetector:self]; - [[handlerManager registry] handlerWithTag:@(tag)].virtualViewTag = @(viewTag); - } - } else { - [handlerManager.registry attachHandlerWithTag:@(tag) - toView:self - withActionType:actionType - withHostDetector:self]; - } - [attachedHandlers addObject:@(tag)]; - } + if ([subscribedHandlers containsObject:@(tag)]) { + continue; } + [handlerManager.registry observeHandlerWithTag:@(tag) + owner:self + usingBlock:^(RNGestureHandler *handler) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + [strongSelf attachReadyHandler:handler + actionType:capturedActionType + viewTag:capturedViewTag]; + }]; + [subscribedHandlers addObject:@(tag)]; } for (const id tag : handlersToDetach) { - [handlerManager.registry detachHandlerWithTag:tag fromHostDetector:self]; - [attachedHandlers removeObject:tag]; + [handlerManager.registry cancelObservationForTag:tag owner:self]; + if ([_attachedHandlers containsObject:tag]) { + [handlerManager.registry detachHandlerWithTag:tag fromHostDetector:self]; + [_attachedHandlers removeObject:tag]; + } + [subscribedHandlers removeObject:tag]; [_nativeHandlers removeObject:tag]; } +} + +// Invoked from the registry's `observeHandlerWithTag:` block once the handler is known to exist. +// Branches on handler kind + actionType to pick the right binding flow. May be called multiple +// times for the same tag (handler re-registration), so each branch must be idempotent. +- (void)attachReadyHandler:(RNGestureHandler *)handler + actionType:(RNGestureHandlerActionType)actionType + viewTag:(int)viewTag +{ + RNGestureHandlerManager *manager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + react_native_assert(manager != nullptr && "Tried to access a non-existent handler manager"); - // This covers the case where `NativeViewGestureHandlers` are attached after child views were created. - if (self.subviews.count != 0) { - [self tryAttachNativeHandlersToChildView]; + if ([handler wantsToAttachDirectlyToView] && actionType == RNGestureHandlerActionTypeNativeDetector) { + react_native_assert( + self.subviews.count <= 1 && "Cannot attach native gesture handlers when the detector has multiple children"); + [_nativeHandlers addObject:handler.tag]; + if (self.subviews.count != 0) { + [self tryAttachNativeHandlersToChildView]; + } + return; } + + if (actionType == RNGestureHandlerActionTypeVirtualDetector) { + RNGHUIView *targetView = [manager viewForReactTag:@(viewTag)]; + if (targetView != nil) { + [manager attachGestureHandler:handler.tag + toViewWithTag:@(viewTag) + withActionType:actionType + withHostDetector:self]; + } else { + // Hierarchy was folded into a single UIView. + [manager.registry attachHandlerWithTag:handler.tag toView:self withActionType:actionType withHostDetector:self]; + handler.virtualViewTag = @(viewTag); + } + [_attachedHandlers addObject:handler.tag]; + return; + } + + [manager.registry attachHandlerWithTag:handler.tag toView:self withActionType:actionType withHostDetector:self]; + [_attachedHandlers addObject:handler.tag]; } - (void)updateProps:(const Props::Shared &)propsBase oldProps:(const Props::Shared &)oldPropsBase @@ -258,9 +274,9 @@ - (void)updateProps:(const Props::Shared &)propsBase oldProps:(const Props::Shar _moduleId = newProps.moduleId; [self attachHandlers:newProps.handlerTags - actionType:RNGestureHandlerActionTypeNativeDetector - viewTag:-1 - attachedHandlers:_attachedHandlers]; + actionType:RNGestureHandlerActionTypeNativeDetector + viewTag:-1 + subscribedHandlers:_subscribedHandlers]; [super updateProps:propsBase oldProps:oldPropsBase]; [self updateVirtualChildren:newProps.virtualChildren]; @@ -275,7 +291,7 @@ - (void)updateVirtualChildren:(const std::vector *handlers; @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m index 0c52243e35..34c6e6aa48 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m @@ -12,12 +12,14 @@ @implementation RNGestureHandlerRegistry { NSMutableDictionary *_handlers; + NSMutableDictionary *> *_observers; } - (instancetype)init { if ((self = [super init])) { _handlers = [NSMutableDictionary new]; + _observers = [NSMutableDictionary new]; } return self; } @@ -36,8 +38,66 @@ - (RNGestureHandler *)handlerWithTag:(NSNumber *)handlerTag - (void)registerGestureHandler:(RNGestureHandler *)gestureHandler { + NSArray *observers = nil; + @synchronized(_handlers) { _handlers[gestureHandler.tag] = gestureHandler; + + NSMapTable *table = _observers[gestureHandler.tag]; + if (table != nil) { + observers = [[table objectEnumerator] allObjects]; + } + } + + for (RNGestureHandlerReadyBlock block in observers) { + block(gestureHandler); + } +} + +- (void)observeHandlerWithTag:(NSNumber *)handlerTag owner:(id)owner usingBlock:(RNGestureHandlerReadyBlock)block +{ + RNGestureHandler *existing = nil; + + @synchronized(_handlers) { + NSMapTable *table = _observers[handlerTag]; + if (table == nil) { + table = + [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsWeakMemory | NSPointerFunctionsObjectPointerPersonality + valueOptions:NSPointerFunctionsStrongMemory]; + _observers[handlerTag] = table; + } + [table setObject:[block copy] forKey:owner]; + + existing = _handlers[handlerTag]; + } + + if (existing != nil) { + block(existing); + } +} + +- (void)cancelObservationForTag:(NSNumber *)handlerTag owner:(id)owner +{ + @synchronized(_handlers) { + NSMapTable *table = _observers[handlerTag]; + [table removeObjectForKey:owner]; + if (table.count == 0) { + [_observers removeObjectForKey:handlerTag]; + } + } +} + +- (void)cancelAllObservationsForOwner:(id)owner +{ + @synchronized(_handlers) { + NSArray *tags = [_observers allKeys]; + for (NSNumber *tag in tags) { + NSMapTable *table = _observers[tag]; + [table removeObjectForKey:owner]; + if (table.count == 0) { + [_observers removeObjectForKey:tag]; + } + } } } diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index aba80c8a8f..f166f7befa 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -1,13 +1,21 @@ import { render, renderHook } from '@testing-library/react-native'; import { act } from 'react'; +import { View } from 'react-native'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; import { State } from '../State'; -import { RectButton, Touchable } from '../v3/components'; -import { usePanGesture } from '../v3/hooks/gestures'; +import { Pressable, RectButton, ScrollView, Touchable } from '../v3/components'; +import { GestureDetector } from '../v3/detectors'; +import { useSimultaneousGestures } from '../v3/hooks'; +import { usePanGesture, useTapGesture } from '../v3/hooks/gestures'; import type { SingleGesture } from '../v3/types'; +const flushImmediate = () => + new Promise((resolve) => { + setImmediate(() => resolve(undefined)); + }); + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -34,6 +42,69 @@ describe('[API v3] Hooks', () => { }); describe('[API v3] Components', () => { + const getScrollViewResponder = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + return views(View).find( + ({ props }) => + props.collapsable === false && + props.onStartShouldSetResponderCapture && + props.onStartShouldSetResponder + ); + }; + + const getNativeDetector = ( + views: ReturnType['UNSAFE_getAllByType'] + ) => { + return views(View).find( + ({ props }) => props.handlerTags && props.onStartShouldSetResponder + ); + }; + + const TapGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const PanGestureDetectorExample = () => { + const pan = usePanGesture({ disableReanimated: true }); + + return ( + + + + ); + }; + + const SimultaneousGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true }); + const pan = usePanGesture({ disableReanimated: true }); + const simultaneous = useSimultaneousGestures(tap, pan); + + return ( + + + + ); + }; + + const DisabledSimultaneousGestureDetectorExample = () => { + const tap = useTapGesture({ disableReanimated: true, enabled: false }); + const pan = usePanGesture({ disableReanimated: true, enabled: false }); + const simultaneous = useSimultaneousGestures(tap, pan); + + return ( + + + + ); + }; + test('Rect Button', () => { const pressFn = jest.fn(); @@ -60,6 +131,151 @@ describe('[API v3] Components', () => { expect(pressFn).toHaveBeenCalledTimes(1); }); + describe('ScrollView', () => { + test('handles responder event passed through Pressable for keyboardShouldPersistTaps handled', async () => { + const { getByTestId, UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const pressable = getByTestId('pressable'); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('does not handle responder event passed through Pressable without keyboardShouldPersistTaps handled', async () => { + const { getByTestId, UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const pressable = getByTestId('pressable'); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(pressable.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + + test('handles responder event passed through NativeDetector for keyboardShouldPersistTaps handled', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('handles responder event passed through NativeDetector for non-tap gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('handles responder event passed through NativeDetector for composed gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe(true); + }); + + test('does not handle responder event passed through NativeDetector for disabled composed gestures', async () => { + const { UNSAFE_getAllByType } = render( + + + + + + ); + + await act(flushImmediate); + + const nativeDetector = getNativeDetector(UNSAFE_getAllByType); + const scrollViewResponder = getScrollViewResponder(UNSAFE_getAllByType); + + expect(nativeDetector).toBeDefined(); + expect(scrollViewResponder).toBeDefined(); + expect( + scrollViewResponder?.props.onStartShouldSetResponderCapture() + ).toBe(false); + expect(nativeDetector?.props.onStartShouldSetResponder()).toBe(false); + expect(scrollViewResponder?.props.onStartShouldSetResponder()).toBe( + false + ); + }); + }); + describe('Touchable', () => { test('calls onPress on successful press', () => { const pressFn = jest.fn(); diff --git a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts index ebc021b447..71416a6165 100644 --- a/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts +++ b/packages/react-native-gesture-handler/src/specs/NativeRNGestureHandlerModule.ts @@ -3,15 +3,13 @@ import { TurboModuleRegistry } from 'react-native'; import type { Double } from 'react-native/Libraries/Types/CodegenTypes'; export interface Spec extends TurboModule { - // This method returns a boolean only to force the codegen to generate - // a synchronous method. The returned value doesn't have any meaning. createGestureHandler: ( handlerName: string, handlerTag: Double, // Record<> is not supported by codegen // eslint-disable-next-line @typescript-eslint/ban-types config: Object - ) => boolean; + ) => void; attachGestureHandler: ( handlerTag: Double, newView: Double, diff --git a/packages/react-native-gesture-handler/src/v3/NativeProxy.ts b/packages/react-native-gesture-handler/src/v3/NativeProxy.ts index ac135334cf..f34ba72c8f 100644 --- a/packages/react-native-gesture-handler/src/v3/NativeProxy.ts +++ b/packages/react-native-gesture-handler/src/v3/NativeProxy.ts @@ -16,11 +16,13 @@ export const NativeProxy = { handlerTag: number, config?: T ) => { - RNGestureHandlerModule.createGestureHandler( - handlerName, - handlerTag, - config || {} - ); + scheduleOperationToBeFlushed(() => { + RNGestureHandlerModule.createGestureHandler( + handlerName, + handlerTag, + config || {} + ); + }); }, setGestureHandlerConfig: < TConfig, diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx index d0c01952a5..e41a749dae 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureComponents.tsx @@ -21,6 +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'; export const RefreshControl = createNativeWrapper< RNRefreshControl, @@ -53,8 +54,11 @@ export const ScrollView = ( props: RNScrollViewProps & NativeWrapperProperties ) => { const { + children, refreshControl, onGestureUpdate_CAN_CAUSE_INFINITE_RERENDER, + horizontal, + keyboardShouldPersistTaps, ...rest } = props; @@ -75,6 +79,8 @@ export const ScrollView = ( + }> + + {children} + + ); }; diff --git a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx index e1ccae5b0f..f475019f7c 100644 --- a/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/Pressable.tsx @@ -1,4 +1,5 @@ import React, { + use, useCallback, useEffect, useMemo, @@ -41,6 +42,7 @@ import { useSimultaneousGestures, } from '../hooks'; import { PureNativeButton } from './GestureButtons'; +import { JSResponderContext } from './ScrollViewResponderInterceptor'; const DEFAULT_LONG_PRESS_DURATION = 500; const IS_TEST_ENV = isTestEnv(); @@ -78,6 +80,7 @@ const Pressable = (props: PressableProps) => { const longPressTimeoutRef = useRef(null); const pressDelayTimeoutRef = useRef(null); const isOnPressAllowed = useRef(true); + const jsResponderContext = use(JSResponderContext); const isCurrentlyPressed = useRef(false); const dimensions = useRef({ width: 0, @@ -365,9 +368,25 @@ const Pressable = (props: PressableProps) => { [onLayout] ); + // Let RN components higher in the tree handle JS responder negotiation. + // RNGH ScrollView uses this marker to preserve keyboardShouldPersistTaps='handled' + // when there are no RN responder components between it and this Pressable. + const handleStartShouldSetResponder = useCallback(() => { + if (!disabled) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [disabled, jsResponderContext]); + return ( ; +}; + +export const JSResponderContext = + React.createContext(null); + +type ScrollViewResponderInterceptorProps = PropsWithChildren<{ + keyboardShouldPersistTaps?: RNScrollViewProps['keyboardShouldPersistTaps']; +}>; + +const ScrollViewResponderInterceptor = ({ + children, + keyboardShouldPersistTaps, +}: ScrollViewResponderInterceptorProps) => { + const isRNGHResponderEvent = useRef(false); + const contextValue = useMemo( + () => ({ isRNGHResponderEvent }), + [isRNGHResponderEvent] + ); + + const resetRNGHResponderEvent = useCallback(() => { + isRNGHResponderEvent.current = false; + return false; + }, []); + + const handleStartShouldSetResponder = useCallback(() => { + const shouldHandleRNGHEvent = + keyboardShouldPersistTaps === 'handled' && isRNGHResponderEvent.current; + + isRNGHResponderEvent.current = false; + + return shouldHandleRNGHEvent; + }, [keyboardShouldPersistTaps]); + + // 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 ( + + + {children} + + + ); +}; + +const styles = StyleSheet.create({ + logicalResponder: { + display: 'contents', + }, +}); + +export default ScrollViewResponderInterceptor; diff --git a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx index 9a3220ddfd..7606ff2776 100644 --- a/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx +++ b/packages/react-native-gesture-handler/src/v3/detectors/NativeDetector.tsx @@ -1,13 +1,29 @@ -import React, { useMemo } from 'react'; +import React, { use, useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; +import { JSResponderContext } from '../components/ScrollViewResponderInterceptor'; +import { maybeUnpackValue } from '../hooks/utils'; import { isComposedGesture } from '../hooks/utils/relationUtils'; +import type { Gesture } from '../types'; import type { NativeDetectorProps } from './common'; import { AnimatedNativeDetector, nativeDetectorStyles } from './common'; import HostGestureDetector from './HostGestureDetector'; import { ReanimatedNativeDetector } from './ReanimatedNativeDetector'; import { configureRelations, ensureNativeDetectorComponent } from './utils'; +function isGestureEnabled< + TConfig, + THandlerData, + TExtendedHandlerData extends THandlerData, +>(gesture: Gesture): boolean { + if (isComposedGesture(gesture)) { + // For composed gestures, we need to check if at least one of the composed gestures is enabled + return gesture.gestures.some(isGestureEnabled); + } + + return maybeUnpackValue(gesture.config.enabled) !== false; +} + export function NativeDetector< TConfig, THandlerData, @@ -19,6 +35,8 @@ export function NativeDetector< userSelect, enableContextMenu, }: NativeDetectorProps) { + const jsResponderContext = use(JSResponderContext); + const NativeDetectorComponent = gesture.config.dispatchesAnimatedEvents ? AnimatedNativeDetector : gesture.config.shouldUseReanimatedDetector @@ -55,8 +73,25 @@ export function NativeDetector< gesture.detectorCallbacks.reanimatedEventHandler, }; + const shouldHandleJSResponderEvent = useCallback(() => { + return isGestureEnabled(gesture); + }, [gesture]); + + const handleStartShouldSetResponder = useCallback(() => { + if (shouldHandleJSResponderEvent()) { + const responderEventRef = jsResponderContext?.isRNGHResponderEvent; + + if (responderEventRef) { + responderEventRef.current = true; + } + } + + return false; + }, [jsResponderContext, shouldHandleJSResponderEvent]); + return ( ({ handlerTag, @@ -94,11 +85,9 @@ export function useGesture< ); useEffect(() => { + NativeProxy.createGestureHandler(type, handlerTag, {}); + scheduleFlushOperations(); return () => { - if (currentGestureRef.current.handlerTag === handlerTag) { - currentGestureRef.current = { type: '', handlerTag: -1 }; - } - NativeProxy.dropGestureHandler(handlerTag); scheduleFlushOperations(); };