[iOS] Fix touch events never being delivered to `VirtualDetector` handlers (#4392)
## Description
On iOS, handlers attached through `VirtualGestureDetector` never
received `onTouches*` callbacks: state events
(`onBegin`/`onActivate`/`onFinalize`) arrived, but every pointer-data
event was silently dropped. Android and web deliver all of them for the
same code.
Two spots in the touch-event path were only half-migrated when the
virtual detector joined the native one — both still special-cased
`RNGestureHandlerActionTypeNativeDetector` alone:
1. `RNGestureHandler.sendTouchEventInState` routed touch events through
the codegen detector path (`sendNativeTouchEventForGestureHandler`,
which resolves its target via `findViewForEvents` → `hostDetectorView`)
only for the native detector. A virtual handler's touch events fell into
the legacy branch that builds a `reactTag`-routed event.
2. `RNGestureHandlerPointerTracker.sendEvent` drops events when the
recognizer's view has no `reactTag`, with an exemption only for the
native detector. The virtual recognizer's view has no `reactTag`, so the
guard swallowed every touch event for virtual handlers unconditionally.
Both checks now use the existing `usesNativeOrVirtualDetector` helper,
mirroring how Android's `RNGestureHandlerEventDispatcher` treats the two
detector types uniformly (single branch, delivery through the detector
view). With (1) in place, detector-attached touch events never involve
the view's `reactTag`, which is what makes the widened exemption in (2)
safe.
## Test plan
<details>
<summary>Tested on the following code:</summary>
```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import {
GestureDetector,
InterceptingGestureDetector,
usePanGesture,
useTapGesture,
VirtualGestureDetector,
} from 'react-native-gesture-handler';
export default function EmptyExample() {
const interceptingTap = useTapGesture({ disableReanimated: true });
const virtualTapWithTouches = useTapGesture({
disableReanimated: true,
onActivate: () => console.log('[virtual-tap-touches] onActivate'),
onFinalize: () => console.log('[virtual-tap-touches] onFinalize'),
onTouchesDown: () => console.log('[virtual-tap-touches] onTouchesDown'),
onTouchesUp: () => console.log('[virtual-tap-touches] onTouchesUp'),
});
const nativePan = usePanGesture({
disableReanimated: true,
onBegin: () => console.log('[native] onBegin'),
onActivate: () => console.log('[native] onActivate'),
onDeactivate: () => console.log('[native] onDeactivate'),
onFinalize: () => console.log('[native] onFinalize'),
onTouchesDown: () => console.log('[native] onTouchesDown'),
onTouchesMove: () => console.log('[native] onTouchesMove'),
onTouchesUp: () => console.log('[native] onTouchesUp'),
onTouchesCancel: () => console.log('[native] onTouchesCancel'),
});
return (
<View style={styles.container}>
<InterceptingGestureDetector gesture={interceptingTap}>
<Text style={styles.paragraph}>
Virtual detector target:{' '}
<VirtualGestureDetector gesture={virtualTapWithTouches}>
<Text style={styles.virtualTarget}>TAP ME</Text>
</VirtualGestureDetector>
</Text>
</InterceptingGestureDetector>
<GestureDetector gesture={nativePan}>
<View style={styles.box}>
<Text style={styles.boxLabel}>native detector control</Text>
</View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 24, padding: 24 },
paragraph: { fontSize: 18, textAlign: 'center' },
virtualTarget: { fontSize: 22, fontWeight: 'bold', color: 'darkgreen' },
box: {
width: 220,
height: 100,
borderRadius: 12,
backgroundColor: 'steelblue',
justifyContent: 'center',
alignItems: 'center',
},
boxLabel: { color: 'white' },
});
```
</details>
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> M
Michał Bert committed
7b35938179972ec3eb42dc0be490b8df4c8ffa7c
Parent: 73c51c7
Committed by GitHub <noreply@github.com>
on 8/7/2026, 9:33:23 AM