@rootnative/inertia-gestures 0.0.7 → 0.0.8

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 CHANGED
@@ -6,6 +6,18 @@ This package ships in lockstep with `@rootnative/inertia` — version numbers tr
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.0.8] - 2026-08-16
10
+
11
+ ### Added
12
+
13
+ - **`useSwipe` snap-back is configurable: `releaseTransition`.** The reset to zero after a release was a hard-coded `withSpring(0)` — it could not join a design-token motion system, and it dropped the release velocity, so a flick that stopped short of the threshold reset as if the finger had been still. The option accepts a spring / timing / no-animation config inline, or a `TransitionName` registered on the nearest `<MotionConfig transitions={...}>`. The release velocity is passed into a spring automatically (unless the config sets `velocity` itself), on both the default spring and a configured one. Decay is excluded — the snap-back always targets zero and decay has no target; a name that resolves to a decay config dev-warns and falls back to the default spring.
14
+
15
+ - **`useSwipe` commit exit: `onCommit` + `onSwipeEnd`.** The hook always sprang back to zero, which is right for swipe-to-delete and wrong for a card deck — a committed card must continue in the swipe direction and leave the screen, and consumers were rebuilding that with a parallel translation layer, an animation-end handler, and a safety timer. `onCommit` is a UI-thread worklet that fires when the gesture commits and returns per-axis release transitions (the same `ReleaseResult` shape as `useDrag`'s `onRelease`) to run **instead of** the snap-back; an omitted axis or a `void` return snaps back as usual. `onSwipeEnd(direction, { finished })` fires on the JS thread when the committed swipe's release animation settles — the commit exit if one ran, the snap-back otherwise — which is the "card is gone, advance the deck" moment `onSwipe` (fired at release) cannot give. It does not fire for a release that did not commit.
16
+
17
+ - **`useSwipe` returns `reset()`.** Snaps both shared values back to zero with no animation, cancelling anything in flight. After a commit exit the values stay at the exit target; call `reset()` when the next card takes over the same mounted component. A keyed remount gets fresh values and doesn't need it.
18
+
19
+ - New exported type: `SnapBackTransition` (the `releaseTransition` shapes). Requires `@rootnative/inertia` >=0.0.8 — the settle callback rides a new third parameter on core's `buildReleaseAnimation`.
20
+
9
21
  ## [0.0.7] - 2026-08-14
10
22
 
11
23
  **Lockstep version bump** alongside `@rootnative/inertia@0.0.7` (`Motion.FlatList`, a virtualized animated scroller, and `gesture={{ pressed }}` responding to a mouse on web). No runtime changes in this adapter; the `@rootnative/inertia` peer range moves to `>=0.0.7`.
@@ -55,7 +67,8 @@ Initial alpha publish alongside `@rootnative/inertia@0.0.0-alpha.0`. Optional ad
55
67
  - `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.
56
68
  - `useSwipe`, `usePan` hooks composable with any `Motion.*` primitive via `<GestureDetector>`.
57
69
 
58
- [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.7...HEAD
70
+ [unreleased]: https://github.com/rootnative/inertia/compare/core+gestures+gradients+svg@0.0.8...HEAD
71
+ [0.0.8]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.8
59
72
  [0.0.7]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.7
60
73
  [0.0.6]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.6
61
74
  [0.0.5]: https://github.com/rootnative/inertia/releases/tag/core+gestures+gradients+svg@0.0.5
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { PanGesture } from 'react-native-gesture-handler';
2
2
  import { useAnimatedStyle, SharedValue } from 'react-native-reanimated';
3
- import { SpringTransition, TimingTransition, DecayTransition, NoAnimationTransition } from '@rootnative/inertia';
3
+ import { SpringTransition, TimingTransition, DecayTransition, NoAnimationTransition, TransitionName } from '@rootnative/inertia';
4
4
 
5
5
  /**
6
6
  * Public types for `@rootnative/inertia-gestures`.
@@ -48,11 +48,22 @@ type ReleaseTransition = (SpringTransition & {
48
48
  /**
49
49
  * Per-axis release transitions returned by `onRelease`. Omit an axis to leave
50
50
  * its SV where it landed (no release animation on that axis).
51
+ *
52
+ * `useSwipe`'s `onCommit` returns the same shape with one difference in the
53
+ * omit semantics: an omitted axis snaps back to zero (the hook's resting
54
+ * state) rather than staying where it landed.
51
55
  */
52
56
  interface ReleaseResult {
53
57
  x?: ReleaseTransition;
54
58
  y?: ReleaseTransition;
55
59
  }
60
+ /**
61
+ * Transition shapes `useSwipe` accepts for its snap-back (the
62
+ * `releaseTransition` option). The target is always zero, so decay — which
63
+ * has no target — is excluded; a registered name that resolves to a decay
64
+ * config dev-warns and falls back to the default spring.
65
+ */
66
+ type SnapBackTransition = SpringTransition | TimingTransition | NoAnimationTransition;
56
67
  /**
57
68
  * Configuration for `useDrag`. All fields are optional; the defaults give an
58
69
  * unconstrained two-axis drag with no elasticity.
@@ -104,8 +115,14 @@ interface UseDragResult {
104
115
  /** Pan gesture to pass to a `<GestureDetector>`. */
105
116
  gesture: PanGesture;
106
117
  /**
107
- * Animated style fragment (a single `transform` entry) to stack onto the
108
- * dragged Motion primitive's `style` prop. Stable across renders.
118
+ * Animated style fragment for the dragged Motion primitive's `style` prop.
119
+ * Stable across renders.
120
+ *
121
+ * This owns the whole `transform` key. Do not stack a second transform style
122
+ * beside it — `transform` is one key in React Native, so the later style
123
+ * replaces this one instead of merging, and the drag silently stops
124
+ * following the finger. Nest another animated view, or build one style from
125
+ * `dragX` / `dragY` with `useInterpolatedStyle`, to add a transform.
109
126
  */
110
127
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
111
128
  /** Current x translation in pixels. UI-thread shared value. */
@@ -155,6 +172,17 @@ interface SwipeOptions {
155
172
  * to `800`.
156
173
  */
157
174
  velocityThreshold?: number;
175
+ /**
176
+ * Transition for the snap-back to zero, given inline or as a registered
177
+ * `TransitionName` from the nearest `<MotionConfig transitions={...}>`.
178
+ * Defaults to the library default spring. The release velocity is passed
179
+ * into a spring automatically (unless the config sets `velocity` itself),
180
+ * so a flick that stops short of the threshold resets with the momentum it
181
+ * had. Decay is excluded — the snap-back always targets zero, and decay has
182
+ * no target; a name that resolves to a decay config dev-warns and falls
183
+ * back to the default spring.
184
+ */
185
+ releaseTransition?: SnapBackTransition | TransitionName;
158
186
  /**
159
187
  * Fired on the JS thread when the gesture commits in an allowed direction.
160
188
  */
@@ -162,14 +190,62 @@ interface SwipeOptions {
162
190
  distance: number;
163
191
  velocity: number;
164
192
  }) => void;
193
+ /**
194
+ * UI-thread callback fired when the gesture commits, before any release
195
+ * animation starts. Return per-axis release transitions to run **instead
196
+ * of** the snap-back — the commit-exit path a card deck needs, where the
197
+ * committed card continues in the swipe direction and leaves the screen:
198
+ *
199
+ * ```ts
200
+ * onCommit: (direction, info) => {
201
+ * 'worklet'
202
+ * return {
203
+ * x: {
204
+ * type: 'spring',
205
+ * to: direction === 'right' ? 500 : -500,
206
+ * velocity: info.velocity.x,
207
+ * },
208
+ * }
209
+ * }
210
+ * ```
211
+ *
212
+ * An omitted axis (or a `void` return) snaps back to zero as usual. This
213
+ * callback runs as a worklet so the release velocity stays on the UI
214
+ * thread — author it with the `'worklet'` directive at the top of the body.
215
+ *
216
+ * Composes with `onSwipe`: both fire on commit. `onCommit` picks the
217
+ * release animation on the UI thread; `onSwipe` is for JS-thread side
218
+ * effects. Note the shared values stay at the exit target afterwards — call
219
+ * `reset()` when the next card takes over without a remount.
220
+ */
221
+ onCommit?: (direction: SwipeDirection, info: ReleaseInfo) => ReleaseResult | void;
222
+ /**
223
+ * Fired on the JS thread when a committed swipe's release animation
224
+ * settles — the commit exit from `onCommit` if one ran, the snap-back
225
+ * otherwise. This is the "card is gone, advance the deck" moment that
226
+ * `onSwipe` (which fires at release) cannot give you. `finished` is `false`
227
+ * when the animation was interrupted (for example by a new gesture). Does
228
+ * not fire for a release that did not commit.
229
+ */
230
+ onSwipeEnd?: (direction: SwipeDirection, info: {
231
+ finished: boolean;
232
+ }) => void;
165
233
  }
166
234
  interface UseSwipeResult {
167
235
  /** Pan gesture to pass to a `<GestureDetector>`. */
168
236
  gesture: PanGesture;
169
237
  /**
170
238
  * Animated style fragment exposing live translation while the gesture is
171
- * active. Snaps back to `{ 0, 0 }` after release (whether or not the swipe
172
- * committed) via a default spring.
239
+ * active. After release it snaps back to `{ 0, 0 }` via `releaseTransition`
240
+ * (default spring), unless a committed swipe's `onCommit` returned a
241
+ * commit-exit transition for that axis.
242
+ *
243
+ * This owns the whole `transform` style key. `transform` is one key in React
244
+ * Native, so a second style in the same array **replaces** this array rather
245
+ * than merging with it — `style={[swipe.animatedStyle, tiltStyle]}` silently
246
+ * drops the translation and only the tilt runs. To add a transform of your
247
+ * own, nest another animated view, or build one style from `swipeX` /
248
+ * `swipeY` yourself with `useInterpolatedStyle`.
173
249
  */
174
250
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
175
251
  /** Live x translation. */
@@ -178,14 +254,24 @@ interface UseSwipeResult {
178
254
  swipeY: SharedValue<number>;
179
255
  /** True while the user is actively swiping. */
180
256
  isActive: SharedValue<boolean>;
257
+ /**
258
+ * Snap both shared values back to zero with no animation, cancelling
259
+ * anything in flight. After a commit exit the values stay at the exit
260
+ * target — call this when the next card takes over the same mounted
261
+ * component. A keyed remount gets fresh values and doesn't need it.
262
+ */
263
+ reset: () => void;
181
264
  }
182
265
  /**
183
266
  * Directional commit-or-snap-back gesture. Tracks live translation while the
184
267
  * user drags and fires `onSwipe(direction)` on release if either the distance
185
- * or velocity threshold is exceeded in an allowed direction. The position
186
- * shared values always animate back to zero — the consumer is responsible
187
- * for whatever side effect the commit drives (delete a row, dismiss a sheet,
188
- * etc.).
268
+ * or velocity threshold is exceeded in an allowed direction.
269
+ *
270
+ * By default the position shared values animate back to zero whether or not
271
+ * the swipe commits — the right shape for swipe-to-delete, where the row
272
+ * returns and the consumer removes it. For a card deck, return a commit-exit
273
+ * transition from `onCommit` so the committed card continues off screen, and
274
+ * advance the deck from `onSwipeEnd` when it settles.
189
275
  *
190
276
  * Usage:
191
277
  * ```tsx
@@ -199,6 +285,22 @@ interface UseSwipeResult {
199
285
  * </GestureDetector>
200
286
  * )
201
287
  * ```
288
+ *
289
+ * `animatedStyle` owns the whole `transform` key, so it does not compose with
290
+ * a second transform style. To add rotation — the usual card-deck shape —
291
+ * build one style from `swipeX` instead of stacking two:
292
+ *
293
+ * ```tsx
294
+ * const cardStyle = useInterpolatedStyle(swipe.swipeX, {
295
+ * translateX: [-200, 0, 200],
296
+ * rotate: ['-12deg', '0deg', '12deg'],
297
+ * })
298
+ * return (
299
+ * <GestureDetector gesture={swipe.gesture}>
300
+ * <Motion.View style={cardStyle}>...</Motion.View>
301
+ * </GestureDetector>
302
+ * )
303
+ * ```
202
304
  */
203
305
  declare function useSwipe(options?: SwipeOptions): UseSwipeResult;
204
306
 
@@ -227,7 +329,12 @@ interface PanOptions {
227
329
  interface UsePanResult {
228
330
  /** Pan gesture to pass to a `<GestureDetector>`. */
229
331
  gesture: PanGesture;
230
- /** Stable animated `transform` style. */
332
+ /**
333
+ * Stable animated `transform` style. This owns the whole `transform` key —
334
+ * a second transform style in the same array replaces it rather than merging
335
+ * with it. Nest another animated view, or build one style from `panX` /
336
+ * `panY` with `useInterpolatedStyle`, to add a transform of your own.
337
+ */
231
338
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
232
339
  /** Live x translation, persistent across gestures. */
233
340
  panX: SharedValue<number>;
@@ -247,4 +354,4 @@ interface UsePanResult {
247
354
  */
248
355
  declare function usePan(options?: PanOptions): UsePanResult;
249
356
 
250
- export { type DragConstraints, type DragOptions, type PanOptions, type ReleaseInfo, type ReleaseResult, type ReleaseTransition, type SwipeDirection, type SwipeOptions, type UseDragResult, type UsePanResult, type UseSwipeResult, useDrag, usePan, useSwipe };
357
+ export { type DragConstraints, type DragOptions, type PanOptions, type ReleaseInfo, type ReleaseResult, type ReleaseTransition, type SnapBackTransition, type SwipeDirection, type SwipeOptions, type UseDragResult, type UsePanResult, type UseSwipeResult, useDrag, usePan, useSwipe };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { PanGesture } from 'react-native-gesture-handler';
2
2
  import { useAnimatedStyle, SharedValue } from 'react-native-reanimated';
3
- import { SpringTransition, TimingTransition, DecayTransition, NoAnimationTransition } from '@rootnative/inertia';
3
+ import { SpringTransition, TimingTransition, DecayTransition, NoAnimationTransition, TransitionName } from '@rootnative/inertia';
4
4
 
5
5
  /**
6
6
  * Public types for `@rootnative/inertia-gestures`.
@@ -48,11 +48,22 @@ type ReleaseTransition = (SpringTransition & {
48
48
  /**
49
49
  * Per-axis release transitions returned by `onRelease`. Omit an axis to leave
50
50
  * its SV where it landed (no release animation on that axis).
51
+ *
52
+ * `useSwipe`'s `onCommit` returns the same shape with one difference in the
53
+ * omit semantics: an omitted axis snaps back to zero (the hook's resting
54
+ * state) rather than staying where it landed.
51
55
  */
52
56
  interface ReleaseResult {
53
57
  x?: ReleaseTransition;
54
58
  y?: ReleaseTransition;
55
59
  }
60
+ /**
61
+ * Transition shapes `useSwipe` accepts for its snap-back (the
62
+ * `releaseTransition` option). The target is always zero, so decay — which
63
+ * has no target — is excluded; a registered name that resolves to a decay
64
+ * config dev-warns and falls back to the default spring.
65
+ */
66
+ type SnapBackTransition = SpringTransition | TimingTransition | NoAnimationTransition;
56
67
  /**
57
68
  * Configuration for `useDrag`. All fields are optional; the defaults give an
58
69
  * unconstrained two-axis drag with no elasticity.
@@ -104,8 +115,14 @@ interface UseDragResult {
104
115
  /** Pan gesture to pass to a `<GestureDetector>`. */
105
116
  gesture: PanGesture;
106
117
  /**
107
- * Animated style fragment (a single `transform` entry) to stack onto the
108
- * dragged Motion primitive's `style` prop. Stable across renders.
118
+ * Animated style fragment for the dragged Motion primitive's `style` prop.
119
+ * Stable across renders.
120
+ *
121
+ * This owns the whole `transform` key. Do not stack a second transform style
122
+ * beside it — `transform` is one key in React Native, so the later style
123
+ * replaces this one instead of merging, and the drag silently stops
124
+ * following the finger. Nest another animated view, or build one style from
125
+ * `dragX` / `dragY` with `useInterpolatedStyle`, to add a transform.
109
126
  */
110
127
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
111
128
  /** Current x translation in pixels. UI-thread shared value. */
@@ -155,6 +172,17 @@ interface SwipeOptions {
155
172
  * to `800`.
156
173
  */
157
174
  velocityThreshold?: number;
175
+ /**
176
+ * Transition for the snap-back to zero, given inline or as a registered
177
+ * `TransitionName` from the nearest `<MotionConfig transitions={...}>`.
178
+ * Defaults to the library default spring. The release velocity is passed
179
+ * into a spring automatically (unless the config sets `velocity` itself),
180
+ * so a flick that stops short of the threshold resets with the momentum it
181
+ * had. Decay is excluded — the snap-back always targets zero, and decay has
182
+ * no target; a name that resolves to a decay config dev-warns and falls
183
+ * back to the default spring.
184
+ */
185
+ releaseTransition?: SnapBackTransition | TransitionName;
158
186
  /**
159
187
  * Fired on the JS thread when the gesture commits in an allowed direction.
160
188
  */
@@ -162,14 +190,62 @@ interface SwipeOptions {
162
190
  distance: number;
163
191
  velocity: number;
164
192
  }) => void;
193
+ /**
194
+ * UI-thread callback fired when the gesture commits, before any release
195
+ * animation starts. Return per-axis release transitions to run **instead
196
+ * of** the snap-back — the commit-exit path a card deck needs, where the
197
+ * committed card continues in the swipe direction and leaves the screen:
198
+ *
199
+ * ```ts
200
+ * onCommit: (direction, info) => {
201
+ * 'worklet'
202
+ * return {
203
+ * x: {
204
+ * type: 'spring',
205
+ * to: direction === 'right' ? 500 : -500,
206
+ * velocity: info.velocity.x,
207
+ * },
208
+ * }
209
+ * }
210
+ * ```
211
+ *
212
+ * An omitted axis (or a `void` return) snaps back to zero as usual. This
213
+ * callback runs as a worklet so the release velocity stays on the UI
214
+ * thread — author it with the `'worklet'` directive at the top of the body.
215
+ *
216
+ * Composes with `onSwipe`: both fire on commit. `onCommit` picks the
217
+ * release animation on the UI thread; `onSwipe` is for JS-thread side
218
+ * effects. Note the shared values stay at the exit target afterwards — call
219
+ * `reset()` when the next card takes over without a remount.
220
+ */
221
+ onCommit?: (direction: SwipeDirection, info: ReleaseInfo) => ReleaseResult | void;
222
+ /**
223
+ * Fired on the JS thread when a committed swipe's release animation
224
+ * settles — the commit exit from `onCommit` if one ran, the snap-back
225
+ * otherwise. This is the "card is gone, advance the deck" moment that
226
+ * `onSwipe` (which fires at release) cannot give you. `finished` is `false`
227
+ * when the animation was interrupted (for example by a new gesture). Does
228
+ * not fire for a release that did not commit.
229
+ */
230
+ onSwipeEnd?: (direction: SwipeDirection, info: {
231
+ finished: boolean;
232
+ }) => void;
165
233
  }
166
234
  interface UseSwipeResult {
167
235
  /** Pan gesture to pass to a `<GestureDetector>`. */
168
236
  gesture: PanGesture;
169
237
  /**
170
238
  * Animated style fragment exposing live translation while the gesture is
171
- * active. Snaps back to `{ 0, 0 }` after release (whether or not the swipe
172
- * committed) via a default spring.
239
+ * active. After release it snaps back to `{ 0, 0 }` via `releaseTransition`
240
+ * (default spring), unless a committed swipe's `onCommit` returned a
241
+ * commit-exit transition for that axis.
242
+ *
243
+ * This owns the whole `transform` style key. `transform` is one key in React
244
+ * Native, so a second style in the same array **replaces** this array rather
245
+ * than merging with it — `style={[swipe.animatedStyle, tiltStyle]}` silently
246
+ * drops the translation and only the tilt runs. To add a transform of your
247
+ * own, nest another animated view, or build one style from `swipeX` /
248
+ * `swipeY` yourself with `useInterpolatedStyle`.
173
249
  */
174
250
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
175
251
  /** Live x translation. */
@@ -178,14 +254,24 @@ interface UseSwipeResult {
178
254
  swipeY: SharedValue<number>;
179
255
  /** True while the user is actively swiping. */
180
256
  isActive: SharedValue<boolean>;
257
+ /**
258
+ * Snap both shared values back to zero with no animation, cancelling
259
+ * anything in flight. After a commit exit the values stay at the exit
260
+ * target — call this when the next card takes over the same mounted
261
+ * component. A keyed remount gets fresh values and doesn't need it.
262
+ */
263
+ reset: () => void;
181
264
  }
182
265
  /**
183
266
  * Directional commit-or-snap-back gesture. Tracks live translation while the
184
267
  * user drags and fires `onSwipe(direction)` on release if either the distance
185
- * or velocity threshold is exceeded in an allowed direction. The position
186
- * shared values always animate back to zero — the consumer is responsible
187
- * for whatever side effect the commit drives (delete a row, dismiss a sheet,
188
- * etc.).
268
+ * or velocity threshold is exceeded in an allowed direction.
269
+ *
270
+ * By default the position shared values animate back to zero whether or not
271
+ * the swipe commits — the right shape for swipe-to-delete, where the row
272
+ * returns and the consumer removes it. For a card deck, return a commit-exit
273
+ * transition from `onCommit` so the committed card continues off screen, and
274
+ * advance the deck from `onSwipeEnd` when it settles.
189
275
  *
190
276
  * Usage:
191
277
  * ```tsx
@@ -199,6 +285,22 @@ interface UseSwipeResult {
199
285
  * </GestureDetector>
200
286
  * )
201
287
  * ```
288
+ *
289
+ * `animatedStyle` owns the whole `transform` key, so it does not compose with
290
+ * a second transform style. To add rotation — the usual card-deck shape —
291
+ * build one style from `swipeX` instead of stacking two:
292
+ *
293
+ * ```tsx
294
+ * const cardStyle = useInterpolatedStyle(swipe.swipeX, {
295
+ * translateX: [-200, 0, 200],
296
+ * rotate: ['-12deg', '0deg', '12deg'],
297
+ * })
298
+ * return (
299
+ * <GestureDetector gesture={swipe.gesture}>
300
+ * <Motion.View style={cardStyle}>...</Motion.View>
301
+ * </GestureDetector>
302
+ * )
303
+ * ```
202
304
  */
203
305
  declare function useSwipe(options?: SwipeOptions): UseSwipeResult;
204
306
 
@@ -227,7 +329,12 @@ interface PanOptions {
227
329
  interface UsePanResult {
228
330
  /** Pan gesture to pass to a `<GestureDetector>`. */
229
331
  gesture: PanGesture;
230
- /** Stable animated `transform` style. */
332
+ /**
333
+ * Stable animated `transform` style. This owns the whole `transform` key —
334
+ * a second transform style in the same array replaces it rather than merging
335
+ * with it. Nest another animated view, or build one style from `panX` /
336
+ * `panY` with `useInterpolatedStyle`, to add a transform of your own.
337
+ */
231
338
  animatedStyle: ReturnType<typeof useAnimatedStyle>;
232
339
  /** Live x translation, persistent across gestures. */
233
340
  panX: SharedValue<number>;
@@ -247,4 +354,4 @@ interface UsePanResult {
247
354
  */
248
355
  declare function usePan(options?: PanOptions): UsePanResult;
249
356
 
250
- export { type DragConstraints, type DragOptions, type PanOptions, type ReleaseInfo, type ReleaseResult, type ReleaseTransition, type SwipeDirection, type SwipeOptions, type UseDragResult, type UsePanResult, type UseSwipeResult, useDrag, usePan, useSwipe };
357
+ export { type DragConstraints, type DragOptions, type PanOptions, type ReleaseInfo, type ReleaseResult, type ReleaseTransition, type SnapBackTransition, type SwipeDirection, type SwipeOptions, type UseDragResult, type UsePanResult, type UseSwipeResult, useDrag, usePan, useSwipe };
package/dist/index.js CHANGED
@@ -116,12 +116,16 @@ function applyBounds(value, min, max, elastic) {
116
116
  return value;
117
117
  }
118
118
  var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
119
+ var DEFAULT_SNAP_BACK = { type: "spring" };
119
120
  function useSwipe(options = {}) {
120
121
  const {
121
122
  directions = DEFAULT_DIRECTIONS,
122
123
  distanceThreshold = 80,
123
124
  velocityThreshold = 800,
124
- onSwipe
125
+ releaseTransition,
126
+ onSwipe,
127
+ onCommit,
128
+ onSwipeEnd
125
129
  } = options;
126
130
  const swipeX = reactNativeReanimated.useSharedValue(0);
127
131
  const swipeY = reactNativeReanimated.useSharedValue(0);
@@ -130,6 +134,19 @@ function useSwipe(options = {}) {
130
134
  const allowRight = directions.includes("right");
131
135
  const allowUp = directions.includes("up");
132
136
  const allowDown = directions.includes("down");
137
+ const registry = inertia.useNamedTransitions();
138
+ const snapBack = react.useMemo(() => {
139
+ const cfg = inertia.resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
140
+ if (cfg.type === "decay") {
141
+ if (__DEV__) {
142
+ console.warn(
143
+ "[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
+ );
145
+ }
146
+ return DEFAULT_SNAP_BACK;
147
+ }
148
+ return cfg;
149
+ }, [releaseTransition, registry]);
133
150
  const gesture = react.useMemo(() => {
134
151
  const pan = reactNativeGestureHandler.Gesture.Pan().onStart(() => {
135
152
  "worklet";
@@ -159,8 +176,53 @@ function useSwipe(options = {}) {
159
176
  const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
160
177
  reactNativeReanimated.runOnJS(onSwipe)(direction, { distance, velocity });
161
178
  }
162
- swipeX.value = reactNativeReanimated.withSpring(0);
163
- swipeY.value = reactNativeReanimated.withSpring(0);
179
+ let exit = void 0;
180
+ if (direction !== null && onCommit) {
181
+ exit = onCommit(direction, {
182
+ x: swipeX.value,
183
+ y: swipeY.value,
184
+ velocity: { x: e.velocityX, y: e.velocityY }
185
+ });
186
+ }
187
+ let settle;
188
+ if (direction !== null && onSwipeEnd) {
189
+ const dir = direction;
190
+ const end = onSwipeEnd;
191
+ settle = (finished) => {
192
+ reactNativeReanimated.runOnJS(end)(dir, { finished: finished === true });
193
+ };
194
+ }
195
+ const isHorizontalCommit = direction === "left" || direction === "right";
196
+ const settleX = isHorizontalCommit ? settle : void 0;
197
+ const settleY = isHorizontalCommit ? void 0 : settle;
198
+ const exitX = exit ? exit.x : void 0;
199
+ const exitY = exit ? exit.y : void 0;
200
+ if (exitX) {
201
+ swipeX.value = inertia.buildReleaseAnimation(
202
+ exitX,
203
+ "to" in exitX ? exitX.to : swipeX.value,
204
+ settleX
205
+ );
206
+ } else {
207
+ swipeX.value = inertia.buildReleaseAnimation(
208
+ withReleaseVelocity(snapBack, e.velocityX),
209
+ 0,
210
+ settleX
211
+ );
212
+ }
213
+ if (exitY) {
214
+ swipeY.value = inertia.buildReleaseAnimation(
215
+ exitY,
216
+ "to" in exitY ? exitY.to : swipeY.value,
217
+ settleY
218
+ );
219
+ } else {
220
+ swipeY.value = inertia.buildReleaseAnimation(
221
+ withReleaseVelocity(snapBack, e.velocityY),
222
+ 0,
223
+ settleY
224
+ );
225
+ }
164
226
  }).onFinalize(() => {
165
227
  "worklet";
166
228
  isActive.value = false;
@@ -173,7 +235,10 @@ function useSwipe(options = {}) {
173
235
  allowRight,
174
236
  allowUp,
175
237
  allowDown,
238
+ snapBack,
176
239
  onSwipe,
240
+ onCommit,
241
+ onSwipeEnd,
177
242
  swipeX,
178
243
  swipeY,
179
244
  isActive
@@ -181,7 +246,17 @@ function useSwipe(options = {}) {
181
246
  const animatedStyle = reactNativeReanimated.useAnimatedStyle(() => ({
182
247
  transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
183
248
  }));
184
- return { gesture, animatedStyle, swipeX, swipeY, isActive };
249
+ const reset = react.useCallback(() => {
250
+ swipeX.value = 0;
251
+ swipeY.value = 0;
252
+ }, [swipeX, swipeY]);
253
+ return { gesture, animatedStyle, swipeX, swipeY, isActive, reset };
254
+ }
255
+ function withReleaseVelocity(cfg, velocity) {
256
+ "worklet";
257
+ if (cfg.type !== "spring" && cfg.type !== void 0) return cfg;
258
+ if (cfg.velocity !== void 0) return cfg;
259
+ return { ...cfg, velocity };
185
260
  }
186
261
  function pickDirection(tx, ty, vx, vy, distanceThreshold, velocityThreshold, allowLeft, allowRight, allowUp, allowDown) {
187
262
  "worklet";
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { useMemo } from 'react';
1
+ import { useMemo, useCallback } from 'react';
2
2
  import { Gesture } from 'react-native-gesture-handler';
3
- import { useSharedValue, runOnJS, useAnimatedStyle, withSpring, withDecay } from 'react-native-reanimated';
4
- import { buildReleaseAnimation } from '@rootnative/inertia';
3
+ import { useSharedValue, runOnJS, useAnimatedStyle, withDecay } from 'react-native-reanimated';
4
+ import { buildReleaseAnimation, useNamedTransitions, resolveNamedTransition } from '@rootnative/inertia';
5
5
 
6
6
  // src/useDrag.ts
7
7
  function useDrag(options = {}) {
@@ -114,12 +114,16 @@ function applyBounds(value, min, max, elastic) {
114
114
  return value;
115
115
  }
116
116
  var DEFAULT_DIRECTIONS = ["left", "right", "up", "down"];
117
+ var DEFAULT_SNAP_BACK = { type: "spring" };
117
118
  function useSwipe(options = {}) {
118
119
  const {
119
120
  directions = DEFAULT_DIRECTIONS,
120
121
  distanceThreshold = 80,
121
122
  velocityThreshold = 800,
122
- onSwipe
123
+ releaseTransition,
124
+ onSwipe,
125
+ onCommit,
126
+ onSwipeEnd
123
127
  } = options;
124
128
  const swipeX = useSharedValue(0);
125
129
  const swipeY = useSharedValue(0);
@@ -128,6 +132,19 @@ function useSwipe(options = {}) {
128
132
  const allowRight = directions.includes("right");
129
133
  const allowUp = directions.includes("up");
130
134
  const allowDown = directions.includes("down");
135
+ const registry = useNamedTransitions();
136
+ const snapBack = useMemo(() => {
137
+ const cfg = resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK;
138
+ if (cfg.type === "decay") {
139
+ if (__DEV__) {
140
+ console.warn(
141
+ "[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
+ );
143
+ }
144
+ return DEFAULT_SNAP_BACK;
145
+ }
146
+ return cfg;
147
+ }, [releaseTransition, registry]);
131
148
  const gesture = useMemo(() => {
132
149
  const pan = Gesture.Pan().onStart(() => {
133
150
  "worklet";
@@ -157,8 +174,53 @@ function useSwipe(options = {}) {
157
174
  const velocity = isHoriz ? Math.abs(e.velocityX) : Math.abs(e.velocityY);
158
175
  runOnJS(onSwipe)(direction, { distance, velocity });
159
176
  }
160
- swipeX.value = withSpring(0);
161
- swipeY.value = withSpring(0);
177
+ let exit = void 0;
178
+ if (direction !== null && onCommit) {
179
+ exit = onCommit(direction, {
180
+ x: swipeX.value,
181
+ y: swipeY.value,
182
+ velocity: { x: e.velocityX, y: e.velocityY }
183
+ });
184
+ }
185
+ let settle;
186
+ if (direction !== null && onSwipeEnd) {
187
+ const dir = direction;
188
+ const end = onSwipeEnd;
189
+ settle = (finished) => {
190
+ runOnJS(end)(dir, { finished: finished === true });
191
+ };
192
+ }
193
+ const isHorizontalCommit = direction === "left" || direction === "right";
194
+ const settleX = isHorizontalCommit ? settle : void 0;
195
+ const settleY = isHorizontalCommit ? void 0 : settle;
196
+ const exitX = exit ? exit.x : void 0;
197
+ const exitY = exit ? exit.y : void 0;
198
+ if (exitX) {
199
+ swipeX.value = buildReleaseAnimation(
200
+ exitX,
201
+ "to" in exitX ? exitX.to : swipeX.value,
202
+ settleX
203
+ );
204
+ } else {
205
+ swipeX.value = buildReleaseAnimation(
206
+ withReleaseVelocity(snapBack, e.velocityX),
207
+ 0,
208
+ settleX
209
+ );
210
+ }
211
+ if (exitY) {
212
+ swipeY.value = buildReleaseAnimation(
213
+ exitY,
214
+ "to" in exitY ? exitY.to : swipeY.value,
215
+ settleY
216
+ );
217
+ } else {
218
+ swipeY.value = buildReleaseAnimation(
219
+ withReleaseVelocity(snapBack, e.velocityY),
220
+ 0,
221
+ settleY
222
+ );
223
+ }
162
224
  }).onFinalize(() => {
163
225
  "worklet";
164
226
  isActive.value = false;
@@ -171,7 +233,10 @@ function useSwipe(options = {}) {
171
233
  allowRight,
172
234
  allowUp,
173
235
  allowDown,
236
+ snapBack,
174
237
  onSwipe,
238
+ onCommit,
239
+ onSwipeEnd,
175
240
  swipeX,
176
241
  swipeY,
177
242
  isActive
@@ -179,7 +244,17 @@ function useSwipe(options = {}) {
179
244
  const animatedStyle = useAnimatedStyle(() => ({
180
245
  transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }]
181
246
  }));
182
- return { gesture, animatedStyle, swipeX, swipeY, isActive };
247
+ const reset = useCallback(() => {
248
+ swipeX.value = 0;
249
+ swipeY.value = 0;
250
+ }, [swipeX, swipeY]);
251
+ return { gesture, animatedStyle, swipeX, swipeY, isActive, reset };
252
+ }
253
+ function withReleaseVelocity(cfg, velocity) {
254
+ "worklet";
255
+ if (cfg.type !== "spring" && cfg.type !== void 0) return cfg;
256
+ if (cfg.velocity !== void 0) return cfg;
257
+ return { ...cfg, velocity };
183
258
  }
184
259
  function pickDirection(tx, ty, vx, vy, distanceThreshold, velocityThreshold, allowLeft, allowRight, allowUp, allowDown) {
185
260
  "worklet";
package/llms.txt CHANGED
@@ -100,7 +100,7 @@ Internally this routes through `buildReleaseAnimation` exported from `@rootnativ
100
100
 
101
101
  ## `useSwipe`
102
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).
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. By default the position animates back to zero whether or not it commits the right shape for swipe-to-delete, where the row returns and the consumer removes it. For a card deck, return a commit-exit transition from `onCommit` so the committed card continues off screen instead (see below).
104
104
 
105
105
  ```tsx
106
106
  const swipe = useSwipe({
@@ -121,15 +121,95 @@ return (
121
121
  )
122
122
  ```
123
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. |
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
+ | `releaseTransition` | `SnapBackTransition \| TransitionName` | default spring | Transition for the snap-back to zero see below. |
130
+ | `onSwipe` | `(direction, { distance, velocity }) => void` | none | JS-thread callback at release. Direction is one of the allowed values. |
131
+ | `onCommit` | `(direction, info) => ReleaseResult \| void` (worklet) | none | UI-thread commit-exit transition — see below. |
132
+ | `onSwipeEnd` | `(direction, { finished }) => void` | none | JS-thread callback when a committed swipe's release animation settles. |
130
133
 
131
134
  The dominant axis (whichever of \|tx\|, \|ty\| is larger at release) decides which direction is checked.
132
135
 
136
+ Returns `{ gesture, animatedStyle, swipeX, swipeY, isActive, reset }`. `reset()` snaps both shared values back to zero with no animation — useful after a commit exit when the next card takes over the same mounted component (a keyed remount gets fresh values and doesn't need it).
137
+
138
+ ### Snap-back transition (`releaseTransition`)
139
+
140
+ The snap-back to zero defaults to the library default spring. Pass `releaseTransition` to use your own — inline, or as a `TransitionName` registered on the nearest [`<MotionConfig transitions={...}>`](./motion-config#named-transitions), which is how the reset joins a design-token motion system:
141
+
142
+ ```tsx
143
+ const swipe = useSwipe({
144
+ directions: ['left', 'right'],
145
+ releaseTransition: 'spatial-default', // or { type: 'spring', tension: 220 }
146
+ onSwipe: (direction) => archive(direction),
147
+ })
148
+ ```
149
+
150
+ The release velocity is passed into a spring automatically (unless the config sets `velocity` itself), so a flick that stops short of the threshold resets with the momentum it had instead of resetting as if the finger had been still.
151
+
152
+ `SnapBackTransition` is spring / timing / no-animation — decay is excluded, because the snap-back always targets zero and decay has no target. A registered name that resolves to a decay config dev-warns and falls back to the default spring.
153
+
154
+ ### Commit exit (`onCommit` + `onSwipeEnd`)
155
+
156
+ The snap-back is right for swipe-to-delete, but a card deck needs the opposite: a **committed** card must continue in the swipe direction and leave the screen. Return per-axis release transitions from `onCommit` to run instead of the snap-back, and advance the deck from `onSwipeEnd` when the exit settles:
157
+
158
+ ```tsx
159
+ const swipe = useSwipe({
160
+ directions: ['left', 'right'],
161
+ onCommit: (direction, info) => {
162
+ 'worklet'
163
+ return {
164
+ x: {
165
+ type: 'spring',
166
+ to: direction === 'right' ? 500 : -500,
167
+ velocity: info.velocity.x, // continue the finger's momentum
168
+ },
169
+ }
170
+ },
171
+ onSwipeEnd: (direction, { finished }) => {
172
+ if (finished) advanceDeck(direction)
173
+ },
174
+ })
175
+ ```
176
+
177
+ `onCommit` runs as a **worklet** on the UI thread (author it with the `'worklet'` directive), fires only when the gesture commits, and receives the same `info` shape as `useDrag`'s `onRelease` — final translation plus release velocity. The return shape is the same `ReleaseResult` too, with one difference: an omitted axis (or a `void` return) snaps back to zero as usual, because zero is this hook's resting state.
178
+
179
+ `onSwipeEnd` fires on the JS thread when the committed swipe's release animation settles — the commit exit if one ran, the snap-back otherwise. That is the "card is gone, the next one takes over" moment that `onSwipe` (which fires at release) cannot give you. `finished` is `false` when the animation was interrupted, for example by a new gesture. It does not fire for a release that did not commit.
180
+
181
+ Composition: `onSwipe` (JS side effects at release) → `onCommit` (UI-thread exit decision) → `onSwipeEnd` (JS side effects at settle). Use whichever subset the interaction needs.
182
+
183
+ ### `animatedStyle` owns the whole `transform` key
184
+
185
+ `animatedStyle` returns a complete `transform` array. In React Native `transform` is a **single** style key, so a second style carrying its own `transform` **replaces** this one rather than merging with it. Stacking a rotation beside it — the first thing almost every card deck does — silently drops the translation, and the card rotates without following the finger:
186
+
187
+ ```tsx
188
+ // Broken: tiltStyle's transform replaces the swipe translation.
189
+ <Motion.View style={[swipe.animatedStyle, tiltStyle]} />
190
+ ```
191
+
192
+ Pairing `animatedStyle` with a static style is safe (`[styles.card, swipe.animatedStyle]` above) because a static style declares no `transform`. The conflict only appears when the second style is itself a transform.
193
+
194
+ Build one style from `swipeX` instead of stacking two:
195
+
196
+ ```tsx
197
+ const cardStyle = useInterpolatedStyle(swipe.swipeX, {
198
+ translateX: [-200, 0, 200],
199
+ rotate: ['-12deg', '0deg', '12deg'],
200
+ })
201
+
202
+ return (
203
+ <GestureDetector gesture={swipe.gesture}>
204
+ <Motion.View style={[styles.card, cardStyle]}>{children}</Motion.View>
205
+ </GestureDetector>
206
+ )
207
+ ```
208
+
209
+ Nesting a second `Motion.View` also works — one view per transform source — when the transforms come from genuinely independent drivers.
210
+
211
+ The same applies to `useDrag` and `usePan`: each owns its `transform` key too.
212
+
133
213
  ## `usePan`
134
214
 
135
215
  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`.
@@ -159,11 +239,11 @@ For rubber-banded constraints, prefer `useDrag({ elastic })` — `usePan` hard-c
159
239
 
160
240
  ## When to pick which
161
241
 
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. |
242
+ | Gesture | Snap back on release? | Momentum on release? | Typical UX |
243
+ | ---------- | --------------------------------------- | ---------------------------- | -------------------------------------------------- |
244
+ | `useDrag` | No — stays where left | No | Move-to-position, sliders, sortable handles. |
245
+ | `useSwipe` | Yes — unless `onCommit` returns an exit | No (springs back) | Swipe-to-delete rows, card stacks, dismiss sheets. |
246
+ | `usePan` | No — stays where left | Yes — coasts via `withDecay` | Maps, zoomable canvases, large-image navigation. |
167
247
 
168
248
  ## Scope: touch + mouse only
169
249
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia-gestures",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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.7",
53
+ "@rootnative/inertia": ">=0.0.8",
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.7"
72
+ "@rootnative/inertia": "0.0.8"
73
73
  },
74
74
  "publishConfig": {
75
75
  "access": "public"
package/src/index.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * - `useDrag` — one- or two-axis drag with optional constraints and
7
7
  * rubber-band elasticity.
8
8
  * - `useSwipe` — directional commit-or-snap-back gesture (distance + velocity
9
- * thresholds).
9
+ * thresholds), with a configurable snap-back and an optional commit-exit
10
+ * path for card decks.
10
11
  * - `usePan` — camera-style pan with momentum on release.
11
12
  */
12
13
  export { useDrag } from './useDrag'
@@ -21,4 +22,5 @@ export type {
21
22
  ReleaseInfo,
22
23
  ReleaseResult,
23
24
  ReleaseTransition,
25
+ SnapBackTransition,
24
26
  } from './types'
package/src/types.ts CHANGED
@@ -49,12 +49,27 @@ export type ReleaseTransition =
49
49
  /**
50
50
  * Per-axis release transitions returned by `onRelease`. Omit an axis to leave
51
51
  * its SV where it landed (no release animation on that axis).
52
+ *
53
+ * `useSwipe`'s `onCommit` returns the same shape with one difference in the
54
+ * omit semantics: an omitted axis snaps back to zero (the hook's resting
55
+ * state) rather than staying where it landed.
52
56
  */
53
57
  export interface ReleaseResult {
54
58
  x?: ReleaseTransition
55
59
  y?: ReleaseTransition
56
60
  }
57
61
 
62
+ /**
63
+ * Transition shapes `useSwipe` accepts for its snap-back (the
64
+ * `releaseTransition` option). The target is always zero, so decay — which
65
+ * has no target — is excluded; a registered name that resolves to a decay
66
+ * config dev-warns and falls back to the default spring.
67
+ */
68
+ export type SnapBackTransition =
69
+ | SpringTransition
70
+ | TimingTransition
71
+ | NoAnimationTransition
72
+
58
73
  /**
59
74
  * Configuration for `useDrag`. All fields are optional; the defaults give an
60
75
  * unconstrained two-axis drag with no elasticity.
package/src/useDrag.ts CHANGED
@@ -13,8 +13,14 @@ export interface UseDragResult {
13
13
  /** Pan gesture to pass to a `<GestureDetector>`. */
14
14
  gesture: PanGesture
15
15
  /**
16
- * Animated style fragment (a single `transform` entry) to stack onto the
17
- * dragged Motion primitive's `style` prop. Stable across renders.
16
+ * Animated style fragment for the dragged Motion primitive's `style` prop.
17
+ * Stable across renders.
18
+ *
19
+ * This owns the whole `transform` key. Do not stack a second transform style
20
+ * beside it — `transform` is one key in React Native, so the later style
21
+ * replaces this one instead of merging, and the drag silently stops
22
+ * following the finger. Nest another animated view, or build one style from
23
+ * `dragX` / `dragY` with `useInterpolatedStyle`, to add a transform.
18
24
  */
19
25
  animatedStyle: ReturnType<typeof useAnimatedStyle>
20
26
  /** Current x translation in pixels. UI-thread shared value. */
package/src/usePan.ts CHANGED
@@ -34,7 +34,12 @@ export interface PanOptions {
34
34
  export interface UsePanResult {
35
35
  /** Pan gesture to pass to a `<GestureDetector>`. */
36
36
  gesture: PanGesture
37
- /** Stable animated `transform` style. */
37
+ /**
38
+ * Stable animated `transform` style. This owns the whole `transform` key —
39
+ * a second transform style in the same array replaces it rather than merging
40
+ * with it. Nest another animated view, or build one style from `panX` /
41
+ * `panY` with `useInterpolatedStyle`, to add a transform of your own.
42
+ */
38
43
  animatedStyle: ReturnType<typeof useAnimatedStyle>
39
44
  /** Live x translation, persistent across gestures. */
40
45
  panX: SharedValue<number>
package/src/useSwipe.ts CHANGED
@@ -1,12 +1,21 @@
1
- import { useMemo } from 'react'
1
+ import { useCallback, useMemo } from 'react'
2
2
  import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
3
  import {
4
4
  runOnJS,
5
5
  useAnimatedStyle,
6
6
  useSharedValue,
7
- withSpring,
8
7
  type SharedValue,
9
8
  } from 'react-native-reanimated'
9
+ import {
10
+ buildReleaseAnimation,
11
+ resolveNamedTransition,
12
+ useNamedTransitions,
13
+ type TransitionConfig,
14
+ type TransitionName,
15
+ } from '@rootnative/inertia'
16
+ import type { ReleaseInfo, ReleaseResult, SnapBackTransition } from './types'
17
+
18
+ declare const __DEV__: boolean
10
19
 
11
20
  export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
12
21
 
@@ -28,6 +37,17 @@ export interface SwipeOptions {
28
37
  * to `800`.
29
38
  */
30
39
  velocityThreshold?: number
40
+ /**
41
+ * Transition for the snap-back to zero, given inline or as a registered
42
+ * `TransitionName` from the nearest `<MotionConfig transitions={...}>`.
43
+ * Defaults to the library default spring. The release velocity is passed
44
+ * into a spring automatically (unless the config sets `velocity` itself),
45
+ * so a flick that stops short of the threshold resets with the momentum it
46
+ * had. Decay is excluded — the snap-back always targets zero, and decay has
47
+ * no target; a name that resolves to a decay config dev-warns and falls
48
+ * back to the default spring.
49
+ */
50
+ releaseTransition?: SnapBackTransition | TransitionName
31
51
  /**
32
52
  * Fired on the JS thread when the gesture commits in an allowed direction.
33
53
  */
@@ -35,6 +55,47 @@ export interface SwipeOptions {
35
55
  direction: SwipeDirection,
36
56
  info: { distance: number; velocity: number },
37
57
  ) => void
58
+ /**
59
+ * UI-thread callback fired when the gesture commits, before any release
60
+ * animation starts. Return per-axis release transitions to run **instead
61
+ * of** the snap-back — the commit-exit path a card deck needs, where the
62
+ * committed card continues in the swipe direction and leaves the screen:
63
+ *
64
+ * ```ts
65
+ * onCommit: (direction, info) => {
66
+ * 'worklet'
67
+ * return {
68
+ * x: {
69
+ * type: 'spring',
70
+ * to: direction === 'right' ? 500 : -500,
71
+ * velocity: info.velocity.x,
72
+ * },
73
+ * }
74
+ * }
75
+ * ```
76
+ *
77
+ * An omitted axis (or a `void` return) snaps back to zero as usual. This
78
+ * callback runs as a worklet so the release velocity stays on the UI
79
+ * thread — author it with the `'worklet'` directive at the top of the body.
80
+ *
81
+ * Composes with `onSwipe`: both fire on commit. `onCommit` picks the
82
+ * release animation on the UI thread; `onSwipe` is for JS-thread side
83
+ * effects. Note the shared values stay at the exit target afterwards — call
84
+ * `reset()` when the next card takes over without a remount.
85
+ */
86
+ onCommit?: (
87
+ direction: SwipeDirection,
88
+ info: ReleaseInfo,
89
+ ) => ReleaseResult | void
90
+ /**
91
+ * Fired on the JS thread when a committed swipe's release animation
92
+ * settles — the commit exit from `onCommit` if one ran, the snap-back
93
+ * otherwise. This is the "card is gone, advance the deck" moment that
94
+ * `onSwipe` (which fires at release) cannot give you. `finished` is `false`
95
+ * when the animation was interrupted (for example by a new gesture). Does
96
+ * not fire for a release that did not commit.
97
+ */
98
+ onSwipeEnd?: (direction: SwipeDirection, info: { finished: boolean }) => void
38
99
  }
39
100
 
40
101
  export interface UseSwipeResult {
@@ -42,8 +103,16 @@ export interface UseSwipeResult {
42
103
  gesture: PanGesture
43
104
  /**
44
105
  * Animated style fragment exposing live translation while the gesture is
45
- * active. Snaps back to `{ 0, 0 }` after release (whether or not the swipe
46
- * committed) via a default spring.
106
+ * active. After release it snaps back to `{ 0, 0 }` via `releaseTransition`
107
+ * (default spring), unless a committed swipe's `onCommit` returned a
108
+ * commit-exit transition for that axis.
109
+ *
110
+ * This owns the whole `transform` style key. `transform` is one key in React
111
+ * Native, so a second style in the same array **replaces** this array rather
112
+ * than merging with it — `style={[swipe.animatedStyle, tiltStyle]}` silently
113
+ * drops the translation and only the tilt runs. To add a transform of your
114
+ * own, nest another animated view, or build one style from `swipeX` /
115
+ * `swipeY` yourself with `useInterpolatedStyle`.
47
116
  */
48
117
  animatedStyle: ReturnType<typeof useAnimatedStyle>
49
118
  /** Live x translation. */
@@ -52,17 +121,29 @@ export interface UseSwipeResult {
52
121
  swipeY: SharedValue<number>
53
122
  /** True while the user is actively swiping. */
54
123
  isActive: SharedValue<boolean>
124
+ /**
125
+ * Snap both shared values back to zero with no animation, cancelling
126
+ * anything in flight. After a commit exit the values stay at the exit
127
+ * target — call this when the next card takes over the same mounted
128
+ * component. A keyed remount gets fresh values and doesn't need it.
129
+ */
130
+ reset: () => void
55
131
  }
56
132
 
57
133
  const DEFAULT_DIRECTIONS: SwipeDirection[] = ['left', 'right', 'up', 'down']
58
134
 
135
+ const DEFAULT_SNAP_BACK: TransitionConfig = { type: 'spring' }
136
+
59
137
  /**
60
138
  * Directional commit-or-snap-back gesture. Tracks live translation while the
61
139
  * user drags and fires `onSwipe(direction)` on release if either the distance
62
- * or velocity threshold is exceeded in an allowed direction. The position
63
- * shared values always animate back to zero — the consumer is responsible
64
- * for whatever side effect the commit drives (delete a row, dismiss a sheet,
65
- * etc.).
140
+ * or velocity threshold is exceeded in an allowed direction.
141
+ *
142
+ * By default the position shared values animate back to zero whether or not
143
+ * the swipe commits — the right shape for swipe-to-delete, where the row
144
+ * returns and the consumer removes it. For a card deck, return a commit-exit
145
+ * transition from `onCommit` so the committed card continues off screen, and
146
+ * advance the deck from `onSwipeEnd` when it settles.
66
147
  *
67
148
  * Usage:
68
149
  * ```tsx
@@ -76,13 +157,32 @@ const DEFAULT_DIRECTIONS: SwipeDirection[] = ['left', 'right', 'up', 'down']
76
157
  * </GestureDetector>
77
158
  * )
78
159
  * ```
160
+ *
161
+ * `animatedStyle` owns the whole `transform` key, so it does not compose with
162
+ * a second transform style. To add rotation — the usual card-deck shape —
163
+ * build one style from `swipeX` instead of stacking two:
164
+ *
165
+ * ```tsx
166
+ * const cardStyle = useInterpolatedStyle(swipe.swipeX, {
167
+ * translateX: [-200, 0, 200],
168
+ * rotate: ['-12deg', '0deg', '12deg'],
169
+ * })
170
+ * return (
171
+ * <GestureDetector gesture={swipe.gesture}>
172
+ * <Motion.View style={cardStyle}>...</Motion.View>
173
+ * </GestureDetector>
174
+ * )
175
+ * ```
79
176
  */
80
177
  export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
81
178
  const {
82
179
  directions = DEFAULT_DIRECTIONS,
83
180
  distanceThreshold = 80,
84
181
  velocityThreshold = 800,
182
+ releaseTransition,
85
183
  onSwipe,
184
+ onCommit,
185
+ onSwipeEnd,
86
186
  } = options
87
187
 
88
188
  const swipeX = useSharedValue(0)
@@ -94,6 +194,25 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
94
194
  const allowUp = directions.includes('up')
95
195
  const allowDown = directions.includes('down')
96
196
 
197
+ // Resolve a named snap-back on the JS thread, at the nearest provider —
198
+ // the worklet captures the resolved plain config.
199
+ const registry = useNamedTransitions()
200
+ const snapBack = useMemo(() => {
201
+ const cfg =
202
+ resolveNamedTransition(releaseTransition, registry) ?? DEFAULT_SNAP_BACK
203
+ if (cfg.type === 'decay') {
204
+ if (__DEV__) {
205
+ console.warn(
206
+ '[inertia] useSwipe `releaseTransition` resolved to a decay ' +
207
+ 'transition, which has no target and cannot snap back to zero — ' +
208
+ 'falling back to the default spring.',
209
+ )
210
+ }
211
+ return DEFAULT_SNAP_BACK
212
+ }
213
+ return cfg
214
+ }, [releaseTransition, registry])
215
+
97
216
  const gesture = useMemo(() => {
98
217
  const pan = Gesture.Pan()
99
218
  .onStart(() => {
@@ -130,8 +249,63 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
130
249
  : Math.abs(e.velocityY)
131
250
  runOnJS(onSwipe)(direction, { distance, velocity })
132
251
  }
133
- swipeX.value = withSpring(0)
134
- swipeY.value = withSpring(0)
252
+
253
+ // A commit may return per-axis exit transitions to run instead of
254
+ // the snap-back; any axis it leaves out snaps back as usual.
255
+ let exit: ReleaseResult | void = undefined
256
+ if (direction !== null && onCommit) {
257
+ exit = onCommit(direction, {
258
+ x: swipeX.value,
259
+ y: swipeY.value,
260
+ velocity: { x: e.velocityX, y: e.velocityY },
261
+ })
262
+ }
263
+
264
+ // `onSwipeEnd` rides the settle callback of the swipe axis — the one
265
+ // animation whose end means "the card has arrived".
266
+ let settle: ((finished?: boolean) => void) | undefined
267
+ if (direction !== null && onSwipeEnd) {
268
+ const dir = direction
269
+ const end = onSwipeEnd
270
+ settle = (finished?: boolean) => {
271
+ runOnJS(end)(dir, { finished: finished === true })
272
+ }
273
+ }
274
+ const isHorizontalCommit = direction === 'left' || direction === 'right'
275
+ const settleX = isHorizontalCommit ? settle : undefined
276
+ const settleY = isHorizontalCommit ? undefined : settle
277
+
278
+ const exitX = exit ? exit.x : undefined
279
+ const exitY = exit ? exit.y : undefined
280
+ // Decay ignores its target (it has no `to`); the other types animate
281
+ // to `to`. The snap-back injects the release velocity so a spring
282
+ // reset continues the finger's momentum.
283
+ if (exitX) {
284
+ swipeX.value = buildReleaseAnimation(
285
+ exitX,
286
+ 'to' in exitX ? exitX.to : swipeX.value,
287
+ settleX,
288
+ ) as unknown as number
289
+ } else {
290
+ swipeX.value = buildReleaseAnimation(
291
+ withReleaseVelocity(snapBack, e.velocityX),
292
+ 0,
293
+ settleX,
294
+ ) as unknown as number
295
+ }
296
+ if (exitY) {
297
+ swipeY.value = buildReleaseAnimation(
298
+ exitY,
299
+ 'to' in exitY ? exitY.to : swipeY.value,
300
+ settleY,
301
+ ) as unknown as number
302
+ } else {
303
+ swipeY.value = buildReleaseAnimation(
304
+ withReleaseVelocity(snapBack, e.velocityY),
305
+ 0,
306
+ settleY,
307
+ ) as unknown as number
308
+ }
135
309
  })
136
310
  .onFinalize(() => {
137
311
  'worklet'
@@ -145,7 +319,10 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
145
319
  allowRight,
146
320
  allowUp,
147
321
  allowDown,
322
+ snapBack,
148
323
  onSwipe,
324
+ onCommit,
325
+ onSwipeEnd,
149
326
  swipeX,
150
327
  swipeY,
151
328
  isActive,
@@ -155,7 +332,30 @@ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
155
332
  transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }],
156
333
  }))
157
334
 
158
- return { gesture, animatedStyle, swipeX, swipeY, isActive }
335
+ const reset = useCallback(() => {
336
+ swipeX.value = 0
337
+ swipeY.value = 0
338
+ }, [swipeX, swipeY])
339
+
340
+ return { gesture, animatedStyle, swipeX, swipeY, isActive, reset }
341
+ }
342
+
343
+ /**
344
+ * Inject the release velocity into a spring snap-back so the reset continues
345
+ * the finger's momentum. Leaves every other transition type — and a spring
346
+ * whose config already sets `velocity` — untouched.
347
+ *
348
+ * Worklet — runs on the UI thread inside the pan handler.
349
+ */
350
+ function withReleaseVelocity(
351
+ cfg: TransitionConfig,
352
+ velocity: number,
353
+ ): TransitionConfig {
354
+ 'worklet'
355
+ // An omitted `type` means spring — the library default everywhere.
356
+ if (cfg.type !== 'spring' && cfg.type !== undefined) return cfg
357
+ if (cfg.velocity !== undefined) return cfg
358
+ return { ...cfg, velocity }
159
359
  }
160
360
 
161
361
  /**