@onlynative/inertia 0.0.1-alpha.7 → 0.0.1-alpha.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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/gestureLayer/index.d.mts +119 -0
  3. package/dist/gestureLayer/index.d.ts +119 -0
  4. package/dist/gestureLayer/index.js +1745 -0
  5. package/dist/gestureLayer/index.js.map +1 -0
  6. package/dist/gestureLayer/index.mjs +1743 -0
  7. package/dist/gestureLayer/index.mjs.map +1 -0
  8. package/dist/index.d.mts +114 -74
  9. package/dist/index.d.ts +114 -74
  10. package/dist/index.js +279 -41
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +279 -44
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/motion/Image.d.mts +1 -1
  15. package/dist/motion/Image.d.ts +1 -1
  16. package/dist/motion/Image.js +178 -4
  17. package/dist/motion/Image.js.map +1 -1
  18. package/dist/motion/Image.mjs +180 -6
  19. package/dist/motion/Image.mjs.map +1 -1
  20. package/dist/motion/Pressable.d.mts +1 -1
  21. package/dist/motion/Pressable.d.ts +1 -1
  22. package/dist/motion/Pressable.js +178 -4
  23. package/dist/motion/Pressable.js.map +1 -1
  24. package/dist/motion/Pressable.mjs +180 -6
  25. package/dist/motion/Pressable.mjs.map +1 -1
  26. package/dist/motion/ScrollView.d.mts +1 -1
  27. package/dist/motion/ScrollView.d.ts +1 -1
  28. package/dist/motion/ScrollView.js +178 -4
  29. package/dist/motion/ScrollView.js.map +1 -1
  30. package/dist/motion/ScrollView.mjs +180 -6
  31. package/dist/motion/ScrollView.mjs.map +1 -1
  32. package/dist/motion/Text.d.mts +1 -1
  33. package/dist/motion/Text.d.ts +1 -1
  34. package/dist/motion/Text.js +178 -4
  35. package/dist/motion/Text.js.map +1 -1
  36. package/dist/motion/Text.mjs +180 -6
  37. package/dist/motion/Text.mjs.map +1 -1
  38. package/dist/motion/View.d.mts +1 -1
  39. package/dist/motion/View.d.ts +1 -1
  40. package/dist/motion/View.js +178 -4
  41. package/dist/motion/View.js.map +1 -1
  42. package/dist/motion/View.mjs +180 -6
  43. package/dist/motion/View.mjs.map +1 -1
  44. package/dist/touch/index.d.mts +146 -0
  45. package/dist/touch/index.d.ts +146 -0
  46. package/dist/touch/index.js +166 -0
  47. package/dist/touch/index.js.map +1 -0
  48. package/dist/touch/index.mjs +164 -0
  49. package/dist/touch/index.mjs.map +1 -0
  50. package/dist/{types-NmNeJjo1.d.mts → types-BwyvoH2V.d.mts} +24 -4
  51. package/dist/{types-NmNeJjo1.d.ts → types-BwyvoH2V.d.ts} +24 -4
  52. package/dist/useGesture-BPPp9LhV.d.ts +84 -0
  53. package/dist/useGesture-BnBF4OtT.d.mts +84 -0
  54. package/llms.txt +12 -3
  55. package/package.json +15 -1
  56. package/src/gestureLayer/index.ts +21 -0
  57. package/src/gestureLayer/useGestureLayer.ts +285 -0
  58. package/src/index.ts +7 -0
  59. package/src/layout/index.ts +15 -0
  60. package/src/layout/sharedRegistry.ts +108 -0
  61. package/src/layout/useSharedLayout.ts +289 -0
  62. package/src/motion/createMotionComponent.tsx +60 -4
  63. package/src/touch/index.ts +18 -0
  64. package/src/touch/useTouchDrag.ts +289 -0
  65. package/src/types.ts +23 -3
  66. package/src/values/index.ts +11 -0
  67. package/src/values/useBooleanSpring.ts +33 -0
  68. package/src/values/useColorTransition.ts +72 -0
  69. package/src/values/useShadow.ts +116 -0
@@ -0,0 +1,289 @@
1
+ import {
2
+ type MutableRefObject,
3
+ type Ref,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ } from 'react'
9
+ import { type LayoutChangeEvent } from 'react-native'
10
+ import {
11
+ type SharedValue,
12
+ useSharedValue,
13
+ withSequence,
14
+ withSpring,
15
+ withTiming,
16
+ } from 'react-native-reanimated'
17
+ import { DEFAULT_SPRING, springToReanimated } from '../transitions/spring'
18
+ import { type SpringTransition, type TransitionConfig } from '../types'
19
+ import {
20
+ consumeLayout,
21
+ registerLayout,
22
+ releaseLayout,
23
+ type SharedRect,
24
+ } from './sharedRegistry'
25
+
26
+ /**
27
+ * Shared values produced by `useSharedLayout`. The worklet inside
28
+ * `createMotionComponent` appends `translateX/Y` and `scaleX/Y` transforms
29
+ * built from these so a shared-layout source rect maps onto the new
30
+ * element's transform stack without conflicting with the user's `animate`
31
+ * transforms — multiple transform entries of the same key compose
32
+ * additively (for translates) and multiplicatively (for scales), which is
33
+ * exactly the FLIP semantic.
34
+ *
35
+ * At rest the values are `(0, 0, 1, 1)` — the identity transform — so when
36
+ * no shared-layout transition is active the worklet's contribution is a
37
+ * no-op.
38
+ */
39
+ export interface SharedLayoutValues {
40
+ dx: SharedValue<number>
41
+ dy: SharedValue<number>
42
+ sx: SharedValue<number>
43
+ sy: SharedValue<number>
44
+ }
45
+
46
+ /** What the host component needs to wire into its rendered tree. */
47
+ export interface SharedLayoutBindings {
48
+ flip: SharedLayoutValues
49
+ /**
50
+ * Composite ref the consumer attaches to the rendered animated
51
+ * component. Forwards the underlying ref to the user-supplied `ref`
52
+ * (when present); kept as a stable callback so a `<Motion.*>` with no
53
+ * `layoutId` doesn't pay anything extra.
54
+ */
55
+ setRef: (node: unknown) => void
56
+ /** onLayout handler the consumer must attach to the animated component. */
57
+ onLayout: (event: LayoutChangeEvent) => void
58
+ }
59
+
60
+ /**
61
+ * Hook backing `<Motion.* layoutId="..." />`.
62
+ *
63
+ * Responsibilities, in order:
64
+ * 1. Allocate FLIP shared values (identity at rest).
65
+ * 2. Track the latest layout rect via the `onLayout` event, and push it
66
+ * into the registry under `layoutId` so this primitive can serve as
67
+ * the source for a future transition.
68
+ * 3. On unmount, hand the last measured rect to `releaseLayout` so the
69
+ * next mount with the same id can consume it.
70
+ * 4. On the first layout commit, consume any pending source rect and
71
+ * drive the FLIP shared values: snap them to the (delta, scale) that
72
+ * visually places the new element at the source position, then
73
+ * animate back to identity. `withSequence(snap, animate)` keeps the
74
+ * animation starting from the snapped delta rather than from zero.
75
+ *
76
+ * Coordinate space note: rects are in parent-relative coordinates (what
77
+ * `onLayout` reports). For the common cross-screen navigator pattern —
78
+ * both screens share an outer content container — parent-relative deltas
79
+ * match what the user perceives. Nested-parent setups where the source
80
+ * and target screens sit under containers at different screen offsets
81
+ * will be off by that offset; v1 documents this and leaves a precise
82
+ * window-coords path for v2.
83
+ *
84
+ * When `layoutId` is `undefined`, every callback is a no-op and the FLIP
85
+ * shared values stay at identity — the host's worklet then skips the
86
+ * transform contribution entirely.
87
+ */
88
+ export function useSharedLayout(options: {
89
+ layoutId: string | undefined
90
+ userRef: Ref<unknown> | undefined
91
+ transition: TransitionConfig | undefined
92
+ shouldReduceMotion: boolean
93
+ userOnLayout: ((event: LayoutChangeEvent) => void) | undefined
94
+ }): SharedLayoutBindings {
95
+ const { layoutId, userRef, transition, shouldReduceMotion, userOnLayout } =
96
+ options
97
+
98
+ const dx = useSharedValue(0)
99
+ const dy = useSharedValue(0)
100
+ const sx = useSharedValue(1)
101
+ const sy = useSharedValue(1)
102
+
103
+ // Most-recent rect for this primitive. Updated on every layout commit;
104
+ // read on unmount to populate the registry as the FLIP source for the
105
+ // next mount with the same id.
106
+ const lastRectRef = useRef<SharedRect | null>(null)
107
+
108
+ // First-layout latch — only the first measurement after a fresh mount
109
+ // can consume a source rect. Subsequent layouts (resizes, prop changes)
110
+ // refresh the registry but never re-trigger a FLIP from an old source.
111
+ const consumedRef = useRef(false)
112
+
113
+ const transitionRef = useRef(transition)
114
+ transitionRef.current = transition
115
+ const reducedMotionRef = useRef(shouldReduceMotion)
116
+ reducedMotionRef.current = shouldReduceMotion
117
+
118
+ const setRef = useCallback(
119
+ (node: unknown) => {
120
+ if (typeof userRef === 'function') userRef(node)
121
+ else if (userRef) (userRef as MutableRefObject<unknown>).current = node
122
+ },
123
+ [userRef],
124
+ )
125
+
126
+ const onLayout = useCallback(
127
+ (event: LayoutChangeEvent) => {
128
+ userOnLayout?.(event)
129
+ if (!layoutId) return
130
+
131
+ const { x, y, width, height } = event.nativeEvent.layout
132
+ const rect: SharedRect = { x, y, width, height }
133
+ lastRectRef.current = rect
134
+
135
+ // First-layout-only: read the registry BEFORE writing our own rect
136
+ // so a previously-released source rect can be consumed cleanly
137
+ // without being overwritten by the current rect first.
138
+ let source: SharedRect | undefined
139
+ if (!consumedRef.current) {
140
+ consumedRef.current = true
141
+ source = consumeLayout(layoutId)
142
+ }
143
+ registerLayout(layoutId, rect)
144
+
145
+ if (source) {
146
+ applyFlip({
147
+ source,
148
+ target: rect,
149
+ dx,
150
+ dy,
151
+ sx,
152
+ sy,
153
+ transition: transitionRef.current,
154
+ shouldReduceMotion: reducedMotionRef.current,
155
+ })
156
+ }
157
+ },
158
+ // dx/dy/sx/sy are stable refs from useSharedValue, but eslint's
159
+ // exhaustive-deps would flag them — including them is harmless and
160
+ // silences the warning.
161
+ [layoutId, userOnLayout, dx, dy, sx, sy],
162
+ )
163
+
164
+ // Reset the first-layout latch when the id changes — a new id is logically
165
+ // a new shared-element identity and should be allowed to consume a source.
166
+ useEffect(() => {
167
+ consumedRef.current = false
168
+ }, [layoutId])
169
+
170
+ // On unmount, hand the latest rect to the registry under this id so the
171
+ // next mount can consume it as a FLIP source.
172
+ useEffect(() => {
173
+ return () => {
174
+ if (!layoutId) return
175
+ const rect = lastRectRef.current
176
+ if (!rect) return
177
+ releaseLayout(layoutId, rect)
178
+ }
179
+ }, [layoutId])
180
+
181
+ return useMemo<SharedLayoutBindings>(
182
+ () => ({
183
+ flip: { dx, dy, sx, sy },
184
+ setRef,
185
+ onLayout,
186
+ }),
187
+ [dx, dy, sx, sy, setRef, onLayout],
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Snap the FLIP shared values so the new element visually overlays its
193
+ * source rect, then animate back to identity. The `withSequence(snap,
194
+ * animate)` shape is what makes the spring start from the snapped delta —
195
+ * a plain `withSpring(0)` from a zero base would animate from-zero, not
196
+ * from-source.
197
+ */
198
+ function applyFlip(args: {
199
+ source: SharedRect
200
+ target: SharedRect
201
+ dx: SharedValue<number>
202
+ dy: SharedValue<number>
203
+ sx: SharedValue<number>
204
+ sy: SharedValue<number>
205
+ transition: TransitionConfig | undefined
206
+ shouldReduceMotion: boolean
207
+ }): void {
208
+ const { source, target, dx, dy, sx, sy, transition, shouldReduceMotion } =
209
+ args
210
+
211
+ // Compute the delta that would visually place the new element at the
212
+ // source rect. The transform origin matters: RN scales around the
213
+ // element's center, so the translation needs to account for the
214
+ // center-of-source vs center-of-target offset, not the top-left offset.
215
+ const sourceCenterX = source.x + source.width / 2
216
+ const sourceCenterY = source.y + source.height / 2
217
+ const targetCenterX = target.x + target.width / 2
218
+ const targetCenterY = target.y + target.height / 2
219
+ const deltaX = sourceCenterX - targetCenterX
220
+ const deltaY = sourceCenterY - targetCenterY
221
+ // Guard against zero-sized targets (degenerate layout) — keep scale at 1
222
+ // so the element at least renders even if the FLIP isn't a perfect match.
223
+ const scaleX = target.width > 0 ? source.width / target.width : 1
224
+ const scaleY = target.height > 0 ? source.height / target.height : 1
225
+
226
+ if (shouldReduceMotion) {
227
+ // Reduced-motion: skip the visual transition entirely. The element
228
+ // appears at its natural position; the source rect is discarded.
229
+ dx.value = 0
230
+ dy.value = 0
231
+ sx.value = 1
232
+ sy.value = 1
233
+ return
234
+ }
235
+
236
+ if (transition?.type === 'no-animation') {
237
+ dx.value = 0
238
+ dy.value = 0
239
+ sx.value = 1
240
+ sy.value = 1
241
+ return
242
+ }
243
+
244
+ if (transition?.type === 'timing') {
245
+ const duration = transition.duration ?? 300
246
+ dx.value = withSequence(
247
+ withTiming(deltaX, { duration: 0 }),
248
+ withTiming(0, { duration }),
249
+ )
250
+ dy.value = withSequence(
251
+ withTiming(deltaY, { duration: 0 }),
252
+ withTiming(0, { duration }),
253
+ )
254
+ sx.value = withSequence(
255
+ withTiming(scaleX, { duration: 0 }),
256
+ withTiming(1, { duration }),
257
+ )
258
+ sy.value = withSequence(
259
+ withTiming(scaleY, { duration: 0 }),
260
+ withTiming(1, { duration }),
261
+ )
262
+ return
263
+ }
264
+
265
+ // Spring path (default, and the fallback for `'decay'` which doesn't
266
+ // have a meaningful target value for a FLIP transition).
267
+ const springCfg: SpringTransition =
268
+ transition?.type === 'spring'
269
+ ? { ...DEFAULT_SPRING, ...transition }
270
+ : { type: 'spring', ...DEFAULT_SPRING }
271
+ const springParams = springToReanimated(springCfg)
272
+
273
+ dx.value = withSequence(
274
+ withTiming(deltaX, { duration: 0 }),
275
+ withSpring(0, springParams),
276
+ )
277
+ dy.value = withSequence(
278
+ withTiming(deltaY, { duration: 0 }),
279
+ withSpring(0, springParams),
280
+ )
281
+ sx.value = withSequence(
282
+ withTiming(scaleX, { duration: 0 }),
283
+ withSpring(1, springParams),
284
+ )
285
+ sy.value = withSequence(
286
+ withTiming(scaleY, { duration: 0 }),
287
+ withSpring(1, springParams),
288
+ )
289
+ }
@@ -13,9 +13,14 @@ import Animated, {
13
13
  useSharedValue,
14
14
  type SharedValue,
15
15
  } from 'react-native-reanimated'
16
+ import { type LayoutChangeEvent } from 'react-native'
16
17
  import { useShouldReduceMotion } from '../config'
17
18
  import { isFocusVisible } from '../gestures'
18
- import { resolveLayoutTransition, type LayoutProp } from '../layout'
19
+ import {
20
+ resolveLayoutTransition,
21
+ type LayoutProp,
22
+ useSharedLayout,
23
+ } from '../layout'
19
24
  import { usePresence } from '../presence'
20
25
  import {
21
26
  isTopLevelTransition,
@@ -223,10 +228,31 @@ export function createMotionComponent<C extends ComponentType<any>>(
223
228
  controller,
224
229
  gesture,
225
230
  layout,
231
+ layoutId,
226
232
  onAnimationEnd,
227
233
  style,
234
+ onLayout: userOnLayout,
228
235
  ...rest
229
- } = props as Props & { style?: unknown; layout?: LayoutProp }
236
+ } = props as Props & {
237
+ style?: unknown
238
+ layout?: LayoutProp
239
+ layoutId?: string
240
+ onLayout?: (event: LayoutChangeEvent) => void
241
+ }
242
+
243
+ // Function-form `style={(state) => ...}` is the Pressable render-prop API.
244
+ // Inertia drives press/focus state through `gesture.*` and merges its own
245
+ // animated style; a function passed here lands inside a style array where
246
+ // the underlying component never invokes it, so the resulting styles are
247
+ // silently dropped. Throw loudly in dev rather than ship the footgun.
248
+ if (__DEV__ && typeof style === 'function') {
249
+ throw new Error(
250
+ '[inertia] `style` must be a style object or array of style objects, ' +
251
+ 'not a function. The function-form `style={(state) => ...}` Pressable ' +
252
+ 'API is not supported — use `gesture.pressed` (or `gesture.focused`, ' +
253
+ 'etc.) to drive state-dependent styling instead.',
254
+ )
255
+ }
230
256
 
231
257
  // <Presence> contract: when an ancestor flips `isPresent` to false the
232
258
  // child stays rendered until `safeToRemove` is called, giving the exit
@@ -531,6 +557,23 @@ export function createMotionComponent<C extends ComponentType<any>>(
531
557
  shouldReduceMotion,
532
558
  )
533
559
 
560
+ // Shared-element transition wiring. `useSharedLayout` allocates FLIP
561
+ // shared values (identity at rest), measures via the merged `onLayout`,
562
+ // and on first-mount snaps the FLIP transform to a source rect popped
563
+ // from the registry. The worklet below appends those entries to the
564
+ // transform array so they compose with the user's animate transforms —
565
+ // multiple `translateX` entries sum, multiple `scaleX` entries multiply,
566
+ // which is exactly the FLIP semantic.
567
+ const sharedLayout = useSharedLayout({
568
+ layoutId,
569
+ userRef: ref,
570
+ transition: isTopLevelTransition(transition) ? transition : undefined,
571
+ shouldReduceMotion,
572
+ userOnLayout,
573
+ })
574
+ const flip = sharedLayout.flip
575
+ const hasLayoutId = layoutId !== undefined
576
+
534
577
  const animatedStyle = useAnimatedStyle(() => {
535
578
  const activeKeys = activeKeysRef.current!
536
579
  const hasTransform = hasTransformRef.current
@@ -611,7 +654,19 @@ export function createMotionComponent<C extends ComponentType<any>>(
611
654
  out[key] = v
612
655
  }
613
656
  }
614
- if (hasTransform) out.transform = transform
657
+ // Shared-element FLIP transforms append after the user's transform
658
+ // entries so they compose multiplicatively in the same `transform`
659
+ // array — separate style entries with `transform` keys would
660
+ // last-write-wins, which is what we explicitly avoid here. At rest
661
+ // (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
662
+ // when no shared-element transition is active.
663
+ if (hasLayoutId) {
664
+ transform.push({ translateX: flip.dx.value })
665
+ transform.push({ translateY: flip.dy.value })
666
+ transform.push({ scaleX: flip.sx.value })
667
+ transform.push({ scaleY: flip.sy.value })
668
+ }
669
+ if (hasTransform || hasLayoutId) out.transform = transform
615
670
  if (hasShadowOffset) {
616
671
  out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
617
672
  }
@@ -654,9 +709,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
654
709
 
655
710
  return (
656
711
  <AnimatedComponent
657
- ref={ref as never}
712
+ ref={sharedLayout.setRef as never}
658
713
  {...(rest as object)}
659
714
  {...gestureHandlers}
715
+ onLayout={sharedLayout.onLayout}
660
716
  layout={layoutTransition}
661
717
  style={mergedStyle}
662
718
  />
@@ -0,0 +1,18 @@
1
+ /**
2
+ * PanResponder-backed drag hook. Lives in core because `PanResponder` is
3
+ * built into React Native — no extra peer dependency. Use this when you
4
+ * need keyboard a11y alongside drag, or when you don't want to take
5
+ * `react-native-gesture-handler` as a dependency.
6
+ *
7
+ * For pointer-only drag in a project that already uses gesture-handler,
8
+ * prefer `useDrag` from `@onlynative/inertia-gestures` — its UI-thread
9
+ * release path is more precise.
10
+ */
11
+ export { useTouchDrag } from './useTouchDrag'
12
+ export type {
13
+ TouchReleaseInfo,
14
+ TouchReleaseResult,
15
+ TouchReleaseTransition,
16
+ UseTouchDragOptions,
17
+ UseTouchDragResult,
18
+ } from './useTouchDrag'
@@ -0,0 +1,289 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ PanResponder,
4
+ type PanResponderGestureState,
5
+ type PanResponderInstance,
6
+ } from 'react-native'
7
+ import {
8
+ useAnimatedStyle,
9
+ useSharedValue,
10
+ type SharedValue,
11
+ } from 'react-native-reanimated'
12
+ import { buildReleaseAnimation } from '../transitions'
13
+ import type { TransitionConfig } from '../types'
14
+
15
+ /**
16
+ * Same drag-result shape as `useDrag` from `@onlynative/inertia-gestures`,
17
+ * minus the `gesture` field (PanResponder spreads handlers, no
18
+ * `<GestureDetector>` wrapper). The shared values + animatedStyle are
19
+ * interchangeable across both hooks; consumers can swap implementations
20
+ * without touching their `useAnimatedStyle` consumers.
21
+ */
22
+ export interface UseTouchDragResult {
23
+ /** Spread onto a `View` / `Pressable` to install the pan responder. */
24
+ panHandlers: PanResponderInstance['panHandlers']
25
+ /** Stable animated `transform` style. */
26
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
27
+ /** Live x translation, persistent across gestures. */
28
+ dragX: SharedValue<number>
29
+ /** Live y translation, persistent across gestures. */
30
+ dragY: SharedValue<number>
31
+ /** True while the gesture is active. */
32
+ isDragging: SharedValue<boolean>
33
+ }
34
+
35
+ /**
36
+ * Release transition shape for PanResponder's JS-thread `onRelease`. Mirrors
37
+ * the gesture-handler adapter's `ReleaseTransition` but with `to` typed as
38
+ * required for spring/timing/no-animation (decay omits it).
39
+ */
40
+ export type TouchReleaseTransition =
41
+ | (TransitionConfig & { type: 'spring'; to: number })
42
+ | (TransitionConfig & { type: 'timing'; to: number })
43
+ | (TransitionConfig & { type: 'decay' })
44
+ | (TransitionConfig & { type: 'no-animation'; to: number })
45
+
46
+ export interface TouchReleaseInfo {
47
+ x: number
48
+ y: number
49
+ velocity: { x: number; y: number }
50
+ }
51
+
52
+ export interface TouchReleaseResult {
53
+ x?: TouchReleaseTransition
54
+ y?: TouchReleaseTransition
55
+ }
56
+
57
+ export interface UseTouchDragOptions {
58
+ /**
59
+ * Restrict the drag to one axis. Defaults to `'both'`. When `'x'` is set
60
+ * the y-axis shared value never updates (and vice versa); velocity is
61
+ * still reported on both for `onDragEnd`.
62
+ */
63
+ axis?: 'x' | 'y' | 'both'
64
+ /**
65
+ * Travel bounds (px from resting). Each side is independently optional.
66
+ * Out-of-bounds values clamp to the limit unless `elastic > 0`.
67
+ */
68
+ constraints?: {
69
+ left?: number
70
+ right?: number
71
+ top?: number
72
+ bottom?: number
73
+ }
74
+ /**
75
+ * Rubber-band coefficient applied to overshoot past `constraints`. `0`
76
+ * (default) hard-clamps; `0.2`-`0.4` is a typical Framer-Motion feel.
77
+ */
78
+ elastic?: number
79
+ /**
80
+ * Fires when the user starts dragging. JS thread.
81
+ */
82
+ onDragStart?: () => void
83
+ /**
84
+ * Fires when the user releases or the gesture terminates. JS thread.
85
+ *
86
+ * Velocity is in px/sec to match the `@onlynative/inertia-gestures` API
87
+ * (PanResponder's native `vx` / `vy` are px/ms; the hook normalizes).
88
+ */
89
+ onDragEnd?: (info: TouchReleaseInfo) => void
90
+ /**
91
+ * Optional release-animation callback. Return per-axis release transitions
92
+ * to animate the SVs to a settled position via Inertia's transition
93
+ * resolver — spring snap-to-tick, decay with bounds, timing settle.
94
+ *
95
+ * Unlike the gesture-handler version, this callback runs on the **JS
96
+ * thread** (PanResponder is JS-only). The returned transitions still drive
97
+ * UI-thread animations via Reanimated — only the decision logic is JS-side.
98
+ */
99
+ onRelease?: (info: TouchReleaseInfo) => TouchReleaseResult | void
100
+ }
101
+
102
+ /**
103
+ * PanResponder-backed drag hook. Pointer-equivalent of `useDrag` from
104
+ * `@onlynative/inertia-gestures`, with two differences:
105
+ *
106
+ * 1. No `react-native-gesture-handler` peer dep required — PanResponder is
107
+ * built into React Native, so this lives in core.
108
+ * 2. Returns `panHandlers` to spread on a `View` / `Pressable` instead of
109
+ * a `gesture` to plug into `<GestureDetector>`.
110
+ *
111
+ * Use this when:
112
+ * - You need keyboard a11y alongside drag (a slider with arrow-key step,
113
+ * a scrollbar with `PageUp` / `PageDown`). PanResponder composes
114
+ * cleanly with `onKeyDown`; gesture-handler doesn't surface keyboard.
115
+ * - You don't want to take `react-native-gesture-handler` as a dependency
116
+ * (smaller bundle, simpler install).
117
+ *
118
+ * Skip this when:
119
+ * - You're already using `react-native-gesture-handler` elsewhere (use
120
+ * `useDrag` from `@onlynative/inertia-gestures` for consistency and
121
+ * better worklet-thread fidelity on release velocity).
122
+ * - You need momentum semantics like the gesture-handler `usePan` —
123
+ * PanResponder's release velocity is JS-thread and slightly less precise.
124
+ *
125
+ * @example
126
+ * ```tsx
127
+ * import { useTouchDrag } from '@onlynative/inertia/touch'
128
+ *
129
+ * function Slider({ ticks }: { ticks: number[] }) {
130
+ * const drag = useTouchDrag({
131
+ * axis: 'x',
132
+ * constraints: { left: 0, right: 280 },
133
+ * onRelease: (e) => {
134
+ * const snap = nearestTick(e.x, ticks)
135
+ * return { x: { type: 'spring', to: snap, velocity: e.velocity.x } }
136
+ * },
137
+ * })
138
+ *
139
+ * return (
140
+ * <Motion.View
141
+ * style={[styles.thumb, drag.animatedStyle]}
142
+ * {...drag.panHandlers}
143
+ * />
144
+ * )
145
+ * }
146
+ * ```
147
+ */
148
+ export function useTouchDrag(
149
+ options: UseTouchDragOptions = {},
150
+ ): UseTouchDragResult {
151
+ const { axis = 'both', constraints, elastic = 0 } = options
152
+
153
+ const dragX = useSharedValue(0)
154
+ const dragY = useSharedValue(0)
155
+ const startX = useSharedValue(0)
156
+ const startY = useSharedValue(0)
157
+ const isDragging = useSharedValue(false)
158
+
159
+ // Snapshot scalars into local consts so the responder callbacks close over
160
+ // primitives, not the `options` literal — a fresh `options` each render
161
+ // would otherwise force the PanResponder identity to change.
162
+ const lockX = axis !== 'y'
163
+ const lockY = axis !== 'x'
164
+ const left = constraints?.left
165
+ const right = constraints?.right
166
+ const top = constraints?.top
167
+ const bottom = constraints?.bottom
168
+ const elasticCoef = elastic
169
+ const { onDragStart, onDragEnd, onRelease } = options
170
+
171
+ const responder = useMemo(
172
+ () => buildResponder(),
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ [
175
+ lockX,
176
+ lockY,
177
+ left,
178
+ right,
179
+ top,
180
+ bottom,
181
+ elasticCoef,
182
+ onDragStart,
183
+ onDragEnd,
184
+ onRelease,
185
+ ],
186
+ )
187
+
188
+ // Hoisted out of the inline `useMemo` factory to keep the dep list readable
189
+ // and avoid re-declaring closure helpers each render.
190
+ function buildResponder(): PanResponderInstance {
191
+ const handleEnd = (g: PanResponderGestureState) => {
192
+ isDragging.value = false
193
+ const x = dragX.value
194
+ const y = dragY.value
195
+ // PanResponder velocity is px/ms; multiply to match the
196
+ // `@onlynative/inertia-gestures` API (px/sec from gesture-handler).
197
+ const vx = g.vx * 1000
198
+ const vy = g.vy * 1000
199
+ if (onRelease) {
200
+ const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
201
+ if (result) {
202
+ if (result.x && lockX) {
203
+ const toX = 'to' in result.x ? result.x.to : x
204
+ dragX.value = buildReleaseAnimation(
205
+ result.x,
206
+ toX,
207
+ ) as unknown as number
208
+ }
209
+ if (result.y && lockY) {
210
+ const toY = 'to' in result.y ? result.y.to : y
211
+ dragY.value = buildReleaseAnimation(
212
+ result.y,
213
+ toY,
214
+ ) as unknown as number
215
+ }
216
+ }
217
+ }
218
+ if (onDragEnd) onDragEnd({ x, y, velocity: { x: vx, y: vy } })
219
+ }
220
+
221
+ return PanResponder.create({
222
+ // Always claim the start so taps that turn into drags don't slip
223
+ // through to a parent ScrollView. Consumers can compose their own
224
+ // capture predicates by wrapping the returned `panHandlers`.
225
+ onStartShouldSetPanResponder: () => true,
226
+ onMoveShouldSetPanResponder: () => true,
227
+ onPanResponderGrant: () => {
228
+ startX.value = dragX.value
229
+ startY.value = dragY.value
230
+ isDragging.value = true
231
+ if (onDragStart) onDragStart()
232
+ },
233
+ onPanResponderMove: (_e, g) => {
234
+ if (lockX) {
235
+ dragX.value = applyBounds(
236
+ startX.value + g.dx,
237
+ left,
238
+ right,
239
+ elasticCoef,
240
+ )
241
+ }
242
+ if (lockY) {
243
+ dragY.value = applyBounds(
244
+ startY.value + g.dy,
245
+ top,
246
+ bottom,
247
+ elasticCoef,
248
+ )
249
+ }
250
+ },
251
+ onPanResponderRelease: (_e, g) => handleEnd(g),
252
+ onPanResponderTerminate: (_e, g) => handleEnd(g),
253
+ })
254
+ }
255
+
256
+ const animatedStyle = useAnimatedStyle(() => ({
257
+ transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
258
+ }))
259
+
260
+ return {
261
+ panHandlers: responder.panHandlers,
262
+ animatedStyle,
263
+ dragX,
264
+ dragY,
265
+ isDragging,
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
271
+ * bound is scaled by `elastic`, giving a rubber-band feel. `min` / `max`
272
+ * may be `undefined` to leave that side unbounded.
273
+ *
274
+ * JS-thread (PanResponder callbacks are JS, not worklets).
275
+ */
276
+ function applyBounds(
277
+ value: number,
278
+ min: number | undefined,
279
+ max: number | undefined,
280
+ elastic: number,
281
+ ): number {
282
+ if (min !== undefined && value < min) {
283
+ return elastic > 0 ? min + (value - min) * elastic : min
284
+ }
285
+ if (max !== undefined && value > max) {
286
+ return elastic > 0 ? max + (value - max) * elastic : max
287
+ }
288
+ return value
289
+ }