@rootnative/inertia-gestures 0.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }