Skip to content

Commit 4033189

Browse files
committed
[macOS] Fix manualActivation never blocking gesture activation (#4389)
## Description `manualActivation: true` has never worked on macOS — the handler activated on its own as if the flag wasn't set. Found while verifying a review comment on #4387. Four stacked defects in `RNManualActivationRecognizer`, all macOS-specific: 1. The failure requirement was never established. The blocking mechanism relies on `shouldBeRequiredToFailByGestureRecognizer:`, which is a `UIGestureRecognizer` *subclass* hook (`UIGestureRecognizerSubclass.h`). `NSGestureRecognizer` has no such hook, so AppKit never called it and the handler's recognizer never waited for the blocker — a pan activated straight from mouse-down. AppKit only consults the *delegate*; since the blocker is already its own `NSGestureRecognizerDelegate`, the two-arg delegate callback now forwards to the shared logic. 2. AppKit does not reliably deny the dependent recognizer when the blocker recognizes. On iOS, the blocker completing (`Began` → `Ended` in its action handler) causes UIKit to fail the recognizer that required it to fail — that's how a released-without-`activate()` gesture is discarded. On AppKit, completing the blocker can instead *flush* the dependent recognizer's buffered recognition: with the requirement in place, releasing after a drag delivered the pan's entire withheld sequence (`Began`/`Changed`/`Ended` with the full accumulated translation) instead of discarding it, while a motionless press-release was discarded correctly (traced with state-transition logging). The blocker now explicitly fails the handler's recognizer before completing. 3. The release-without-activation cleanup was dead code. The original macOS port (#2588) crossed the `touchesBegan`/`touchesEnded` bodies: `mouseDown` incremented `_activePointers` and then checked `if (_activePointers == 0)` — never true after an increment — while `mouseUp` only decremented. The zero-check now lives in `mouseUp`, mirroring `touchesEnded` on iOS. 4. Stale pointer count after a JS `activate()`. `stopActivationBlocker` disables the blocker, so it misses the subsequent mouse-up; iOS recovers in `touchesCancelled`, which has no AppKit equivalent. The count would stay at 1 and the cleanup in (3) would never fire again. `reset` now zeroes `_activePointers`. ## 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: every press/drag/release cycle logs onBegin -> onFinalize canceled=true, // never onActivate, and behaves identically on every repeat. const neverActivatePan = usePanGesture({ manualActivation: true, onBegin: () => console.log('[never-activate] onBegin'), onActivate: () => console.log('[never-activate] onActivate (SHOULD NOT HAPPEN)'), onDeactivate: () => console.log('[never-activate] onDeactivate'), onFinalize: (e) => console.log(`[never-activate] onFinalize canceled=${e.canceled}`), }); // Box B: manualActivation, activates itself from a timer while the button is held // (onTouchesMove is not delivered on macOS — separate bug). // Expected: hold past 300 ms -> onActivate (tx ~0), onUpdate while dragging, // onDeactivate + onFinalize canceled=false on release. Release before 300 ms -> // canceled=true and the late activate() is a no-op. const selfTag = useSharedValue(-1); const selfActivatePan = usePanGesture({ manualActivation: true, onTouchesDown: () => { const tag = selfTag.value; setTimeout(() => { console.log(`[self-activate] calling activate(${tag})`); GestureStateManager.activate(tag); }, 300); }, 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 300 ms after press</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 bfe7941 commit 4033189

1 file changed

Lines changed: 25 additions & 6 deletions

File tree

‎packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,31 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler
2222
- (void)handleGesture:(UIGestureRecognizer *)recognizer
2323
{
2424
if (recognizer.state == UIGestureRecognizerStateBegan) {
25+
#if TARGET_OS_OSX
26+
// On iOS, this recognizer completing is enough to deny the handler's recognizer —
27+
// UIKit fails a recognizer whose required-to-fail dependency recognizes. AppKit
28+
// does not do this reliably: completing the blocker can flush the dependent
29+
// recognizer's buffered recognition instead of discarding it.
30+
_handler.recognizer.state = UIGestureRecognizerStateFailed;
31+
#endif
2532
self.state = UIGestureRecognizerStateEnded;
2633
[self reset];
2734
}
2835
}
2936

3037
#if TARGET_OS_OSX
31-
- (void)mouseUp:(NSEvent *)event
38+
- (void)mouseDown:(NSEvent *)event
3239
{
33-
[super mouseUp:event];
40+
[super mouseDown:event];
3441

35-
_activePointers -= 1;
42+
_activePointers += 1;
3643
}
3744

38-
- (void)mouseDown:(NSEvent *)event
45+
- (void)mouseUp:(NSEvent *)event
3946
{
40-
[super mouseDown:event];
47+
[super mouseUp:event];
4148

42-
_activePointers += 1;
49+
_activePointers -= 1;
4350

4451
if (_activePointers == 0) {
4552
self.state = UIGestureRecognizerStateBegan;
@@ -80,6 +87,7 @@ - (void)touchesCancelled:(NSSet<RNGHUITouch *> *)touches withEvent:(UIEvent *)ev
8087
- (void)reset
8188
{
8289
self.enabled = YES;
90+
_activePointers = 0;
8391
[super reset];
8492
}
8593

@@ -106,4 +114,15 @@ - (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGe
106114
return NO;
107115
}
108116

117+
#if TARGET_OS_OSX
118+
// On iOS the method above is a UIGestureRecognizer subclass override, called by the system
119+
// to establish the failure requirement. NSGestureRecognizer has no such subclass hook —
120+
// AppKit only consults the delegate, so forward the delegate callback to the shared logic.
121+
- (BOOL)gestureRecognizer:(NSGestureRecognizer *)gestureRecognizer
122+
shouldBeRequiredToFailByGestureRecognizer:(NSGestureRecognizer *)otherGestureRecognizer
123+
{
124+
return [self shouldBeRequiredToFailByGestureRecognizer:otherGestureRecognizer];
125+
}
126+
#endif
127+
109128
@end

0 commit comments

Comments
 (0)