@rootnative/impulse 0.0.0-alpha.0
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 +48 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/chunk-5BMRKYVY.js +39 -0
- package/dist/chunk-F4RHM4ZK.js +77 -0
- package/dist/chunk-FR242SUF.js +174 -0
- package/dist/chunk-IG5RXCYR.js +74 -0
- package/dist/chunk-LM645QQT.js +37 -0
- package/dist/chunk-PMR25UCT.js +8 -0
- package/dist/chunk-ZX7WNICB.js +39 -0
- package/dist/compose/index.d.ts +80 -0
- package/dist/compose/index.js +2 -0
- package/dist/drag/index.d.ts +277 -0
- package/dist/drag/index.js +4 -0
- package/dist/gesture-handler/index.d.ts +1 -0
- package/dist/gesture-handler/index.js +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/raw/index.d.ts +63 -0
- package/dist/raw/index.js +3 -0
- package/dist/tap/index.d.ts +150 -0
- package/dist/tap/index.js +4 -0
- package/dist/types-Ch2HM3aP.d.ts +142 -0
- package/dist/useGestureMemo-Ccv8rB0C.d.ts +38 -0
- package/jest-preset.cjs +60 -0
- package/jest-setup.cjs +56 -0
- package/package.json +115 -0
- package/src/compose/index.ts +7 -0
- package/src/compose/useGestures.ts +136 -0
- package/src/gesture-handler/index.ts +18 -0
- package/src/index.ts +53 -0
- package/src/intents/drag/index.ts +8 -0
- package/src/intents/tap/index.ts +2 -0
- package/src/intents/useDrag.ts +563 -0
- package/src/intents/useTap.ts +285 -0
- package/src/internal/useGestureMemo.ts +110 -0
- package/src/internal/useLatestCallback.ts +64 -0
- package/src/internal/useStableList.ts +62 -0
- package/src/internal/useStableRecord.ts +72 -0
- package/src/internal/warnOnce.ts +60 -0
- package/src/raw/index.ts +2 -0
- package/src/raw/useRawGesture.ts +71 -0
- package/src/relations/index.ts +120 -0
- package/src/types.ts +187 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@rootnative/impulse` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pre-`1.0.0`, breaking changes may land in minor versions and are called out under their release.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
Nothing is published. This section holds the scaffold as it is built; the first release note is written when Milestone 1's graduation gate is met and `0.0.1` is cut.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`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
|
+
- **`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
|
+
- **`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
|
+
- **Public types `DragAxis`, `DragBounds`, and `DragEvent`.**
|
|
16
|
+
- **`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
|
+
- **`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`.
|
|
18
|
+
- **Activation criteria are Impulse's decision, not the platform's.** `maxDuration` restates RNGH's 500ms so the value cannot move underneath us in an RNGH release; `maxDistance` defaults to 10 points, which RNGH does **not** do — it defers to the platform there, so the same tap is accepted on one operating system and rejected on the other. Neither number has been measured on hardware.
|
|
19
|
+
- **`useStableRecord`** — the record counterpart of `useStableList`. `hitSlop` is the first option a consumer writes as an object literal, so it is the first that would rebuild the gesture every render. Compared shallowly, an inline `hitSlop` no longer changes gesture identity.
|
|
20
|
+
- **Public types `IntentResult`, `Point`, and `HitSlop`.** `IntentResult` is the `{ gesture, ref, isActive }` shape every intent hook returns. `HitSlop` is derived from RNGH's own method signature rather than copied, for the same reason `GestureReference` is.
|
|
21
|
+
- **A Reanimated mock in `jest-setup.cjs`**, carrying only what Impulse's surface touches plus the two members RNGH's own `GestureDetector` needs. The stock `react-native-reanimated/mock` is not used because its `useSharedValue` returns a fresh object per render, which would make every hook's `isActive` silently dead. `useTap.test.tsx` pins that identity so the mock cannot regress to it unnoticed.
|
|
22
|
+
- **`react-native-reanimated` added to the shipped preset's `transformIgnorePatterns`.** Impulse now imports it, so a consumer's Jest run needs it transformed.
|
|
23
|
+
- **`example/screens/TapScreen.tsx`**, wired into the gallery. It exists for the two things Jest cannot check: whether the pressed state tracks the finger, and whether the 10-point distance default feels right.
|
|
24
|
+
|
|
25
|
+
- **`useGestures`** — the composition surface, with all three modes (`race`, `simultaneous`, `exclusive`) and nesting. Composition is a flat list plus the relation that holds over it, so precedence reads left to right rather than inside out, and a composed result is itself a valid member. Tests assert the RNGH relation each mode produces after `prepare()`, not the class it constructs, because "who waits for whom" is what a consumer depends on.
|
|
26
|
+
- **`alongside` / `blocks` / `deferTo` resolution.** The three coexistence options resolve to RNGH's three external-gesture relations in one module, `src/relations/`, so the choice is made once. All three may be set at once; they are independent relations, not a choice of one. Naming the same gesture in two of them is contradictory, so it warns in development and still applies both — Impulse cannot know which one was meant.
|
|
27
|
+
- **`useRawGesture`** — the mechanism-level escape hatch. It takes a builder and a dependency list and returns a gesture with Impulse's memoisation, relation handling, and `ref` applied, for a recognizer the intent hooks do not model.
|
|
28
|
+
- **Gesture identity is stable by construction.** A gesture is built once, inside the shared memoisation helper, and relations are applied in the same place — so "configured once" is tied to "built once" rather than to consumer discipline. `useLatestCallback` keeps a JS-thread callback out of the dependency list; a worklet callback stays a direct dependency, because a worklet is captured as written. `useStableList` compares coexistence options by content, so an inline `deferTo: [scrollRef]` does not rebuild the gesture it configures. `src/__tests__/gestureIdentity.test.tsx` pins all of it, including the asymmetry.
|
|
29
|
+
- **`@rootnative/impulse/compose` and `@rootnative/impulse/raw`** — per-hook subpaths, so an app that uses one does not ship the set.
|
|
30
|
+
- **Public type `AttachableGesture`** — a gesture `<GestureDetector>` accepts, single or composed. RNGH spells this inline in its props and exports no name for it, so a compile-time assertion pins ours to theirs.
|
|
31
|
+
- **Repository scaffold.** pnpm 10 workspace, Turborepo, TypeScript 5 strict mode, ESLint flat config, Prettier, and Jest 29. `build`, `dev`, `typecheck`, `test`, `lint`, and `format` run from the root and are green.
|
|
32
|
+
- **`@rootnative/impulse/gesture-handler`** — the RNGH interop subpath. Pure re-exports of RNGH's primitives under their original names, so an app that needs a mechanism Impulse does not model still has `@rootnative/impulse` as its only gesture import. A test asserts every symbol is re-exported **by reference**, not through a wrapper, and it is written against RNGH's own export list so a symbol added upstream is covered without an edit.
|
|
33
|
+
- **`GestureDetector`** re-exported from the root entry. Every hook's result is handed to it.
|
|
34
|
+
- **Public types** — `CoexistenceOptions` (`alongside` / `blocks` / `deferTo`), `ComposeMode`, `GestureReference`, `GestureReferences`.
|
|
35
|
+
- **`@rootnative/impulse/jest-preset`** and `@rootnative/impulse/jest-setup`. The preset layers RNGH's own `jestSetup` onto `@react-native/jest-preset` and widens `transformIgnorePatterns` for this package's ESM bundle. Workspace-internal tests run through the same preset that ships, so a missing mock fails here before it fails for a consumer.
|
|
36
|
+
- **`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
|
+
- **`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
|
+
|
|
39
|
+
### Notes
|
|
40
|
+
|
|
41
|
+
- **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` and `useDrag` are the intents implemented.** `useSwipe`, `usePinch`, and the rest are designed but not written. Milestone 1's code is now complete; its graduation gate is a device pass that has not happened, so every activation-criteria default in this release is a design intention rather than a measurement.
|
|
43
|
+
- **A pan that fails before it activates cannot be tested in Jest.** 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 the drag always starts. The threshold's real effect is checked on the example screen.
|
|
44
|
+
- **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
|
+
- **`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
|
+
- **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
|
+
|
|
48
|
+
[unreleased]: https://github.com/rootnative/impulse/commits/main
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RootNative
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Absolute URL, not a relative path: this README is the npm package page, and
|
|
3
|
+
npm does not resolve repository-relative image paths.
|
|
4
|
+
-->
|
|
5
|
+
<img src="https://raw.githubusercontent.com/rootnative/impulse/main/assets/brand/impulse-mark.png" alt="" width="88" height="88" />
|
|
6
|
+
|
|
7
|
+
# @rootnative/impulse
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/@rootnative/impulse)
|
|
10
|
+
[](https://docs.swmansion.com/react-native-gesture-handler/)
|
|
11
|
+
[](./LICENSE)
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
> **Status:** `0.0.0-alpha.0` — pre-release, not published. What ships today is the composition and coexistence core — `useGestures`, `useRawGesture`, and the `alongside` / `blocks` / `deferTo` options — plus `useTap`, the first intent hook, and the `@rootnative/impulse/gesture-handler` interop subpath. Every other intent hook is **not implemented**. See the [CHANGELOG](https://github.com/rootnative/impulse/blob/main/packages/core/CHANGELOG.md).
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pnpm add @rootnative/impulse react-native-gesture-handler react-native-reanimated
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then follow the [gesture-handler install guide](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation) and the [Reanimated install guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/installation) to enable their Babel plugins.
|
|
24
|
+
|
|
25
|
+
**Peer dependencies:** `react >=19.2.3`, `react-native >=0.83.0 <0.87.0`, `react-native-gesture-handler >=2.28.0 <3.0.0`, `react-native-reanimated >=4.5.0 <4.6.0`, `react-native-worklets >=0.10.0 <0.11.0`.
|
|
26
|
+
|
|
27
|
+
Wrap your app in `<GestureHandlerRootView>`. Without it a gesture never fires, and it fails silently — nothing happens and nothing is logged.
|
|
28
|
+
|
|
29
|
+
## Impulse or `@rootnative/inertia-gestures`?
|
|
30
|
+
|
|
31
|
+
Both build on gesture-handler, and both ship a `useDrag`, a `usePan`, and a `useSwipe`. They answer different questions, and the peer list is the quickest way to tell them apart.
|
|
32
|
+
|
|
33
|
+
| | `@rootnative/impulse` | `@rootnative/inertia-gestures` |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| Animation library required | No | Yes — `@rootnative/inertia` is a mandatory peer |
|
|
36
|
+
| `useDrag` gives you | `gesture`, `ref`, and shared values | an `animatedStyle` `transform` fragment, plus `dragX` / `dragY` |
|
|
37
|
+
| Bounds and spring-back | Not included — Impulse starts no animation | Included |
|
|
38
|
+
|
|
39
|
+
Reach for **`-gestures`** when you want a Motion primitive to follow a finger and spring back, and you are already using Inertia. Reach for **Impulse** when you want the gesture itself — to drive your own values, to compose recognizers, or in an app that has no animation library at all.
|
|
40
|
+
|
|
41
|
+
Neither is a replacement for the other, and `-gestures` is not deprecated.
|
|
42
|
+
|
|
43
|
+
## What ships today
|
|
44
|
+
|
|
45
|
+
### `useTap` — the first intent
|
|
46
|
+
|
|
47
|
+
One hook, one intent. The callback name states its thread: `onTap` runs on the JS thread and may set React state directly, because Impulse owns the `runOnJS` boundary. `onBegin` and `onFinalize` are worklets.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { GestureDetector, useTap } from '@rootnative/impulse'
|
|
51
|
+
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
|
52
|
+
|
|
53
|
+
const tap = useTap({
|
|
54
|
+
onTap: (event) => select(event.x, event.y),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// `isActive` is a shared value, so a pressed state costs no re-render.
|
|
58
|
+
const style = useAnimatedStyle(() => ({
|
|
59
|
+
opacity: tap.isActive.value ? 0.55 : 1,
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<GestureDetector gesture={tap.gesture}>
|
|
64
|
+
<Animated.View style={style} />
|
|
65
|
+
</GestureDetector>
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The payload is shaped by intent rather than passed through: `{ x, y, absolute: { x, y }, pointers }`. RNGH's flat `absoluteX` / `absoluteY` never reach you.
|
|
70
|
+
|
|
71
|
+
**Activation criteria**, and both are Impulse's decision:
|
|
72
|
+
|
|
73
|
+
| Option | Default | Note |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `maxDuration` | `500` ms | RNGH's own default, restated so it cannot move underneath you in an RNGH release. |
|
|
76
|
+
| `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
|
+
| `pointers` | `1` | Raise it for a two-finger tap. |
|
|
78
|
+
|
|
79
|
+
Neither default has been measured on hardware yet.
|
|
80
|
+
|
|
81
|
+
**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
|
+
|
|
83
|
+
**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
|
+
|
|
85
|
+
### `useGestures` — composition
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
import { GestureDetector, useGestures } from '@rootnative/impulse'
|
|
91
|
+
|
|
92
|
+
// tap and double-tap race; the winner runs alongside the drag
|
|
93
|
+
const { gesture } = useGestures(
|
|
94
|
+
[useGestures([tap, double], { mode: 'race' }), drag],
|
|
95
|
+
{ mode: 'simultaneous' },
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<GestureDetector gesture={gesture}>
|
|
100
|
+
<View />
|
|
101
|
+
</GestureDetector>
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`mode` is `'race'` (first to activate wins), `'simultaneous'` (each recognizes independently), or `'exclusive'` (a later member activates only after every earlier one fails).
|
|
106
|
+
|
|
107
|
+
### `alongside` / `blocks` / `deferTo` — coexistence
|
|
108
|
+
|
|
109
|
+
RNGH exposes three external-gesture relations whose names describe the mechanism and give no hint which one a case wants. These name the outcome, and each maps to exactly one of them.
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
useRawGesture(build, deps, { alongside: pinchRef }) // both recognize at once
|
|
113
|
+
useRawGesture(build, deps, { blocks: listRef }) // this gesture wins
|
|
114
|
+
useRawGesture(build, deps, { deferTo: scrollRef }) // the other one wins
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
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
|
+
|
|
119
|
+
### `useRawGesture` — the escape hatch
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
```tsx
|
|
124
|
+
import { useRawGesture } from '@rootnative/impulse'
|
|
125
|
+
import { Directions, Gesture } from '@rootnative/impulse/gesture-handler'
|
|
126
|
+
|
|
127
|
+
const fling = useRawGesture(
|
|
128
|
+
() => Gesture.Fling().direction(Directions.RIGHT).onEnd(onFling),
|
|
129
|
+
[onFling],
|
|
130
|
+
{ deferTo: scrollRef },
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### The rest
|
|
135
|
+
|
|
136
|
+
- **`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
|
+
- **`@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`, `@rootnative/impulse/compose`, and `@rootnative/impulse/raw`, so an app that uses one hook does not ship the set.
|
|
139
|
+
- **Types** — `AttachableGesture`, `CoexistenceOptions`, `ComposeMode`, `GestureReference`, `GestureReferences`, `HitSlop`, `IntentResult`, `Point`, and per-intent `TapEvent` / `UseTapOptions` / `UseTapResult`.
|
|
140
|
+
- **`@rootnative/impulse/jest-preset`** — one-line Jest wiring, layered on `@react-native/jest-preset`.
|
|
141
|
+
|
|
142
|
+
## Gesture identity is stable by construction
|
|
143
|
+
|
|
144
|
+
A gesture whose shape changed is re-attached by `<GestureDetector>`, and a re-attach mid-drag drops the drag. An inline callback or an inline `deferTo: [ref]` is enough to cause it. Impulse builds every gesture once and configures it in the same place, routes JS-thread callbacks through a latest-value ref so they are never gesture dependencies, and compares options written as literals — a `deferTo: [ref]` array, a `hitSlop: { horizontal: 12 }` object — by content rather than by identity.
|
|
145
|
+
|
|
146
|
+
Worklet callbacks are the deliberate exception: a worklet is captured as written and serialized to the UI thread, so it stays a direct dependency and changing it does rebuild the gesture. That asymmetry is why the callback name states its thread.
|
|
147
|
+
|
|
148
|
+
## What does not ship yet
|
|
149
|
+
|
|
150
|
+
Every intent hook except `useTap` — `useDoubleTap`, `useLongPress`, `useDrag`, `usePan`, `useSwipe`, `usePinch`, `useRotate`, `useHover`, `useEdgeSwipe`. They are designed and the design is locked; none of them is written.
|
|
151
|
+
|
|
152
|
+
Two further limits today:
|
|
153
|
+
|
|
154
|
+
- **`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
|
+
- **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.
|
|
156
|
+
|
|
157
|
+
## Testing
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
// jest.config.js
|
|
161
|
+
module.exports = {
|
|
162
|
+
preset: require.resolve('@rootnative/impulse/jest-preset'),
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The preset also needs `@react-native/jest-preset` as a devDependency at the version matching your `react-native`. React Native 0.86 moved its Jest preset into that package and declares it as an optional peer, so no package manager installs it for you.
|
|
167
|
+
|
|
168
|
+
## Documentation
|
|
169
|
+
|
|
170
|
+
The docs site is not built yet. Until it is, the [repository README](https://github.com/rootnative/impulse) carries the design principles, the boundary with `@rootnative/inertia`, and the roadmap.
|
|
171
|
+
|
|
172
|
+
## Licence
|
|
173
|
+
|
|
174
|
+
MIT © RootNative. See [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useRef, useInsertionEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/internal/useLatestCallback.ts
|
|
4
|
+
function useLatestCallback(callback) {
|
|
5
|
+
const latest = useRef(callback);
|
|
6
|
+
useInsertionEffect(() => {
|
|
7
|
+
latest.current = callback;
|
|
8
|
+
});
|
|
9
|
+
return useCallback((...args) => latest.current?.(...args), []);
|
|
10
|
+
}
|
|
11
|
+
function sameEntries(a, b) {
|
|
12
|
+
if (Object.is(a, b)) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const left = a;
|
|
19
|
+
const right = b;
|
|
20
|
+
const leftKeys = Object.keys(left);
|
|
21
|
+
if (leftKeys.length !== Object.keys(right).length) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
for (const key of leftKeys) {
|
|
25
|
+
if (!Object.is(left[key], right[key])) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function useStableRecord(value) {
|
|
32
|
+
const held = useRef(value);
|
|
33
|
+
if (!sameEntries(held.current, value)) {
|
|
34
|
+
held.current = value;
|
|
35
|
+
}
|
|
36
|
+
return held.current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { useLatestCallback, useStableRecord };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useStableList, isDevBuild, warnOnce } from './chunk-ZX7WNICB.js';
|
|
2
|
+
import { useRef, useMemo } from 'react';
|
|
3
|
+
|
|
4
|
+
// src/relations/index.ts
|
|
5
|
+
var RELATIONS = [
|
|
6
|
+
["alongside", "simultaneousWithExternalGesture"],
|
|
7
|
+
["blocks", "blocksExternalGesture"],
|
|
8
|
+
["deferTo", "requireExternalGestureToFail"]
|
|
9
|
+
];
|
|
10
|
+
function applyRelations(gesture, relations, hookName) {
|
|
11
|
+
warnOnConflictingRelations(relations, hookName);
|
|
12
|
+
for (const [option, method] of RELATIONS) {
|
|
13
|
+
const references = relations[option];
|
|
14
|
+
if (references.length > 0) {
|
|
15
|
+
gesture[method](...references);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function warnOnConflictingRelations(relations, hookName) {
|
|
20
|
+
if (!isDevBuild()) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const namedBy = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const [option] of RELATIONS) {
|
|
25
|
+
for (const reference of relations[option]) {
|
|
26
|
+
const options = namedBy.get(reference);
|
|
27
|
+
if (options) {
|
|
28
|
+
options.push(option);
|
|
29
|
+
} else {
|
|
30
|
+
namedBy.set(reference, [option]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const options of namedBy.values()) {
|
|
35
|
+
if (options.length > 1) {
|
|
36
|
+
const listed = options.map((option) => `\`${option}\``).join(" and ");
|
|
37
|
+
warnOnce(
|
|
38
|
+
`relations:conflict:${hookName}:${options.join("+")}`,
|
|
39
|
+
`${hookName} names the same gesture in ${listed}. Those are independent relations, not a choice of one, so both are applied and they contradict each other. Keep the one that describes the outcome you want: \`alongside\` for both recognizing at once, \`blocks\` for this gesture winning, \`deferTo\` for the other one winning.`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/internal/useGestureMemo.ts
|
|
46
|
+
function useGestureMemo(hookName, build, deps, options) {
|
|
47
|
+
const alongside = useStableList(options?.alongside);
|
|
48
|
+
const blocks = useStableList(options?.blocks);
|
|
49
|
+
const deferTo = useStableList(options?.deferTo);
|
|
50
|
+
const testId = options?.testId;
|
|
51
|
+
const ref = useRef(void 0);
|
|
52
|
+
const gesture = useMemo(
|
|
53
|
+
() => {
|
|
54
|
+
const built = build();
|
|
55
|
+
const base = built;
|
|
56
|
+
base.withRef(ref);
|
|
57
|
+
if (testId !== void 0) {
|
|
58
|
+
base.withTestId(testId);
|
|
59
|
+
}
|
|
60
|
+
applyRelations(base, { alongside, blocks, deferTo }, hookName);
|
|
61
|
+
return built;
|
|
62
|
+
},
|
|
63
|
+
// `build` is deliberately absent: it is written inline at every call
|
|
64
|
+
// site, so its identity changes every render and including it would
|
|
65
|
+
// rebuild the gesture every render — the exact defect this helper
|
|
66
|
+
// exists to prevent. `deps` is what the caller declares instead, which
|
|
67
|
+
// is the same contract `useMemo` itself has. The lint rule cannot see
|
|
68
|
+
// through a forwarded dependency list, and the spread is what makes the
|
|
69
|
+
// array one flat list rather than a nested one; its length is fixed per
|
|
70
|
+
// call site, which is what React requires.
|
|
71
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
|
+
[...deps, alongside, blocks, deferTo, testId, hookName]
|
|
73
|
+
);
|
|
74
|
+
return useMemo(() => ({ gesture, ref }), [gesture]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export { useGestureMemo };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useStableRecord, useLatestCallback } from './chunk-5BMRKYVY.js';
|
|
2
|
+
import { useGestureMemo } from './chunk-F4RHM4ZK.js';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
5
|
+
import { useSharedValue, runOnJS } from 'react-native-reanimated';
|
|
6
|
+
|
|
7
|
+
var DEFAULT_THRESHOLD = 10;
|
|
8
|
+
function resist(value, min, max, elastic) {
|
|
9
|
+
"worklet";
|
|
10
|
+
if (min !== void 0 && value < min) {
|
|
11
|
+
return min + (value - min) * elastic;
|
|
12
|
+
}
|
|
13
|
+
if (max !== void 0 && value > max) {
|
|
14
|
+
return max + (value - max) * elastic;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function clamp(value, min, max) {
|
|
19
|
+
"worklet";
|
|
20
|
+
if (min !== void 0 && value < min) {
|
|
21
|
+
return min;
|
|
22
|
+
}
|
|
23
|
+
if (max !== void 0 && value > max) {
|
|
24
|
+
return max;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function useDrag(options = {}) {
|
|
29
|
+
const {
|
|
30
|
+
axis = "both",
|
|
31
|
+
threshold = DEFAULT_THRESHOLD,
|
|
32
|
+
failOffset,
|
|
33
|
+
elastic = 0,
|
|
34
|
+
pointers,
|
|
35
|
+
enabled,
|
|
36
|
+
onDragStart,
|
|
37
|
+
onDragEnd,
|
|
38
|
+
onBegin,
|
|
39
|
+
onUpdate,
|
|
40
|
+
onFinalize
|
|
41
|
+
} = options;
|
|
42
|
+
const x = useSharedValue(options.initial?.x ?? 0);
|
|
43
|
+
const y = useSharedValue(options.initial?.y ?? 0);
|
|
44
|
+
const isActive = useSharedValue(false);
|
|
45
|
+
const startX = useSharedValue(0);
|
|
46
|
+
const startY = useSharedValue(0);
|
|
47
|
+
const bounds = useStableRecord(options.bounds);
|
|
48
|
+
const hitSlop = useStableRecord(options.hitSlop);
|
|
49
|
+
const left = bounds?.left;
|
|
50
|
+
const right = bounds?.right;
|
|
51
|
+
const top = bounds?.top;
|
|
52
|
+
const bottom = bounds?.bottom;
|
|
53
|
+
const movesX = axis !== "y";
|
|
54
|
+
const movesY = axis !== "x";
|
|
55
|
+
const handleDragStart = useLatestCallback(onDragStart);
|
|
56
|
+
const handleDragEnd = useLatestCallback(onDragEnd);
|
|
57
|
+
const hasDragStart = onDragStart !== void 0;
|
|
58
|
+
const hasDragEnd = onDragEnd !== void 0;
|
|
59
|
+
const built = useGestureMemo(
|
|
60
|
+
"useDrag",
|
|
61
|
+
() => {
|
|
62
|
+
const toDragEvent = (event) => {
|
|
63
|
+
"worklet";
|
|
64
|
+
const position = { x: x.value, y: y.value };
|
|
65
|
+
return {
|
|
66
|
+
position,
|
|
67
|
+
translation: { x: event.translationX, y: event.translationY },
|
|
68
|
+
velocity: { x: event.velocityX, y: event.velocityY },
|
|
69
|
+
absolute: { x: event.absoluteX, y: event.absoluteY },
|
|
70
|
+
settled: {
|
|
71
|
+
x: clamp(position.x, left, right),
|
|
72
|
+
y: clamp(position.y, top, bottom)
|
|
73
|
+
},
|
|
74
|
+
pointers: event.numberOfPointers
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const pan = Gesture.Pan().onBegin((event) => {
|
|
78
|
+
"worklet";
|
|
79
|
+
onBegin?.(toDragEvent(event));
|
|
80
|
+
}).onStart((event) => {
|
|
81
|
+
"worklet";
|
|
82
|
+
startX.value = x.value - event.translationX;
|
|
83
|
+
startY.value = y.value - event.translationY;
|
|
84
|
+
isActive.value = true;
|
|
85
|
+
if (hasDragStart) {
|
|
86
|
+
runOnJS(handleDragStart)(toDragEvent(event));
|
|
87
|
+
}
|
|
88
|
+
}).onUpdate((event) => {
|
|
89
|
+
"worklet";
|
|
90
|
+
if (movesX) {
|
|
91
|
+
x.value = resist(
|
|
92
|
+
startX.value + event.translationX,
|
|
93
|
+
left,
|
|
94
|
+
right,
|
|
95
|
+
elastic
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (movesY) {
|
|
99
|
+
y.value = resist(
|
|
100
|
+
startY.value + event.translationY,
|
|
101
|
+
top,
|
|
102
|
+
bottom,
|
|
103
|
+
elastic
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
onUpdate?.(toDragEvent(event));
|
|
107
|
+
}).onEnd((event, success) => {
|
|
108
|
+
"worklet";
|
|
109
|
+
if (success && hasDragEnd) {
|
|
110
|
+
runOnJS(handleDragEnd)(toDragEvent(event));
|
|
111
|
+
}
|
|
112
|
+
}).onFinalize((event, success) => {
|
|
113
|
+
"worklet";
|
|
114
|
+
isActive.value = false;
|
|
115
|
+
onFinalize?.(toDragEvent(event), success);
|
|
116
|
+
});
|
|
117
|
+
if (axis === "x") {
|
|
118
|
+
pan.activeOffsetX([-threshold, threshold]);
|
|
119
|
+
} else if (axis === "y") {
|
|
120
|
+
pan.activeOffsetY([-threshold, threshold]);
|
|
121
|
+
} else {
|
|
122
|
+
pan.minDistance(threshold);
|
|
123
|
+
}
|
|
124
|
+
if (failOffset !== void 0) {
|
|
125
|
+
if (axis === "x") {
|
|
126
|
+
pan.failOffsetY([-failOffset, failOffset]);
|
|
127
|
+
} else if (axis === "y") {
|
|
128
|
+
pan.failOffsetX([-failOffset, failOffset]);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (pointers !== void 0) {
|
|
132
|
+
pan.minPointers(pointers).maxPointers(pointers);
|
|
133
|
+
}
|
|
134
|
+
if (hitSlop !== void 0) {
|
|
135
|
+
pan.hitSlop(hitSlop);
|
|
136
|
+
}
|
|
137
|
+
if (enabled !== void 0) {
|
|
138
|
+
pan.enabled(enabled);
|
|
139
|
+
}
|
|
140
|
+
return pan;
|
|
141
|
+
},
|
|
142
|
+
[
|
|
143
|
+
axis,
|
|
144
|
+
threshold,
|
|
145
|
+
failOffset,
|
|
146
|
+
left,
|
|
147
|
+
right,
|
|
148
|
+
top,
|
|
149
|
+
bottom,
|
|
150
|
+
elastic,
|
|
151
|
+
pointers,
|
|
152
|
+
hitSlop,
|
|
153
|
+
enabled,
|
|
154
|
+
movesX,
|
|
155
|
+
movesY,
|
|
156
|
+
hasDragStart,
|
|
157
|
+
hasDragEnd,
|
|
158
|
+
handleDragStart,
|
|
159
|
+
handleDragEnd,
|
|
160
|
+
x,
|
|
161
|
+
y,
|
|
162
|
+
startX,
|
|
163
|
+
startY,
|
|
164
|
+
isActive,
|
|
165
|
+
onBegin,
|
|
166
|
+
onUpdate,
|
|
167
|
+
onFinalize
|
|
168
|
+
],
|
|
169
|
+
options
|
|
170
|
+
);
|
|
171
|
+
return useMemo(() => ({ ...built, x, y, isActive }), [built, x, y, isActive]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { useDrag };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useStableRecord, useLatestCallback } from './chunk-5BMRKYVY.js';
|
|
2
|
+
import { useGestureMemo } from './chunk-F4RHM4ZK.js';
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
5
|
+
import { useSharedValue, runOnJS } from 'react-native-reanimated';
|
|
6
|
+
|
|
7
|
+
var DEFAULT_MAX_DURATION = 500;
|
|
8
|
+
var DEFAULT_MAX_DISTANCE = 10;
|
|
9
|
+
function toTapEvent(event) {
|
|
10
|
+
"worklet";
|
|
11
|
+
return {
|
|
12
|
+
x: event.x,
|
|
13
|
+
y: event.y,
|
|
14
|
+
absolute: { x: event.absoluteX, y: event.absoluteY },
|
|
15
|
+
pointers: event.numberOfPointers
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function useTap(options = {}) {
|
|
19
|
+
const {
|
|
20
|
+
pointers = 1,
|
|
21
|
+
maxDuration = DEFAULT_MAX_DURATION,
|
|
22
|
+
maxDistance = DEFAULT_MAX_DISTANCE,
|
|
23
|
+
enabled,
|
|
24
|
+
onTap,
|
|
25
|
+
onBegin,
|
|
26
|
+
onFinalize
|
|
27
|
+
} = options;
|
|
28
|
+
const isActive = useSharedValue(false);
|
|
29
|
+
const hitSlop = useStableRecord(options.hitSlop);
|
|
30
|
+
const handleTap = useLatestCallback(onTap);
|
|
31
|
+
const hasTapHandler = onTap !== void 0;
|
|
32
|
+
const built = useGestureMemo(
|
|
33
|
+
"useTap",
|
|
34
|
+
() => {
|
|
35
|
+
const tap = Gesture.Tap().numberOfTaps(1).minPointers(pointers).maxDuration(maxDuration).maxDistance(maxDistance).onBegin((event) => {
|
|
36
|
+
"worklet";
|
|
37
|
+
isActive.value = true;
|
|
38
|
+
onBegin?.(toTapEvent(event));
|
|
39
|
+
}).onEnd((event, success) => {
|
|
40
|
+
"worklet";
|
|
41
|
+
if (success && hasTapHandler) {
|
|
42
|
+
runOnJS(handleTap)(toTapEvent(event));
|
|
43
|
+
}
|
|
44
|
+
}).onFinalize((event, success) => {
|
|
45
|
+
"worklet";
|
|
46
|
+
isActive.value = false;
|
|
47
|
+
onFinalize?.(toTapEvent(event), success);
|
|
48
|
+
});
|
|
49
|
+
if (hitSlop !== void 0) {
|
|
50
|
+
tap.hitSlop(hitSlop);
|
|
51
|
+
}
|
|
52
|
+
if (enabled !== void 0) {
|
|
53
|
+
tap.enabled(enabled);
|
|
54
|
+
}
|
|
55
|
+
return tap;
|
|
56
|
+
},
|
|
57
|
+
[
|
|
58
|
+
pointers,
|
|
59
|
+
maxDuration,
|
|
60
|
+
maxDistance,
|
|
61
|
+
hitSlop,
|
|
62
|
+
enabled,
|
|
63
|
+
hasTapHandler,
|
|
64
|
+
handleTap,
|
|
65
|
+
isActive,
|
|
66
|
+
onBegin,
|
|
67
|
+
onFinalize
|
|
68
|
+
],
|
|
69
|
+
options
|
|
70
|
+
);
|
|
71
|
+
return useMemo(() => ({ ...built, isActive }), [built, isActive]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { useTap };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useStableList, warnOnce } from './chunk-ZX7WNICB.js';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
4
|
+
|
|
5
|
+
function useGestures(members, options) {
|
|
6
|
+
const { mode } = options;
|
|
7
|
+
const gestures = useStableList(members.map(memberGesture));
|
|
8
|
+
const gesture = useMemo(() => compose(mode, gestures), [mode, gestures]);
|
|
9
|
+
return useMemo(() => ({ gesture }), [gesture]);
|
|
10
|
+
}
|
|
11
|
+
function memberGesture(member) {
|
|
12
|
+
return "gesture" in member ? member.gesture : member;
|
|
13
|
+
}
|
|
14
|
+
function compose(mode, gestures) {
|
|
15
|
+
if (gestures.length === 0) {
|
|
16
|
+
warnOnce(
|
|
17
|
+
"compose:empty",
|
|
18
|
+
"useGestures was given an empty list. The composition recognizes nothing, so the `<GestureDetector>` holding it is inert. Build the list conditionally around the hook call rather than passing no members."
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
switch (mode) {
|
|
22
|
+
case "race":
|
|
23
|
+
return Gesture.Race(...gestures);
|
|
24
|
+
case "simultaneous":
|
|
25
|
+
return Gesture.Simultaneous(...gestures);
|
|
26
|
+
case "exclusive":
|
|
27
|
+
return Gesture.Exclusive(...gestures);
|
|
28
|
+
default:
|
|
29
|
+
warnOnce(
|
|
30
|
+
`compose:mode:${String(mode)}`,
|
|
31
|
+
`useGestures received an unknown mode ${JSON.stringify(mode)}. Falling back to \`race\`. Valid modes are \`race\`, \`simultaneous\`, and \`exclusive\`.`
|
|
32
|
+
);
|
|
33
|
+
return Gesture.Race(...gestures);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { useGestures };
|