Skip to content

Commit 6819385

Browse files
authored
[macOS] Fix Pan activation criteria being ignored (#4387)
## Description On macOS, all of `Pan`'s custom activation criteria — `minDistance`, `activeOffsetX/Y`, `minVelocity(X/Y)` — were silently ignored: the handler activated the moment the mouse button went down. Two things combined to cause this: 1. Both custom-activation code paths in `RNPanHandler.m` were compiled out for macOS. The early-activation guard in `interactionsBegan` and the `shouldActivateUnderCustomCriteria` check in `interactionsMoved` were wrapped in `#if !TARGET_OS_TV && !TARGET_OS_OSX`, so the config values were stored but never evaluated. (Only the fail criteria worked — `shouldFailUnderCustomCriteria` runs unconditionally.) 2. `NSPanGestureRecognizer` begins on mouse-down. Unlike `UIPanGestureRecognizer`, which has a built-in ~10 pt hysteresis, the AppKit recognizer transitions to `Began` immediately, so without the check there is nothing preventing a click from activating the handler. The iOS implementation holds the recognizer back with the `minimumNumberOfTouches = 20` trick, which has no AppKit equivalent. Instead, on macOS the recognizer is held in the `Possible` state explicitly: - When a mouse-down arrives and custom activation criteria are configured, a `_blockAutomaticActivation` flag is set. - A `setState:` override swallows the superclass's `Began`/`Changed` transitions while the flag is up. The superclass keeps receiving events, so `translationInView:` / `velocityInView:` stay valid — which also keeps `failOffsetX/Y` and the JS event payload working. - `interactionsMoved` now evaluates `shouldActivateUnderCustomCriteria` on macOS and, once it passes, clears the flag, sets `Began` and resets the translation to zero — same semantics as iOS/Android (translation counts from the activation point). - A mouse-up before the criteria are met arrives at `setState:` as `Ended` and is rewritten to `Failed`, so the gesture finalizes correctly (`onFinalize` with `canceled=true`). - The flag is cleared in `reset` and in `activateAfterLongPress` (which already forces `minDistSq >= 100`, so the block is active while waiting for the long-press timer). ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; export default function EmptyExample() { const minDistPan = usePanGesture({ minDistance: 50, onBegin: () => console.log('[minDist] onBegin'), onActivate: (e) => console.log(`[minDist] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[minDist] onDeactivate'), onFinalize: (e) => console.log(`[minDist] onFinalize canceled=${e.canceled}`), }); const activeOffsetPan = usePanGesture({ activeOffsetX: [-50, 50], onBegin: () => console.log('[activeOffsetX] onBegin'), onActivate: (e) => console.log(`[activeOffsetX] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[activeOffsetX] onDeactivate'), onFinalize: (e) => console.log(`[activeOffsetX] onFinalize canceled=${e.canceled}`), }); const minVelocityPan = usePanGesture({ minVelocity: 800, onBegin: () => console.log('[minVelocity] onBegin'), onActivate: (e) => console.log(`[minVelocity] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[minVelocity] onDeactivate'), onFinalize: (e) => console.log(`[minVelocity] onFinalize canceled=${e.canceled}`), }); return ( <View style={styles.container}> <Text style={styles.label}>minDistance: 50</Text> <GestureDetector gesture={minDistPan}> <View style={[styles.box, { backgroundColor: 'tomato' }]} /> </GestureDetector> <Text style={styles.label}>activeOffsetX: [-50, 50]</Text> <GestureDetector gesture={activeOffsetPan}> <View style={[styles.box, { backgroundColor: 'mediumseagreen' }]} /> </GestureDetector> <Text style={styles.label}>minVelocity: 800</Text> <GestureDetector gesture={minVelocityPan}> <View style={[styles.box, { backgroundColor: 'steelblue' }]} /> </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: 140, height: 140, borderRadius: 12 }, }); ``` </details>
1 parent 991ccb5 commit 6819385

1 file changed

Lines changed: 39 additions & 2 deletions

File tree

‎packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler;
4242

4343
@implementation RNBetterPanGestureRecognizer {
4444
__weak RNGestureHandler *_gestureHandler;
45-
#if !TARGET_OS_OSX
45+
#if TARGET_OS_OSX
46+
BOOL _blockAutomaticActivation;
47+
#else
4648
NSUInteger _realMinimumNumberOfTouches;
4749
#endif
4850
BOOL _hasCustomActivationCriteria;
@@ -114,6 +116,9 @@ - (void)tryUpdateStylusData:(UIEvent *)event
114116

115117
- (void)activateAfterLongPress
116118
{
119+
#if TARGET_OS_OSX
120+
_blockAutomaticActivation = NO;
121+
#endif
117122
self.state = UIGestureRecognizerStateBegan;
118123
// Send event in ACTIVE state because UIGestureRecognizerStateBegan is mapped to RNGestureHandlerStateBegan
119124
[_gestureHandler handleGesture:self inState:RNGestureHandlerStateActive];
@@ -144,6 +149,11 @@ - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event
144149
#endif
145150

146151
#if TARGET_OS_OSX
152+
// NSPanGestureRecognizer transitions to the Began state on mouse-down, without any
153+
// built-in movement hysteresis. To honor the custom activation criteria (minDist,
154+
// activeOffsets, minVelocity) we hold the recognizer in the Possible state until
155+
// they are met in interactionsMoved.
156+
_blockAutomaticActivation = _hasCustomActivationCriteria;
147157
[super mouseDown:event];
148158
#else
149159
[super touchesBegan:touches withEvent:event];
@@ -181,7 +191,14 @@ - (void)interactionsMoved:(NSSet *)touches withEvent:(UIEvent *)event
181191
}
182192
}
183193

184-
#if !TARGET_OS_TV && !TARGET_OS_OSX
194+
#if TARGET_OS_OSX
195+
if (_hasCustomActivationCriteria && self.state == UIGestureRecognizerStatePossible &&
196+
[self shouldActivateUnderCustomCriteria]) {
197+
_blockAutomaticActivation = NO;
198+
self.state = UIGestureRecognizerStateBegan;
199+
[self setTranslation:CGPointMake(0, 0) inView:self.view];
200+
}
201+
#elif !TARGET_OS_TV
185202
if (_hasCustomActivationCriteria && self.state == UIGestureRecognizerStatePossible &&
186203
[self shouldActivateUnderCustomCriteria]) {
187204
super.minimumNumberOfTouches = _realMinimumNumberOfTouches;
@@ -214,6 +231,23 @@ - (void)interactionsCancelled:(NSSet *)touches withEvent:(UIEvent *)event
214231

215232
#if TARGET_OS_OSX
216233

234+
- (void)setState:(NSGestureRecognizerState)state
235+
{
236+
if (_blockAutomaticActivation) {
237+
if (state == NSGestureRecognizerStateBegan || state == NSGestureRecognizerStateChanged) {
238+
// Hold the recognizer in the Possible state until the custom activation criteria
239+
// are met — the superclass keeps tracking the mouse regardless, so translation
240+
// and velocity stay valid for the criteria checks.
241+
return;
242+
}
243+
if (state == NSGestureRecognizerStateEnded) {
244+
// Mouse released before the activation criteria were met — the gesture failed.
245+
state = NSGestureRecognizerStateFailed;
246+
}
247+
}
248+
[super setState:state];
249+
}
250+
217251
- (void)mouseDown:(NSEvent *)event
218252
{
219253
[_gestureHandler setCurrentPointerTypeToMouse];
@@ -269,6 +303,9 @@ - (void)reset
269303
[self triggerActionFromReset];
270304
[_gestureHandler.pointerTracker reset];
271305
[NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(activateAfterLongPress) object:nil];
306+
#if TARGET_OS_OSX
307+
_blockAutomaticActivation = NO;
308+
#endif
272309
[super reset];
273310
[_gestureHandler reset];
274311

0 commit comments

Comments
 (0)