@onlynative/inertia 0.0.1-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.
Files changed (63) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +90 -0
  4. package/dist/index.d.mts +185 -0
  5. package/dist/index.d.ts +185 -0
  6. package/dist/index.js +817 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/index.mjs +796 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/motion/Image.d.mts +12 -0
  11. package/dist/motion/Image.d.ts +12 -0
  12. package/dist/motion/Image.js +656 -0
  13. package/dist/motion/Image.js.map +1 -0
  14. package/dist/motion/Image.mjs +650 -0
  15. package/dist/motion/Image.mjs.map +1 -0
  16. package/dist/motion/Pressable.d.mts +15 -0
  17. package/dist/motion/Pressable.d.ts +15 -0
  18. package/dist/motion/Pressable.js +656 -0
  19. package/dist/motion/Pressable.js.map +1 -0
  20. package/dist/motion/Pressable.mjs +650 -0
  21. package/dist/motion/Pressable.mjs.map +1 -0
  22. package/dist/motion/ScrollView.d.mts +12 -0
  23. package/dist/motion/ScrollView.d.ts +12 -0
  24. package/dist/motion/ScrollView.js +656 -0
  25. package/dist/motion/ScrollView.js.map +1 -0
  26. package/dist/motion/ScrollView.mjs +650 -0
  27. package/dist/motion/ScrollView.mjs.map +1 -0
  28. package/dist/motion/Text.d.mts +11 -0
  29. package/dist/motion/Text.d.ts +11 -0
  30. package/dist/motion/Text.js +656 -0
  31. package/dist/motion/Text.js.map +1 -0
  32. package/dist/motion/Text.mjs +650 -0
  33. package/dist/motion/Text.mjs.map +1 -0
  34. package/dist/motion/View.d.mts +11 -0
  35. package/dist/motion/View.d.ts +11 -0
  36. package/dist/motion/View.js +656 -0
  37. package/dist/motion/View.js.map +1 -0
  38. package/dist/motion/View.mjs +650 -0
  39. package/dist/motion/View.mjs.map +1 -0
  40. package/dist/types-CmbXx-G3.d.mts +185 -0
  41. package/dist/types-CmbXx-G3.d.ts +185 -0
  42. package/llms.txt +78 -0
  43. package/package.json +120 -0
  44. package/src/config/MotionConfig.tsx +30 -0
  45. package/src/config/MotionConfigContext.ts +53 -0
  46. package/src/config/index.ts +9 -0
  47. package/src/index.ts +49 -0
  48. package/src/motion/Image.tsx +9 -0
  49. package/src/motion/Pressable.tsx +12 -0
  50. package/src/motion/ScrollView.tsx +9 -0
  51. package/src/motion/Text.tsx +8 -0
  52. package/src/motion/View.tsx +8 -0
  53. package/src/motion/createMotionComponent.tsx +850 -0
  54. package/src/motion/index.ts +26 -0
  55. package/src/presence/Presence.tsx +165 -0
  56. package/src/presence/PresenceContext.ts +28 -0
  57. package/src/presence/index.ts +6 -0
  58. package/src/transitions/easing.ts +29 -0
  59. package/src/transitions/index.ts +2 -0
  60. package/src/transitions/resolve.ts +265 -0
  61. package/src/types.ts +207 -0
  62. package/src/values/index.ts +1 -0
  63. package/src/values/useVariants.ts +60 -0
@@ -0,0 +1,850 @@
1
+ import {
2
+ type ComponentType,
3
+ forwardRef,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'
9
+ import Animated, {
10
+ runOnJS,
11
+ useAnimatedStyle,
12
+ useSharedValue,
13
+ type SharedValue,
14
+ } from 'react-native-reanimated'
15
+ import { useShouldReduceMotion } from '../config'
16
+ import { usePresence } from '../presence'
17
+ import { resolveAnimatableValue } from '../transitions'
18
+ import {
19
+ type AnimatableValue,
20
+ type AnimateStyle,
21
+ type AnimationCallbackInfo,
22
+ type GestureSubStates,
23
+ type MotionComponent,
24
+ type MotionProps,
25
+ type PerPropertyTransition,
26
+ type Transition,
27
+ type TransitionConfig,
28
+ type VariantController,
29
+ type VariantsMap,
30
+ } from '../types'
31
+
32
+ /**
33
+ * Animatable properties supported in the alpha. Expanding this set is a
34
+ * mechanical change — add the key here, decide whether it lives inside
35
+ * `transform`, and wire it through `buildAnimatedStyle` below.
36
+ */
37
+ const TRANSFORM_KEYS = [
38
+ 'translateX',
39
+ 'translateY',
40
+ 'scale',
41
+ 'scaleX',
42
+ 'scaleY',
43
+ 'rotate',
44
+ ] as const
45
+
46
+ const TOP_LEVEL_KEYS = ['opacity', 'width', 'height', 'borderRadius'] as const
47
+
48
+ /**
49
+ * Per-effect transform-group coordinator. Counts how many transform-axis
50
+ * terminal callbacks are still pending; when the last one fires, the
51
+ * factory emits a single coalesced `onAnimationEnd({ key: 'transform' })`
52
+ * instead of N per-axis callbacks. Mutated by the dispatch closure.
53
+ */
54
+ type TransformGroup = { remaining: number }
55
+
56
+ const ALL_KEYS = [...TRANSFORM_KEYS, ...TOP_LEVEL_KEYS] as const
57
+ type AnimatableKey = (typeof ALL_KEYS)[number]
58
+ type TransformKey = (typeof TRANSFORM_KEYS)[number]
59
+
60
+ const TRANSFORM_KEY_SET = new Set<AnimatableKey>(TRANSFORM_KEYS)
61
+
62
+ // Stable style object applied while a Motion primitive is mid-exit so taps
63
+ // fall through. Hoisted so every render shares the same reference and
64
+ // Reanimated's style merging treats it as a no-op when present.
65
+ const EXITING_POINTER_EVENTS_STYLE = { pointerEvents: 'none' } as const
66
+
67
+ const DEFAULT_RESTING: Record<AnimatableKey, number> = {
68
+ translateX: 0,
69
+ translateY: 0,
70
+ scale: 1,
71
+ scaleX: 1,
72
+ scaleY: 1,
73
+ rotate: 0,
74
+ opacity: 1,
75
+ width: 0,
76
+ height: 0,
77
+ borderRadius: 0,
78
+ }
79
+
80
+ const TRANSITION_KEYS = new Set([
81
+ 'type',
82
+ 'tension',
83
+ 'friction',
84
+ 'mass',
85
+ 'velocity',
86
+ 'restSpeedThreshold',
87
+ 'restDisplacementThreshold',
88
+ 'duration',
89
+ 'easing',
90
+ 'delay',
91
+ 'repeat',
92
+ 'deceleration',
93
+ 'clamp',
94
+ ])
95
+
96
+ function isTopLevelTransition(t: unknown): t is TransitionConfig {
97
+ if (t === null || typeof t !== 'object') return false
98
+ const keys = Object.keys(t as object)
99
+ if (keys.length === 0) return false
100
+ return keys.every((k) => TRANSITION_KEYS.has(k))
101
+ }
102
+
103
+ function transitionFor<S>(
104
+ prop: keyof S,
105
+ transition: Transition<S> | undefined,
106
+ ): TransitionConfig | undefined {
107
+ if (!transition) return undefined
108
+ if (isTopLevelTransition(transition)) return transition
109
+ return (transition as PerPropertyTransition<S>)[prop]
110
+ }
111
+
112
+ /**
113
+ * Factory that wraps a React Native primitive as a `Motion.*` component.
114
+ *
115
+ * The generic `C` flows through `MotionProps`, so `animate` / `initial` /
116
+ * `exit` / `transition` all infer from `C`'s `style` prop. There is no
117
+ * shared `ViewStyle & TextStyle & ImageStyle` fallback.
118
+ *
119
+ * Alpha scope: numeric properties listed in `ALL_KEYS`, applied via
120
+ * Reanimated shared values + `useAnimatedStyle`. Sequences, variants,
121
+ * gestures, color animation, and the cross-render memoization optimization
122
+ * land in later phases.
123
+ */
124
+ export function createMotionComponent<C extends ComponentType<any>>(
125
+ Component: C,
126
+ ): MotionComponent<C> {
127
+ const AnimatedComponent = Animated.createAnimatedComponent(
128
+ Component as ComponentType<any>,
129
+ )
130
+
131
+ type Props = React.ComponentProps<C> & MotionProps<React.ComponentProps<C>>
132
+
133
+ const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
134
+ const {
135
+ initial,
136
+ animate,
137
+ exit,
138
+ transition,
139
+ variants,
140
+ controller,
141
+ gesture,
142
+ onAnimationEnd,
143
+ style,
144
+ ...rest
145
+ } = props as Props & { style?: unknown }
146
+
147
+ // <Presence> contract: when an ancestor flips `isPresent` to false the
148
+ // child stays rendered until `safeToRemove` is called, giving the exit
149
+ // animation time to play. `null` when there is no <Presence> ancestor.
150
+ const presence = usePresence()
151
+ const isExiting = presence !== null && presence.isPresent === false
152
+
153
+ // Resolved reduced-motion preference for this subtree. When true, every
154
+ // per-key transition is replaced with `no-animation` below, so values
155
+ // snap to target without interpolation. The hook also subscribes to OS
156
+ // changes (via Reanimated's `useReducedMotion`), so toggling the
157
+ // accessibility setting at runtime re-renders this component.
158
+ const shouldReduceMotion = useShouldReduceMotion()
159
+
160
+ // Pin the latest `onAnimationEnd` in a ref so the worklet callback always
161
+ // dispatches against the current closure without re-resolving the
162
+ // animation graph. Worklets can read refs via `runOnJS`.
163
+ const onAnimationEndRef = useRef(onAnimationEnd)
164
+ onAnimationEndRef.current = onAnimationEnd
165
+
166
+ // Resolve `animate` against `variants` / `controller`. The controller's
167
+ // `current` wins when both are set (typed contract: don't mix
168
+ // `controller` and `animate` — controller drives the animation in that
169
+ // mode). When `animate` is a string and `variants` exist, look it up.
170
+ const variantKey = useControllerKey(controller)
171
+ const resolvedAnimate = resolveAnimateInput(
172
+ animate as AnimateStyle<unknown> | string | undefined,
173
+ variants as VariantsMap<unknown> | undefined,
174
+ variantKey,
175
+ )
176
+
177
+ const animateRecord = (resolvedAnimate ?? {}) as Partial<
178
+ Record<AnimatableKey, AnimatableValue<number>>
179
+ >
180
+ const initialRecord =
181
+ initial && initial !== false
182
+ ? (initial as Partial<Record<AnimatableKey, number>>)
183
+ : undefined
184
+ const exitRecord = exit
185
+ ? (exit as Partial<Record<AnimatableKey, AnimatableValue<number>>>)
186
+ : undefined
187
+
188
+ // Gesture sub-state activation tracked as JS state so changes invalidate
189
+ // the merged-target signature and re-run the animation effect. The cost
190
+ // is three useState slots regardless of whether `gesture` is set; that's
191
+ // tiny and lets us stay rules-of-hooks-clean.
192
+ const [pressed, setPressed] = useState(false)
193
+ const [focused, setFocused] = useState(false)
194
+ const [hovered, setHovered] = useState(false)
195
+
196
+ // The set of keys this instance animates is locked at first render. With
197
+ // variants in play the union across all variants is what matters — a key
198
+ // touched by any variant must be active so the worklet picks it up when
199
+ // the controller transitions. Gesture sub-states join the same union so
200
+ // pressed/focused/hovered targets can drive any key they declare.
201
+ const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
202
+ if (activeKeysRef.current === null) {
203
+ const touched = new Set<AnimatableKey>()
204
+ for (const k of ALL_KEYS) {
205
+ if (k in animateRecord) touched.add(k)
206
+ if (initialRecord && k in initialRecord) touched.add(k)
207
+ }
208
+ if (variants) {
209
+ for (const variant of Object.values(variants) as object[]) {
210
+ if (!variant) continue
211
+ for (const k of ALL_KEYS) {
212
+ if (k in variant) touched.add(k)
213
+ }
214
+ }
215
+ }
216
+ if (gesture) {
217
+ for (const subState of [
218
+ gesture.pressed,
219
+ gesture.focused,
220
+ gesture.hovered,
221
+ ] as Array<object | undefined>) {
222
+ if (!subState) continue
223
+ for (const k of ALL_KEYS) {
224
+ if (k in subState) touched.add(k)
225
+ }
226
+ }
227
+ }
228
+ if (exitRecord) {
229
+ for (const k of ALL_KEYS) {
230
+ if (k in exitRecord) touched.add(k)
231
+ }
232
+ }
233
+ activeKeysRef.current = ALL_KEYS.filter((k) => touched.has(k))
234
+ }
235
+ const hasTransformRef = useRef<boolean>(
236
+ activeKeysRef.current.some((k) => TRANSFORM_KEY_SET.has(k)),
237
+ )
238
+
239
+ const sharedValues = useAnimatableSharedValues((key) => {
240
+ if (initial === false) {
241
+ const a = animateRecord[key]
242
+ return restValue(a) ?? DEFAULT_RESTING[key]
243
+ }
244
+ return (
245
+ initialRecord?.[key] ??
246
+ restValue(animateRecord[key]) ??
247
+ DEFAULT_RESTING[key]
248
+ )
249
+ })
250
+
251
+ // Merge gesture sub-state targets over the base `animate` record. Keys
252
+ // touched by any sub-state always appear in the merged record (falling
253
+ // back to `animateRecord` or `DEFAULT_RESTING`) so releasing a gesture
254
+ // animates back to a defined value rather than getting skipped.
255
+ //
256
+ // While exiting, exit values override everything — gesture / animate
257
+ // targets are inert because the component is on its way out.
258
+ const mergedRecord =
259
+ isExiting && exitRecord
260
+ ? { ...animateRecord, ...exitRecord }
261
+ : mergeGestureTargets(animateRecord, gesture, {
262
+ pressed,
263
+ focused,
264
+ hovered,
265
+ })
266
+ const mergedSig =
267
+ stableSig(mergedRecord) +
268
+ (isExiting ? '|exit' : '') +
269
+ (shouldReduceMotion ? '|rm' : '')
270
+ const transitionSig = stableSig(transition)
271
+
272
+ // Stable ref to the live `safeToRemove` so the effect's settle-counter
273
+ // closure can reach the latest <Presence> binding without retriggering.
274
+ const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
275
+ safeToRemoveRef.current = presence?.safeToRemove
276
+
277
+ useEffect(() => {
278
+ // Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
279
+ // immediately so the unmount isn't gated on a phantom animation.
280
+ if (isExiting && (!exitRecord || Object.keys(exitRecord).length === 0)) {
281
+ safeToRemoveRef.current?.()
282
+ return
283
+ }
284
+
285
+ let pending = 0
286
+ let done = false
287
+ const onSettle = () => {
288
+ if (done) return
289
+ pending--
290
+ if (pending <= 0) {
291
+ done = true
292
+ if (isExiting) safeToRemoveRef.current?.()
293
+ }
294
+ }
295
+
296
+ // Count transform axes participating in this effect run so the factory
297
+ // can coalesce their terminal callbacks into a single transform-group
298
+ // event. `undefined` when no transform axis is animating, which lets
299
+ // the factory skip the coalescing branch entirely.
300
+ let transformPending = 0
301
+ for (const k of ALL_KEYS) {
302
+ if (TRANSFORM_KEY_SET.has(k) && mergedRecord[k] !== undefined) {
303
+ transformPending++
304
+ }
305
+ }
306
+ const transformGroup: TransformGroup | undefined =
307
+ transformPending > 0 ? { remaining: transformPending } : undefined
308
+
309
+ for (const key of ALL_KEYS) {
310
+ const target = mergedRecord[key]
311
+ if (target === undefined) continue
312
+ // Reduced-motion overrides every per-key transition (and any nested
313
+ // sequence-step transition) with `no-animation`, which the resolver
314
+ // turns into a direct value assignment. Sequences still iterate but
315
+ // each step settles instantly, which matches the "snap to final
316
+ // state" expectation.
317
+ const cfg = shouldReduceMotion
318
+ ? ({ type: 'no-animation' } as const)
319
+ : transitionFor(key, transition)
320
+ if (isExiting) pending++
321
+ const factory = makeKeyCallbackFactory(
322
+ key,
323
+ sharedValues[key],
324
+ targetEndValue(target),
325
+ onAnimationEndRef,
326
+ {
327
+ stepCount: stepCountOf(target),
328
+ totalIterations: totalIterationsOf(cfg),
329
+ },
330
+ isExiting ? onSettle : undefined,
331
+ TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
332
+ )
333
+ sharedValues[key].value = resolveAnimatableValue(
334
+ target,
335
+ cfg,
336
+ factory,
337
+ ) as never
338
+ }
339
+
340
+ // No exit-targeted keys (only `animate` keys present, no `exit`)
341
+ // → release immediately rather than wait for animations that aren't
342
+ // headed toward an exit value.
343
+ if (isExiting && pending === 0) {
344
+ safeToRemoveRef.current?.()
345
+ }
346
+ // eslint-disable-next-line react-hooks/exhaustive-deps
347
+ }, [mergedSig, transitionSig])
348
+
349
+ const animatedStyle = useAnimatedStyle(() => {
350
+ const activeKeys = activeKeysRef.current!
351
+ const hasTransform = hasTransformRef.current
352
+ const out: Record<string, unknown> = {}
353
+ const transform: Array<Record<string, unknown>> = []
354
+ for (const key of activeKeys) {
355
+ const v = sharedValues[key].value
356
+ if (TRANSFORM_KEY_SET.has(key)) {
357
+ transform.push(
358
+ key === 'rotate' ? { rotate: `${v}deg` } : { [key]: v },
359
+ )
360
+ } else {
361
+ out[key] = v
362
+ }
363
+ }
364
+ if (hasTransform) out.transform = transform
365
+ return out
366
+ })
367
+
368
+ // Exiting children are tap-deaf: the next press should fall through to
369
+ // whatever is underneath, not re-trigger a soon-to-unmount node. This is
370
+ // the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
371
+ // `pointerEvents` as a prop in favor of the style key, so we merge it
372
+ // alongside the animated style instead of spreading as a prop.
373
+ const mergedStyle = useMemo(
374
+ () =>
375
+ (isExiting
376
+ ? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
377
+ : [style, animatedStyle]) as unknown,
378
+ [style, animatedStyle, isExiting],
379
+ )
380
+
381
+ const gestureHandlers = useGestureHandlers(
382
+ gesture,
383
+ rest as Record<string, unknown>,
384
+ setPressed,
385
+ setFocused,
386
+ setHovered,
387
+ )
388
+
389
+ return (
390
+ <AnimatedComponent
391
+ ref={ref as never}
392
+ {...(rest as object)}
393
+ {...gestureHandlers}
394
+ style={mergedStyle}
395
+ />
396
+ )
397
+ })
398
+
399
+ Motion.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
400
+
401
+ return Motion as unknown as MotionComponent<C>
402
+ }
403
+
404
+ type SharedValueMap = Record<AnimatableKey, SharedValue<number>>
405
+
406
+ /**
407
+ * Allocate one shared value per animatable key in `ALL_KEYS` and return a
408
+ * **stable** map — same object reference across every render.
409
+ *
410
+ * Stability matters: `useAnimatedStyle` derives its dep array from
411
+ * `Object.values(updater.__closure)`. Our worklet captures `sharedValues`,
412
+ * so a fresh object literal each render would change that dep, fire
413
+ * Reanimated's effect, and re-bind the worklet on the UI thread on every
414
+ * render — the exact cost design principle 8 calls out. The shared values themselves
415
+ * are stable across renders (Reanimated's `useSharedValue` is a `useRef`
416
+ * under the hood), so snapshotting the wrapping object once is safe.
417
+ *
418
+ * Hooks are called in a stable, lexical order — fine for rules-of-hooks.
419
+ * Unused shared values are cheap; the worklet skips them via
420
+ * `activeKeysRef`.
421
+ */
422
+ function useAnimatableSharedValues(
423
+ init: (key: AnimatableKey) => number,
424
+ ): SharedValueMap {
425
+ const translateX = useSharedValue(init('translateX'))
426
+ const translateY = useSharedValue(init('translateY'))
427
+ const scale = useSharedValue(init('scale'))
428
+ const scaleX = useSharedValue(init('scaleX'))
429
+ const scaleY = useSharedValue(init('scaleY'))
430
+ const rotate = useSharedValue(init('rotate'))
431
+ const opacity = useSharedValue(init('opacity'))
432
+ const width = useSharedValue(init('width'))
433
+ const height = useSharedValue(init('height'))
434
+ const borderRadius = useSharedValue(init('borderRadius'))
435
+
436
+ const ref = useRef<SharedValueMap | null>(null)
437
+ if (ref.current === null) {
438
+ ref.current = {
439
+ translateX,
440
+ translateY,
441
+ scale,
442
+ scaleX,
443
+ scaleY,
444
+ rotate,
445
+ opacity,
446
+ width,
447
+ height,
448
+ borderRadius,
449
+ }
450
+ }
451
+ return ref.current
452
+ }
453
+
454
+ /**
455
+ * Build a per-key `CallbackFactory` for the resolver. Each step in a sequence
456
+ * (or the single animation, when `value` isn't an array) gets its own
457
+ * Reanimated callback; when it settles on the UI thread, the callback bridges
458
+ * to JS via `runOnJS` and invokes the user's `onAnimationEnd` with a fully
459
+ * populated `AnimationCallbackInfo`.
460
+ *
461
+ * Phase resolution lives here, on the JS thread. The resolver hands us a
462
+ * coarse rawPhase (`'step'` for any sequence step, `'animation'` for a
463
+ * single-shot terminal); we map that onto the public phase set
464
+ * (`'step' | 'sequence' | 'repeat' | 'animation'`) using `meta` and the
465
+ * iteration counter. `iteration` resets per effect run because the factory
466
+ * is constructed fresh inside the effect.
467
+ */
468
+ function makeKeyCallbackFactory(
469
+ key: string,
470
+ sharedValue: SharedValue<number>,
471
+ target: number | string | undefined,
472
+ onAnimationEndRef: {
473
+ current: ((info: AnimationCallbackInfo<unknown>) => void) | undefined
474
+ },
475
+ meta: { stepCount: number; totalIterations: number },
476
+ onSettle?: () => void,
477
+ transformGroup?: TransformGroup,
478
+ ) {
479
+ if (!onAnimationEndRef.current && !onSettle) return undefined
480
+
481
+ // Shared across this animation graph's callbacks (one per sequence step,
482
+ // or one for a single-shot). Mutated when a full pass completes.
483
+ const state = { iteration: 0 }
484
+
485
+ const isTransformKey = TRANSFORM_KEY_SET.has(key as AnimatableKey)
486
+
487
+ const dispatch = (
488
+ rawPhase: 'step' | 'animation',
489
+ step: number | undefined,
490
+ finished: boolean,
491
+ value: number | string | undefined,
492
+ ) => {
493
+ const isLastIteration = state.iteration >= meta.totalIterations - 1
494
+ let phase: 'step' | 'sequence' | 'repeat' | 'animation'
495
+ let isTerminal = false
496
+
497
+ if (rawPhase === 'step') {
498
+ const isLastInPass = step !== undefined && step === meta.stepCount - 1
499
+ if (!isLastInPass) {
500
+ phase = 'step'
501
+ } else if (isLastIteration) {
502
+ phase = 'animation'
503
+ isTerminal = true
504
+ } else {
505
+ phase = 'sequence'
506
+ }
507
+ } else if (isLastIteration) {
508
+ phase = 'animation'
509
+ isTerminal = true
510
+ } else {
511
+ phase = 'repeat'
512
+ }
513
+
514
+ const reportedIteration = state.iteration
515
+ if (phase === 'sequence' || phase === 'repeat') state.iteration++
516
+
517
+ const fn = onAnimationEndRef.current
518
+ if (fn) {
519
+ // Transform-group coalescing: a multi-axis translate / scale /
520
+ // rotate animation should fire onAnimationEnd ONCE for the logical
521
+ // transform, not once per axis. We only coalesce the terminal
522
+ // `'animation'` phase — `step`/`sequence`/`repeat` events fire
523
+ // per-axis since each is its own logical event. Released per-axis
524
+ // for a single-axis case too, with `key: 'transform'` for
525
+ // consistency.
526
+ if (isTransformKey && transformGroup && phase === 'animation') {
527
+ transformGroup.remaining--
528
+ if (transformGroup.remaining <= 0) {
529
+ fn({
530
+ key: 'transform' as never,
531
+ finished,
532
+ value,
533
+ target,
534
+ phase,
535
+ step,
536
+ iteration: reportedIteration,
537
+ })
538
+ }
539
+ } else {
540
+ fn({
541
+ key: key as never,
542
+ finished,
543
+ value,
544
+ target,
545
+ phase,
546
+ step,
547
+ iteration: reportedIteration,
548
+ })
549
+ }
550
+ }
551
+ // Settle hooks fire per-axis on the terminal phase — <Presence> waits
552
+ // for *every* exiting property to settle before unmounting, so we
553
+ // intentionally do not coalesce these (the transform-group coalesce
554
+ // is purely a user-callback ergonomic).
555
+ if (onSettle && isTerminal) onSettle()
556
+ }
557
+
558
+ return (rawPhase: 'step' | 'animation', step: number | undefined) => {
559
+ // Reanimated invokes the callback with only `finished` (see
560
+ // valueSetter.js:24,40,51 in 4.x) — `current` is never passed. Read the
561
+ // shared value inside the worklet; by the time the callback fires the
562
+ // final/clamped value has already been written to it.
563
+ const cb = (finished?: boolean) => {
564
+ 'worklet'
565
+ runOnJS(dispatch)(rawPhase, step, !!finished, sharedValue.value)
566
+ }
567
+ return cb
568
+ }
569
+ }
570
+
571
+ /**
572
+ * Number of sequence steps in an animatable value. `1` for plain values and
573
+ * single-step `{ to }` objects; the array length for keyframe arrays.
574
+ */
575
+ function stepCountOf(v: AnimatableValue<number> | undefined): number {
576
+ if (Array.isArray(v)) return v.length
577
+ return 1
578
+ }
579
+
580
+ /**
581
+ * Total number of iterations the animation will run, including the initial
582
+ * pass. `1` when there is no `repeat`; `Number.POSITIVE_INFINITY` for
583
+ * `'infinite'`. Decay and `no-animation` configs cannot repeat — both return
584
+ * `1` so the iteration counter stays at 0.
585
+ */
586
+ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
587
+ if (!cfg || cfg.type === 'no-animation' || cfg.type === 'decay') return 1
588
+ const r = cfg.repeat
589
+ if (r === undefined) return 1
590
+ if (r === 'infinite') return Number.POSITIVE_INFINITY
591
+ if (typeof r === 'number') return r
592
+ if (r.count === 'infinite') return Number.POSITIVE_INFINITY
593
+ return r.count
594
+ }
595
+
596
+ /**
597
+ * Pull a single end-value out of an `AnimatableValue` for the
598
+ * `AnimationCallbackInfo.target` field. Plain numbers/strings come through;
599
+ * the last sequence step's `to`/value is used for arrays; `{ to }` step
600
+ * objects use `to`. Returns `undefined` for unrecognized shapes.
601
+ */
602
+ function targetEndValue(
603
+ v: AnimatableValue<number> | undefined,
604
+ ): number | string | undefined {
605
+ if (v === undefined) return undefined
606
+ if (typeof v === 'number' || typeof v === 'string') return v
607
+ if (Array.isArray(v)) {
608
+ return v.length > 0
609
+ ? targetEndValue(v[v.length - 1] as AnimatableValue<number>)
610
+ : undefined
611
+ }
612
+ if (typeof v === 'object' && v !== null && 'to' in v) {
613
+ const to = (v as { to: unknown }).to
614
+ return typeof to === 'number' || typeof to === 'string' ? to : undefined
615
+ }
616
+ return undefined
617
+ }
618
+
619
+ /**
620
+ * Subscribe to a `VariantController` and return its `current` key. Returns
621
+ * `undefined` when no controller is provided so callers can fall back to a
622
+ * literal `animate` value.
623
+ */
624
+ function useControllerKey(
625
+ controller: VariantController | undefined,
626
+ ): string | undefined {
627
+ const [, setTick] = useState(0)
628
+ useEffect(() => {
629
+ if (!controller) return
630
+ const unsub = controller.subscribe(() => setTick((n) => n + 1))
631
+ return unsub
632
+ }, [controller])
633
+ return controller?.current
634
+ }
635
+
636
+ /**
637
+ * Resolve the effective `animate` target from the public-prop tuple.
638
+ *
639
+ * Precedence: `controller.current` (when controller is set) > string-keyed
640
+ * `animate` looked up in `variants` > literal `animate` object > `undefined`.
641
+ */
642
+ function resolveAnimateInput(
643
+ animate: AnimateStyle<unknown> | string | undefined,
644
+ variants: VariantsMap<unknown> | undefined,
645
+ controllerKey: string | undefined,
646
+ ): AnimateStyle<unknown> | undefined {
647
+ if (controllerKey !== undefined && variants && controllerKey in variants) {
648
+ return variants[controllerKey]
649
+ }
650
+ if (typeof animate === 'string') {
651
+ if (variants && animate in variants) return variants[animate]
652
+ if (__DEV__) {
653
+ console.warn(
654
+ `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
655
+ )
656
+ }
657
+ return undefined
658
+ }
659
+ return animate as AnimateStyle<unknown> | undefined
660
+ }
661
+
662
+ declare const __DEV__: boolean
663
+
664
+ /**
665
+ * Pick the resting/initial-frame number out of an `AnimatableValue`. Plain
666
+ * numbers come through unchanged; sequence arrays use their first element;
667
+ * `{ to }` step objects use `to`. Non-numeric or unresolvable shapes return
668
+ * `undefined` so the caller can fall back to `DEFAULT_RESTING`.
669
+ */
670
+ function restValue(v: AnimatableValue<number> | undefined): number | undefined {
671
+ if (v === undefined) return undefined
672
+ if (typeof v === 'number') return v
673
+ if (Array.isArray(v)) {
674
+ return v.length > 0 ? restValue(v[0] as AnimatableValue<number>) : undefined
675
+ }
676
+ if (typeof v === 'object' && v !== null && 'to' in v) {
677
+ const to = (v as { to: unknown }).to
678
+ return typeof to === 'number' ? to : undefined
679
+ }
680
+ return undefined
681
+ }
682
+
683
+ function stableSig(value: unknown): string {
684
+ if (value === undefined) return ''
685
+ try {
686
+ return stableStringify(value)
687
+ } catch {
688
+ return String(value)
689
+ }
690
+ }
691
+
692
+ /**
693
+ * JSON.stringify with keys sorted at every level — gives a stable signature
694
+ * regardless of property declaration order. Functions serialize as `null` so a
695
+ * change in easing-fn reference is invisible here; that's fine for v0.1
696
+ * (easing swaps are rare and the worklet wrapper handles correctness).
697
+ */
698
+ function stableStringify(v: unknown): string {
699
+ if (v === null || typeof v !== 'object') {
700
+ if (typeof v === 'function' || v === undefined) return 'null'
701
+ return JSON.stringify(v)
702
+ }
703
+ if (Array.isArray(v)) {
704
+ return '[' + v.map(stableStringify).join(',') + ']'
705
+ }
706
+ const obj = v as Record<string, unknown>
707
+ const keys = Object.keys(obj).sort()
708
+ return (
709
+ '{' +
710
+ keys
711
+ .map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k]))
712
+ .join(',') +
713
+ '}'
714
+ )
715
+ }
716
+
717
+ /**
718
+ * Merge gesture sub-state targets over the base `animate` record. Keys touched
719
+ * by any declared sub-state are always present in the result so releasing a
720
+ * gesture animates the property back to a defined value (the base `animate`
721
+ * value when present, otherwise `DEFAULT_RESTING`). Sub-states layer in
722
+ * priority order: `hovered` < `focused` < `pressed`.
723
+ */
724
+ function mergeGestureTargets(
725
+ base: Partial<Record<AnimatableKey, AnimatableValue<number>>>,
726
+ gesture: GestureSubStates<unknown> | undefined,
727
+ active: { pressed: boolean; focused: boolean; hovered: boolean },
728
+ ): Partial<Record<AnimatableKey, AnimatableValue<number>>> {
729
+ if (!gesture) return base
730
+ const merged: Partial<Record<AnimatableKey, AnimatableValue<number>>> = {
731
+ ...base,
732
+ }
733
+ const subStates = [
734
+ gesture.hovered,
735
+ gesture.focused,
736
+ gesture.pressed,
737
+ ] as Array<
738
+ Partial<Record<AnimatableKey, AnimatableValue<number>>> | undefined
739
+ >
740
+ for (const sub of subStates) {
741
+ if (!sub) continue
742
+ for (const k of ALL_KEYS) {
743
+ if (k in sub && !(k in merged)) {
744
+ merged[k] = DEFAULT_RESTING[k]
745
+ }
746
+ }
747
+ }
748
+ if (active.hovered && gesture.hovered) {
749
+ Object.assign(
750
+ merged,
751
+ gesture.hovered as Partial<
752
+ Record<AnimatableKey, AnimatableValue<number>>
753
+ >,
754
+ )
755
+ }
756
+ if (active.focused && gesture.focused) {
757
+ Object.assign(
758
+ merged,
759
+ gesture.focused as Partial<
760
+ Record<AnimatableKey, AnimatableValue<number>>
761
+ >,
762
+ )
763
+ }
764
+ if (active.pressed && gesture.pressed) {
765
+ Object.assign(
766
+ merged,
767
+ gesture.pressed as Partial<
768
+ Record<AnimatableKey, AnimatableValue<number>>
769
+ >,
770
+ )
771
+ }
772
+ return merged
773
+ }
774
+
775
+ type GestureHandlers = Record<string, (event: unknown) => void>
776
+
777
+ /**
778
+ * Build the touch / focus / hover handler props for a gesture-enabled Motion
779
+ * primitive. Returns an empty object when `gesture` is undefined so the
780
+ * component renders identically to the gesture-less path (zero overhead).
781
+ *
782
+ * Existing user-supplied handlers on the same events are composed: the user's
783
+ * handler runs first, then the internal state setter. We pull user handlers
784
+ * out of `rest` rather than overwriting them.
785
+ */
786
+ function useGestureHandlers(
787
+ gesture: GestureSubStates<unknown> | undefined,
788
+ rest: Record<string, unknown>,
789
+ setPressed: (next: boolean) => void,
790
+ setFocused: (next: boolean) => void,
791
+ setHovered: (next: boolean) => void,
792
+ ): GestureHandlers {
793
+ return useMemo(() => {
794
+ if (!gesture) return {}
795
+ const handlers: GestureHandlers = {}
796
+ if (gesture.pressed) {
797
+ handlers.onTouchStart = compose(rest.onTouchStart, () => setPressed(true))
798
+ handlers.onTouchEnd = compose(rest.onTouchEnd, () => setPressed(false))
799
+ handlers.onTouchCancel = compose(rest.onTouchCancel, () =>
800
+ setPressed(false),
801
+ )
802
+ // Pressable / TouchableOpacity expose press hooks above the touch layer;
803
+ // forward to those when present so wrapping consumers stay consistent.
804
+ handlers.onPressIn = compose(rest.onPressIn, () => setPressed(true))
805
+ handlers.onPressOut = compose(rest.onPressOut, () => setPressed(false))
806
+ }
807
+ if (gesture.focused) {
808
+ handlers.onFocus = compose(rest.onFocus, () => setFocused(true))
809
+ handlers.onBlur = compose(rest.onBlur, () => setFocused(false))
810
+ }
811
+ if (gesture.hovered) {
812
+ // Web-only events. RN-Web 0.72+ accepts these on View; native ignores
813
+ // them so the cost is zero on iOS / Android.
814
+ handlers.onMouseEnter = compose(rest.onMouseEnter, () => setHovered(true))
815
+ handlers.onMouseLeave = compose(rest.onMouseLeave, () =>
816
+ setHovered(false),
817
+ )
818
+ }
819
+ return handlers
820
+ // eslint-disable-next-line react-hooks/exhaustive-deps
821
+ }, [
822
+ gesture?.pressed ? 1 : 0,
823
+ gesture?.focused ? 1 : 0,
824
+ gesture?.hovered ? 1 : 0,
825
+ rest.onTouchStart,
826
+ rest.onTouchEnd,
827
+ rest.onTouchCancel,
828
+ rest.onPressIn,
829
+ rest.onPressOut,
830
+ rest.onFocus,
831
+ rest.onBlur,
832
+ rest.onMouseEnter,
833
+ rest.onMouseLeave,
834
+ ])
835
+ }
836
+
837
+ function compose(
838
+ user: unknown,
839
+ ours: (event: unknown) => void,
840
+ ): (event: unknown) => void {
841
+ if (typeof user !== 'function') return ours
842
+ return (event: unknown) => {
843
+ ;(user as (event: unknown) => void)(event)
844
+ ours(event)
845
+ }
846
+ }
847
+
848
+ // Suppress the implicit any-return of the rotate ternary's union shape.
849
+ // `TransformKey` is exported only to keep the type readable in d.ts.
850
+ export type { TransformKey }