@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
package/CHANGELOG.md
CHANGED
|
@@ -4,14 +4,50 @@ All notable changes to `@rootnative/impulse` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`0.0.0-alpha.1` is on npm, cut from the `core@0.0.0-alpha.1` tag. This section holds every change since it.
|
|
8
|
+
|
|
9
|
+
## [0.0.0-alpha.1] - 2026-09-27
|
|
10
|
+
|
|
11
|
+
The second alpha. It carries every change since `0.0.0-alpha.0`: the eight intent hooks, the `IntentEndInfo` argument on every end callback, and the worklet warning. Milestone 1's gate is closed on mechanics only, and feel is untested.
|
|
8
12
|
|
|
9
13
|
### Added
|
|
10
14
|
|
|
15
|
+
- **A phase callback that is not a worklet now warns in development.** `onBegin`, `onUpdate`, and `onFinalize` run on the UI thread. A plain function there throws `Tried to synchronously call a Remote Function. Called "anonymous"` on the first touch that reaches the phase, and that error names neither the hook nor the option. The warning names both and gives the fix: add the `'worklet'` directive. It runs from `useGestureMemo`, so all eight intents get it and a new intent cannot skip it. It is `warnOnce`-keyed by hook and option. The shipped Jest setup now mocks `isWorkletFunction` and reports every function as a worklet, because the mock has no UI thread and a Jest run without the Worklets Babel plugin marks nothing.
|
|
16
|
+
|
|
17
|
+
- **Every intent result now hides `gesture` and `ref` from enumeration, which fixes a render crash in every intent.** Reading a shared value off a hook result inside a worklet — `useAnimatedStyle(() => ({ transform: [{ translateX: drag.x.value }] }))`, the pattern every docs page shows — made the worklet capture `drag` itself, because a worklet captures the root identifier it reads through. Worklets then tried to copy the whole result to the UI thread, could not copy the RNGH gesture inside it, and the component threw `[Worklets] Cannot copy value of type 'PanGesture'` at render. **Every example screen died of this**, and it was invisible to the test suite because the Jest Reanimated mock serializes nothing. Worklets copies an object through `Object.entries`, so making the two fields non-enumerable removes them from the copy and leaves the shared values, which is all a worklet ever wanted. **No public API changed and no consumer code needs editing** — property access still works, so `<GestureDetector gesture={drag.gesture}>` and every coexistence option are unaffected. The only observable difference is that `gesture` and `ref` no longer appear in `Object.keys`, a spread, `JSON.stringify`, or a logged object.
|
|
18
|
+
|
|
19
|
+
- **`useRotate`** — a two-finger rotation that owns the angle it produces. It accumulates across gestures the way `usePinch` accumulates a scale, so `min` and `max` are the travel of the control rather than of one gesture, and `elastic` lets the fingers turn past an end with `settled` reporting where the angle belongs. `anchor` is the point the turn happens about, which is `focal`'s counterpart. `onRotateStart` / `onRotateEnd` on the JS thread, `onBegin` / `onUpdate` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/rotate`.
|
|
20
|
+
- **`useRotate` reports degrees, and RNGH reports radians.** This is the only place in the library where a unit is changed rather than passed through. `min: -45` is a range a person wrote; `-Math.PI / 4` is one they derived, and deriving it per project is the work Impulse exists to remove. A React Native style takes either unit, so the call site pays nothing. The conversion is applied at all four boundaries — `angle`, `gestureAngle`, `velocity`, and the `min` / `max` comparison — and `useRotate.test.tsx` pins each one, because a boundary added later and forgotten is a silent factor of 57.
|
|
21
|
+
- **`useRotate` does not wrap at a full turn.** A second revolution reports `720`. A dial counting turns needs exactly that, and a photo editor wants `10` rather than `370` — Impulse does not guess which, because wrapping by default makes the counting case unrecoverable while not wrapping makes the editor case one `%` at the call site.
|
|
22
|
+
- **Public types `RotateEvent`, `UseRotateOptions`, and `UseRotateResult`.**
|
|
23
|
+
- **`example/screens/RotateScreen.tsx`**, wired into the gallery. A levelling control carries the anchor transform and the elastic ends; a second panel composes `useRotate` with `usePinch` under `mode: 'simultaneous'`, which is the photo-editor pair and the case neither hook can express alone — neither has a threshold, so nothing else would separate them.
|
|
24
|
+
- **`usePinch`** — a two-finger pinch that owns the scale it produces. It accumulates across gestures the way `useDrag` accumulates a position, so `min` and `max` are the zoom range of the viewer rather than of one gesture, and `elastic` lets the fingers pull past an end with `settled` reporting where the scale belongs. `onPinchStart` / `onPinchEnd` on the JS thread, `onBegin` / `onUpdate` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/pinch`.
|
|
25
|
+
- **`usePinch` recovers the gesture's start by division, not by assignment.** RNGH activates a pinch on movement, so its `scale` at `onStart` is not reliably `1`; assigning the current scale there multiplies that head start into the value and the content jumps before it grows. Dividing it out means the first update reproduces the scale the view already had — the same defect `useDrag` avoids by subtracting the threshold once at the start.
|
|
26
|
+
- **`focal` is reported because scaling about the view's centre is wrong.** It is the midpoint between the fingers, relative to the view, and without it the content slides out from under them as it grows. It is read at `onStart` as well as at every update, so `onPinchStart` does not hand over the previous gesture's point, and it keeps its last value after the fingers lift so a release animation scales about the same place the pinch did.
|
|
27
|
+
- **`usePinch` has no activation criteria, and that is RNGH's limit rather than a choice.** `Gesture.Pinch()` takes the touch as soon as a second finger moves and exposes no threshold. It is therefore the one continuous intent that cannot separate itself from a neighbour with a number: a pinch and a pan share a view through `useGestures([pinch, pan], { mode: 'simultaneous' })` or through `alongside`, and through nothing else. Faking a threshold would need `manualActivation`, which the design contract discourages.
|
|
28
|
+
- **Public types `PinchEvent`, `UsePinchOptions`, and `UsePinchResult`.**
|
|
29
|
+
- **`example/screens/PinchScreen.tsx`**, wired into the gallery. It carries the two questions Jest cannot answer: whether the content stays under the fingers, which is what the focal transform is for, and whether an `elastic` of `0.35` reads as resistance or as a fault. That number is a guess — `useDrag` defaults `elastic` to `0` and has none to borrow — and the resistance is computed on the scale rather than on its logarithm, so the two ends do not resist symmetrically. Only a device settles either.
|
|
30
|
+
- **`usePan`** — a pan that reports movement rather than owning a position, and the line between it and `useDrag`. `useDrag` holds `x` and `y` as the place a thing sits: they accumulate across gestures and `bounds` clamps them. `usePan` holds nothing — it zeroes both at the start of every gesture, has no `bounds` and no `elastic`, and hands over a per-frame `change` the consumer adds up. That is the camera, the scrubber, and the canvas, where `bounds` is the wrong owner. `onPanStart` / `onPanEnd` on the JS thread, `onBegin` / `onUpdate` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/pan`.
|
|
31
|
+
- **`usePan` computes `change` itself rather than passing RNGH's through.** RNGH reports the whole translation as the first `changeX`, threshold included, so a consumer accumulating it jumps ten points before anything moves — the same defect `useDrag` avoids by subtracting the offset once at the start. `translation` is measured from the activation point for the same reason.
|
|
32
|
+
- **`useSwipe`** — a pan judged at release. The finger moves and `x` and `y` report it so a view can follow; on release the dominant axis alone is tested, against `commitDistance` (80 points) or `commitSpeed` (800 points per second), either of which is enough on its own. Neither number has been measured on hardware. `onSwipe` and `onSwipeEnd` on the JS thread, `onBegin` / `onUpdate` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/swipe`.
|
|
33
|
+
- **`useSwipe`'s `directions` is the activation criterion, not only a filter.** An all-horizontal list produces `activeOffsetX`, so a swipeable row lives inside a vertical list with no relation declared; an all-vertical list does the same on `y`; a mixed list has no axis to lock, falls back to `minDistance`, and has to declare `deferTo` or `blocks`. Narrowing `directions` is the cheapest coexistence fix in the library.
|
|
34
|
+
- **`onSwipe` and `onSwipeEnd` do different jobs, and the payload says so.** `onSwipe` fires only for a release that committed, and takes a `CommittedSwipeEvent` whose `direction` is never `null` — no null check for a case it cannot be in. `onSwipeEnd` fires for every release of a swipe that activated, which is how a view that followed the finger learns to go back, and its `direction` is `null` for a release that stopped short. A cancelled gesture never commits: the finger did not lift, so its velocity describes the last movement rather than a release.
|
|
35
|
+
- **Public types `PanAxis`, `PanEvent`, `UsePanOptions`, `UsePanResult`, `SwipeDirection`, `SwipeEvent`, `CommittedSwipeEvent`, `UseSwipeOptions`, and `UseSwipeResult`.**
|
|
36
|
+
- **`example/screens/PanScreen.tsx` and `example/screens/SwipeScreen.tsx`**, wired into the gallery. The pan screen pushes a canvas whose offset the *screen* owns, advanced by `change` on every frame, so drift between the finger and the grid is visible rather than asserted; the swipe screen puts horizontal cards inside a vertical list to re-test the coexistence claim through a second hook, and pairs them with an all-directions panel that has to declare `deferTo`.
|
|
37
|
+
|
|
38
|
+
- **A relation to a component that owns no gesture now warns in development.** This is the silent failure the package exists to remove. RNGH resolves every `alongside` / `blocks` / `deferTo` reference through `ref.current?.handlerTag ?? -1` and then keeps only tags above zero, so a ref to React Native's own `ScrollView` is dropped — no warning, no error, and no way to tell the result apart from a relation nobody wrote. The gesture still works, and it still fights the scroll view for the touch. The warning names the hook, the option, and the fix: import `ScrollView` or `FlatList` from `@rootnative/impulse/gesture-handler`. It fires from an effect rather than during render, because a ref is empty while the component that owns it renders, and it is `warnOnce`-keyed by hook and option, so a hook that re-renders at frame rate does not print at frame rate.
|
|
39
|
+
- **The same check stays quiet for a target that mounts in a later commit**, which is the one case it cannot see. Its ref is empty when the check runs and nothing re-checks it. Silence is correct rather than cautious there: RNGH re-resolves relations through `MountRegistry` when a handler mounts late, so the relation is installed and a warning would report a defect that is not there. `relationReferences.test.tsx` pins the three outcomes through a real render, and `relations.test.ts` pins the unit rules — the tag-above-zero threshold, one report per option rather than per reference, and each option reported separately.
|
|
40
|
+
- **`useDoubleTap`** — two taps in quick succession. It shares `useTap`'s payload and its `maxDistance`, because the two hooks recognize the same touch and differ only in how many times it happens; a double tap that is fussier about travel than a single tap on the same view is a difference nobody asked for. `onDoubleTap` on the JS thread, `onBegin` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/double-tap`.
|
|
41
|
+
- **Pairing a single tap with a double tap is `exclusive`, not `race`, and the docs said `race`.** A single tap recognizes on the first release, so under `Gesture.Race` it wins every time and the double tap never fires. `useTap`'s hook comment, the root README, and the package README all carried the wrong composition; all three now say `useGestures([double, tap], { mode: 'exclusive' })`, and `useDoubleTap.test.tsx` asserts the RNGH relation after `prepare()` rather than trusting the prose. The cost of the right answer is stated too: the single tap cannot report for `maxDelay` milliseconds, which is why lowering `maxDelay` is the first thing to reach for.
|
|
42
|
+
- **`useLongPress`** — a press held past a duration, and the first intent whose JS-thread callback fires **while the finger is still down**. That is the difference between a long press and a slow tap: a context menu opens under a finger that has not lifted. `onLongPress` at recognition and `onLongPressEnd` at release, both on the JS thread; `onBegin` / `onFinalize` as worklets. Reachable as `@rootnative/impulse/long-press`.
|
|
43
|
+
- **`useLongPress`'s `isActive` is the held state, not a pressed state.** It turns true at recognition rather than at touch-down, which is the opposite of `useTap` and deliberate: every ordinary tap on the view reaches `onBegin`, and almost none of them are this gesture, so a flag that is true for all of them tells a consumer nothing. Drive a pressed state from a composed `useTap` instead.
|
|
44
|
+
- **`LongPressEvent` carries `duration`** — roughly `minDuration` at `onLongPress`, and the whole hold at `onLongPressEnd`, which is what a hold-to-record affordance stops on. Deriving it from two timestamps the consumer records themselves is the work the payload exists to remove.
|
|
45
|
+
- **Public types `UseDoubleTapOptions`, `UseDoubleTapResult`, `LongPressEvent`, `UseLongPressOptions`, and `UseLongPressResult`.** `TapEvent` is now shared by both tap hooks and moved to `src/intents/tapEvent.ts` with its normalizer, so the two cannot disagree about which RNGH field means what. It is still exported under the same name from the root entry and from `@rootnative/impulse/tap`.
|
|
46
|
+
- **`example/screens/DoubleTapScreen.tsx` and `example/screens/LongPressScreen.tsx`**, wired into the gallery. Each carries the question its hook cannot answer in Jest: the double-tap screen switches `maxDelay` between 500ms and 250ms so the single-tap latency of the pairing can be felt against the double-tap tolerance it buys, and the long-press screen switches `minDuration` and turns its card green at recognition, so a callback firing at the wrong phase is visible rather than asserted.
|
|
47
|
+
- **`scripts/check-artifact.mjs` derives its required-file list from `exports`** instead of holding a copy of it. The literal list was written when there were three subpaths and would not have covered the fourth and fifth, which is the commit where a guard against publishing an `exports` entry with no file behind it becomes a guard against publishing the entries somebody remembered. It also now checks each subpath's `.d.ts`, which the literal never did.
|
|
11
48
|
- **`useDrag`** — the second intent hook, and the first continuous one. Shared values `x` and `y` that follow the finger on the UI thread and accumulate across gestures, `bounds` with an `elastic` coefficient, `onDragStart` / `onDragEnd` on the JS thread, and `onBegin` / `onUpdate` / `onFinalize` as worklets. Returns values and no style, and starts no animation: `onDragEnd` carries `velocity` and `settled` — the nearest in-bounds point — and the consumer springs it home. Reachable as `@rootnative/impulse/drag`.
|
|
12
49
|
- **`useDrag`'s threshold is directional on a single axis and radial on both.** `axis: 'x'` maps to `activeOffsetX`, so a horizontal drag inside a vertical scroll view leaves the scroll alone until the finger commits sideways; `axis: 'both'` maps to `minDistance`. The default of 10 points is Impulse's — a bare `Gesture.Pan()` activates almost immediately, which is what makes a drag steal a scroll. `failOffset` is available for the stricter case and unset by default. The number has not been measured on hardware.
|
|
13
50
|
- **`useDrag` does not jump by the threshold when it activates.** The finger has already travelled that distance when RNGH reports the start, and it stays in `translationX` for the rest of the gesture, so the offset is subtracted once at the start. Without that, the view leaps the threshold the moment the drag wins.
|
|
14
|
-
- **`onDragEnd` is guarded on `success`.** RNGH calls `onEnd` for a cancelled gesture too. A cancelled drag never released and carries no velocity worth seeding a spring with, so it reaches `onFinalize` and not `onDragEnd`.
|
|
15
51
|
- **Public types `DragAxis`, `DragBounds`, and `DragEvent`.**
|
|
16
52
|
- **`example/screens/DragScreen.tsx`**, wired into the gallery. It carries Milestone 1's gate — a horizontal drag inside a vertical scroller — plus a two-axis drag that declares `blocks` against that scroller, and it springs both home from `settled` to show where Impulse stops and the consumer starts.
|
|
17
53
|
- **`useTap`** — the first intent hook, and the pattern the rest follow. An intent-shaped payload (`{ x, y, absolute, pointers }`) instead of RNGH's flat event, `onTap` on the JS thread with Impulse owning the `runOnJS` boundary, `onBegin` / `onFinalize` as worklets, and an `isActive` shared value for a pressed state that needs no re-render. Reachable as `@rootnative/impulse/tap`.
|
|
@@ -36,13 +72,23 @@ Nothing is published. This section holds the scaffold as it is built; the first
|
|
|
36
72
|
- **`scripts/check-versions.mjs`** — the release-consistency guard. It verifies that exactly one public package exists, that the CHANGELOG has a section for the current version, that the link footer resolves, that the status lines in both READMEs are current, and that no `v`-prefixed version string reaches a published surface.
|
|
37
73
|
- **`example/`** — Expo SDK 57 app for manual validation. It carries the navigator shell and no intent screens yet; a screen is added with each intent, and a device pass is a release requirement.
|
|
38
74
|
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **The docs now record the device sweep of 2026-09-19 and 2026-09-20.** It ran on an Android emulator with injected touches and on an iOS simulator with XCUITest. Both checks of Milestone 1's gate passed on both platforms, and every intent was driven on at least one platform. Six docs pages, the doc comments, and `llms.txt` said "No hardware pass has happened". Each one now says what the sweep proved and what it did not. The sweep measured no feel, no physical device ran it, and `useSwipe`'s `commitSpeed` is still untested.
|
|
78
|
+
- **Every end callback now reports how the gesture ended.** `onTap`, `onDoubleTap`, `onLongPressEnd`, and `onDragEnd` take a second argument, `IntentEndInfo`, and fire on **both** endings: `{ cancelled: false }` when the user completed the gesture, `{ cancelled: true }` when the system took it away. **This is a breaking change to four public signatures**, and alpha is when to make it — a consumer who ignores the new argument now runs their handler on the cancelled path. Before this, a cancel was reported only by `onFinalize`, which is a worklet, so a consumer holding phase in React state had to write `'worklet'` plus `scheduleOnRN` by hand — the exact ceremony the library exists to remove. Found by the web survey: hold a `useLongPress` past `minDuration`, then move the pointer past `maxDistance` without releasing, and `isActive` clears while React state does not. It is an object rather than a bare boolean so a later reason field does not break the signature a second time.
|
|
79
|
+
- **The cancel never announces the end of a gesture that never started.** RNGH calls `onEnd` only when the old state was `ACTIVE`, so a touch that never activated — a tap that moved too far, a press released before `minDuration`, a drag that never passed `threshold` — reaches `onFinalize` and no end callback on either path. That is also why removing the `success` guard was the whole change: `onEnd(event, false)` already meant "activated, then cancelled", one-to-one. No activation tracking was added.
|
|
80
|
+
- **`onDragEnd`'s velocity is not a throw on the cancelled path.** The finger never lifted, so it describes the last movement. Springing on it flings a view the user never released; return to `settled` instead. The docs page and `example/screens/DragScreen.tsx` both say so.
|
|
81
|
+
- **`onFinalize` is unchanged, on purpose.** Its `success: false` still covers both a gesture that never activated and one that was cancelled, and the hook still clears `isActive` before calling it. Nothing has to decode that any more, so it stays the UI-thread cleanup hook that runs for every attempt. Rejected: a third `{ activated }` argument, and reordering the two lines so `isActive` survives into the callback — the first adds public surface nothing needs, the second is an implicit signal that works only for the two hooks that set `isActive` at recognition.
|
|
82
|
+
- **`example/screens/LongPressScreen.tsx` no longer crosses the thread boundary by hand.** It carried the workaround on purpose, with a comment saying so: a `useRef` for "was it recognized", a worklet `onFinalize`, and a `scheduleOnRN` back to the JS thread. All three are gone, which is the clearest measure of what the argument bought. All four screens now branch on `cancelled`.
|
|
83
|
+
|
|
39
84
|
### Notes
|
|
40
85
|
|
|
41
86
|
- **The package ships ESM only.** CJS with code splitting defeats the Reanimated Babel plugin and ships worklets that crash on native — that defect cost `@rootnative/components` two releases. See the comment in `tsup.config.ts` before changing the format.
|
|
42
|
-
- **`useTap`
|
|
43
|
-
- **A
|
|
87
|
+
- **`useTap`, `useDoubleTap`, `useLongPress`, `useDrag`, `usePan`, `useSwipe`, `usePinch`, and `useRotate` are the intents implemented.** `useHover` and `useEdgeSwipe` are designed but not written. Milestone 1's code is complete and Milestone 2 is under way. The device sweep of 2026-09-19 and 2026-09-20 measured the mechanical half of Milestone 1's gate on both platforms; feel is untested, so the activation-criteria defaults a scripted touch could not reach are still design intentions.
|
|
88
|
+
- **A gesture that fails before it activates cannot be tested in Jest, and it is not only pan.** RNGH's mock fills every gesture's state sequence from `[BEGAN, ACTIVE, END]`, so it injects an ACTIVE event before any FAILED it is given and `onStart` always runs. For `useDrag` that means the threshold never rejects; for `useLongPress` it means a press released early still reaches `onLongPress`. Both are checked against the gesture's config and on the example screen instead.
|
|
44
89
|
- **A relation cannot name a component ref without a cast.** RNGH types a relation target as `RefObject<ComponentType | undefined | null>` — a ref to a component *type*, which is not what `ref={}` produces — so a real `ScrollView` ref does not typecheck. Impulse's `GestureReference` mirrors RNGH's type exactly, so the mismatch is visible rather than absorbed.
|
|
45
90
|
- **`useGestures` does not take coexistence options**, and that is a limitation rather than a choice. RNGH's three external-gesture relations are methods on a single gesture, and a composed gesture does not have them. Set `alongside` / `blocks` / `deferTo` on the member hooks instead.
|
|
46
91
|
- **Impulse does not yet warn when a hook mounts with no `<GestureHandlerRootView>` above it.** RNGH's root-view context is not exported from its package entry, and reaching it would mean a deep import into `lib/commonjs/`, which pins one module format and breaks under the others.
|
|
47
92
|
|
|
48
|
-
[unreleased]: https://github.com/rootnative/impulse/
|
|
93
|
+
[unreleased]: https://github.com/rootnative/impulse/compare/core@0.0.0-alpha.1...HEAD
|
|
94
|
+
[0.0.0-alpha.1]: https://github.com/rootnative/impulse/releases/tag/core@0.0.0-alpha.1
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
Declarative gesture primitives for React Native, built as a thin wrapper around [`react-native-gesture-handler`](https://docs.swmansion.com/react-native-gesture-handler/). A gesture is written as an intent, not assembled from a builder chain.
|
|
14
14
|
|
|
15
|
-
> **Status:** `0.0.0-alpha.
|
|
15
|
+
> **Status:** `0.0.0-alpha.1` — published as an alpha on the `alpha` dist-tag. Install it with `@rootnative/impulse@alpha`. What ships today is the composition and coexistence core — `useGestures`, `useRawGesture`, and the `alongside` / `blocks` / `deferTo` options — plus eight intent hooks, **`useTap`, `useDoubleTap`, `useLongPress`, `useDrag`, `usePan`, `useSwipe`, `usePinch`, and `useRotate`**, and the `@rootnative/impulse/gesture-handler` interop subpath. `useHover` and `useEdgeSwipe` are **not implemented**. A device sweep on 2026-09-19 measured the activation criteria a scripted touch can reach; how a gesture feels is still untested. See the [CHANGELOG](https://github.com/rootnative/impulse/blob/main/packages/core/CHANGELOG.md).
|
|
16
16
|
|
|
17
17
|
## Install
|
|
18
18
|
|
|
@@ -42,9 +42,13 @@ Neither is a replacement for the other, and `-gestures` is not deprecated.
|
|
|
42
42
|
|
|
43
43
|
## What ships today
|
|
44
44
|
|
|
45
|
-
###
|
|
45
|
+
### The intent hooks
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
Six so far. Every one returns `{ gesture, ref, isActive }` plus whatever values its own intent produces, and every one takes the same `alongside` / `blocks` / `deferTo` options.
|
|
48
|
+
|
|
49
|
+
**The callback name states its thread.** A callback named for what happened — `onTap`, `onDoubleTap`, `onLongPress`, `onDragEnd`, `onSwipe` — runs on the JS thread and may set React state directly, because Impulse owns the `scheduleOnRN` boundary. A callback named for a phase — `onBegin`, `onUpdate`, `onFinalize` — is a worklet and runs on the UI thread. There is no flag to set and no `scheduleOnRN` to write.
|
|
50
|
+
|
|
51
|
+
#### `useTap`
|
|
48
52
|
|
|
49
53
|
```tsx
|
|
50
54
|
import { GestureDetector, useTap } from '@rootnative/impulse'
|
|
@@ -76,12 +80,194 @@ The payload is shaped by intent rather than passed through: `{ x, y, absolute: {
|
|
|
76
80
|
| `maxDistance` | `10` points | **Not** RNGH's behaviour. RNGH defers the slop to the platform, so the same tap is accepted on one operating system and rejected on the other. |
|
|
77
81
|
| `pointers` | `1` | Raise it for a two-finger tap. |
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
The device sweep of 2026-09-19 did not test either default.
|
|
80
84
|
|
|
81
85
|
**Accessibility.** A tap gesture is invisible to a screen reader and unreachable from a keyboard, and this hook does not fix that. Whatever the tap does must also be reachable another way — the same action on a `<Pressable>`, or `accessibilityActions` on the view. A tap-only affordance is a bug, not a trade-off.
|
|
82
86
|
|
|
83
87
|
**Web.** A single-finger tap behaves as it does on native. `pointers` above 1 is unreliable, because a mouse reports one pointer and touch emulation varies by browser.
|
|
84
88
|
|
|
89
|
+
#### `useDoubleTap`
|
|
90
|
+
|
|
91
|
+
Two taps in quick succession, sharing `useTap`'s payload and its `maxDistance` — a double tap that is fussier about travel than a single tap on the same view is a difference nobody asked for.
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
import { GestureDetector, useDoubleTap, useGestures, useTap } from '@rootnative/impulse'
|
|
95
|
+
|
|
96
|
+
const double = useDoubleTap({ maxDelay: 250, onDoubleTap: zoomIn })
|
|
97
|
+
const tap = useTap({ onTap: select })
|
|
98
|
+
|
|
99
|
+
// The double tap first, and the mode is `exclusive`.
|
|
100
|
+
const { gesture } = useGestures([double, tap], { mode: 'exclusive' })
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Pairing it with a single tap is the whole difficulty, and `race` is the wrong mode.** A single tap recognizes on the first release, so under `race` it wins every time and the double tap never fires. `exclusive` makes the single tap wait to learn whether a second tap is coming — and that wait is `maxDelay` long, on every ordinary tap. Lowering `maxDelay` is what buys the latency back.
|
|
104
|
+
|
|
105
|
+
| Option | Default | Note |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `maxDelay` | `500` ms | RNGH's own default, and the latency a composed single tap pays. 250–300 is closer to what the platforms use. |
|
|
108
|
+
| `maxDuration` | `500` ms | Per tap, not for the pair. |
|
|
109
|
+
| `maxDistance` | `10` points | Impulse's number, the same as `useTap`'s. |
|
|
110
|
+
|
|
111
|
+
A view that needs only a double tap needs no composition — use the hook alone. Three taps and up are not modelled: build one with `useRawGesture` and `Gesture.Tap().numberOfTaps(3)`.
|
|
112
|
+
|
|
113
|
+
**Accessibility.** Worse than a tap: VoiceOver and TalkBack both consume a double tap as their own activation gesture, so a screen-reader user cannot reach this at all. The action must be offered explicitly somewhere else.
|
|
114
|
+
|
|
115
|
+
#### `useLongPress`
|
|
116
|
+
|
|
117
|
+
A press held past a duration. **`onLongPress` fires while the finger is still down** — that is the difference between a long press and a slow tap, and it is where a context menu opens and a haptic fires.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { GestureDetector, useLongPress } from '@rootnative/impulse'
|
|
121
|
+
|
|
122
|
+
const hold = useLongPress({
|
|
123
|
+
minDuration: 400,
|
|
124
|
+
onLongPress: openMenu, // finger still down
|
|
125
|
+
onLongPressEnd: (event, { cancelled }) => { // and how it ended
|
|
126
|
+
if (cancelled) return closeMenu() // the system took it away
|
|
127
|
+
stopRecording(event.duration) // the finger lifted
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`isActive` is the **held** state, not a pressed state: it turns true at recognition, not at touch-down, so an ordinary tap on the view never changes it. The payload carries `duration` — roughly `minDuration` at `onLongPress`, and the whole hold at `onLongPressEnd`, which is what a hold-to-record affordance stops on.
|
|
133
|
+
|
|
134
|
+
**Every end callback takes a second argument.** `onTap`, `onDoubleTap`, `onLongPressEnd`, and `onDragEnd` each fire on both endings, and `cancelled` says which — `true` when the system took the gesture away rather than the user completing it. Check it before you navigate, submit, or count. Without it a cancel is reportable only from `onFinalize`, which is a worklet, so a consumer holding phase in React state has to cross the thread boundary by hand.
|
|
135
|
+
|
|
136
|
+
| Option | Default | Note |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `minDuration` | `500` ms | RNGH's own default, restated. Below ~200ms it stops being distinguishable from a held tap. |
|
|
139
|
+
| `maxDistance` | `10` points | RNGH's own default. It bounds the **wait**, not the hold — the finger may travel freely once the press is recognized. |
|
|
140
|
+
|
|
141
|
+
**Hold, then drag.** RNGH has no "activate after this one activates" relation, so it is two gestures and a gate: run them `alongside` each other and let the drag's worklets read `hold.isActive`. Raise the drag's `threshold` too, or the drag activates before the press ever does.
|
|
142
|
+
|
|
143
|
+
**Accessibility.** The best fallback of any gesture here, so use it: `<Pressable>` takes `onLongPress` directly and is reachable by every assistive technology.
|
|
144
|
+
|
|
145
|
+
#### `useDrag`
|
|
146
|
+
|
|
147
|
+
Shared values that follow the finger on the UI thread and accumulate across gestures. **No style and no animation** — that is what separates it from `@rootnative/inertia-gestures`' hook of the same name.
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
import { GestureDetector, useDrag } from '@rootnative/impulse'
|
|
151
|
+
|
|
152
|
+
const drag = useDrag({ axis: 'x', bounds: { left: -120, right: 0 }, elastic: 0.3 })
|
|
153
|
+
const style = useAnimatedStyle(() => ({
|
|
154
|
+
transform: [{ translateX: drag.x.value }],
|
|
155
|
+
}))
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Option | Default | Note |
|
|
159
|
+
| --- | --- | --- |
|
|
160
|
+
| `threshold` | `10` points | Impulse's number. Directional on a single axis (`activeOffsetX` / `activeOffsetY`), radial on `'both'` (`minDistance`). A bare `Gesture.Pan()` activates almost immediately, which is what makes a drag steal a scroll. |
|
|
161
|
+
| `axis` | `'both'` | The locked axis's shared value never changes. |
|
|
162
|
+
| `elastic` | `0` | `0` clamps hard at a bound; `0.3` gives the rubber-band pull an over-scroll has. |
|
|
163
|
+
| `failOffset` | unset | Cross-axis movement that makes the drag give up. |
|
|
164
|
+
|
|
165
|
+
`onDragEnd` carries `velocity` and `settled` — the nearest in-bounds point — so an elastic overshoot can be sprung home without re-deriving the clamp. Impulse does not move it back itself; that is an animation. On the cancelled path the finger never lifted, so `velocity` describes the last movement rather than a throw: return to `settled` instead of springing on it.
|
|
166
|
+
|
|
167
|
+
**Coexistence.** A threshold decides who moves first, not who wins a contested touch. Say that too: `deferTo: scrollRef` for a drag that is the fallback, `blocks: listRef` for one that is the foreground affordance.
|
|
168
|
+
|
|
169
|
+
#### `usePan`
|
|
170
|
+
|
|
171
|
+
The same recognizer as `useDrag`, and the opposite contract. **`usePan` reports movement; `useDrag` owns a position.** `usePan` holds no value at all: it zeroes at the start of every gesture, it has no `bounds` and no `elastic`, and it reports a per-frame `change` the consumer adds up.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { GestureDetector, usePan } from '@rootnative/impulse'
|
|
175
|
+
|
|
176
|
+
const pan = usePan({
|
|
177
|
+
onUpdate: (event) => {
|
|
178
|
+
'worklet'
|
|
179
|
+
camera.x.value += event.change.x
|
|
180
|
+
camera.y.value += event.change.y
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
| Option | Default | Note |
|
|
186
|
+
| --- | --- | --- |
|
|
187
|
+
| `threshold` | `10` points | Same form as `useDrag`: directional on a single axis, radial on `'both'`. |
|
|
188
|
+
| `axis` | `'both'` | The locked axis reports zero. |
|
|
189
|
+
| `failOffset` | unset | Cross-axis movement that makes the pan give up. |
|
|
190
|
+
| `pointers` | unset | Setting it fixes the count exactly. |
|
|
191
|
+
|
|
192
|
+
RNGH's own `changeX` reports the whole translation on the first update, threshold included, so a consumer accumulating it jumps ten points before anything moves. `usePan` computes the delta itself, so `change` and `translation` are both measured from the activation point.
|
|
193
|
+
|
|
194
|
+
Reach for `useDrag` to move a view. Reach for `usePan` to pan a camera, scrub a value, or feed a number Impulse has no business clamping.
|
|
195
|
+
|
|
196
|
+
#### `useSwipe`
|
|
197
|
+
|
|
198
|
+
A pan judged at release. The finger moves and `x` and `y` report it so a view can follow; on release the travel and the speed along the dominant axis decide whether it counted.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { GestureDetector, useSwipe } from '@rootnative/impulse'
|
|
202
|
+
|
|
203
|
+
const swipe = useSwipe({
|
|
204
|
+
directions: ['left', 'right'],
|
|
205
|
+
onSwipe: (event) => remove(item.id, event.direction),
|
|
206
|
+
onSwipeEnd: (event) => {
|
|
207
|
+
swipe.x.value = withSpring(event.direction === null ? 0 : EXIT)
|
|
208
|
+
},
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
| Option | Default | Note |
|
|
213
|
+
| --- | --- | --- |
|
|
214
|
+
| `directions` | all four | Which directions may commit — **and the activation criterion**. |
|
|
215
|
+
| `threshold` | `10` points | When the swipe takes the touch and starts reporting. Not the commit test. |
|
|
216
|
+
| `commitDistance` | `80` points | How far a release must have travelled to count. |
|
|
217
|
+
| `commitSpeed` | `800` pt/s | How fast a release must be moving to count. The flick. Either test is enough on its own. |
|
|
218
|
+
|
|
219
|
+
**`directions` is the coexistence setting, not only a filter.** An all-horizontal list gives the gesture a directional threshold on x, so a swipeable row lives inside a vertical list with no relation declared. An all-vertical list does the same on y. A mixed list has no axis to lock, so the threshold is radial and the swipe competes for every touch — declare `deferTo` or `blocks` there.
|
|
220
|
+
|
|
221
|
+
`onSwipe` fires only for a release that counted, and its payload's `direction` is never `null`. `onSwipeEnd` fires for every release of a swipe that activated, which is how a view that followed the finger learns to go back. A cancelled gesture never commits.
|
|
222
|
+
|
|
223
|
+
#### `usePinch`
|
|
224
|
+
|
|
225
|
+
A two-finger pinch that **owns the scale it produces**. RNGH's own factor restarts at `1` on every gesture; `usePinch` multiplies it into the scale it already holds, so `min` and `max` are the zoom range of the viewer rather than of one gesture.
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
import { GestureDetector, usePinch } from '@rootnative/impulse'
|
|
229
|
+
|
|
230
|
+
const pinch = usePinch({ min: 1, max: 4 })
|
|
231
|
+
const style = useAnimatedStyle(() => ({
|
|
232
|
+
transform: [{ scale: pinch.scale.value }],
|
|
233
|
+
}))
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
| Option | Default | Note |
|
|
237
|
+
| --- | --- | --- |
|
|
238
|
+
| `initial` | `1` | The scale before any pinch. Read once, at mount. |
|
|
239
|
+
| `min` / `max` | unset | The zoom range of the viewer. Unset is unbounded. |
|
|
240
|
+
| `elastic` | `0` | How much of the pull past an end reaches `scale`. `0` stops dead; `1` ignores the end. |
|
|
241
|
+
|
|
242
|
+
`focal` is the midpoint between the fingers, relative to the view, and a zoom viewer cannot skip it: scaling about the view's centre slides the content out from under the fingers. Translate the focal point to the origin, scale, then translate back. `gestureScale` in the payload is RNGH's own factor, for the cases that want the raw gesture.
|
|
243
|
+
|
|
244
|
+
**`usePinch` has no activation criteria, and that is RNGH's limit rather than a choice.** `Gesture.Pinch()` takes the touch as soon as a second finger moves and exposes no threshold, so a pinch and a pan are separated by a relation alone — `useGestures([pinch, pan], { mode: 'simultaneous' })`.
|
|
245
|
+
|
|
246
|
+
#### `useRotate`
|
|
247
|
+
|
|
248
|
+
A two-finger rotation that owns the angle it produces, the way `usePinch` owns a scale.
|
|
249
|
+
|
|
250
|
+
```tsx
|
|
251
|
+
import { GestureDetector, useRotate } from '@rootnative/impulse'
|
|
252
|
+
|
|
253
|
+
const rotate = useRotate({ min: -45, max: 45 })
|
|
254
|
+
const style = useAnimatedStyle(() => ({
|
|
255
|
+
transform: [{ rotate: `${rotate.angle.value}deg` }],
|
|
256
|
+
}))
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
| Option | Default | Note |
|
|
260
|
+
| --- | --- | --- |
|
|
261
|
+
| `initial` | `0` | The angle before any rotation, in degrees. Read once, at mount. |
|
|
262
|
+
| `min` / `max` | unset | The travel of the control, in degrees. Unset is unbounded. |
|
|
263
|
+
| `elastic` | `0` | How much of the turn past an end reaches `angle`. `0` stops dead; `1` ignores the end. |
|
|
264
|
+
|
|
265
|
+
**`useRotate` reports degrees, and RNGH reports radians.** This is the one place in the library where a unit is changed rather than passed through: write `min: -45`, not `-Math.PI / 4`. The conversion applies to `angle`, `gestureAngle`, `velocity`, and the `min` / `max` comparison.
|
|
266
|
+
|
|
267
|
+
The angle **does not wrap** at a full turn — a second revolution reports `720`. A dial counting turns needs that, and a photo editor takes the remainder itself.
|
|
268
|
+
|
|
269
|
+
`anchor` is the point the turn happens about, and it is the counterpart of pinch's `focal`. `useRotate` has no activation criteria either, so rotate and pinch are composed `simultaneous`.
|
|
270
|
+
|
|
85
271
|
### `useGestures` — composition
|
|
86
272
|
|
|
87
273
|
Composition is **data, not nesting**. One flat list plus the relation that holds over it, and the result is itself a member, so precedence reads left to right instead of inside out.
|
|
@@ -89,9 +275,10 @@ Composition is **data, not nesting**. One flat list plus the relation that holds
|
|
|
89
275
|
```tsx
|
|
90
276
|
import { GestureDetector, useGestures } from '@rootnative/impulse'
|
|
91
277
|
|
|
92
|
-
// tap
|
|
278
|
+
// the single tap waits for the double tap to fail; the winner runs
|
|
279
|
+
// alongside the drag
|
|
93
280
|
const { gesture } = useGestures(
|
|
94
|
-
[useGestures([
|
|
281
|
+
[useGestures([double, tap], { mode: 'exclusive' }), drag],
|
|
95
282
|
{ mode: 'simultaneous' },
|
|
96
283
|
)
|
|
97
284
|
|
|
@@ -116,6 +303,8 @@ useRawGesture(build, deps, { deferTo: scrollRef }) // the other one wins
|
|
|
116
303
|
|
|
117
304
|
All three may be set at once — they are independent relations, not a choice of one. Naming the same gesture in two of them warns in development.
|
|
118
305
|
|
|
306
|
+
**The ref has to belong to a gesture.** RNGH resolves a relation through `ref.current?.handlerTag` and drops a ref that has none, without a word — so `deferTo` on React Native's own `ScrollView` installs nothing and the drag and the scroll keep fighting for the touch. Import `ScrollView` or `FlatList` from `@rootnative/impulse/gesture-handler` instead; another Impulse hook's `ref` always carries a tag. Impulse warns once in development when it catches this, which is when the ref is filled by the time the gesture mounts. A target that mounts in a later commit stays silent.
|
|
307
|
+
|
|
119
308
|
### `useRawGesture` — the escape hatch
|
|
120
309
|
|
|
121
310
|
For a recognizer the intent hooks will not model. You own the dependency list, the thread, and the payload; Impulse still owns gesture identity, relation resolution, and a `ref` other hooks can name.
|
|
@@ -135,8 +324,8 @@ const fling = useRawGesture(
|
|
|
135
324
|
|
|
136
325
|
- **`GestureDetector`**, re-exported from the root entry. Every hook's result is handed to it, so reaching for it is not a reason to add a second gesture import to an app.
|
|
137
326
|
- **`@rootnative/impulse/gesture-handler`** — RNGH's own primitives, re-exported under their original names by reference. It keeps `@rootnative/impulse` the only gesture import in an app.
|
|
138
|
-
- **Subpaths** — `@rootnative/impulse/tap`,
|
|
139
|
-
- **Types** — `AttachableGesture`, `CoexistenceOptions`, `ComposeMode`, `GestureReference`, `GestureReferences`, `HitSlop`, `IntentResult`, `Point`, and per-intent `TapEvent` / `
|
|
327
|
+
- **Subpaths** — `@rootnative/impulse/tap`, `/double-tap`, `/long-press`, `/drag`, `/pan`, `/swipe`, `/pinch`, `/rotate`, `/compose`, and `/raw`, so an app that uses one hook does not ship the set.
|
|
328
|
+
- **Types** — `AttachableGesture`, `CoexistenceOptions`, `ComposeMode`, `GestureReference`, `GestureReferences`, `HitSlop`, `IntentResult`, `Point`, and per-intent `TapEvent`, `LongPressEvent`, `DragAxis` / `DragBounds` / `DragEvent`, `PanAxis` / `PanEvent`, `SwipeDirection` / `SwipeEvent` / `CommittedSwipeEvent`, `PinchEvent`, `RotateEvent`, plus the `Use*Options` and `Use*Result` pair for each hook. `useTap` and `useDoubleTap` share one `TapEvent`.
|
|
140
329
|
- **`@rootnative/impulse/jest-preset`** — one-line Jest wiring, layered on `@react-native/jest-preset`.
|
|
141
330
|
|
|
142
331
|
## Gesture identity is stable by construction
|
|
@@ -147,9 +336,11 @@ Worklet callbacks are the deliberate exception: a worklet is captured as written
|
|
|
147
336
|
|
|
148
337
|
## What does not ship yet
|
|
149
338
|
|
|
150
|
-
|
|
339
|
+
Two intent hooks — `useHover` and `useEdgeSwipe`. They are designed and the design is locked; neither is written.
|
|
340
|
+
|
|
341
|
+
Three further limits today:
|
|
151
342
|
|
|
152
|
-
|
|
343
|
+
- **Most activation-criteria defaults are still design intentions.** A device sweep on 2026-09-19 drove every intent on Android or iOS and measured what a scripted touch can: `useDrag`'s `bounds` and `elastic`, `usePinch`'s and `useRotate`'s `min` and `max`, `useLongPress`'s `minDuration`, and `useSwipe`'s `commitDistance` are all honoured exactly. That the library obeys a number is not evidence that the number is right — only a hand reports that. A deliberate double tap still registers at `maxDelay: 250` on both platforms, so the 500ms default is the one most likely to move. Three numbers stay unmeasured, because no instrument here could produce them: `useTap`'s and `useDoubleTap`'s `maxDistance`, the shared 10-point `threshold`, and `useSwipe`'s `commitSpeed`.
|
|
153
344
|
|
|
154
345
|
- **`useGestures` does not take coexistence options.** RNGH's three relations are methods on a single gesture, and a composed gesture does not have them. Set them on the member hooks instead.
|
|
155
346
|
- **No warning when `<GestureHandlerRootView>` is missing.** Its absence is silent — the gesture simply never fires — and RNGH does not export the context that would let Impulse detect it.
|
|
@@ -167,7 +358,11 @@ The preset also needs `@react-native/jest-preset` as a devDependency at the vers
|
|
|
167
358
|
|
|
168
359
|
## Documentation
|
|
169
360
|
|
|
170
|
-
|
|
361
|
+
**[rootnative.github.io/impulse](https://rootnative.github.io/impulse/)** — installation, a page per intent hook, composition, coexistence, threads and callbacks, and the [web behaviour](https://rootnative.github.io/impulse/web) of each intent. The example app runs in a browser at [/impulse/example/](https://rootnative.github.io/impulse/example/).
|
|
362
|
+
|
|
363
|
+
The [repository README](https://github.com/rootnative/impulse) carries the design principles, the boundary with `@rootnative/inertia`, and the roadmap.
|
|
364
|
+
|
|
365
|
+
An agent reads `node_modules/@rootnative/impulse/llms.txt` for the exact API of the installed version, or [`llms-full.txt`](https://rootnative.github.io/impulse/llms-full.txt) for the full reference.
|
|
171
366
|
|
|
172
367
|
## Licence
|
|
173
368
|
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import { useRef, useInsertionEffect, useCallback } from 'react';
|
|
2
2
|
|
|
3
|
-
// src/internal/
|
|
3
|
+
// src/internal/intentResult.ts
|
|
4
|
+
function buildIntentResult(built, values) {
|
|
5
|
+
const result = { ...values };
|
|
6
|
+
Object.defineProperty(result, "gesture", {
|
|
7
|
+
value: built.gesture,
|
|
8
|
+
enumerable: false,
|
|
9
|
+
// Configurable so the object stays describable and a future field can
|
|
10
|
+
// replace it. Not writable: the result is read-only by type, and a
|
|
11
|
+
// consumer swapping the gesture would defeat the memoisation that keeps
|
|
12
|
+
// gesture identity stable.
|
|
13
|
+
configurable: true
|
|
14
|
+
});
|
|
15
|
+
Object.defineProperty(result, "ref", {
|
|
16
|
+
value: built.ref,
|
|
17
|
+
enumerable: false,
|
|
18
|
+
configurable: true
|
|
19
|
+
});
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
4
22
|
function useLatestCallback(callback) {
|
|
5
23
|
const latest = useRef(callback);
|
|
6
24
|
useInsertionEffect(() => {
|
|
@@ -36,4 +54,4 @@ function useStableRecord(value) {
|
|
|
36
54
|
return held.current;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
export { useLatestCallback, useStableRecord };
|
|
57
|
+
export { buildIntentResult, useLatestCallback, useStableRecord };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { toTapEvent } from './chunk-VEPUHGPN.js';
|
|
2
|
+
import { useStableRecord, useLatestCallback, buildIntentResult } from './chunk-2UZTAWUQ.js';
|
|
3
|
+
import { useGestureMemo } from './chunk-NYDDZD4G.js';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
6
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
7
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
8
|
+
|
|
9
|
+
var DEFAULT_MAX_DURATION = 500;
|
|
10
|
+
var DEFAULT_MAX_DELAY = 500;
|
|
11
|
+
var DEFAULT_MAX_DISTANCE = 10;
|
|
12
|
+
function useDoubleTap(options = {}) {
|
|
13
|
+
const {
|
|
14
|
+
pointers = 1,
|
|
15
|
+
maxDuration = DEFAULT_MAX_DURATION,
|
|
16
|
+
maxDelay = DEFAULT_MAX_DELAY,
|
|
17
|
+
maxDistance = DEFAULT_MAX_DISTANCE,
|
|
18
|
+
enabled,
|
|
19
|
+
onDoubleTap,
|
|
20
|
+
onBegin,
|
|
21
|
+
onFinalize
|
|
22
|
+
} = options;
|
|
23
|
+
const isActive = useSharedValue(false);
|
|
24
|
+
const hitSlop = useStableRecord(options.hitSlop);
|
|
25
|
+
const handleDoubleTap = useLatestCallback(onDoubleTap);
|
|
26
|
+
const hasDoubleTapHandler = onDoubleTap !== void 0;
|
|
27
|
+
const built = useGestureMemo(
|
|
28
|
+
"useDoubleTap",
|
|
29
|
+
() => {
|
|
30
|
+
const tap = Gesture.Tap().numberOfTaps(2).minPointers(pointers).maxDuration(maxDuration).maxDelay(maxDelay).maxDistance(maxDistance).onBegin((event) => {
|
|
31
|
+
"worklet";
|
|
32
|
+
isActive.value = true;
|
|
33
|
+
onBegin?.(toTapEvent(event));
|
|
34
|
+
}).onEnd((event, success) => {
|
|
35
|
+
"worklet";
|
|
36
|
+
if (hasDoubleTapHandler) {
|
|
37
|
+
scheduleOnRN(handleDoubleTap, toTapEvent(event), {
|
|
38
|
+
cancelled: !success
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}).onFinalize((event, success) => {
|
|
42
|
+
"worklet";
|
|
43
|
+
isActive.value = false;
|
|
44
|
+
onFinalize?.(toTapEvent(event), success);
|
|
45
|
+
});
|
|
46
|
+
if (hitSlop !== void 0) {
|
|
47
|
+
tap.hitSlop(hitSlop);
|
|
48
|
+
}
|
|
49
|
+
if (enabled !== void 0) {
|
|
50
|
+
tap.enabled(enabled);
|
|
51
|
+
}
|
|
52
|
+
return tap;
|
|
53
|
+
},
|
|
54
|
+
[
|
|
55
|
+
pointers,
|
|
56
|
+
maxDuration,
|
|
57
|
+
maxDelay,
|
|
58
|
+
maxDistance,
|
|
59
|
+
hitSlop,
|
|
60
|
+
enabled,
|
|
61
|
+
hasDoubleTapHandler,
|
|
62
|
+
handleDoubleTap,
|
|
63
|
+
isActive,
|
|
64
|
+
onBegin,
|
|
65
|
+
onFinalize
|
|
66
|
+
],
|
|
67
|
+
options
|
|
68
|
+
);
|
|
69
|
+
return useMemo(
|
|
70
|
+
() => buildIntentResult(built, { isActive }),
|
|
71
|
+
[built, isActive]
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { useDoubleTap };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useStableRecord, useLatestCallback, buildIntentResult } from './chunk-2UZTAWUQ.js';
|
|
2
|
+
import { useGestureMemo } from './chunk-NYDDZD4G.js';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
5
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
6
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
7
|
+
|
|
8
|
+
var DEFAULT_INITIAL_SCALE = 1;
|
|
9
|
+
function resist(value, min, max, elastic) {
|
|
10
|
+
"worklet";
|
|
11
|
+
if (min !== void 0 && value < min) {
|
|
12
|
+
return min + (value - min) * elastic;
|
|
13
|
+
}
|
|
14
|
+
if (max !== void 0 && value > max) {
|
|
15
|
+
return max + (value - max) * elastic;
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function clamp(value, min, max) {
|
|
20
|
+
"worklet";
|
|
21
|
+
if (min !== void 0 && value < min) {
|
|
22
|
+
return min;
|
|
23
|
+
}
|
|
24
|
+
if (max !== void 0 && value > max) {
|
|
25
|
+
return max;
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
function usePinch(options = {}) {
|
|
30
|
+
const {
|
|
31
|
+
initial = DEFAULT_INITIAL_SCALE,
|
|
32
|
+
min,
|
|
33
|
+
max,
|
|
34
|
+
elastic = 0,
|
|
35
|
+
enabled,
|
|
36
|
+
onPinchStart,
|
|
37
|
+
onPinchEnd,
|
|
38
|
+
onBegin,
|
|
39
|
+
onUpdate,
|
|
40
|
+
onFinalize
|
|
41
|
+
} = options;
|
|
42
|
+
const scale = useSharedValue(initial);
|
|
43
|
+
const focal = useSharedValue({ x: 0, y: 0 });
|
|
44
|
+
const isActive = useSharedValue(false);
|
|
45
|
+
const startScale = useSharedValue(initial);
|
|
46
|
+
const hitSlop = useStableRecord(options.hitSlop);
|
|
47
|
+
const handlePinchStart = useLatestCallback(onPinchStart);
|
|
48
|
+
const handlePinchEnd = useLatestCallback(onPinchEnd);
|
|
49
|
+
const hasPinchStart = onPinchStart !== void 0;
|
|
50
|
+
const hasPinchEnd = onPinchEnd !== void 0;
|
|
51
|
+
const built = useGestureMemo(
|
|
52
|
+
"usePinch",
|
|
53
|
+
() => {
|
|
54
|
+
const toPinchEvent = (event) => {
|
|
55
|
+
"worklet";
|
|
56
|
+
return {
|
|
57
|
+
scale: scale.value,
|
|
58
|
+
gestureScale: event.scale,
|
|
59
|
+
focal: focal.value,
|
|
60
|
+
velocity: event.velocity,
|
|
61
|
+
settled: clamp(scale.value, min, max),
|
|
62
|
+
pointers: event.numberOfPointers
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const trackFocal = (event) => {
|
|
66
|
+
"worklet";
|
|
67
|
+
focal.value = { x: event.focalX, y: event.focalY };
|
|
68
|
+
};
|
|
69
|
+
const pinch = Gesture.Pinch().onBegin((event) => {
|
|
70
|
+
"worklet";
|
|
71
|
+
onBegin?.(toPinchEvent(event));
|
|
72
|
+
}).onStart((event) => {
|
|
73
|
+
"worklet";
|
|
74
|
+
startScale.value = event.scale === 0 ? scale.value : scale.value / event.scale;
|
|
75
|
+
trackFocal(event);
|
|
76
|
+
isActive.value = true;
|
|
77
|
+
if (hasPinchStart) {
|
|
78
|
+
scheduleOnRN(handlePinchStart, toPinchEvent(event));
|
|
79
|
+
}
|
|
80
|
+
}).onUpdate((event) => {
|
|
81
|
+
"worklet";
|
|
82
|
+
trackFocal(event);
|
|
83
|
+
scale.value = resist(
|
|
84
|
+
startScale.value * event.scale,
|
|
85
|
+
min,
|
|
86
|
+
max,
|
|
87
|
+
elastic
|
|
88
|
+
);
|
|
89
|
+
onUpdate?.(toPinchEvent(event));
|
|
90
|
+
}).onEnd((event, success) => {
|
|
91
|
+
"worklet";
|
|
92
|
+
if (hasPinchEnd) {
|
|
93
|
+
scheduleOnRN(handlePinchEnd, toPinchEvent(event), {
|
|
94
|
+
cancelled: !success
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}).onFinalize((event, success) => {
|
|
98
|
+
"worklet";
|
|
99
|
+
isActive.value = false;
|
|
100
|
+
onFinalize?.(toPinchEvent(event), success);
|
|
101
|
+
});
|
|
102
|
+
if (hitSlop !== void 0) {
|
|
103
|
+
pinch.hitSlop(hitSlop);
|
|
104
|
+
}
|
|
105
|
+
if (enabled !== void 0) {
|
|
106
|
+
pinch.enabled(enabled);
|
|
107
|
+
}
|
|
108
|
+
return pinch;
|
|
109
|
+
},
|
|
110
|
+
[
|
|
111
|
+
min,
|
|
112
|
+
max,
|
|
113
|
+
elastic,
|
|
114
|
+
hitSlop,
|
|
115
|
+
enabled,
|
|
116
|
+
hasPinchStart,
|
|
117
|
+
hasPinchEnd,
|
|
118
|
+
handlePinchStart,
|
|
119
|
+
handlePinchEnd,
|
|
120
|
+
scale,
|
|
121
|
+
focal,
|
|
122
|
+
startScale,
|
|
123
|
+
isActive,
|
|
124
|
+
onBegin,
|
|
125
|
+
onUpdate,
|
|
126
|
+
onFinalize
|
|
127
|
+
],
|
|
128
|
+
options
|
|
129
|
+
);
|
|
130
|
+
return useMemo(
|
|
131
|
+
() => buildIntentResult(built, { scale, focal, isActive }),
|
|
132
|
+
[built, scale, focal, isActive]
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { usePinch };
|