Skip to content

Commit bc605eb

Browse files
authored
[macOS] Fix touch events never being delivered (#4390)
## Description `onTouchesMove` and `onTouchesUp` callbacks never fired on macOS — a gesture produced a single `onTouchesDown` and then a cancel sweep when the recognizer reset, never a move or up event. Found while testing the `manualActivation` fix (#4389), which this bug masks entirely in its real-world form (activating from `onTouchesMove`). `RNGestureHandlerPointerTracker` tracks pointers by storing the touch object at registration and matching subsequent events by object identity (`_trackedPointers[index] == touch`). That's correct on iOS, where a `UITouch` is one stable object for the whole lifetime of a touch. On macOS the recognizers forward `NSEvent`s, and every event in a mouse sequence is a fresh instance — so `findTouchIndex:` / `unregisterTouch:` never matched, move/up events were dropped with `changedCount == 0`, and the registered pointer leaked until the tracker's `reset` swept it out via `cancelPointers` (which is why JS saw down → cancel instead of down → moves → up). Since macOS has exactly one mouse pointer, the tracker now matches the tracked slot itself on macOS instead of comparing object identity, and `touchesMoved` replaces the stored event with the latest one so `extractAllTouches` reports the pointer's current position rather than where the sequence started. ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React, { useEffect } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureStateManager, usePanGesture, } from 'react-native-gesture-handler'; import { useSharedValue } from 'react-native-reanimated'; export default function EmptyExample() { // Box A: manualActivation, JS never calls activate(). // Expected: down -> move stream -> up, onFinalize canceled=true, never onActivate. const neverActivatePan = usePanGesture({ manualActivation: true, onBegin: () => console.log('[never-activate] onBegin'), onActivate: () => console.log('[never-activate] onActivate (SHOULD NOT HAPPEN)'), onTouchesDown: () => console.log('[never-activate] onTouchesDown'), onTouchesMove: () => console.log('[never-activate] onTouchesMove'), onTouchesUp: () => console.log('[never-activate] onTouchesUp'), onDeactivate: () => console.log('[never-activate] onDeactivate'), onFinalize: (e) => console.log(`[never-activate] onFinalize canceled=${e.canceled}`), }); // Box B: manualActivation, activates from onTouchesMove — the real-world pattern. // Expected: onActivate on first movement (tx ~0), onUpdate while dragging, // onTouchesUp + onDeactivate + onFinalize canceled=false on release. const selfTag = useSharedValue(-1); const selfActivatePan = usePanGesture({ manualActivation: true, onTouchesDown: () => console.log(`[self-activate] onTouchesDown tag=${selfTag.value}`), onTouchesMove: () => { console.log(`[self-activate] onTouchesMove tag=${selfTag.value}`); if (selfTag.value !== -1) { GestureStateManager.activate(selfTag.value); } }, onTouchesUp: () => console.log('[self-activate] onTouchesUp'), onBegin: () => console.log('[self-activate] onBegin'), onActivate: (e) => console.log(`[self-activate] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onUpdate: (e) => console.log(`[self-activate] onUpdate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[self-activate] onDeactivate'), onFinalize: (e) => console.log(`[self-activate] onFinalize canceled=${e.canceled}`), }); useEffect(() => { selfTag.value = selfActivatePan.handlerTag; }, [selfActivatePan.handlerTag, selfTag]); return ( <View style={styles.container}> <Text style={styles.label}>A: manualActivation, never activated — click & drag, release</Text> <GestureDetector gesture={neverActivatePan}> <View style={[styles.box, { backgroundColor: 'darkorange' }]} /> </GestureDetector> <Text style={styles.label}>B: manualActivation, self-activates on touch move — drag</Text> <GestureDetector gesture={selfActivatePan}> <View style={[styles.box, { backgroundColor: 'mediumpurple' }]} /> </GestureDetector> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 }, label: { marginTop: 16, fontSize: 15, opacity: 0.6 }, box: { width: 150, height: 150, borderRadius: 12 }, }); ``` </details>
1 parent 787f165 commit bc605eb

1 file changed

Lines changed: 30 additions & 0 deletions

File tree

‎packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.mm‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ - (int)registerTouch:(RNGHUITouch *)touch
3939
- (int)unregisterTouch:(RNGHUITouch *)touch
4040
{
4141
for (int index = 0; index < MAX_POINTERS_COUNT; index++) {
42+
#if TARGET_OS_OSX
43+
// A macOS mouse sequence delivers a fresh NSEvent per event, so identity matching
44+
// (valid on iOS, where a UITouch is one stable object for the whole touch) never
45+
// matches. There is only one mouse pointer — match the tracked slot instead.
46+
if (_trackedPointers[index] != nil) {
47+
#else
4248
if (_trackedPointers[index] == touch) {
49+
#endif
4350
_trackedPointers[index] = nil;
4451
return index;
4552
}
@@ -51,7 +58,12 @@ - (int)unregisterTouch:(RNGHUITouch *)touch
5158
- (int)findTouchIndex:(RNGHUITouch *)touch
5259
{
5360
for (int index = 0; index < MAX_POINTERS_COUNT; index++) {
61+
#if TARGET_OS_OSX
62+
// See unregisterTouch: — identity matching cannot work for NSEvents.
63+
if (_trackedPointers[index] != nil) {
64+
#else
5465
if (_trackedPointers[index] == touch) {
66+
#endif
5567
return index;
5668
}
5769
}
@@ -159,6 +171,12 @@ - (void)touchesMoved:(NSSet<RNGHUITouch *> *)touches withEvent:(UIEvent *)event
159171
continue;
160172
}
161173

174+
#if TARGET_OS_OSX
175+
// Replace the stored mouse-down event with the latest one so extractAllTouches
176+
// reads the pointer's current position, not where the sequence started.
177+
_trackedPointers[index] = touch;
178+
#endif
179+
162180
data[changedCount++] = [self extractPointerData:index forTouch:touch];
163181
}
164182

@@ -177,6 +195,18 @@ - (void)touchesEnded:(NSSet<RNGHUITouch *> *)touches withEvent:(UIEvent *)event
177195
return;
178196
}
179197

198+
#if TARGET_OS_OSX
199+
// Refresh the tracked slot with the ending event so extractAllTouches below reports
200+
// the pointer's final position. On iOS the stored UITouch is a live object that
201+
// always reads its current location; a stored NSEvent is a stale snapshot.
202+
for (RNGHUITouch *touch in touches) {
203+
int index = [self findTouchIndex:touch];
204+
if (index >= 0) {
205+
_trackedPointers[index] = touch;
206+
}
207+
}
208+
#endif
209+
180210
// extract all touches first to include the ones that were just lifted
181211
[self extractAllTouches];
182212

0 commit comments

Comments
 (0)