@rootnative/inertia-gestures 0.0.8 → 0.0.10
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 +25 -1
- package/README.md +2 -2
- package/dist/index.js +41 -34
- package/dist/index.mjs +42 -35
- package/llms.txt +0 -4
- package/package.json +3 -3
- package/src/useDrag.ts +24 -33
- package/src/useLatestCallback.ts +18 -0
- package/src/usePan.ts +3 -4
- package/src/useSwipe.ts +28 -12
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ This package ships in lockstep with `@rootnative/inertia` — version numbers tr
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.0.10] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **`useDrag` and `useSwipe` no longer rebuild their gesture when an inline callback changes identity.** `onDragStart` / `onDragEnd` (`useDrag`) and `onSwipe` / `onSwipeEnd` (`useSwipe`) were dependencies of the gesture memo, so a callback written inline in the options rebuilt the `Pan` gesture on every render, and `<GestureDetector>` re-attached it. The JS-thread callbacks are now reached through stable wrappers (`useLatestCallback`) that always call the latest function; the worklet callbacks (`onRelease`, `onCommit`) stay direct dependencies because a worklet must capture them as-is. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
|
|
14
|
+
|
|
15
|
+
- **`useSwipe`'s decay `releaseTransition` warning fires once per name, not once per render.** Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **The `@rootnative/inertia` peer range has an upper bound: `>=0.0.9 <0.1.0`.** Pre-`1.0.0`, a minor bump of core is where a breaking change lands, so this adapter must not accept it. Enforced by `check:versions`. Found by the `1.0.0` readiness audit (2026-09-05), Phase 3.
|
|
20
|
+
|
|
21
|
+
- `useDrag`, `usePan`, and `useSwipe` return the `animatedStyle` from core's new `useTranslateStyle`, and `useDrag` applies its constraints through core's exported `applyBounds`. Same behaviour, one implementation. Both come from `@rootnative/inertia`, so this adapter needs the core release that ships them; the lockstep release carries both.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Tests for `useDrag`'s `onDragStart` and decay release branch, `usePan`'s `withDecay` config (velocity, deceleration, clamp widening), and gesture identity across inline callback changes.
|
|
26
|
+
|
|
27
|
+
## [0.0.9] - 2026-08-22
|
|
28
|
+
|
|
29
|
+
**Lockstep version bump** alongside `@rootnative/inertia@0.0.9` (`useInterpolatedStyle` types its return against the map it was given, so a `style` array no longer needs a cast). No runtime changes in this adapter; the `@rootnative/inertia` peer range moves to `>=0.0.9`.
|
|
30
|
+
|
|
9
31
|
## [0.0.8] - 2026-08-16
|
|
10
32
|
|
|
11
33
|
### Added
|
|
@@ -67,7 +89,9 @@ Initial alpha publish alongside `@rootnative/inertia@0.0.0-alpha.0`. Optional ad
|
|
|
67
89
|
- `useDrag({ onRelease })` — release worklet returns per-axis Inertia transitions (snap-to-tick spring, decay with bounds, etc.). Velocity stays on the UI thread; no JS round-trip.
|
|
68
90
|
- `useSwipe`, `usePan` hooks composable with any `Motion.*` primitive via `<GestureDetector>`.
|
|
69
91
|
|
|
70
|
-
[unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.
|
|
92
|
+
[unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.10...HEAD
|
|
93
|
+
[0.0.10]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.10
|
|
94
|
+
[0.0.9]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.9
|
|
71
95
|
[0.0.8]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.8
|
|
72
96
|
[0.0.7]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.7
|
|
73
97
|
[0.0.6]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.6
|
package/README.md
CHANGED
|
@@ -36,12 +36,12 @@ import { useDrag } from '@rootnative/inertia-gestures'
|
|
|
36
36
|
function DraggableBox() {
|
|
37
37
|
const drag = useDrag({
|
|
38
38
|
axis: 'both',
|
|
39
|
-
|
|
39
|
+
constraints: { left: -120, right: 120, top: -120, bottom: 120 },
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
return (
|
|
43
43
|
<GestureDetector gesture={drag.gesture}>
|
|
44
|
-
<Motion.View style={drag.
|
|
44
|
+
<Motion.View style={drag.animatedStyle} />
|
|
45
45
|
</GestureDetector>
|
|
46
46
|
)
|
|
47
47
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,15 @@ var reactNativeGestureHandler = require('react-native-gesture-handler');
|
|
|
5
5
|
var reactNativeReanimated = require('react-native-reanimated');
|
|
6
6
|
var inertia = require('@rootnative/inertia');
|
|
7
7
|
|
|
8
|
+
// src/useDrag.ts
|
|
9
|
+
function useLatestCallback(fn) {
|
|
10
|
+
const ref = react.useRef(fn);
|
|
11
|
+
ref.current = fn;
|
|
12
|
+
return react.useCallback((...args) => {
|
|
13
|
+
ref.current?.(...args);
|
|
14
|
+
}, []);
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
// src/useDrag.ts
|
|
9
18
|
function useDrag(options = {}) {
|
|
10
19
|
const {
|
|
@@ -27,17 +36,21 @@ function useDrag(options = {}) {
|
|
|
27
36
|
const top = constraints?.top;
|
|
28
37
|
const bottom = constraints?.bottom;
|
|
29
38
|
const elasticCoef = elastic;
|
|
39
|
+
const hasDragStart = onDragStart !== void 0;
|
|
40
|
+
const hasDragEnd = onDragEnd !== void 0;
|
|
41
|
+
const fireDragStart = useLatestCallback(onDragStart);
|
|
42
|
+
const fireDragEnd = useLatestCallback(onDragEnd);
|
|
30
43
|
const gesture = react.useMemo(() => {
|
|
31
44
|
const pan = reactNativeGestureHandler.Gesture.Pan().onStart(() => {
|
|
32
45
|
"worklet";
|
|
33
46
|
startX.value = dragX.value;
|
|
34
47
|
startY.value = dragY.value;
|
|
35
48
|
isDragging.value = true;
|
|
36
|
-
if (
|
|
49
|
+
if (hasDragStart) reactNativeReanimated.runOnJS(fireDragStart)();
|
|
37
50
|
}).onUpdate((e) => {
|
|
38
51
|
"worklet";
|
|
39
52
|
if (lockX) {
|
|
40
|
-
dragX.value = applyBounds(
|
|
53
|
+
dragX.value = inertia.applyBounds(
|
|
41
54
|
startX.value + e.translationX,
|
|
42
55
|
left,
|
|
43
56
|
right,
|
|
@@ -45,7 +58,7 @@ function useDrag(options = {}) {
|
|
|
45
58
|
);
|
|
46
59
|
}
|
|
47
60
|
if (lockY) {
|
|
48
|
-
dragY.value = applyBounds(
|
|
61
|
+
dragY.value = inertia.applyBounds(
|
|
49
62
|
startY.value + e.translationY,
|
|
50
63
|
top,
|
|
51
64
|
bottom,
|
|
@@ -78,8 +91,8 @@ function useDrag(options = {}) {
|
|
|
78
91
|
}
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
|
-
if (
|
|
82
|
-
reactNativeReanimated.runOnJS(
|
|
94
|
+
if (hasDragEnd) {
|
|
95
|
+
reactNativeReanimated.runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } });
|
|
83
96
|
}
|
|
84
97
|
});
|
|
85
98
|
return pan;
|
|
@@ -91,8 +104,10 @@ function useDrag(options = {}) {
|
|
|
91
104
|
top,
|
|
92
105
|
bottom,
|
|
93
106
|
elasticCoef,
|
|
94
|
-
|
|
95
|
-
|
|
107
|
+
hasDragStart,
|
|
108
|
+
hasDragEnd,
|
|
109
|
+
fireDragStart,
|
|
110
|
+
fireDragEnd,
|
|
96
111
|
onRelease,
|
|
97
112
|
dragX,
|
|
98
113
|
dragY,
|
|
@@ -100,21 +115,10 @@ function useDrag(options = {}) {
|
|
|
100
115
|
startY,
|
|
101
116
|
isDragging
|
|
102
117
|
]);
|
|
103
|
-
const animatedStyle =
|
|
104
|
-
transform: [{ translateX: dragX.value }, { translateY: dragY.value }]
|
|
105
|
-
}));
|
|
118
|
+
const animatedStyle = inertia.useTranslateStyle(dragX, dragY);
|
|
106
119
|
return { gesture, animatedStyle, dragX, dragY, isDragging };
|
|
107
120
|
}
|
|
108
|
-
|
|
109
|
-
"worklet";
|
|
110
|
-
if (min !== void 0 && value < min) {
|
|
111
|
-
return elastic > 0 ? min + (value - min) * elastic : min;
|
|
112
|
-
}
|
|
113
|
-
if (max !== void 0 && value > max) {
|
|
114
|
-
return elastic > 0 ? max + (value - max) * elastic : max;
|
|
115
|
-
}
|
|
116
|
-
return value;
|
|
117
|
-
}
|
|
121
|
+
var warnedDecayNames = /* @__PURE__ */ new Set();
|
|
118
122
|
var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
|
|
119
123
|
var DEFAULT_SNAP_BACK = { type: "spring" };
|
|
120
124
|
function useSwipe(options = {}) {
|
|
@@ -138,7 +142,9 @@ function useSwipe(options = {}) {
|
|
|
138
142
|
const snapBack = react.useMemo(() => {
|
|
139
143
|
const cfg = inertia.resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
|
|
140
144
|
if (cfg.type === "decay") {
|
|
141
|
-
|
|
145
|
+
const key = typeof releaseTransition === "string" ? releaseTransition : "<inline>";
|
|
146
|
+
if (__DEV__ && !warnedDecayNames.has(key)) {
|
|
147
|
+
warnedDecayNames.add(key);
|
|
142
148
|
console.warn(
|
|
143
149
|
"[inertia] useSwipe `releaseTransition` resolved to a decay transition, which has no target and cannot snap back to zero \u2014 falling back to the default spring."
|
|
144
150
|
);
|
|
@@ -147,6 +153,10 @@ function useSwipe(options = {}) {
|
|
|
147
153
|
}
|
|
148
154
|
return cfg;
|
|
149
155
|
}, [releaseTransition, registry]);
|
|
156
|
+
const hasSwipe = onSwipe !== void 0;
|
|
157
|
+
const hasSwipeEnd = onSwipeEnd !== void 0;
|
|
158
|
+
const fireSwipe = useLatestCallback(onSwipe);
|
|
159
|
+
const fireSwipeEnd = useLatestCallback(onSwipeEnd);
|
|
150
160
|
const gesture = react.useMemo(() => {
|
|
151
161
|
const pan = reactNativeGestureHandler.Gesture.Pan().onStart(() => {
|
|
152
162
|
"worklet";
|
|
@@ -170,11 +180,11 @@ function useSwipe(options = {}) {
|
|
|
170
180
|
allowUp,
|
|
171
181
|
allowDown
|
|
172
182
|
);
|
|
173
|
-
if (direction !== null &&
|
|
183
|
+
if (direction !== null && hasSwipe) {
|
|
174
184
|
const isHoriz = direction === "left" || direction === "right";
|
|
175
185
|
const distance = isHoriz ? Math.abs(e.translationX) : Math.abs(e.translationY);
|
|
176
186
|
const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
|
|
177
|
-
reactNativeReanimated.runOnJS(
|
|
187
|
+
reactNativeReanimated.runOnJS(fireSwipe)(direction, { distance, velocity });
|
|
178
188
|
}
|
|
179
189
|
let exit = void 0;
|
|
180
190
|
if (direction !== null && onCommit) {
|
|
@@ -185,11 +195,10 @@ function useSwipe(options = {}) {
|
|
|
185
195
|
});
|
|
186
196
|
}
|
|
187
197
|
let settle;
|
|
188
|
-
if (direction !== null &&
|
|
198
|
+
if (direction !== null && hasSwipeEnd) {
|
|
189
199
|
const dir = direction;
|
|
190
|
-
const end = onSwipeEnd;
|
|
191
200
|
settle = (finished) => {
|
|
192
|
-
reactNativeReanimated.runOnJS(
|
|
201
|
+
reactNativeReanimated.runOnJS(fireSwipeEnd)(dir, { finished: finished === true });
|
|
193
202
|
};
|
|
194
203
|
}
|
|
195
204
|
const isHorizontalCommit = direction === "left" || direction === "right";
|
|
@@ -236,16 +245,16 @@ function useSwipe(options = {}) {
|
|
|
236
245
|
allowUp,
|
|
237
246
|
allowDown,
|
|
238
247
|
snapBack,
|
|
239
|
-
|
|
248
|
+
hasSwipe,
|
|
249
|
+
hasSwipeEnd,
|
|
250
|
+
fireSwipe,
|
|
251
|
+
fireSwipeEnd,
|
|
240
252
|
onCommit,
|
|
241
|
-
onSwipeEnd,
|
|
242
253
|
swipeX,
|
|
243
254
|
swipeY,
|
|
244
255
|
isActive
|
|
245
256
|
]);
|
|
246
|
-
const animatedStyle =
|
|
247
|
-
transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
|
|
248
|
-
}));
|
|
257
|
+
const animatedStyle = inertia.useTranslateStyle(swipeX, swipeY);
|
|
249
258
|
const reset = react.useCallback(() => {
|
|
250
259
|
swipeX.value = 0;
|
|
251
260
|
swipeY.value = 0;
|
|
@@ -323,9 +332,7 @@ function usePan(options = {}) {
|
|
|
323
332
|
startY,
|
|
324
333
|
isPanning
|
|
325
334
|
]);
|
|
326
|
-
const animatedStyle =
|
|
327
|
-
transform: [{ translateX: panX.value }, { translateY: panY.value }]
|
|
328
|
-
}));
|
|
335
|
+
const animatedStyle = inertia.useTranslateStyle(panX, panY);
|
|
329
336
|
return { gesture, animatedStyle, panX, panY, isPanning };
|
|
330
337
|
}
|
|
331
338
|
function clamp(value, min, max) {
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import { useMemo, useCallback } from 'react';
|
|
1
|
+
import { useMemo, useCallback, useRef } from 'react';
|
|
2
2
|
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
-
import { useSharedValue, runOnJS,
|
|
4
|
-
import { buildReleaseAnimation, useNamedTransitions, resolveNamedTransition } from '@rootnative/inertia';
|
|
3
|
+
import { useSharedValue, runOnJS, withDecay } from 'react-native-reanimated';
|
|
4
|
+
import { applyBounds, buildReleaseAnimation, useTranslateStyle, useNamedTransitions, resolveNamedTransition } from '@rootnative/inertia';
|
|
5
|
+
|
|
6
|
+
// src/useDrag.ts
|
|
7
|
+
function useLatestCallback(fn) {
|
|
8
|
+
const ref = useRef(fn);
|
|
9
|
+
ref.current = fn;
|
|
10
|
+
return useCallback((...args) => {
|
|
11
|
+
ref.current?.(...args);
|
|
12
|
+
}, []);
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
// src/useDrag.ts
|
|
7
16
|
function useDrag(options = {}) {
|
|
@@ -25,13 +34,17 @@ function useDrag(options = {}) {
|
|
|
25
34
|
const top = constraints?.top;
|
|
26
35
|
const bottom = constraints?.bottom;
|
|
27
36
|
const elasticCoef = elastic;
|
|
37
|
+
const hasDragStart = onDragStart !== void 0;
|
|
38
|
+
const hasDragEnd = onDragEnd !== void 0;
|
|
39
|
+
const fireDragStart = useLatestCallback(onDragStart);
|
|
40
|
+
const fireDragEnd = useLatestCallback(onDragEnd);
|
|
28
41
|
const gesture = useMemo(() => {
|
|
29
42
|
const pan = Gesture.Pan().onStart(() => {
|
|
30
43
|
"worklet";
|
|
31
44
|
startX.value = dragX.value;
|
|
32
45
|
startY.value = dragY.value;
|
|
33
46
|
isDragging.value = true;
|
|
34
|
-
if (
|
|
47
|
+
if (hasDragStart) runOnJS(fireDragStart)();
|
|
35
48
|
}).onUpdate((e) => {
|
|
36
49
|
"worklet";
|
|
37
50
|
if (lockX) {
|
|
@@ -76,8 +89,8 @@ function useDrag(options = {}) {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
}
|
|
79
|
-
if (
|
|
80
|
-
runOnJS(
|
|
92
|
+
if (hasDragEnd) {
|
|
93
|
+
runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } });
|
|
81
94
|
}
|
|
82
95
|
});
|
|
83
96
|
return pan;
|
|
@@ -89,8 +102,10 @@ function useDrag(options = {}) {
|
|
|
89
102
|
top,
|
|
90
103
|
bottom,
|
|
91
104
|
elasticCoef,
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
hasDragStart,
|
|
106
|
+
hasDragEnd,
|
|
107
|
+
fireDragStart,
|
|
108
|
+
fireDragEnd,
|
|
94
109
|
onRelease,
|
|
95
110
|
dragX,
|
|
96
111
|
dragY,
|
|
@@ -98,21 +113,10 @@ function useDrag(options = {}) {
|
|
|
98
113
|
startY,
|
|
99
114
|
isDragging
|
|
100
115
|
]);
|
|
101
|
-
const animatedStyle =
|
|
102
|
-
transform: [{ translateX: dragX.value }, { translateY: dragY.value }]
|
|
103
|
-
}));
|
|
116
|
+
const animatedStyle = useTranslateStyle(dragX, dragY);
|
|
104
117
|
return { gesture, animatedStyle, dragX, dragY, isDragging };
|
|
105
118
|
}
|
|
106
|
-
|
|
107
|
-
"worklet";
|
|
108
|
-
if (min !== void 0 && value < min) {
|
|
109
|
-
return elastic > 0 ? min + (value - min) * elastic : min;
|
|
110
|
-
}
|
|
111
|
-
if (max !== void 0 && value > max) {
|
|
112
|
-
return elastic > 0 ? max + (value - max) * elastic : max;
|
|
113
|
-
}
|
|
114
|
-
return value;
|
|
115
|
-
}
|
|
119
|
+
var warnedDecayNames = /* @__PURE__ */ new Set();
|
|
116
120
|
var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
|
|
117
121
|
var DEFAULT_SNAP_BACK = { type: "spring" };
|
|
118
122
|
function useSwipe(options = {}) {
|
|
@@ -136,7 +140,9 @@ function useSwipe(options = {}) {
|
|
|
136
140
|
const snapBack = useMemo(() => {
|
|
137
141
|
const cfg = resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
|
|
138
142
|
if (cfg.type === "decay") {
|
|
139
|
-
|
|
143
|
+
const key = typeof releaseTransition === "string" ? releaseTransition : "<inline>";
|
|
144
|
+
if (__DEV__ && !warnedDecayNames.has(key)) {
|
|
145
|
+
warnedDecayNames.add(key);
|
|
140
146
|
console.warn(
|
|
141
147
|
"[inertia] useSwipe `releaseTransition` resolved to a decay transition, which has no target and cannot snap back to zero \u2014 falling back to the default spring."
|
|
142
148
|
);
|
|
@@ -145,6 +151,10 @@ function useSwipe(options = {}) {
|
|
|
145
151
|
}
|
|
146
152
|
return cfg;
|
|
147
153
|
}, [releaseTransition, registry]);
|
|
154
|
+
const hasSwipe = onSwipe !== void 0;
|
|
155
|
+
const hasSwipeEnd = onSwipeEnd !== void 0;
|
|
156
|
+
const fireSwipe = useLatestCallback(onSwipe);
|
|
157
|
+
const fireSwipeEnd = useLatestCallback(onSwipeEnd);
|
|
148
158
|
const gesture = useMemo(() => {
|
|
149
159
|
const pan = Gesture.Pan().onStart(() => {
|
|
150
160
|
"worklet";
|
|
@@ -168,11 +178,11 @@ function useSwipe(options = {}) {
|
|
|
168
178
|
allowUp,
|
|
169
179
|
allowDown
|
|
170
180
|
);
|
|
171
|
-
if (direction !== null &&
|
|
181
|
+
if (direction !== null && hasSwipe) {
|
|
172
182
|
const isHoriz = direction === "left" || direction === "right";
|
|
173
183
|
const distance = isHoriz ? Math.abs(e.translationX) : Math.abs(e.translationY);
|
|
174
184
|
const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
|
|
175
|
-
runOnJS(
|
|
185
|
+
runOnJS(fireSwipe)(direction, { distance, velocity });
|
|
176
186
|
}
|
|
177
187
|
let exit = void 0;
|
|
178
188
|
if (direction !== null && onCommit) {
|
|
@@ -183,11 +193,10 @@ function useSwipe(options = {}) {
|
|
|
183
193
|
});
|
|
184
194
|
}
|
|
185
195
|
let settle;
|
|
186
|
-
if (direction !== null &&
|
|
196
|
+
if (direction !== null && hasSwipeEnd) {
|
|
187
197
|
const dir = direction;
|
|
188
|
-
const end = onSwipeEnd;
|
|
189
198
|
settle = (finished) => {
|
|
190
|
-
runOnJS(
|
|
199
|
+
runOnJS(fireSwipeEnd)(dir, { finished: finished === true });
|
|
191
200
|
};
|
|
192
201
|
}
|
|
193
202
|
const isHorizontalCommit = direction === "left" || direction === "right";
|
|
@@ -234,16 +243,16 @@ function useSwipe(options = {}) {
|
|
|
234
243
|
allowUp,
|
|
235
244
|
allowDown,
|
|
236
245
|
snapBack,
|
|
237
|
-
|
|
246
|
+
hasSwipe,
|
|
247
|
+
hasSwipeEnd,
|
|
248
|
+
fireSwipe,
|
|
249
|
+
fireSwipeEnd,
|
|
238
250
|
onCommit,
|
|
239
|
-
onSwipeEnd,
|
|
240
251
|
swipeX,
|
|
241
252
|
swipeY,
|
|
242
253
|
isActive
|
|
243
254
|
]);
|
|
244
|
-
const animatedStyle =
|
|
245
|
-
transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
|
|
246
|
-
}));
|
|
255
|
+
const animatedStyle = useTranslateStyle(swipeX, swipeY);
|
|
247
256
|
const reset = useCallback(() => {
|
|
248
257
|
swipeX.value = 0;
|
|
249
258
|
swipeY.value = 0;
|
|
@@ -321,9 +330,7 @@ function usePan(options = {}) {
|
|
|
321
330
|
startY,
|
|
322
331
|
isPanning
|
|
323
332
|
]);
|
|
324
|
-
const animatedStyle =
|
|
325
|
-
transform: [{ translateX: panX.value }, { translateY: panY.value }]
|
|
326
|
-
}));
|
|
333
|
+
const animatedStyle = useTranslateStyle(panX, panY);
|
|
327
334
|
return { gesture, animatedStyle, panX, panY, isPanning };
|
|
328
335
|
}
|
|
329
336
|
function clamp(value, min, max) {
|
package/llms.txt
CHANGED
|
@@ -258,10 +258,6 @@ The full ladder:
|
|
|
258
258
|
|
|
259
259
|
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.
|
|
260
260
|
|
|
261
|
-
## Try it
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
261
|
## Why a separate package?
|
|
266
262
|
|
|
267
263
|
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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rootnative/inertia-gestures",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"description": "Gesture-handler-driven drag / pan / swipe adapters for @rootnative/inertia.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "RootNative",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"!**/*.test.*"
|
|
51
51
|
],
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@rootnative/inertia": ">=0.0.
|
|
53
|
+
"@rootnative/inertia": ">=0.0.10 <0.1.0",
|
|
54
54
|
"react": ">=19.0.0",
|
|
55
55
|
"react-native": ">=0.81.0",
|
|
56
56
|
"react-native-gesture-handler": ">=2.0.0",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"react-test-renderer": "19.1.0",
|
|
70
70
|
"tsup": "^8.3.5",
|
|
71
71
|
"typescript": "^5.7.3",
|
|
72
|
-
"@rootnative/inertia": "0.0.
|
|
72
|
+
"@rootnative/inertia": "0.0.10"
|
|
73
73
|
},
|
|
74
74
|
"publishConfig": {
|
|
75
75
|
"access": "public"
|
package/src/useDrag.ts
CHANGED
|
@@ -2,11 +2,16 @@ import { useMemo } from 'react'
|
|
|
2
2
|
import { Gesture, type PanGesture } from 'react-native-gesture-handler'
|
|
3
3
|
import {
|
|
4
4
|
runOnJS,
|
|
5
|
-
useAnimatedStyle,
|
|
6
5
|
useSharedValue,
|
|
7
6
|
type SharedValue,
|
|
8
7
|
} from 'react-native-reanimated'
|
|
9
|
-
import {
|
|
8
|
+
import type { useAnimatedStyle } from 'react-native-reanimated'
|
|
9
|
+
import {
|
|
10
|
+
applyBounds,
|
|
11
|
+
buildReleaseAnimation,
|
|
12
|
+
useTranslateStyle,
|
|
13
|
+
} from '@rootnative/inertia'
|
|
14
|
+
import { useLatestCallback } from './useLatestCallback'
|
|
10
15
|
import type { DragConstraints, DragOptions } from './types'
|
|
11
16
|
|
|
12
17
|
export interface UseDragResult {
|
|
@@ -77,6 +82,15 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
|
77
82
|
const bottom = constraints?.bottom
|
|
78
83
|
const elasticCoef = elastic
|
|
79
84
|
|
|
85
|
+
// JS-thread callbacks are read through stable wrappers so an inline arrow
|
|
86
|
+
// in `options` does not rebuild the gesture every render. `onRelease` is a
|
|
87
|
+
// worklet and stays a direct dependency — it must be captured by the
|
|
88
|
+
// gesture worklet as-is, since there is no ref on the UI thread.
|
|
89
|
+
const hasDragStart = onDragStart !== undefined
|
|
90
|
+
const hasDragEnd = onDragEnd !== undefined
|
|
91
|
+
const fireDragStart = useLatestCallback(onDragStart)
|
|
92
|
+
const fireDragEnd = useLatestCallback(onDragEnd)
|
|
93
|
+
|
|
80
94
|
const gesture = useMemo(() => {
|
|
81
95
|
const pan = Gesture.Pan()
|
|
82
96
|
.onStart(() => {
|
|
@@ -84,7 +98,7 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
|
84
98
|
startX.value = dragX.value
|
|
85
99
|
startY.value = dragY.value
|
|
86
100
|
isDragging.value = true
|
|
87
|
-
if (
|
|
101
|
+
if (hasDragStart) runOnJS(fireDragStart)()
|
|
88
102
|
})
|
|
89
103
|
.onUpdate((e) => {
|
|
90
104
|
'worklet'
|
|
@@ -134,8 +148,8 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
150
|
}
|
|
137
|
-
if (
|
|
138
|
-
runOnJS(
|
|
151
|
+
if (hasDragEnd) {
|
|
152
|
+
runOnJS(fireDragEnd)({ x, y, velocity: { x: vx, y: vy } })
|
|
139
153
|
}
|
|
140
154
|
})
|
|
141
155
|
return pan
|
|
@@ -147,8 +161,10 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
|
147
161
|
top,
|
|
148
162
|
bottom,
|
|
149
163
|
elasticCoef,
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
hasDragStart,
|
|
165
|
+
hasDragEnd,
|
|
166
|
+
fireDragStart,
|
|
167
|
+
fireDragEnd,
|
|
152
168
|
onRelease,
|
|
153
169
|
dragX,
|
|
154
170
|
dragY,
|
|
@@ -157,34 +173,9 @@ export function useDrag(options: DragOptions = {}): UseDragResult {
|
|
|
157
173
|
isDragging,
|
|
158
174
|
])
|
|
159
175
|
|
|
160
|
-
const animatedStyle =
|
|
161
|
-
transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
|
|
162
|
-
}))
|
|
176
|
+
const animatedStyle = useTranslateStyle(dragX, dragY)
|
|
163
177
|
|
|
164
178
|
return { gesture, animatedStyle, dragX, dragY, isDragging }
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
/**
|
|
168
|
-
* Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot beyond a
|
|
169
|
-
* bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
|
|
170
|
-
* feel. `min` / `max` may be `undefined` to leave that side unbounded.
|
|
171
|
-
*
|
|
172
|
-
* Worklet — runs on the UI thread inside the pan handler.
|
|
173
|
-
*/
|
|
174
|
-
function applyBounds(
|
|
175
|
-
value: number,
|
|
176
|
-
min: number | undefined,
|
|
177
|
-
max: number | undefined,
|
|
178
|
-
elastic: number,
|
|
179
|
-
): number {
|
|
180
|
-
'worklet'
|
|
181
|
-
if (min !== undefined && value < min) {
|
|
182
|
-
return elastic > 0 ? min + (value - min) * elastic : min
|
|
183
|
-
}
|
|
184
|
-
if (max !== undefined && value > max) {
|
|
185
|
-
return elastic > 0 ? max + (value - max) * elastic : max
|
|
186
|
-
}
|
|
187
|
-
return value
|
|
188
|
-
}
|
|
189
|
-
|
|
190
181
|
export type { DragConstraints, DragOptions }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return a function with a stable identity that always calls the latest
|
|
5
|
+
* `fn`. The gesture hooks hand the returned function to `runOnJS` inside a
|
|
6
|
+
* memoised gesture, so an inline callback in the options does not rebuild the
|
|
7
|
+
* gesture on every render. Calling the result when `fn` is `undefined` is a
|
|
8
|
+
* no-op.
|
|
9
|
+
*/
|
|
10
|
+
export function useLatestCallback<A extends unknown[]>(
|
|
11
|
+
fn: ((...args: A) => void) | undefined,
|
|
12
|
+
): (...args: A) => void {
|
|
13
|
+
const ref = useRef(fn)
|
|
14
|
+
ref.current = fn
|
|
15
|
+
return useCallback((...args: A) => {
|
|
16
|
+
ref.current?.(...args)
|
|
17
|
+
}, [])
|
|
18
|
+
}
|
package/src/usePan.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useMemo } from 'react'
|
|
2
2
|
import { Gesture, type PanGesture } from 'react-native-gesture-handler'
|
|
3
3
|
import {
|
|
4
|
-
useAnimatedStyle,
|
|
5
4
|
useSharedValue,
|
|
6
5
|
withDecay,
|
|
7
6
|
type SharedValue,
|
|
8
7
|
} from 'react-native-reanimated'
|
|
8
|
+
import type { useAnimatedStyle } from 'react-native-reanimated'
|
|
9
|
+
import { useTranslateStyle } from '@rootnative/inertia'
|
|
9
10
|
import type { DragConstraints } from './types'
|
|
10
11
|
|
|
11
12
|
export interface PanOptions {
|
|
@@ -114,9 +115,7 @@ export function usePan(options: PanOptions = {}): UsePanResult {
|
|
|
114
115
|
isPanning,
|
|
115
116
|
])
|
|
116
117
|
|
|
117
|
-
const animatedStyle =
|
|
118
|
-
transform: [{ translateX: panX.value }, { translateY: panY.value }],
|
|
119
|
-
}))
|
|
118
|
+
const animatedStyle = useTranslateStyle(panX, panY)
|
|
120
119
|
|
|
121
120
|
return { gesture, animatedStyle, panX, panY, isPanning }
|
|
122
121
|
}
|
package/src/useSwipe.ts
CHANGED
|
@@ -2,21 +2,27 @@ import { useCallback, useMemo } from 'react'
|
|
|
2
2
|
import { Gesture, type PanGesture } from 'react-native-gesture-handler'
|
|
3
3
|
import {
|
|
4
4
|
runOnJS,
|
|
5
|
-
useAnimatedStyle,
|
|
6
5
|
useSharedValue,
|
|
7
6
|
type SharedValue,
|
|
8
7
|
} from 'react-native-reanimated'
|
|
8
|
+
import type { useAnimatedStyle } from 'react-native-reanimated'
|
|
9
9
|
import {
|
|
10
10
|
buildReleaseAnimation,
|
|
11
11
|
resolveNamedTransition,
|
|
12
12
|
useNamedTransitions,
|
|
13
|
+
useTranslateStyle,
|
|
13
14
|
type TransitionConfig,
|
|
14
15
|
type TransitionName,
|
|
15
16
|
} from '@rootnative/inertia'
|
|
17
|
+
import { useLatestCallback } from './useLatestCallback'
|
|
16
18
|
import type { ReleaseInfo, ReleaseResult, SnapBackTransition } from './types'
|
|
17
19
|
|
|
18
20
|
declare const __DEV__: boolean
|
|
19
21
|
|
|
22
|
+
// Dev warning for a decay `releaseTransition`, keyed per name so it fires once
|
|
23
|
+
// per misconfiguration instead of once per render.
|
|
24
|
+
const warnedDecayNames = new Set<string>()
|
|
25
|
+
|
|
20
26
|
export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
|
|
21
27
|
|
|
22
28
|
export interface SwipeOptions {
|
|
@@ -201,7 +207,10 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
201
207
|
const cfg =
|
|
202
208
|
resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK
|
|
203
209
|
if (cfg.type === 'decay') {
|
|
204
|
-
|
|
210
|
+
const key =
|
|
211
|
+
typeof releaseTransition === 'string' ? releaseTransition : '<inline>'
|
|
212
|
+
if (__DEV__ && !warnedDecayNames.has(key)) {
|
|
213
|
+
warnedDecayNames.add(key)
|
|
205
214
|
console.warn(
|
|
206
215
|
'[inertia] useSwipe `releaseTransition` resolved to a decay ' +
|
|
207
216
|
'transition, which has no target and cannot snap back to zero — ' +
|
|
@@ -213,6 +222,14 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
213
222
|
return cfg
|
|
214
223
|
}, [releaseTransition, registry])
|
|
215
224
|
|
|
225
|
+
// JS-thread callbacks are read through stable wrappers so an inline arrow
|
|
226
|
+
// in `options` does not rebuild the gesture every render. `onCommit` is a
|
|
227
|
+
// worklet and stays a direct dependency.
|
|
228
|
+
const hasSwipe = onSwipe !== undefined
|
|
229
|
+
const hasSwipeEnd = onSwipeEnd !== undefined
|
|
230
|
+
const fireSwipe = useLatestCallback(onSwipe)
|
|
231
|
+
const fireSwipeEnd = useLatestCallback(onSwipeEnd)
|
|
232
|
+
|
|
216
233
|
const gesture = useMemo(() => {
|
|
217
234
|
const pan = Gesture.Pan()
|
|
218
235
|
.onStart(() => {
|
|
@@ -239,7 +256,7 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
239
256
|
allowUp,
|
|
240
257
|
allowDown,
|
|
241
258
|
)
|
|
242
|
-
if (direction !== null &&
|
|
259
|
+
if (direction !== null && hasSwipe) {
|
|
243
260
|
const isHoriz = direction === 'left' || direction === 'right'
|
|
244
261
|
const distance = isHoriz
|
|
245
262
|
? Math.abs(e.translationX)
|
|
@@ -247,7 +264,7 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
247
264
|
const velocity = isHoriz
|
|
248
265
|
? Math.abs(e.velocityX)
|
|
249
266
|
: Math.abs(e.velocityY)
|
|
250
|
-
runOnJS(
|
|
267
|
+
runOnJS(fireSwipe)(direction, { distance, velocity })
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
// A commit may return per-axis exit transitions to run instead of
|
|
@@ -264,11 +281,10 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
264
281
|
// `onSwipeEnd` rides the settle callback of the swipe axis — the one
|
|
265
282
|
// animation whose end means "the card has arrived".
|
|
266
283
|
let settle: ((finished?: boolean) => void) | undefined
|
|
267
|
-
if (direction !== null &&
|
|
284
|
+
if (direction !== null && hasSwipeEnd) {
|
|
268
285
|
const dir = direction
|
|
269
|
-
const end = onSwipeEnd
|
|
270
286
|
settle = (finished?: boolean) => {
|
|
271
|
-
runOnJS(
|
|
287
|
+
runOnJS(fireSwipeEnd)(dir, { finished: finished === true })
|
|
272
288
|
}
|
|
273
289
|
}
|
|
274
290
|
const isHorizontalCommit = direction === 'left' || direction === 'right'
|
|
@@ -320,17 +336,17 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
|
|
|
320
336
|
allowUp,
|
|
321
337
|
allowDown,
|
|
322
338
|
snapBack,
|
|
323
|
-
|
|
339
|
+
hasSwipe,
|
|
340
|
+
hasSwipeEnd,
|
|
341
|
+
fireSwipe,
|
|
342
|
+
fireSwipeEnd,
|
|
324
343
|
onCommit,
|
|
325
|
-
onSwipeEnd,
|
|
326
344
|
swipeX,
|
|
327
345
|
swipeY,
|
|
328
346
|
isActive,
|
|
329
347
|
])
|
|
330
348
|
|
|
331
|
-
const animatedStyle =
|
|
332
|
-
transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }],
|
|
333
|
-
}))
|
|
349
|
+
const animatedStyle = useTranslateStyle(swipeX, swipeY)
|
|
334
350
|
|
|
335
351
|
const reset = useCallback(() => {
|
|
336
352
|
swipeX.value = 0
|