@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/LICENSE +21 -0
- package/dist/index.d.mts +207 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +259 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +255 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +81 -0
- package/src/index.ts +18 -0
- package/src/types.ts +54 -0
- package/src/useDrag.ts +159 -0
- package/src/usePan.ts +164 -0
- package/src/useSwipe.ts +195 -0
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
|
+
}
|
package/src/useSwipe.ts
ADDED
|
@@ -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
|
+
}
|