[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> M
Michał Bert committed
6819385fca53c3414feeef4024dc99f5c68d445a
Parent: 991ccb5
Committed by GitHub <noreply@github.com>
on 8/6/2026, 9:56:47 AM