@rootnative/inertia-gestures 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 +19 -0
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/index.d.mts +250 -0
- package/dist/index.d.ts +250 -0
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +277 -0
- package/dist/index.mjs.map +1 -0
- package/llms.txt +189 -0
- package/package.json +84 -0
- package/src/index.ts +24 -0
- package/src/types.ts +103 -0
- package/src/useDrag.ts +184 -0
- package/src/usePan.ts +164 -0
- package/src/useSwipe.ts +195 -0
package/llms.txt
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
> @rootnative/inertia-gestures — adapter package for @rootnative/inertia.
|
|
2
|
+
> This file is generated from the matching docs page by scripts/build-llms.mjs — do not edit by hand.
|
|
3
|
+
|
|
4
|
+
> Full docs: https://rootnative.github.io/inertia/
|
|
5
|
+
> Core overview: see @rootnative/inertia/llms.txt (or docs/static/llms.txt in the repo)
|
|
6
|
+
> Source: https://github.com/rootnative/inertia
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
# Gestures adapter
|
|
10
|
+
|
|
11
|
+
`@rootnative/inertia-gestures` adds drag / swipe / pan hooks built on `react-native-gesture-handler`. It is an **optional** sibling package — install it only if you need richer-than-`pressable` gestures. The core library has no required `gesture-handler` dependency.
|
|
12
|
+
|
|
13
|
+
The hooks compose with any `Motion.*` primitive via a `<GestureDetector>` and a single `style` slot — they don't replace the `gesture` prop, they extend what's reachable beside it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
yarn add @rootnative/inertia-gestures react-native-gesture-handler
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then follow the [`react-native-gesture-handler` install guide](https://docs.swmansion.com/react-native-gesture-handler/docs/installation) — it needs `<GestureHandlerRootView>` near the root of your app.
|
|
22
|
+
|
|
23
|
+
## `useDrag`
|
|
24
|
+
|
|
25
|
+
Drag a Motion primitive on one or two axes, with optional bounds and rubber-band overshoot.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { GestureDetector } from 'react-native-gesture-handler'
|
|
29
|
+
import { Motion } from '@rootnative/inertia'
|
|
30
|
+
import { useDrag } from '@rootnative/inertia-gestures'
|
|
31
|
+
|
|
32
|
+
function DraggableBox() {
|
|
33
|
+
const drag = useDrag({
|
|
34
|
+
axis: 'both',
|
|
35
|
+
constraints: { left: -100, right: 100, top: -60, bottom: 60 },
|
|
36
|
+
elastic: 0.4,
|
|
37
|
+
})
|
|
38
|
+
return (
|
|
39
|
+
<GestureDetector gesture={drag.gesture}>
|
|
40
|
+
<Motion.View style={[styles.box, drag.animatedStyle]} />
|
|
41
|
+
</GestureDetector>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| Option | Type | Default | Notes |
|
|
47
|
+
| ------------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
|
|
48
|
+
| `axis` | `'x' \| 'y' \| 'both'` | `'both'` | Lock the gesture to one axis. |
|
|
49
|
+
| `constraints` | `{ left?, right?, top?, bottom? }` | none | Bounds in px from the resting position. Each side independently optional. |
|
|
50
|
+
| `elastic` | `number` (0–1) | `0` | Rubber-band coefficient past `constraints`. `0` hard-clamps; `0.4` is a typical Framer-Motion feel. |
|
|
51
|
+
| `onDragStart` | `() => void` | none | Fires on JS thread when drag begins. |
|
|
52
|
+
| `onDragEnd` | `(info: { x, y, velocity: { x, y } }) => void` | none | Fires on JS thread when drag ends. |
|
|
53
|
+
| `onRelease` | `(info) => ReleaseResult \| void` (worklet) | none | UI-thread release transition — see below. |
|
|
54
|
+
|
|
55
|
+
Returns `{ gesture, animatedStyle, dragX, dragY, isDragging }`. The shared values are exposed for power use cases (deriving secondary effects from drag position).
|
|
56
|
+
|
|
57
|
+
### Release transitions (`onRelease`)
|
|
58
|
+
|
|
59
|
+
By default `useDrag` leaves the shared values exactly where the finger lifted. Pass `onRelease` to animate them to a settled position via Inertia's transition vocabulary — spring snap-to-tick, decay with bounds, timing settle. The callback runs as a **worklet** so the release velocity stays on the UI thread (no JS round-trip):
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
const drag = useDrag({
|
|
63
|
+
axis: 'x',
|
|
64
|
+
constraints: { left: 0, right: trackWidth },
|
|
65
|
+
onRelease: (e) => {
|
|
66
|
+
'worklet'
|
|
67
|
+
// Snap to the nearest tick using the release velocity for natural continuation.
|
|
68
|
+
const snap = nearestTick(e.x, ticks)
|
|
69
|
+
return {
|
|
70
|
+
x: { type: 'spring', to: snap, velocity: e.velocity.x },
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Return per-axis release transitions in a `ReleaseResult` shape — omit an axis (or return nothing) to leave that SV where it landed. The transition shapes mirror the core `transition` prop:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
type ReleaseTransition =
|
|
80
|
+
| (SpringTransition & { to: number })
|
|
81
|
+
| (TimingTransition & { to: number })
|
|
82
|
+
| DecayTransition // no `to` — decay decelerates from current position
|
|
83
|
+
| (NoAnimationTransition & { to: number })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Decay-on-release (for free-flick behaviour without snapping):
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
onRelease: (e) => {
|
|
90
|
+
'worklet'
|
|
91
|
+
return {
|
|
92
|
+
x: { type: 'decay', velocity: e.velocity.x, clamp: [0, trackWidth] },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`onRelease` composes additively with `onDragEnd` — both fire on release. Use `onRelease` for the SV animation; use `onDragEnd` for JS-thread side effects (analytics, state updates).
|
|
98
|
+
|
|
99
|
+
Internally this routes through `buildReleaseAnimation` exported from `@rootnative/inertia` — a worklet-safe builder that covers spring / timing / decay / no-animation single-step transitions. Reach for it directly if you're writing a custom `Gesture.Pan().onEnd(...)` worklet outside `useDrag`.
|
|
100
|
+
|
|
101
|
+
## `useSwipe`
|
|
102
|
+
|
|
103
|
+
Directional commit-or-snap-back gesture. Tracks live translation while the user drags; on release, fires `onSwipe` if either the distance or velocity threshold is met along an allowed axis. Whether or not it commits, the position springs back to zero — the consumer drives the side effect (delete, dismiss, advance).
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
const swipe = useSwipe({
|
|
107
|
+
directions: ['left', 'right'],
|
|
108
|
+
distanceThreshold: 100,
|
|
109
|
+
onSwipe: (direction) => {
|
|
110
|
+
if (direction === 'left') dismiss()
|
|
111
|
+
if (direction === 'right') accept()
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<GestureDetector gesture={swipe.gesture}>
|
|
117
|
+
<Motion.View style={[styles.card, swipe.animatedStyle]}>
|
|
118
|
+
{children}
|
|
119
|
+
</Motion.View>
|
|
120
|
+
</GestureDetector>
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Option | Type | Default | Notes |
|
|
125
|
+
| ------------------- | --------------------------------------------- | -------- | ----------------------------------------------------------------- |
|
|
126
|
+
| `directions` | `Array<'left' \| 'right' \| 'up' \| 'down'>` | all four | Only commits along listed directions. Off-axis swipes do nothing. |
|
|
127
|
+
| `distanceThreshold` | `number` (px) | `80` | Distance past which a release commits. |
|
|
128
|
+
| `velocityThreshold` | `number` (px/sec) | `800` | Flick velocity that commits even before distance threshold. |
|
|
129
|
+
| `onSwipe` | `(direction, { distance, velocity }) => void` | none | JS-thread callback. Direction is one of the allowed values. |
|
|
130
|
+
|
|
131
|
+
The dominant axis (whichever of \|tx\|, \|ty\| is larger at release) decides which direction is checked.
|
|
132
|
+
|
|
133
|
+
## `usePan`
|
|
134
|
+
|
|
135
|
+
Camera-style pan with momentum on release. Position **persists across separate gestures** — the next pan starts from the current offset, not zero — and on release the position continues gliding via Reanimated's `withDecay`.
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
const pan = usePan({
|
|
139
|
+
constraints: { left: -240, right: 240, top: -240, bottom: 240 },
|
|
140
|
+
deceleration: 0.997,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<GestureDetector gesture={pan.gesture}>
|
|
145
|
+
<Motion.View style={[styles.canvas, pan.animatedStyle]}>
|
|
146
|
+
{/* large content */}
|
|
147
|
+
</Motion.View>
|
|
148
|
+
</GestureDetector>
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
| Option | Type | Default | Notes |
|
|
153
|
+
| ----------------- | ---------------------------------- | ------------------ | ----------------------------------------------------------------------------- |
|
|
154
|
+
| `constraints` | `{ left?, right?, top?, bottom? }` | none | Hard clamp during gesture and during decay. |
|
|
155
|
+
| `deceleration` | `number` | Reanimated default | Higher = momentum dies faster. Roughly `0.99` (slow) to `0.999` (long glide). |
|
|
156
|
+
| `disableMomentum` | `boolean` | `false` | Hard stop on release (drag-like). |
|
|
157
|
+
|
|
158
|
+
For rubber-banded constraints, prefer `useDrag({ elastic })` — `usePan` hard-clamps because it composes with `withDecay`'s `clamp` parameter, which doesn't have an overshoot mode.
|
|
159
|
+
|
|
160
|
+
## When to pick which
|
|
161
|
+
|
|
162
|
+
| Gesture | Snap back on release? | Momentum on release? | Typical UX |
|
|
163
|
+
| ---------- | --------------------- | ---------------------------- | -------------------------------------------------- |
|
|
164
|
+
| `useDrag` | No — stays where left | No | Move-to-position, sliders, sortable handles. |
|
|
165
|
+
| `useSwipe` | Yes — always | No (springs back) | Swipe-to-delete rows, card stacks, dismiss sheets. |
|
|
166
|
+
| `usePan` | No — stays where left | Yes — coasts via `withDecay` | Maps, zoomable canvases, large-image navigation. |
|
|
167
|
+
|
|
168
|
+
## Scope: touch + mouse only
|
|
169
|
+
|
|
170
|
+
All three hooks layer on `react-native-gesture-handler`, which surfaces touch and mouse events but **not keyboard**. For controls that need keyboard a11y in addition to drag — a Slider with arrow-key increment / decrement, a scrollbar with `PageUp` / `PageDown`, a range input — reach for [`useTouchDrag`](./api/hooks#usetouchdragoptions) from `@rootnative/inertia/touch` instead. It's a PanResponder-backed counterpart to `useDrag` that lives in core (no `react-native-gesture-handler` peer required) and composes cleanly with `onKeyDown` on the wrapping `Pressable`.
|
|
171
|
+
|
|
172
|
+
The full ladder:
|
|
173
|
+
|
|
174
|
+
1. `Motion.View` + `gesture` prop — pure state-overlay UX (hover, focus, press).
|
|
175
|
+
2. `useTouchDrag` from `@rootnative/inertia/touch` — pointer + keyboard drag, no extra peer dep.
|
|
176
|
+
3. `useDrag` / `useSwipe` / `usePan` from this package — pointer-only, gesture-handler-backed. Best for projects already using gesture-handler; the UI-thread release path is more precise than PanResponder's JS-thread velocity.
|
|
177
|
+
4. Raw `PanResponder` + `useAnimation` — drop here when the gesture lifecycle itself needs customizing (multi-touch composition, custom capture predicates beyond axis lock).
|
|
178
|
+
|
|
179
|
+
Both `useTouchDrag` and the gesture-handler `useDrag` accept the same `onRelease` shape and route through `buildReleaseAnimation`, so swapping between them only touches the wiring, not the release animation config.
|
|
180
|
+
|
|
181
|
+
## Try it
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
## Why a separate package?
|
|
186
|
+
|
|
187
|
+
The core library's `gesture` prop covers `pressed` / `focused` / `focusVisible` / `hovered` — boolean state-overlay gestures with no continuous translation. Drag, swipe, and pan are **value-driven**: the gesture publishes a continuous position that the consumer composes into the view's style. They don't fit the boolean-state model, and they need `react-native-gesture-handler` (which we don't want as a required peer of core).
|
|
188
|
+
|
|
189
|
+
Splitting them off keeps core's surface area minimal and lets the gesture-handler dependency stay opt-in. The hooks return primitives (`gesture`, `animatedStyle`, shared values) rather than wrapping Motion in a new component, so they layer cleanly on the existing `Motion.*` set.
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rootnative/inertia-gestures",
|
|
3
|
+
"version": "0.0.0-alpha.0",
|
|
4
|
+
"description": "Gesture-handler-driven drag / pan / swipe adapters for @rootnative/inertia.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "RootNative",
|
|
7
|
+
"homepage": "https://github.com/rootnative/inertia",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/rootnative/inertia.git",
|
|
11
|
+
"directory": "packages/gestures"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rootnative/inertia/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"react-native",
|
|
18
|
+
"reanimated",
|
|
19
|
+
"gesture-handler",
|
|
20
|
+
"drag",
|
|
21
|
+
"pan",
|
|
22
|
+
"swipe",
|
|
23
|
+
"inertia"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"module": "./dist/index.mjs",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"react-native": "./src/index.ts",
|
|
30
|
+
"source": "./src/index.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"react-native": "./src/index.ts",
|
|
35
|
+
"source": "./src/index.ts",
|
|
36
|
+
"import": "./dist/index.mjs",
|
|
37
|
+
"require": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"src",
|
|
44
|
+
"llms.txt",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"CHANGELOG.md",
|
|
48
|
+
"!**/__tests__",
|
|
49
|
+
"!**/*.test.*"
|
|
50
|
+
],
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@rootnative/inertia": ">=0.0.0-alpha.0",
|
|
53
|
+
"react": ">=19.0.0",
|
|
54
|
+
"react-native": ">=0.81.0",
|
|
55
|
+
"react-native-gesture-handler": ">=2.0.0",
|
|
56
|
+
"react-native-reanimated": ">=4.0.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@react-native/babel-preset": "^0.81.5",
|
|
60
|
+
"@testing-library/react-native": "^13.3.3",
|
|
61
|
+
"@types/jest": "^29.5.14",
|
|
62
|
+
"@types/react": "^19.1.0",
|
|
63
|
+
"jest": "^29.7.0",
|
|
64
|
+
"react": "19.1.0",
|
|
65
|
+
"react-native": "0.81.5",
|
|
66
|
+
"react-native-gesture-handler": "~2.28.0",
|
|
67
|
+
"react-native-reanimated": "~4.1.1",
|
|
68
|
+
"react-test-renderer": "19.1.0",
|
|
69
|
+
"tsup": "^8.3.5",
|
|
70
|
+
"typescript": "^5.7.3",
|
|
71
|
+
"@rootnative/inertia": "0.0.0-alpha.0"
|
|
72
|
+
},
|
|
73
|
+
"publishConfig": {
|
|
74
|
+
"access": "public"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "tsup",
|
|
78
|
+
"dev": "tsup --watch",
|
|
79
|
+
"typecheck": "tsc --noEmit",
|
|
80
|
+
"test": "jest",
|
|
81
|
+
"lint": "eslint .",
|
|
82
|
+
"clean": "rm -rf dist .turbo *.tsbuildinfo"
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@rootnative/inertia-gestures` — gesture-handler-driven adapters for
|
|
3
|
+
* `@rootnative/inertia`.
|
|
4
|
+
*
|
|
5
|
+
* v0.2 surface:
|
|
6
|
+
* - `useDrag` — one- or two-axis drag with optional constraints and
|
|
7
|
+
* rubber-band elasticity.
|
|
8
|
+
* - `useSwipe` — directional commit-or-snap-back gesture (distance + velocity
|
|
9
|
+
* thresholds).
|
|
10
|
+
* - `usePan` — camera-style pan with momentum on release.
|
|
11
|
+
*/
|
|
12
|
+
export { useDrag } from './useDrag'
|
|
13
|
+
export type { UseDragResult } from './useDrag'
|
|
14
|
+
export { useSwipe } from './useSwipe'
|
|
15
|
+
export type { SwipeDirection, SwipeOptions, UseSwipeResult } from './useSwipe'
|
|
16
|
+
export { usePan } from './usePan'
|
|
17
|
+
export type { PanOptions, UsePanResult } from './usePan'
|
|
18
|
+
export type {
|
|
19
|
+
DragConstraints,
|
|
20
|
+
DragOptions,
|
|
21
|
+
ReleaseInfo,
|
|
22
|
+
ReleaseResult,
|
|
23
|
+
ReleaseTransition,
|
|
24
|
+
} from './types'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for `@rootnative/inertia-gestures`.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
DecayTransition,
|
|
7
|
+
NoAnimationTransition,
|
|
8
|
+
SpringTransition,
|
|
9
|
+
TimingTransition,
|
|
10
|
+
} from '@rootnative/inertia'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Bounds the dragged value can reach. Each side is optional — omit to leave
|
|
14
|
+
* that direction unbounded. Coordinates are in the same space the drag
|
|
15
|
+
* publishes (pixels of translation from the dragged element's resting
|
|
16
|
+
* position), so `{ left: -100, right: 100 }` allows ±100 px of horizontal
|
|
17
|
+
* travel.
|
|
18
|
+
*/
|
|
19
|
+
export interface DragConstraints {
|
|
20
|
+
left?: number
|
|
21
|
+
right?: number
|
|
22
|
+
top?: number
|
|
23
|
+
bottom?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* UI-thread payload delivered to `onRelease` when the drag finishes. `x` and
|
|
28
|
+
* `y` are the final translations the SVs are sitting at; `velocity.x` /
|
|
29
|
+
* `velocity.y` are the release velocities in px/sec.
|
|
30
|
+
*/
|
|
31
|
+
export interface ReleaseInfo {
|
|
32
|
+
x: number
|
|
33
|
+
y: number
|
|
34
|
+
velocity: { x: number; y: number }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-axis release transition. Spring / timing / no-animation animate to a
|
|
39
|
+
* target value (`to`); decay decelerates from the current position via its
|
|
40
|
+
* own physics and has no `to`. Pass the release velocity from `ReleaseInfo`
|
|
41
|
+
* into the transition's own `velocity` field for natural continuation.
|
|
42
|
+
*/
|
|
43
|
+
export type ReleaseTransition =
|
|
44
|
+
| (SpringTransition & { to: number })
|
|
45
|
+
| (TimingTransition & { to: number })
|
|
46
|
+
| DecayTransition
|
|
47
|
+
| (NoAnimationTransition & { to: number })
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-axis release transitions returned by `onRelease`. Omit an axis to leave
|
|
51
|
+
* its SV where it landed (no release animation on that axis).
|
|
52
|
+
*/
|
|
53
|
+
export interface ReleaseResult {
|
|
54
|
+
x?: ReleaseTransition
|
|
55
|
+
y?: ReleaseTransition
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Configuration for `useDrag`. All fields are optional; the defaults give an
|
|
60
|
+
* unconstrained two-axis drag with no elasticity.
|
|
61
|
+
*/
|
|
62
|
+
export interface DragOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Restrict the drag to one axis. Defaults to `'both'`. When `'x'` is set
|
|
65
|
+
* the y-axis shared value never updates (and vice versa); the gesture
|
|
66
|
+
* still tracks both for velocity reporting on `onDragEnd`.
|
|
67
|
+
*/
|
|
68
|
+
axis?: 'x' | 'y' | 'both'
|
|
69
|
+
/**
|
|
70
|
+
* Travel bounds. Out-of-bounds values clamp to the limit unless `elastic`
|
|
71
|
+
* is non-zero, in which case overshoot is dampened (rubber-band feel).
|
|
72
|
+
*/
|
|
73
|
+
constraints?: DragConstraints
|
|
74
|
+
/**
|
|
75
|
+
* Rubber-band coefficient applied to overshoot past `constraints`. `0`
|
|
76
|
+
* (default) hard-clamps; `1` is fully elastic (no resistance). Typical
|
|
77
|
+
* Framer-Motion-style feel sits around `0.2`–`0.4`.
|
|
78
|
+
*/
|
|
79
|
+
elastic?: number
|
|
80
|
+
/**
|
|
81
|
+
* Fired on the JS thread when the drag begins.
|
|
82
|
+
*/
|
|
83
|
+
onDragStart?: () => void
|
|
84
|
+
/**
|
|
85
|
+
* Fired on the JS thread when the drag finishes (release or cancel). The
|
|
86
|
+
* payload is the final translation and the release velocity in px/sec.
|
|
87
|
+
*/
|
|
88
|
+
onDragEnd?: (info: ReleaseInfo) => void
|
|
89
|
+
/**
|
|
90
|
+
* UI-thread callback fired on release. Return per-axis release transitions
|
|
91
|
+
* to animate the SVs to a settled position via Inertia's transition
|
|
92
|
+
* resolver — spring snap-to-tick, decay with bounds, timing settle, etc.
|
|
93
|
+
* Omit an axis (or return nothing) to leave that SV where it landed.
|
|
94
|
+
*
|
|
95
|
+
* This callback runs as a worklet so the release velocity stays on the UI
|
|
96
|
+
* thread. Author it with the `'worklet'` directive at the top of the body.
|
|
97
|
+
*
|
|
98
|
+
* Composes with `onDragEnd`: both fire on release. `onRelease` controls SV
|
|
99
|
+
* animation on the UI thread; `onDragEnd` is for JS-thread side effects
|
|
100
|
+
* (analytics, state updates).
|
|
101
|
+
*/
|
|
102
|
+
onRelease?: (info: ReleaseInfo) => ReleaseResult | void
|
|
103
|
+
}
|
package/src/useDrag.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { Gesture, type PanGesture } from 'react-native-gesture-handler'
|
|
3
|
+
import {
|
|
4
|
+
runOnJS,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
type SharedValue,
|
|
8
|
+
} from 'react-native-reanimated'
|
|
9
|
+
import { buildReleaseAnimation } from '@rootnative/inertia'
|
|
10
|
+
import type { DragConstraints, DragOptions } from './types'
|
|
11
|
+
|
|
12
|
+
export interface UseDragResult {
|
|
13
|
+
/** Pan gesture to pass to a `<GestureDetector>`. */
|
|
14
|
+
gesture: PanGesture
|
|
15
|
+
/**
|
|
16
|
+
* Animated style fragment (a single `transform` entry) to stack onto the
|
|
17
|
+
* dragged Motion primitive's `style` prop. Stable across renders.
|
|
18
|
+
*/
|
|
19
|
+
animatedStyle: ReturnType<typeof useAnimatedStyle>
|
|
20
|
+
/** Current x translation in pixels. UI-thread shared value. */
|
|
21
|
+
dragX: SharedValue<number>
|
|
22
|
+
/** Current y translation in pixels. UI-thread shared value. */
|
|
23
|
+
dragY: SharedValue<number>
|
|
24
|
+
/** True while the gesture is active. */
|
|
25
|
+
isDragging: SharedValue<boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Drag a Motion primitive with `react-native-gesture-handler`'s pan gesture.
|
|
30
|
+
*
|
|
31
|
+
* The hook owns a pair of shared values (`dragX`, `dragY`) and a `Pan`
|
|
32
|
+
* gesture that updates them on the UI thread. The returned `animatedStyle`
|
|
33
|
+
* is a self-contained `transform: [{ translateX }, { translateY }]` fragment;
|
|
34
|
+
* stack it onto the dragged component without colliding with Motion's own
|
|
35
|
+
* `animate` transforms.
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* ```tsx
|
|
39
|
+
* const drag = useDrag({ axis: 'x', constraints: { left: -100, right: 100 } })
|
|
40
|
+
* return (
|
|
41
|
+
* <GestureDetector gesture={drag.gesture}>
|
|
42
|
+
* <Motion.View style={drag.animatedStyle} />
|
|
43
|
+
* </GestureDetector>
|
|
44
|
+
* )
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
48
|
+
const {
|
|
49
|
+
axis = 'both',
|
|
50
|
+
constraints,
|
|
51
|
+
elastic = 0,
|
|
52
|
+
onDragStart,
|
|
53
|
+
onDragEnd,
|
|
54
|
+
onRelease,
|
|
55
|
+
} = options
|
|
56
|
+
|
|
57
|
+
const dragX = useSharedValue(0)
|
|
58
|
+
const dragY = useSharedValue(0)
|
|
59
|
+
const startX = useSharedValue(0)
|
|
60
|
+
const startY = useSharedValue(0)
|
|
61
|
+
const isDragging = useSharedValue(false)
|
|
62
|
+
|
|
63
|
+
// Snapshot scalars into local consts so the Pan handlers (worklets) capture
|
|
64
|
+
// primitives, not the closing `options` object — a fresh `options` literal
|
|
65
|
+
// each render would otherwise force a new gesture identity.
|
|
66
|
+
const lockX = axis !== 'y'
|
|
67
|
+
const lockY = axis !== 'x'
|
|
68
|
+
const left = constraints?.left
|
|
69
|
+
const right = constraints?.right
|
|
70
|
+
const top = constraints?.top
|
|
71
|
+
const bottom = constraints?.bottom
|
|
72
|
+
const elasticCoef = elastic
|
|
73
|
+
|
|
74
|
+
const gesture = useMemo(() => {
|
|
75
|
+
const pan = Gesture.Pan()
|
|
76
|
+
.onStart(() => {
|
|
77
|
+
'worklet'
|
|
78
|
+
startX.value = dragX.value
|
|
79
|
+
startY.value = dragY.value
|
|
80
|
+
isDragging.value = true
|
|
81
|
+
if (onDragStart) runOnJS(onDragStart)()
|
|
82
|
+
})
|
|
83
|
+
.onUpdate((e) => {
|
|
84
|
+
'worklet'
|
|
85
|
+
if (lockX) {
|
|
86
|
+
dragX.value = applyBounds(
|
|
87
|
+
startX.value + e.translationX,
|
|
88
|
+
left,
|
|
89
|
+
right,
|
|
90
|
+
elasticCoef,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (lockY) {
|
|
94
|
+
dragY.value = applyBounds(
|
|
95
|
+
startY.value + e.translationY,
|
|
96
|
+
top,
|
|
97
|
+
bottom,
|
|
98
|
+
elasticCoef,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
.onEnd((e) => {
|
|
103
|
+
'worklet'
|
|
104
|
+
isDragging.value = false
|
|
105
|
+
const x = dragX.value
|
|
106
|
+
const y = dragY.value
|
|
107
|
+
const vx = e.velocityX
|
|
108
|
+
const vy = e.velocityY
|
|
109
|
+
if (onRelease) {
|
|
110
|
+
const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
|
|
111
|
+
if (result) {
|
|
112
|
+
// Decay ignores `toValue` (it has no target); spring/timing/no-animation
|
|
113
|
+
// use it. Build per axis off the SV's current position when no `to`
|
|
114
|
+
// is given (decay branch), the destination otherwise.
|
|
115
|
+
if (result.x && lockX) {
|
|
116
|
+
const toX = 'to' in result.x ? result.x.to : x
|
|
117
|
+
dragX.value = buildReleaseAnimation(
|
|
118
|
+
result.x,
|
|
119
|
+
toX,
|
|
120
|
+
) as unknown as number
|
|
121
|
+
}
|
|
122
|
+
if (result.y && lockY) {
|
|
123
|
+
const toY = 'to' in result.y ? result.y.to : y
|
|
124
|
+
dragY.value = buildReleaseAnimation(
|
|
125
|
+
result.y,
|
|
126
|
+
toY,
|
|
127
|
+
) as unknown as number
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (onDragEnd) {
|
|
132
|
+
runOnJS(onDragEnd)({ x, y, velocity: { x: vx, y: vy } })
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
return pan
|
|
136
|
+
}, [
|
|
137
|
+
lockX,
|
|
138
|
+
lockY,
|
|
139
|
+
left,
|
|
140
|
+
right,
|
|
141
|
+
top,
|
|
142
|
+
bottom,
|
|
143
|
+
elasticCoef,
|
|
144
|
+
onDragStart,
|
|
145
|
+
onDragEnd,
|
|
146
|
+
onRelease,
|
|
147
|
+
dragX,
|
|
148
|
+
dragY,
|
|
149
|
+
startX,
|
|
150
|
+
startY,
|
|
151
|
+
isDragging,
|
|
152
|
+
])
|
|
153
|
+
|
|
154
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
155
|
+
transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
return { gesture, animatedStyle, dragX, dragY, isDragging }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot beyond a
|
|
163
|
+
* bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
|
|
164
|
+
* feel. `min` / `max` may be `undefined` to leave that side unbounded.
|
|
165
|
+
*
|
|
166
|
+
* Worklet — runs on the UI thread inside the pan handler.
|
|
167
|
+
*/
|
|
168
|
+
function applyBounds(
|
|
169
|
+
value: number,
|
|
170
|
+
min: number | undefined,
|
|
171
|
+
max: number | undefined,
|
|
172
|
+
elastic: number,
|
|
173
|
+
): number {
|
|
174
|
+
'worklet'
|
|
175
|
+
if (min !== undefined && value < min) {
|
|
176
|
+
return elastic > 0 ? min + (value - min) * elastic : min
|
|
177
|
+
}
|
|
178
|
+
if (max !== undefined && value > max) {
|
|
179
|
+
return elastic > 0 ? max + (value - max) * elastic : max
|
|
180
|
+
}
|
|
181
|
+
return value
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type { DragConstraints, DragOptions }
|