@onlynative/inertia-gestures 0.0.1-alpha.1

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/src/useDrag.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { useMemo } from 'react'
2
+ import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
+ import {
4
+ runOnJS,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ type SharedValue,
8
+ } from 'react-native-reanimated'
9
+ import type { DragConstraints, DragOptions } from './types'
10
+
11
+ export interface UseDragResult {
12
+ /** Pan gesture to pass to a `<GestureDetector>`. */
13
+ gesture: PanGesture
14
+ /**
15
+ * Animated style fragment (a single `transform` entry) to stack onto the
16
+ * dragged Motion primitive's `style` prop. Stable across renders.
17
+ */
18
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
19
+ /** Current x translation in pixels. UI-thread shared value. */
20
+ dragX: SharedValue<number>
21
+ /** Current y translation in pixels. UI-thread shared value. */
22
+ dragY: SharedValue<number>
23
+ /** True while the gesture is active. */
24
+ isDragging: SharedValue<boolean>
25
+ }
26
+
27
+ /**
28
+ * Drag a Motion primitive with `react-native-gesture-handler`'s pan gesture.
29
+ *
30
+ * The hook owns a pair of shared values (`dragX`, `dragY`) and a `Pan`
31
+ * gesture that updates them on the UI thread. The returned `animatedStyle`
32
+ * is a self-contained `transform: [{ translateX }, { translateY }]` fragment;
33
+ * stack it onto the dragged component without colliding with Motion's own
34
+ * `animate` transforms.
35
+ *
36
+ * Usage:
37
+ * ```tsx
38
+ * const drag = useDrag({ axis: 'x', constraints: { left: -100, right: 100 } })
39
+ * return (
40
+ * <GestureDetector gesture={drag.gesture}>
41
+ * <Motion.View style={drag.animatedStyle} />
42
+ * </GestureDetector>
43
+ * )
44
+ * ```
45
+ */
46
+ export function useDrag(options: DragOptions = {}): UseDragResult {
47
+ const {
48
+ axis = 'both',
49
+ constraints,
50
+ elastic = 0,
51
+ onDragStart,
52
+ onDragEnd,
53
+ } = options
54
+
55
+ const dragX = useSharedValue(0)
56
+ const dragY = useSharedValue(0)
57
+ const startX = useSharedValue(0)
58
+ const startY = useSharedValue(0)
59
+ const isDragging = useSharedValue(false)
60
+
61
+ // Snapshot scalars into local consts so the Pan handlers (worklets) capture
62
+ // primitives, not the closing `options` object — a fresh `options` literal
63
+ // each render would otherwise force a new gesture identity.
64
+ const lockX = axis !== 'y'
65
+ const lockY = axis !== 'x'
66
+ const left = constraints?.left
67
+ const right = constraints?.right
68
+ const top = constraints?.top
69
+ const bottom = constraints?.bottom
70
+ const elasticCoef = elastic
71
+
72
+ const gesture = useMemo(() => {
73
+ const pan = Gesture.Pan()
74
+ .onStart(() => {
75
+ 'worklet'
76
+ startX.value = dragX.value
77
+ startY.value = dragY.value
78
+ isDragging.value = true
79
+ if (onDragStart) runOnJS(onDragStart)()
80
+ })
81
+ .onUpdate((e) => {
82
+ 'worklet'
83
+ if (lockX) {
84
+ dragX.value = applyBounds(
85
+ startX.value + e.translationX,
86
+ left,
87
+ right,
88
+ elasticCoef,
89
+ )
90
+ }
91
+ if (lockY) {
92
+ dragY.value = applyBounds(
93
+ startY.value + e.translationY,
94
+ top,
95
+ bottom,
96
+ elasticCoef,
97
+ )
98
+ }
99
+ })
100
+ .onEnd((e) => {
101
+ 'worklet'
102
+ isDragging.value = false
103
+ if (onDragEnd) {
104
+ const x = dragX.value
105
+ const y = dragY.value
106
+ const vx = e.velocityX
107
+ const vy = e.velocityY
108
+ runOnJS(onDragEnd)({ x, y, velocity: { x: vx, y: vy } })
109
+ }
110
+ })
111
+ return pan
112
+ }, [
113
+ lockX,
114
+ lockY,
115
+ left,
116
+ right,
117
+ top,
118
+ bottom,
119
+ elasticCoef,
120
+ onDragStart,
121
+ onDragEnd,
122
+ dragX,
123
+ dragY,
124
+ startX,
125
+ startY,
126
+ isDragging,
127
+ ])
128
+
129
+ const animatedStyle = useAnimatedStyle(() => ({
130
+ transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
131
+ }))
132
+
133
+ return { gesture, animatedStyle, dragX, dragY, isDragging }
134
+ }
135
+
136
+ /**
137
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot beyond a
138
+ * bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
139
+ * feel. `min` / `max` may be `undefined` to leave that side unbounded.
140
+ *
141
+ * Worklet — runs on the UI thread inside the pan handler.
142
+ */
143
+ function applyBounds(
144
+ value: number,
145
+ min: number | undefined,
146
+ max: number | undefined,
147
+ elastic: number,
148
+ ): number {
149
+ 'worklet'
150
+ if (min !== undefined && value < min) {
151
+ return elastic > 0 ? min + (value - min) * elastic : min
152
+ }
153
+ if (max !== undefined && value > max) {
154
+ return elastic > 0 ? max + (value - max) * elastic : max
155
+ }
156
+ return value
157
+ }
158
+
159
+ export type { DragConstraints, DragOptions }
package/src/usePan.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { useMemo } from 'react'
2
+ import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
+ import {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withDecay,
7
+ type SharedValue,
8
+ } from 'react-native-reanimated'
9
+ import type { DragConstraints } from './types'
10
+
11
+ export interface PanOptions {
12
+ /**
13
+ * Translation bounds. Each side is optional; out-of-bounds motion during
14
+ * the active gesture and during the post-release decay is hard-clamped
15
+ * (Reanimated's `withDecay` `clamp` param). Decay-style overshoot is not
16
+ * supported here — for rubber-banded bounds, prefer `useDrag` with
17
+ * `elastic`.
18
+ */
19
+ constraints?: DragConstraints
20
+ /**
21
+ * Deceleration applied to the post-release momentum. Higher = momentum
22
+ * dies faster. Reanimated default is `0.998`; lower values feel more
23
+ * "slippy". Range: roughly `0.99` (slow) to `0.999` (long glide).
24
+ */
25
+ deceleration?: number
26
+ /**
27
+ * Disable the post-release momentum entirely. Defaults to `false` — pan
28
+ * coasts after release. Set to `true` for a hard stop on release (drag-like
29
+ * behavior).
30
+ */
31
+ disableMomentum?: boolean
32
+ }
33
+
34
+ export interface UsePanResult {
35
+ /** Pan gesture to pass to a `<GestureDetector>`. */
36
+ gesture: PanGesture
37
+ /** Stable animated `transform` style. */
38
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
39
+ /** Live x translation, persistent across gestures. */
40
+ panX: SharedValue<number>
41
+ /** Live y translation, persistent across gestures. */
42
+ panY: SharedValue<number>
43
+ /** True while the user is actively panning. Decay phase reads `false`. */
44
+ isPanning: SharedValue<boolean>
45
+ }
46
+
47
+ /**
48
+ * Camera-pan-style drag with momentum on release. Translation persists
49
+ * across separate pan gestures (the next pan starts from the current
50
+ * position, not zero), and on release the translation continues to glide
51
+ * via Reanimated's `withDecay` until friction stops it.
52
+ *
53
+ * Use for map / zoom-canvas / large-image navigation. For dragging an
54
+ * element to a position with no momentum, use `useDrag` instead.
55
+ */
56
+ export function usePan(options: PanOptions = {}): UsePanResult {
57
+ const { constraints, deceleration, disableMomentum = false } = options
58
+
59
+ const panX = useSharedValue(0)
60
+ const panY = useSharedValue(0)
61
+ const startX = useSharedValue(0)
62
+ const startY = useSharedValue(0)
63
+ const isPanning = useSharedValue(false)
64
+
65
+ const left = constraints?.left
66
+ const right = constraints?.right
67
+ const top = constraints?.top
68
+ const bottom = constraints?.bottom
69
+ const decel = deceleration
70
+
71
+ const gesture = useMemo(() => {
72
+ const pan = Gesture.Pan()
73
+ .onStart(() => {
74
+ 'worklet'
75
+ startX.value = panX.value
76
+ startY.value = panY.value
77
+ isPanning.value = true
78
+ })
79
+ .onUpdate((e) => {
80
+ 'worklet'
81
+ panX.value = clamp(startX.value + e.translationX, left, right)
82
+ panY.value = clamp(startY.value + e.translationY, top, bottom)
83
+ })
84
+ .onEnd((e) => {
85
+ 'worklet'
86
+ isPanning.value = false
87
+ if (disableMomentum) return
88
+ const clampX = boundsTuple(left, right)
89
+ const clampY = boundsTuple(top, bottom)
90
+ panX.value = withDecay(decayConfig(e.velocityX, decel, clampX))
91
+ panY.value = withDecay(decayConfig(e.velocityY, decel, clampY))
92
+ })
93
+ .onFinalize(() => {
94
+ 'worklet'
95
+ isPanning.value = false
96
+ })
97
+ return pan
98
+ }, [
99
+ left,
100
+ right,
101
+ top,
102
+ bottom,
103
+ decel,
104
+ disableMomentum,
105
+ panX,
106
+ panY,
107
+ startX,
108
+ startY,
109
+ isPanning,
110
+ ])
111
+
112
+ const animatedStyle = useAnimatedStyle(() => ({
113
+ transform: [{ translateX: panX.value }, { translateY: panY.value }],
114
+ }))
115
+
116
+ return { gesture, animatedStyle, panX, panY, isPanning }
117
+ }
118
+
119
+ /**
120
+ * Hard-clamp `value` between `min` and `max`. Either bound may be undefined
121
+ * to leave that side unbounded. Worklet — UI thread.
122
+ */
123
+ function clamp(
124
+ value: number,
125
+ min: number | undefined,
126
+ max: number | undefined,
127
+ ): number {
128
+ 'worklet'
129
+ if (min !== undefined && value < min) return min
130
+ if (max !== undefined && value > max) return max
131
+ return value
132
+ }
133
+
134
+ /**
135
+ * Build the `clamp` tuple Reanimated's `withDecay` expects, or `undefined`
136
+ * when the axis is unbounded. `withDecay` requires both ends present, so a
137
+ * one-sided constraint is widened with `±Infinity`.
138
+ */
139
+ function boundsTuple(
140
+ min: number | undefined,
141
+ max: number | undefined,
142
+ ): [number, number] | undefined {
143
+ 'worklet'
144
+ if (min === undefined && max === undefined) return undefined
145
+ return [min ?? Number.NEGATIVE_INFINITY, max ?? Number.POSITIVE_INFINITY]
146
+ }
147
+
148
+ function decayConfig(
149
+ velocity: number,
150
+ deceleration: number | undefined,
151
+ clamp: [number, number] | undefined,
152
+ ) {
153
+ 'worklet'
154
+ const cfg: {
155
+ velocity: number
156
+ deceleration?: number
157
+ clamp?: [number, number]
158
+ } = {
159
+ velocity,
160
+ }
161
+ if (deceleration !== undefined) cfg.deceleration = deceleration
162
+ if (clamp !== undefined) cfg.clamp = clamp
163
+ return cfg
164
+ }
@@ -0,0 +1,195 @@
1
+ import { useMemo } from 'react'
2
+ import { Gesture, type PanGesture } from 'react-native-gesture-handler'
3
+ import {
4
+ runOnJS,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withSpring,
8
+ type SharedValue,
9
+ } from 'react-native-reanimated'
10
+
11
+ export type SwipeDirection = 'left' | 'right' | 'up' | 'down'
12
+
13
+ export interface SwipeOptions {
14
+ /**
15
+ * Allowed swipe directions. Defaults to all four. The gesture only commits
16
+ * for directions in this list — a horizontal swipe with `directions:
17
+ * ['up', 'down']` will not fire `onSwipe`.
18
+ */
19
+ directions?: SwipeDirection[]
20
+ /**
21
+ * Pixel distance threshold past which a release commits the swipe. Defaults
22
+ * to `80`.
23
+ */
24
+ distanceThreshold?: number
25
+ /**
26
+ * Velocity threshold (px/sec) past which a release commits the swipe even
27
+ * before the distance threshold is reached — flick-style gestures. Defaults
28
+ * to `800`.
29
+ */
30
+ velocityThreshold?: number
31
+ /**
32
+ * Fired on the JS thread when the gesture commits in an allowed direction.
33
+ */
34
+ onSwipe?: (
35
+ direction: SwipeDirection,
36
+ info: { distance: number; velocity: number },
37
+ ) => void
38
+ }
39
+
40
+ export interface UseSwipeResult {
41
+ /** Pan gesture to pass to a `<GestureDetector>`. */
42
+ gesture: PanGesture
43
+ /**
44
+ * 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.
47
+ */
48
+ animatedStyle: ReturnType<typeof useAnimatedStyle>
49
+ /** Live x translation. */
50
+ swipeX: SharedValue<number>
51
+ /** Live y translation. */
52
+ swipeY: SharedValue<number>
53
+ /** True while the user is actively swiping. */
54
+ isActive: SharedValue<boolean>
55
+ }
56
+
57
+ const DEFAULT_DIRECTIONS: SwipeDirection[] = ['left', 'right', 'up', 'down']
58
+
59
+ /**
60
+ * Directional commit-or-snap-back gesture. Tracks live translation while the
61
+ * 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.).
66
+ *
67
+ * Usage:
68
+ * ```tsx
69
+ * const swipe = useSwipe({
70
+ * directions: ['left'],
71
+ * onSwipe: (dir) => deleteRow(),
72
+ * })
73
+ * return (
74
+ * <GestureDetector gesture={swipe.gesture}>
75
+ * <Motion.View style={swipe.animatedStyle}>...</Motion.View>
76
+ * </GestureDetector>
77
+ * )
78
+ * ```
79
+ */
80
+ export function useSwipe(options: SwipeOptions = {}): UseSwipeResult {
81
+ const {
82
+ directions = DEFAULT_DIRECTIONS,
83
+ distanceThreshold = 80,
84
+ velocityThreshold = 800,
85
+ onSwipe,
86
+ } = options
87
+
88
+ const swipeX = useSharedValue(0)
89
+ const swipeY = useSharedValue(0)
90
+ const isActive = useSharedValue(false)
91
+
92
+ const allowLeft = directions.includes('left')
93
+ const allowRight = directions.includes('right')
94
+ const allowUp = directions.includes('up')
95
+ const allowDown = directions.includes('down')
96
+
97
+ const gesture = useMemo(() => {
98
+ const pan = Gesture.Pan()
99
+ .onStart(() => {
100
+ 'worklet'
101
+ isActive.value = true
102
+ })
103
+ .onUpdate((e) => {
104
+ 'worklet'
105
+ swipeX.value = e.translationX
106
+ swipeY.value = e.translationY
107
+ })
108
+ .onEnd((e) => {
109
+ 'worklet'
110
+ isActive.value = false
111
+ const direction = pickDirection(
112
+ e.translationX,
113
+ e.translationY,
114
+ e.velocityX,
115
+ e.velocityY,
116
+ distanceThreshold,
117
+ velocityThreshold,
118
+ allowLeft,
119
+ allowRight,
120
+ allowUp,
121
+ allowDown,
122
+ )
123
+ if (direction !== null && onSwipe) {
124
+ const isHoriz = direction === 'left' || direction === 'right'
125
+ const distance = isHoriz
126
+ ? Math.abs(e.translationX)
127
+ : Math.abs(e.translationY)
128
+ const velocity = isHoriz
129
+ ? Math.abs(e.velocityX)
130
+ : Math.abs(e.velocityY)
131
+ runOnJS(onSwipe)(direction, { distance, velocity })
132
+ }
133
+ swipeX.value = withSpring(0)
134
+ swipeY.value = withSpring(0)
135
+ })
136
+ .onFinalize(() => {
137
+ 'worklet'
138
+ isActive.value = false
139
+ })
140
+ return pan
141
+ }, [
142
+ distanceThreshold,
143
+ velocityThreshold,
144
+ allowLeft,
145
+ allowRight,
146
+ allowUp,
147
+ allowDown,
148
+ onSwipe,
149
+ swipeX,
150
+ swipeY,
151
+ isActive,
152
+ ])
153
+
154
+ const animatedStyle = useAnimatedStyle(() => ({
155
+ transform: [{ translateX: swipeX.value }, { translateY: swipeY.value }],
156
+ }))
157
+
158
+ return { gesture, animatedStyle, swipeX, swipeY, isActive }
159
+ }
160
+
161
+ /**
162
+ * Decide which (allowed) direction a release commits to, based on the larger
163
+ * axis of motion. Returns `null` if neither distance nor velocity threshold
164
+ * is met along the dominant axis or if that direction is disallowed.
165
+ *
166
+ * Worklet — runs on the UI thread inside the pan handler.
167
+ */
168
+ function pickDirection(
169
+ tx: number,
170
+ ty: number,
171
+ vx: number,
172
+ vy: number,
173
+ distanceThreshold: number,
174
+ velocityThreshold: number,
175
+ allowLeft: boolean,
176
+ allowRight: boolean,
177
+ allowUp: boolean,
178
+ allowDown: boolean,
179
+ ): SwipeDirection | null {
180
+ 'worklet'
181
+ const absX = Math.abs(tx)
182
+ const absY = Math.abs(ty)
183
+ if (absX >= absY) {
184
+ const meets = absX >= distanceThreshold || Math.abs(vx) >= velocityThreshold
185
+ if (!meets) return null
186
+ if (tx < 0 && allowLeft) return 'left'
187
+ if (tx > 0 && allowRight) return 'right'
188
+ return null
189
+ }
190
+ const meets = absY >= distanceThreshold || Math.abs(vy) >= velocityThreshold
191
+ if (!meets) return null
192
+ if (ty < 0 && allowUp) return 'up'
193
+ if (ty > 0 && allowDown) return 'down'
194
+ return null
195
+ }