@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.
- package/README.md +1 -1
- package/dist/gestureLayer/index.d.mts +119 -0
- package/dist/gestureLayer/index.d.ts +119 -0
- package/dist/gestureLayer/index.js +1745 -0
- package/dist/gestureLayer/index.js.map +1 -0
- package/dist/gestureLayer/index.mjs +1743 -0
- package/dist/gestureLayer/index.mjs.map +1 -0
- package/dist/index.d.mts +114 -74
- package/dist/index.d.ts +114 -74
- package/dist/index.js +279 -41
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +279 -44
- package/dist/index.mjs.map +1 -1
- package/dist/motion/Image.d.mts +1 -1
- package/dist/motion/Image.d.ts +1 -1
- package/dist/motion/Image.js +178 -4
- package/dist/motion/Image.js.map +1 -1
- package/dist/motion/Image.mjs +180 -6
- package/dist/motion/Image.mjs.map +1 -1
- package/dist/motion/Pressable.d.mts +1 -1
- package/dist/motion/Pressable.d.ts +1 -1
- package/dist/motion/Pressable.js +178 -4
- package/dist/motion/Pressable.js.map +1 -1
- package/dist/motion/Pressable.mjs +180 -6
- package/dist/motion/Pressable.mjs.map +1 -1
- package/dist/motion/ScrollView.d.mts +1 -1
- package/dist/motion/ScrollView.d.ts +1 -1
- package/dist/motion/ScrollView.js +178 -4
- package/dist/motion/ScrollView.js.map +1 -1
- package/dist/motion/ScrollView.mjs +180 -6
- package/dist/motion/ScrollView.mjs.map +1 -1
- package/dist/motion/Text.d.mts +1 -1
- package/dist/motion/Text.d.ts +1 -1
- package/dist/motion/Text.js +178 -4
- package/dist/motion/Text.js.map +1 -1
- package/dist/motion/Text.mjs +180 -6
- package/dist/motion/Text.mjs.map +1 -1
- package/dist/motion/View.d.mts +1 -1
- package/dist/motion/View.d.ts +1 -1
- package/dist/motion/View.js +178 -4
- package/dist/motion/View.js.map +1 -1
- package/dist/motion/View.mjs +180 -6
- package/dist/motion/View.mjs.map +1 -1
- package/dist/touch/index.d.mts +146 -0
- package/dist/touch/index.d.ts +146 -0
- package/dist/touch/index.js +166 -0
- package/dist/touch/index.js.map +1 -0
- package/dist/touch/index.mjs +164 -0
- package/dist/touch/index.mjs.map +1 -0
- package/dist/{types-NmNeJjo1.d.mts → types-BwyvoH2V.d.mts} +24 -4
- package/dist/{types-NmNeJjo1.d.ts → types-BwyvoH2V.d.ts} +24 -4
- package/dist/useGesture-BPPp9LhV.d.ts +84 -0
- package/dist/useGesture-BnBF4OtT.d.mts +84 -0
- package/llms.txt +12 -3
- package/package.json +15 -1
- package/src/gestureLayer/index.ts +21 -0
- package/src/gestureLayer/useGestureLayer.ts +285 -0
- package/src/index.ts +7 -0
- package/src/layout/index.ts +15 -0
- package/src/layout/sharedRegistry.ts +108 -0
- package/src/layout/useSharedLayout.ts +289 -0
- package/src/motion/createMotionComponent.tsx +60 -4
- package/src/touch/index.ts +18 -0
- package/src/touch/useTouchDrag.ts +289 -0
- package/src/types.ts +23 -3
- package/src/values/index.ts +11 -0
- package/src/values/useBooleanSpring.ts +33 -0
- package/src/values/useColorTransition.ts +72 -0
- 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 {
|
|
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 & {
|
|
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
|
-
|
|
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={
|
|
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
|
+
}
|