@rootnative/impulse 0.0.0-alpha.0 → 0.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -5
- package/README.md +206 -11
- package/dist/{chunk-5BMRKYVY.js → chunk-2UZTAWUQ.js} +20 -2
- package/dist/chunk-BNSFDNLA.js +75 -0
- package/dist/chunk-DXXGWG4Q.js +136 -0
- package/dist/{chunk-PMR25UCT.js → chunk-HGBCIL6X.js} +1 -1
- package/dist/chunk-HI5PHDJY.js +94 -0
- package/dist/{chunk-IG5RXCYR.js → chunk-IH7SQ5X6.js} +11 -15
- package/dist/chunk-MWCIEVTA.js +199 -0
- package/dist/{chunk-F4RHM4ZK.js → chunk-NYDDZD4G.js} +58 -1
- package/dist/chunk-PGOQSKEJ.js +144 -0
- package/dist/{chunk-FR242SUF.js → chunk-TGOGIZDH.js} +13 -7
- package/dist/chunk-VEPUHGPN.js +12 -0
- package/dist/chunk-YZHAQ4XK.js +136 -0
- package/dist/compose/index.d.ts +1 -1
- package/dist/double-tap/index.d.ts +184 -0
- package/dist/double-tap/index.js +5 -0
- package/dist/drag/index.d.ts +18 -13
- package/dist/drag/index.js +3 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +12 -5
- package/dist/long-press/index.d.ts +219 -0
- package/dist/long-press/index.js +4 -0
- package/dist/pan/index.d.ts +221 -0
- package/dist/pan/index.js +4 -0
- package/dist/pinch/index.d.ts +239 -0
- package/dist/pinch/index.js +4 -0
- package/dist/raw/index.d.ts +3 -3
- package/dist/raw/index.js +2 -2
- package/dist/rotate/index.d.ts +263 -0
- package/dist/rotate/index.js +4 -0
- package/dist/swipe/index.d.ts +254 -0
- package/dist/swipe/index.js +4 -0
- package/dist/tap/index.d.ts +34 -29
- package/dist/tap/index.js +4 -3
- package/dist/tapEvent-KSSojt_l.d.ts +28 -0
- package/dist/{types-Ch2HM3aP.d.ts → types-ChGKY28a.d.ts} +27 -1
- package/dist/{useGestureMemo-Ccv8rB0C.d.ts → useGestureMemo-BRW1EKcJ.d.ts} +1 -1
- package/jest-setup.cjs +32 -1
- package/llms.txt +155 -0
- package/package.json +35 -2
- package/src/index.ts +50 -4
- package/src/intents/double-tap/index.ts +6 -0
- package/src/intents/long-press/index.ts +6 -0
- package/src/intents/pan/index.ts +2 -0
- package/src/intents/pinch/index.ts +2 -0
- package/src/intents/rotate/index.ts +6 -0
- package/src/intents/swipe/index.ts +8 -0
- package/src/intents/tapEvent.ts +50 -0
- package/src/intents/useDoubleTap.ts +321 -0
- package/src/intents/useDrag.ts +49 -28
- package/src/intents/useLongPress.ts +391 -0
- package/src/intents/usePan.ts +444 -0
- package/src/intents/usePinch.ts +483 -0
- package/src/intents/useRotate.ts +507 -0
- package/src/intents/useSwipe.ts +585 -0
- package/src/intents/useTap.ts +51 -60
- package/src/internal/intentResult.ts +67 -0
- package/src/internal/phaseCallbacks.ts +51 -0
- package/src/internal/useGestureMemo.ts +32 -4
- package/src/internal/useLatestCallback.ts +2 -2
- package/src/raw/useRawGesture.ts +1 -1
- package/src/relations/index.ts +94 -0
- package/src/types.ts +27 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Gesture, type TapGesture } from 'react-native-gesture-handler'
|
|
3
|
+
import { useSharedValue } from 'react-native-reanimated'
|
|
4
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
5
|
+
import {
|
|
6
|
+
useGestureMemo,
|
|
7
|
+
type GestureMemoOptions,
|
|
8
|
+
} from '../internal/useGestureMemo'
|
|
9
|
+
import { buildIntentResult } from '../internal/intentResult'
|
|
10
|
+
import { useLatestCallback } from '../internal/useLatestCallback'
|
|
11
|
+
import { useStableRecord } from '../internal/useStableRecord'
|
|
12
|
+
import { toTapEvent, type TapEvent } from './tapEvent'
|
|
13
|
+
import { type HitSlop, type IntentEndInfo, type IntentResult } from '../types'
|
|
14
|
+
|
|
15
|
+
export type { TapEvent } from './tapEvent'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How long each tap may hold the finger down, in milliseconds.
|
|
19
|
+
*
|
|
20
|
+
* RNGH's own default, restated for the same reason `useTap` restates it: the
|
|
21
|
+
* number is part of the documented behaviour, and inheriting it means an RNGH
|
|
22
|
+
* release can move it underneath Impulse without a word.
|
|
23
|
+
*/
|
|
24
|
+
const DEFAULT_MAX_DURATION = 500
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How long the gap between the two taps may be, in milliseconds.
|
|
28
|
+
*
|
|
29
|
+
* RNGH's own default, restated. It is also the number that decides how much
|
|
30
|
+
* latency a single tap pays when the two are composed — see `maxDelay`.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_MAX_DELAY = 500
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How far the finger may travel and still count as a tap, in points.
|
|
36
|
+
*
|
|
37
|
+
* Impulse's number rather than RNGH's, exactly as in `useTap`: RNGH leaves
|
|
38
|
+
* the slop to the platform, so the same tap is accepted on one operating
|
|
39
|
+
* system and rejected on the other. The two hooks share the value on purpose
|
|
40
|
+
* — a double tap that is fussier than a single tap on the same view is a
|
|
41
|
+
* difference the consumer never asked for.
|
|
42
|
+
*
|
|
43
|
+
* **This number is a design intention, not a measurement.** The device sweep
|
|
44
|
+
* of 2026-09-19 did not test it. See Known gaps in docs/docs/roadmap.md.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_MAX_DISTANCE = 10
|
|
47
|
+
|
|
48
|
+
/** Options for {@link useDoubleTap}. */
|
|
49
|
+
export interface UseDoubleTapOptions extends GestureMemoOptions {
|
|
50
|
+
/**
|
|
51
|
+
* How many fingers must be down. Default `1`.
|
|
52
|
+
*
|
|
53
|
+
* The count applies to both taps — a two-finger double tap is two taps of
|
|
54
|
+
* two fingers, not a tap of two followed by a tap of one.
|
|
55
|
+
*/
|
|
56
|
+
pointers?: number
|
|
57
|
+
/**
|
|
58
|
+
* How long each tap may hold the finger down, in milliseconds. Default
|
|
59
|
+
* `500`.
|
|
60
|
+
*
|
|
61
|
+
* This is per tap, not for the pair. A finger that stays down past it fails
|
|
62
|
+
* the gesture rather than counting as the second tap.
|
|
63
|
+
*/
|
|
64
|
+
maxDuration?: number
|
|
65
|
+
/**
|
|
66
|
+
* How long the gap between the two taps may be, in milliseconds. Default
|
|
67
|
+
* `500`.
|
|
68
|
+
*
|
|
69
|
+
* **Lowering it is how a composed single tap stops feeling slow.** When a
|
|
70
|
+
* `useTap` is composed `exclusive` behind this hook, the single tap cannot
|
|
71
|
+
* report until this window has passed without a second tap, so every
|
|
72
|
+
* ordinary tap on that view waits this long. 500ms is RNGH's number and is
|
|
73
|
+
* generous; 250 to 300 is closer to what the platforms themselves use.
|
|
74
|
+
*
|
|
75
|
+
* The device sweep of 2026-09-19 found that a deliberate double tap still
|
|
76
|
+
* registers at 250, on an Android emulator and an iOS simulator. Whether
|
|
77
|
+
* the single-tap latency feels acceptable is unmeasured. See Known gaps in
|
|
78
|
+
* docs/docs/roadmap.md.
|
|
79
|
+
*/
|
|
80
|
+
maxDelay?: number
|
|
81
|
+
/**
|
|
82
|
+
* How far the finger may travel within a tap, in points. Default `10`.
|
|
83
|
+
*
|
|
84
|
+
* This does not limit how far the second tap may land from the first — RNGH
|
|
85
|
+
* measures each tap on its own.
|
|
86
|
+
*/
|
|
87
|
+
maxDistance?: number
|
|
88
|
+
/**
|
|
89
|
+
* Extra touchable area around the view, in points.
|
|
90
|
+
*
|
|
91
|
+
* Written inline as an object is fine — the gesture is not rebuilt when the
|
|
92
|
+
* contents are unchanged.
|
|
93
|
+
*/
|
|
94
|
+
hitSlop?: HitSlop
|
|
95
|
+
/**
|
|
96
|
+
* Whether the gesture is recognized at all. Default `true`.
|
|
97
|
+
*
|
|
98
|
+
* Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
|
|
99
|
+
* keeps its identity and its relations, so re-enabling it does not
|
|
100
|
+
* re-attach anything.
|
|
101
|
+
*/
|
|
102
|
+
enabled?: boolean
|
|
103
|
+
/**
|
|
104
|
+
* The double tap ended. **Runs on the JS thread** — Impulse owns the
|
|
105
|
+
* `scheduleOnRN` boundary, so this is an ordinary function and may touch React
|
|
106
|
+
* state.
|
|
107
|
+
*
|
|
108
|
+
* The payload describes the second tap, which is the one the consumer means
|
|
109
|
+
* when they ask where the double tap was.
|
|
110
|
+
*
|
|
111
|
+
* It fires only for a double tap the recognizer accepted, and `cancelled`
|
|
112
|
+
* says what happened after that. `false` is the ordinary double tap. `true`
|
|
113
|
+
* means the system took the recognized double tap away — a competing
|
|
114
|
+
* gesture in a relation won it, or the app went to the background.
|
|
115
|
+
*
|
|
116
|
+
* **Check `cancelled` before you act on it.** A handler that zooms or
|
|
117
|
+
* navigates should do nothing when it is `true`. The path is rare: a single
|
|
118
|
+
* tap that was never followed by a second reaches `onFinalize` with
|
|
119
|
+
* `success: false` and never gets here.
|
|
120
|
+
*/
|
|
121
|
+
onDoubleTap?: (event: TapEvent, info: IntentEndInfo) => void
|
|
122
|
+
/**
|
|
123
|
+
* The first finger went down and the gesture is now a candidate. **This is
|
|
124
|
+
* a worklet** — mark it with the `'worklet'` directive, and do not touch
|
|
125
|
+
* React state from it.
|
|
126
|
+
*
|
|
127
|
+
* It fires once for the pair, on the first touch, not once per tap. Most of
|
|
128
|
+
* the touches that reach it are ordinary single taps that will fail this
|
|
129
|
+
* gesture, so it is a poor place to show anything the user would read as
|
|
130
|
+
* commitment.
|
|
131
|
+
*/
|
|
132
|
+
onBegin?: (event: TapEvent) => void
|
|
133
|
+
/**
|
|
134
|
+
* The gesture is over, whether both taps happened or not. **This is a
|
|
135
|
+
* worklet.**
|
|
136
|
+
*
|
|
137
|
+
* `success` is `true` when the double tap was recognized. This is the right
|
|
138
|
+
* place to clear anything `onBegin` set, because it runs on both paths.
|
|
139
|
+
*/
|
|
140
|
+
onFinalize?: (event: TapEvent, success: boolean) => void
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* What {@link useDoubleTap} returns.
|
|
145
|
+
*
|
|
146
|
+
* Its own name rather than an alias of `UseTapResult`, so the two can diverge
|
|
147
|
+
* without a breaking rename. A double tap produces no continuous value, so
|
|
148
|
+
* the gesture, the ref, and `isActive` are the whole result.
|
|
149
|
+
*/
|
|
150
|
+
export type UseDoubleTapResult = IntentResult<TapGesture>
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Recognize two taps in quick succession.
|
|
154
|
+
*
|
|
155
|
+
* ```tsx
|
|
156
|
+
* const double = useDoubleTap({ onDoubleTap: () => zoomIn() })
|
|
157
|
+
*
|
|
158
|
+
* return (
|
|
159
|
+
* <GestureDetector gesture={double.gesture}>
|
|
160
|
+
* <View />
|
|
161
|
+
* </GestureDetector>
|
|
162
|
+
* )
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* `onDoubleTap` runs on the JS thread and may set React state directly.
|
|
166
|
+
* `onBegin` and `onFinalize` are worklets and run on the UI thread — the name
|
|
167
|
+
* states the thread, so there is nothing to configure and no `scheduleOnRN` to
|
|
168
|
+
* write.
|
|
169
|
+
*
|
|
170
|
+
* `isActive` is a shared value that is `true` from the first finger down
|
|
171
|
+
* until the pair resolves. It is **not** a useful pressed state on its own:
|
|
172
|
+
* it is `true` for every ordinary single tap on the view as well, and those
|
|
173
|
+
* mostly end in failure. Drive a pressed state from a `useTap` composed with
|
|
174
|
+
* this one, and use `isActive` here only for something that should show while
|
|
175
|
+
* a double tap is genuinely in progress.
|
|
176
|
+
*
|
|
177
|
+
* **Pairing with a single tap.** This is the composition that makes both
|
|
178
|
+
* work, and the mode matters:
|
|
179
|
+
*
|
|
180
|
+
* ```tsx
|
|
181
|
+
* const double = useDoubleTap({ maxDelay: 250, onDoubleTap: zoomIn })
|
|
182
|
+
* const tap = useTap({ onTap: select })
|
|
183
|
+
* const { gesture } = useGestures([double, tap], { mode: 'exclusive' })
|
|
184
|
+
* ```
|
|
185
|
+
*
|
|
186
|
+
* `exclusive` tries the members in order and lets a later one activate only
|
|
187
|
+
* after every earlier one has failed, so the single tap waits to learn
|
|
188
|
+
* whether a second tap is coming. `race` does not work: a single tap
|
|
189
|
+
* recognizes on the first release and would win every time. The cost is
|
|
190
|
+
* latency — the single tap cannot report for `maxDelay` milliseconds — which
|
|
191
|
+
* is why lowering `maxDelay` is the first thing to reach for when a composed
|
|
192
|
+
* single tap feels slow.
|
|
193
|
+
*
|
|
194
|
+
* **A view that only needs a double tap does not need the composition.** Use
|
|
195
|
+
* this hook alone; there is nothing for it to wait on.
|
|
196
|
+
*
|
|
197
|
+
* **Three taps and up are not modelled.** A triple tap is rare enough that a
|
|
198
|
+
* named intent would be dead API. Build it with `useRawGesture` and
|
|
199
|
+
* `Gesture.Tap().numberOfTaps(3)`.
|
|
200
|
+
*
|
|
201
|
+
* **Activation criteria.** `maxDuration` and `maxDelay` default to 500ms, and
|
|
202
|
+
* `maxDistance` to 10 points. The distance default is Impulse's, not RNGH's,
|
|
203
|
+
* and matches `useTap` so the two agree about what counts as a tap on one
|
|
204
|
+
* view. The device sweep found that `maxDelay` works at 500ms and at 250ms.
|
|
205
|
+
* It did not test `maxDuration` or `maxDistance`, and it measured no feel.
|
|
206
|
+
*
|
|
207
|
+
* **Web.** RNGH's web implementation recognizes tap from pointer events, so a
|
|
208
|
+
* double click behaves as a double tap. The browser's own double-click
|
|
209
|
+
* handling — text selection, zoom — is not suppressed by this hook, and
|
|
210
|
+
* `pointers` above 1 is unreliable there because a mouse reports one pointer
|
|
211
|
+
* and touch emulation varies by browser.
|
|
212
|
+
*
|
|
213
|
+
* **Accessibility.** A double tap is invisible to a screen reader and
|
|
214
|
+
* unreachable from a keyboard, and it is worse than a single tap on both
|
|
215
|
+
* counts: VoiceOver and TalkBack both consume a double tap as their own
|
|
216
|
+
* activation gesture, so a screen-reader user cannot reach this at all.
|
|
217
|
+
* Whatever it does must be reachable another way — an explicit control, or
|
|
218
|
+
* `accessibilityActions` with `onAccessibilityAction` on the view the gesture
|
|
219
|
+
* is attached to. A double-tap-only affordance is a bug, not a trade-off.
|
|
220
|
+
*
|
|
221
|
+
* @param options - Activation criteria, callbacks, and the `alongside` /
|
|
222
|
+
* `blocks` / `deferTo` coexistence options every Impulse hook accepts.
|
|
223
|
+
*/
|
|
224
|
+
export function useDoubleTap(
|
|
225
|
+
options: UseDoubleTapOptions = {},
|
|
226
|
+
): UseDoubleTapResult {
|
|
227
|
+
const {
|
|
228
|
+
pointers = 1,
|
|
229
|
+
maxDuration = DEFAULT_MAX_DURATION,
|
|
230
|
+
maxDelay = DEFAULT_MAX_DELAY,
|
|
231
|
+
maxDistance = DEFAULT_MAX_DISTANCE,
|
|
232
|
+
enabled,
|
|
233
|
+
onDoubleTap,
|
|
234
|
+
onBegin,
|
|
235
|
+
onFinalize,
|
|
236
|
+
} = options
|
|
237
|
+
|
|
238
|
+
const isActive = useSharedValue(false)
|
|
239
|
+
// `hitSlop` is the one option a consumer writes as an object literal, so it
|
|
240
|
+
// is the one that would rebuild the gesture every render if taken as-is.
|
|
241
|
+
const hitSlop = useStableRecord(options.hitSlop)
|
|
242
|
+
// JS-thread callback: reached through a stable identity so it is never a
|
|
243
|
+
// gesture dependency. The worklet callbacks below stay direct dependencies,
|
|
244
|
+
// because a worklet is captured as written.
|
|
245
|
+
const handleDoubleTap = useLatestCallback(onDoubleTap)
|
|
246
|
+
// Attaching a handler is not the same as calling it: RNGH decides which
|
|
247
|
+
// thread a gesture's callbacks run on by inspecting the ones it was given,
|
|
248
|
+
// so the gesture does have to change when `onDoubleTap` appears or
|
|
249
|
+
// disappears. This is a boolean, so it changes only when that is actually
|
|
250
|
+
// true.
|
|
251
|
+
const hasDoubleTapHandler = onDoubleTap !== undefined
|
|
252
|
+
|
|
253
|
+
const built = useGestureMemo(
|
|
254
|
+
'useDoubleTap',
|
|
255
|
+
() => {
|
|
256
|
+
const tap = Gesture.Tap()
|
|
257
|
+
.numberOfTaps(2)
|
|
258
|
+
.minPointers(pointers)
|
|
259
|
+
.maxDuration(maxDuration)
|
|
260
|
+
.maxDelay(maxDelay)
|
|
261
|
+
.maxDistance(maxDistance)
|
|
262
|
+
.onBegin((event) => {
|
|
263
|
+
'worklet'
|
|
264
|
+
isActive.value = true
|
|
265
|
+
onBegin?.(toTapEvent(event))
|
|
266
|
+
})
|
|
267
|
+
.onEnd((event, success) => {
|
|
268
|
+
'worklet'
|
|
269
|
+
// Not guarded on `success`: RNGH calls `onEnd` only when the old
|
|
270
|
+
// state was ACTIVE, so reaching here at all means the double tap
|
|
271
|
+
// was recognized. `cancelled` then separates the one the user
|
|
272
|
+
// completed from the one the system took away. Without it the
|
|
273
|
+
// cancel is reportable only from `onFinalize`, which is a worklet.
|
|
274
|
+
if (hasDoubleTapHandler) {
|
|
275
|
+
scheduleOnRN(handleDoubleTap, toTapEvent(event), {
|
|
276
|
+
cancelled: !success,
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
.onFinalize((event, success) => {
|
|
281
|
+
'worklet'
|
|
282
|
+
isActive.value = false
|
|
283
|
+
onFinalize?.(toTapEvent(event), success)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// Applied conditionally rather than with a default, so an option the
|
|
287
|
+
// consumer did not set leaves RNGH's own default in place instead of
|
|
288
|
+
// Impulse overwriting it with a guess.
|
|
289
|
+
if (hitSlop !== undefined) {
|
|
290
|
+
tap.hitSlop(hitSlop)
|
|
291
|
+
}
|
|
292
|
+
if (enabled !== undefined) {
|
|
293
|
+
tap.enabled(enabled)
|
|
294
|
+
}
|
|
295
|
+
return tap
|
|
296
|
+
},
|
|
297
|
+
[
|
|
298
|
+
pointers,
|
|
299
|
+
maxDuration,
|
|
300
|
+
maxDelay,
|
|
301
|
+
maxDistance,
|
|
302
|
+
hitSlop,
|
|
303
|
+
enabled,
|
|
304
|
+
hasDoubleTapHandler,
|
|
305
|
+
handleDoubleTap,
|
|
306
|
+
isActive,
|
|
307
|
+
onBegin,
|
|
308
|
+
onFinalize,
|
|
309
|
+
],
|
|
310
|
+
options,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
// Memoised for the same reason `useGestureMemo` memoises its own result: a
|
|
314
|
+
// consumer may put the whole hook result in a dependency list, and a fresh
|
|
315
|
+
// object every render would make that dependency useless. `isActive` is
|
|
316
|
+
// stable for the life of the hook, so `built` is the only real input.
|
|
317
|
+
return useMemo(
|
|
318
|
+
() => buildIntentResult(built, { isActive }),
|
|
319
|
+
[built, isActive],
|
|
320
|
+
)
|
|
321
|
+
}
|
package/src/intents/useDrag.ts
CHANGED
|
@@ -6,18 +6,21 @@ import {
|
|
|
6
6
|
type PanGesture,
|
|
7
7
|
type PanGestureHandlerEventPayload,
|
|
8
8
|
} from 'react-native-gesture-handler'
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
useSharedValue,
|
|
12
|
-
type SharedValue,
|
|
13
|
-
} from 'react-native-reanimated'
|
|
9
|
+
import { useSharedValue, type SharedValue } from 'react-native-reanimated'
|
|
10
|
+
import { scheduleOnRN } from 'react-native-worklets'
|
|
14
11
|
import {
|
|
15
12
|
useGestureMemo,
|
|
16
13
|
type GestureMemoOptions,
|
|
17
14
|
} from '../internal/useGestureMemo'
|
|
15
|
+
import { buildIntentResult } from '../internal/intentResult'
|
|
18
16
|
import { useLatestCallback } from '../internal/useLatestCallback'
|
|
19
17
|
import { useStableRecord } from '../internal/useStableRecord'
|
|
20
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type HitSlop,
|
|
20
|
+
type IntentEndInfo,
|
|
21
|
+
type IntentResult,
|
|
22
|
+
type Point,
|
|
23
|
+
} from '../types'
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* How far the finger must travel before the drag takes the touch, in points.
|
|
@@ -28,8 +31,10 @@ import { type HitSlop, type IntentResult, type Point } from '../types'
|
|
|
28
31
|
* can share a view, so it is set here rather than left to the consumer to
|
|
29
32
|
* discover.
|
|
30
33
|
*
|
|
31
|
-
* **This number is a design intention, not a measurement.**
|
|
32
|
-
*
|
|
34
|
+
* **This number is a design intention, not a measurement.** The device sweep
|
|
35
|
+
* of 2026-09-19 found that it works mechanically on an Android emulator and
|
|
36
|
+
* an iOS simulator. Whether it feels right is unmeasured. See Known gaps in
|
|
37
|
+
* docs/docs/roadmap.md.
|
|
33
38
|
*/
|
|
34
39
|
const DEFAULT_THRESHOLD = 10
|
|
35
40
|
|
|
@@ -179,7 +184,7 @@ export interface UseDragOptions extends GestureMemoOptions {
|
|
|
179
184
|
enabled?: boolean
|
|
180
185
|
/**
|
|
181
186
|
* The drag passed `threshold` and now owns the touch. **Runs on the JS
|
|
182
|
-
* thread** — Impulse owns the `
|
|
187
|
+
* thread** — Impulse owns the `scheduleOnRN` boundary, so this is an ordinary
|
|
183
188
|
* function and may touch React state.
|
|
184
189
|
*
|
|
185
190
|
* This is the first moment the drag has definitely won. `onBegin` fires
|
|
@@ -187,17 +192,21 @@ export interface UseDragOptions extends GestureMemoOptions {
|
|
|
187
192
|
*/
|
|
188
193
|
onDragStart?: (event: DragEvent) => void
|
|
189
194
|
/**
|
|
190
|
-
* The
|
|
195
|
+
* The drag is over. **Runs on the JS thread.**
|
|
191
196
|
*
|
|
192
|
-
* Fires only for a drag that activated
|
|
193
|
-
*
|
|
194
|
-
* reaches `onFinalize` with `success: false` and never gets here, because
|
|
195
|
-
* there was no release and so no velocity worth seeding a spring with.
|
|
197
|
+
* Fires only for a drag that activated, so a touch that never passed the
|
|
198
|
+
* threshold never reaches here on either path.
|
|
196
199
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
200
|
+
* It fires for both endings, and `cancelled` says which. `false` is the
|
|
201
|
+
* finger lifting, and `velocity` then seeds the release animation. `true`
|
|
202
|
+
* is the system taking the drag away — a competing gesture won, or the app
|
|
203
|
+
* went to the background. **There was no release on that path, so the
|
|
204
|
+
* velocity describes the last movement rather than a throw.** Return the
|
|
205
|
+
* view to `settled` instead of springing it.
|
|
206
|
+
*
|
|
207
|
+
* Read `settled` on both paths for where an elastic overshoot belongs.
|
|
199
208
|
*/
|
|
200
|
-
onDragEnd?: (event: DragEvent) => void
|
|
209
|
+
onDragEnd?: (event: DragEvent, info: IntentEndInfo) => void
|
|
201
210
|
/**
|
|
202
211
|
* The finger went down and the gesture is now a candidate. **This is a
|
|
203
212
|
* worklet** — mark it with the `'worklet'` directive, and do not touch
|
|
@@ -212,7 +221,7 @@ export interface UseDragOptions extends GestureMemoOptions {
|
|
|
212
221
|
* The drag moved. **This is a worklet**, and it runs on every frame the
|
|
213
222
|
* finger moves.
|
|
214
223
|
*
|
|
215
|
-
* There is no JS-thread counterpart on purpose. A per-frame `
|
|
224
|
+
* There is no JS-thread counterpart on purpose. A per-frame `scheduleOnRN` is a
|
|
216
225
|
* scheduling cost paid sixty times a second for a value that is already on
|
|
217
226
|
* the thread that needs it — read `x` and `y` from a `useAnimatedStyle`
|
|
218
227
|
* instead, and let this callback handle what the style cannot.
|
|
@@ -324,7 +333,8 @@ function clamp(
|
|
|
324
333
|
* vertical `ScrollView` leaves the scroll alone until the finger commits
|
|
325
334
|
* sideways; with `'both'` it is a radial distance. Set `failOffset` as well
|
|
326
335
|
* when a mostly-diagonal move should go to the other gesture rather than to
|
|
327
|
-
* this one. The default
|
|
336
|
+
* this one. The default works mechanically in the device sweep, and its feel
|
|
337
|
+
* is unmeasured.
|
|
328
338
|
*
|
|
329
339
|
* **Coexistence.** A threshold decides who moves first; it does not decide
|
|
330
340
|
* who wins a contested touch. For a drag inside a scroll view, say which one
|
|
@@ -452,7 +462,7 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
|
|
|
452
462
|
startY.value = y.value - event.translationY
|
|
453
463
|
isActive.value = true
|
|
454
464
|
if (hasDragStart) {
|
|
455
|
-
|
|
465
|
+
scheduleOnRN(handleDragStart, toDragEvent(event))
|
|
456
466
|
}
|
|
457
467
|
})
|
|
458
468
|
.onUpdate((event) => {
|
|
@@ -477,13 +487,21 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
|
|
|
477
487
|
})
|
|
478
488
|
.onEnd((event, success) => {
|
|
479
489
|
'worklet'
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
// Not guarded on `success`: RNGH calls this for a cancelled drag
|
|
491
|
+
// too, and `cancelled` is what separates the two. The guard used to
|
|
492
|
+
// be here, which left the cancel reportable only from `onFinalize`
|
|
493
|
+
// — a worklet — so a consumer holding phase in React state had to
|
|
494
|
+
// cross the thread boundary by hand. A drag that is taken away
|
|
495
|
+
// still has to put its view somewhere, and that decision belongs on
|
|
496
|
+
// the JS thread as much as the release does.
|
|
497
|
+
//
|
|
498
|
+
// Reporting the cancel is safe because RNGH calls `onEnd` only when
|
|
499
|
+
// the old state was ACTIVE. A touch that never passed the threshold
|
|
500
|
+
// reaches `onFinalize` and never gets here.
|
|
501
|
+
if (hasDragEnd) {
|
|
502
|
+
scheduleOnRN(handleDragEnd, toDragEvent(event), {
|
|
503
|
+
cancelled: !success,
|
|
504
|
+
})
|
|
487
505
|
}
|
|
488
506
|
})
|
|
489
507
|
.onFinalize((event, success) => {
|
|
@@ -559,5 +577,8 @@ export function useDrag(options: UseDragOptions = {}): UseDragResult {
|
|
|
559
577
|
// consumer may put the whole hook result in a dependency list, and a fresh
|
|
560
578
|
// object every render would make that dependency useless. The shared values
|
|
561
579
|
// are stable for the life of the hook, so `built` is the only real input.
|
|
562
|
-
return useMemo(
|
|
580
|
+
return useMemo(
|
|
581
|
+
() => buildIntentResult(built, { x, y, isActive }),
|
|
582
|
+
[built, x, y, isActive],
|
|
583
|
+
)
|
|
563
584
|
}
|