@rootnative/inertia 0.0.9 → 0.0.10

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 (100) hide show
  1. package/CHANGELOG.md +68 -1
  2. package/README.md +7 -7
  3. package/dist/applyBounds-BYGiHrYO.d.mts +18 -0
  4. package/dist/applyBounds-BYGiHrYO.d.ts +18 -0
  5. package/dist/{chunk-E3ALFSH2.js → chunk-2UGWAB6N.js} +78 -70
  6. package/dist/{chunk-ZBHQPVWE.js → chunk-3ZNHPT5Z.js} +2 -2
  7. package/dist/{chunk-IHVMNOS4.js → chunk-46766TBM.js} +2 -2
  8. package/dist/{chunk-MUT6BTZS.js → chunk-4EZ5DBL3.js} +10 -7
  9. package/dist/{chunk-MFAFB4K7.mjs → chunk-6FCATUMY.mjs} +8 -10
  10. package/dist/{chunk-6YIUETBF.mjs → chunk-7BH3CAO5.mjs} +1 -1
  11. package/dist/{chunk-JBK5ZLMH.mjs → chunk-CUHOCONB.mjs} +1 -1
  12. package/dist/{chunk-M4766VUV.mjs → chunk-DRA2K53O.mjs} +1 -1
  13. package/dist/{chunk-L4JFECXU.mjs → chunk-F4HMCXAX.mjs} +6 -3
  14. package/dist/{chunk-W7NTRSPD.mjs → chunk-HEAWMZEM.mjs} +105 -66
  15. package/dist/{chunk-IX6SEOSK.mjs → chunk-JENYDTXH.mjs} +52 -44
  16. package/dist/chunk-KFHZMFAX.js +23 -0
  17. package/dist/{chunk-6AN5X3YE.js → chunk-KXU6VH2Y.js} +10 -11
  18. package/dist/{chunk-WNVHPMBI.js → chunk-N33GSDNQ.js} +106 -65
  19. package/dist/chunk-OH46TGEA.mjs +20 -0
  20. package/dist/{chunk-QNLSCOR5.mjs → chunk-OLOSU2BD.mjs} +1 -1
  21. package/dist/chunk-PLKXCJIB.js +8 -0
  22. package/dist/{chunk-GSUC4HWP.mjs → chunk-QXKPIENP.mjs} +1 -1
  23. package/dist/chunk-UBHQJFUW.js +8 -0
  24. package/dist/{chunk-6NOCY764.js → chunk-VKXSL3ZL.js} +2 -2
  25. package/dist/chunk-XKNEQDAT.js +8 -0
  26. package/dist/{chunk-EN4PN7X3.mjs → chunk-Z23AMUSW.mjs} +1 -1
  27. package/dist/gestureLayer/index.d.mts +3 -3
  28. package/dist/gestureLayer/index.d.ts +3 -3
  29. package/dist/gestureLayer/index.js +12 -18
  30. package/dist/gestureLayer/index.mjs +5 -11
  31. package/dist/index.d.mts +87 -45
  32. package/dist/index.d.ts +87 -45
  33. package/dist/index.js +114 -91
  34. package/dist/index.mjs +66 -59
  35. package/dist/motion/FlatList.d.mts +1 -1
  36. package/dist/motion/FlatList.d.ts +1 -1
  37. package/dist/motion/FlatList.js +5 -5
  38. package/dist/motion/FlatList.mjs +4 -4
  39. package/dist/motion/Image.d.mts +1 -1
  40. package/dist/motion/Image.d.ts +1 -1
  41. package/dist/motion/Image.js +5 -5
  42. package/dist/motion/Image.mjs +4 -4
  43. package/dist/motion/Pressable.d.mts +1 -1
  44. package/dist/motion/Pressable.d.ts +1 -1
  45. package/dist/motion/Pressable.js +5 -5
  46. package/dist/motion/Pressable.mjs +4 -4
  47. package/dist/motion/ScrollView.d.mts +1 -1
  48. package/dist/motion/ScrollView.d.ts +1 -1
  49. package/dist/motion/ScrollView.js +5 -5
  50. package/dist/motion/ScrollView.mjs +4 -4
  51. package/dist/motion/Text.d.mts +1 -1
  52. package/dist/motion/Text.d.ts +1 -1
  53. package/dist/motion/Text.js +5 -5
  54. package/dist/motion/Text.mjs +4 -4
  55. package/dist/motion/View.d.mts +1 -1
  56. package/dist/motion/View.d.ts +1 -1
  57. package/dist/motion/View.js +5 -5
  58. package/dist/motion/View.mjs +4 -4
  59. package/dist/touch/index.d.mts +2 -1
  60. package/dist/touch/index.d.ts +2 -1
  61. package/dist/touch/index.js +16 -31
  62. package/dist/touch/index.mjs +11 -29
  63. package/dist/{types-DyJpG64F.d.mts → types-08mG8fNk.d.mts} +10 -3
  64. package/dist/{types-DyJpG64F.d.ts → types-08mG8fNk.d.ts} +10 -3
  65. package/dist/{useGesture-DbH46EBp.d.ts → useGesture-D1lHeQQ6.d.ts} +1 -1
  66. package/dist/{useGesture-CnZQnYHH.d.mts → useGesture-DjszaHWz.d.mts} +1 -1
  67. package/package.json +4 -2
  68. package/src/config/namedTransitions.ts +7 -9
  69. package/src/gestureLayer/index.ts +3 -0
  70. package/src/gestureLayer/useGestureLayer.ts +6 -9
  71. package/src/gestures/focusVisibility.ts +19 -17
  72. package/src/gestures/index.ts +1 -1
  73. package/src/index.ts +9 -0
  74. package/src/layout/resolveLayout.ts +4 -1
  75. package/src/layout/useSharedLayout.ts +2 -1
  76. package/src/motion/createMotionComponent.tsx +92 -44
  77. package/src/presence/Presence.tsx +5 -7
  78. package/src/touch/applyBounds.ts +30 -0
  79. package/src/touch/index.ts +1 -0
  80. package/src/touch/useTouchDrag.ts +15 -45
  81. package/src/transitions/constants.ts +14 -0
  82. package/src/transitions/easing.ts +23 -11
  83. package/src/transitions/index.ts +5 -0
  84. package/src/transitions/keys.ts +0 -2
  85. package/src/transitions/resolve.ts +86 -11
  86. package/src/transitions/resolveSequence.ts +3 -1
  87. package/src/transitions/runtime.ts +4 -7
  88. package/src/transitions/spring.ts +7 -2
  89. package/src/types.ts +9 -2
  90. package/src/values/extrapolation.ts +21 -0
  91. package/src/values/index.ts +1 -0
  92. package/src/values/useGesture.ts +9 -2
  93. package/src/values/useInterpolatedStyle.ts +5 -12
  94. package/src/values/useShadow.ts +18 -7
  95. package/src/values/useSpring.ts +34 -25
  96. package/src/values/useTransform.ts +24 -20
  97. package/src/values/useTranslateStyle.ts +31 -0
  98. package/dist/chunk-24NUL23E.js +0 -8
  99. package/dist/chunk-4XBX7KOD.js +0 -8
  100. package/dist/chunk-Z5USXWTE.js +0 -8
@@ -21,7 +21,7 @@ import {
21
21
  useNamedTransitions,
22
22
  useShouldReduceMotion,
23
23
  } from '../config'
24
- import { isFocusVisible } from '../gestures'
24
+ import { installFocusVisibility, isFocusVisible } from '../gestures'
25
25
  import {
26
26
  layersToPayload,
27
27
  normalizeBoxShadow,
@@ -47,6 +47,7 @@ import { useStaggerDelay } from '../stagger'
47
47
  import {
48
48
  applyDelay,
49
49
  isTopLevelTransition,
50
+ repeatIterationsOf,
50
51
  resolveAnimatableValue,
51
52
  resolveTransition,
52
53
  stableSig,
@@ -271,6 +272,18 @@ const SHARED_STYLE_KEY_SET = new Set<AnimatableKey>(SHARED_STYLE_KEYS)
271
272
  */
272
273
  type AnimatableSlotValue = number | string | BoxShadowPayload
273
274
 
275
+ /**
276
+ * The keys one instance animates plus the three flags the worklet branches
277
+ * on. Grown monotonically on the JS thread and mirrored to the UI thread
278
+ * through a shared value — see `activeSetSV` in the factory.
279
+ */
280
+ type ActiveKeySet = {
281
+ keys: readonly AnimatableKey[]
282
+ hasTransform: boolean
283
+ hasShadowOffset: boolean
284
+ hasBoxShadow: boolean
285
+ }
286
+
274
287
  /**
275
288
  * Resting `boxShadow` — no shadow at all. Frozen and hoisted so every
276
289
  * primitive shares one reference and an untouched slot never allocates.
@@ -603,10 +616,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
603
616
  // render silently dropped the new key (its SV updated, but the
604
617
  // worklet — which iterates this set — never read it).
605
618
  //
606
- // Growing-only keeps the worklet stable: the `activeKeysRef.current` array
607
- // identity only changes on the renders that actually add a key, so the
608
- // `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
609
- // expansion without churning frame-to-frame.
619
+ // Growing-only keeps the worklet stable: the set object only changes on
620
+ // the renders that actually add a key. The worklet reads it through a
621
+ // shared value (`activeSetSV`, below), so an expansion crosses to the UI
622
+ // thread once per growth and nothing churns frame-to-frame.
610
623
  const hasLayoutId = layoutId !== undefined
611
624
 
612
625
  // The static `style`, flattened at most once per render and only when
@@ -668,14 +681,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
668
681
  }
669
682
  }
670
683
 
671
- const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
672
- const hasTransformRef = useRef<boolean>(false)
673
- const hasShadowOffsetRef = useRef<boolean>(false)
674
- const hasBoxShadowRef = useRef<boolean>(false)
684
+ const activeSetRef = useRef<ActiveKeySet | null>(null)
675
685
  // Expand the active set only when this render touched a key we haven't
676
- // recorded yet. When nothing new appears we keep the existing array
677
- // identity so the worklet's captured ref doesn't see a fresh value.
678
- const prevActive = activeKeysRef.current
686
+ // recorded yet. When nothing new appears we keep the existing object
687
+ // identity so the JS-side readers below see a stable value.
688
+ const prevActive = activeSetRef.current?.keys ?? null
679
689
  let grew = prevActive === null
680
690
  if (!grew && prevActive) {
681
691
  for (const k of touched) {
@@ -688,14 +698,13 @@ export function createMotionComponent<C extends ComponentType<any>>(
688
698
  if (grew) {
689
699
  const merged = new Set<AnimatableKey>(prevActive ?? [])
690
700
  for (const k of touched) merged.add(k)
691
- activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
692
- hasTransformRef.current = activeKeysRef.current.some((k) =>
693
- TRANSFORM_KEY_SET.has(k),
694
- )
695
- hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
696
- SHADOW_OFFSET_KEY_SET.has(k),
697
- )
698
- hasBoxShadowRef.current = activeKeysRef.current.includes('boxShadow')
701
+ const keys = ALL_KEYS.filter((k) => merged.has(k))
702
+ activeSetRef.current = {
703
+ keys,
704
+ hasTransform: keys.some((k) => TRANSFORM_KEY_SET.has(k)),
705
+ hasShadowOffset: keys.some((k) => SHADOW_OFFSET_KEY_SET.has(k)),
706
+ hasBoxShadow: keys.includes('boxShadow'),
707
+ }
699
708
  // Animating `boxShadow` alongside the native `shadow*` keys puts two
700
709
  // shadow systems on one element; whichever the underlying view resolves
701
710
  // last wins, and the result reads as a bug rather than a choice.
@@ -703,10 +712,8 @@ export function createMotionComponent<C extends ComponentType<any>>(
703
712
  // decides it, and this block only runs when that set grows.
704
713
  if (
705
714
  __DEV__ &&
706
- hasBoxShadowRef.current &&
707
- activeKeysRef.current.some(
708
- (k) => k !== 'boxShadow' && k.startsWith('shadow'),
709
- )
715
+ activeSetRef.current.hasBoxShadow &&
716
+ keys.some((k) => k !== 'boxShadow' && k.startsWith('shadow'))
710
717
  ) {
711
718
  warnOnce(
712
719
  'boxShadow-with-native-shadow',
@@ -717,6 +724,25 @@ export function createMotionComponent<C extends ComponentType<any>>(
717
724
  )
718
725
  }
719
726
  }
727
+ const activeKeys = activeSetRef.current!.keys
728
+
729
+ // The worklet's copy of the active set. It must be a shared value, not
730
+ // the ref above: react-native-worklets clones a captured plain object
731
+ // once, when the worklet is first serialised, and in dev it freezes the
732
+ // original so later writes to `.current` are dropped with a warning. A
733
+ // ref read inside the worklet therefore only ever sees the mount-time
734
+ // set, and a key added after mount drives its shared value but never
735
+ // reaches the screen (Worklet rule 2). The Jest mock runs the worklet on
736
+ // the JS thread, so only a device shows the difference. The write lives
737
+ // in an effect because Reanimated's strict mode warns on a shared-value
738
+ // write during render; it is declared before the value-driving effects
739
+ // below, so the grown set reaches the UI thread in the same commit as
740
+ // the first animation of the new key.
741
+ const activeSetSV = useSharedValue<ActiveKeySet>(activeSetRef.current!)
742
+ const activeSet = activeSetRef.current!
743
+ useEffect(() => {
744
+ if (activeSetSV.value !== activeSet) activeSetSV.value = activeSet
745
+ }, [activeSetSV, activeSet])
720
746
 
721
747
  // Which keys a record actually drives *right now*. Everything else in
722
748
  // the active set is there only because a gesture sub-state, an `exit`
@@ -741,7 +767,6 @@ export function createMotionComponent<C extends ComponentType<any>>(
741
767
  // Resting values pulled off the static `style`, for active keys nothing
742
768
  // has driven. Gated so the common `animate`-only instance never flattens
743
769
  // a style it has no use for.
744
- const activeKeys = activeKeysRef.current!
745
770
  const everDriven = everDrivenRef.current
746
771
  const styleResting: Partial<Record<AnimatableKey, AnimatableSlotValue>> =
747
772
  {}
@@ -857,7 +882,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
857
882
 
858
883
  const styleRestingSig = stableSig(styleResting)
859
884
  useEffect(() => {
860
- for (const key of activeKeysRef.current!) {
885
+ for (const key of activeKeys) {
861
886
  if (everDrivenRef.current.has(key)) continue
862
887
  sharedValues[key].value = (styleResting[key] ??
863
888
  DEFAULT_RESTING[key]) as never
@@ -926,6 +951,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
926
951
  (isExiting ? '|exit' : '') +
927
952
  (shouldReduceMotion ? '|rm' : '')
928
953
  const transitionSig = stableSig(transition)
954
+ // The callback factory skips callback installation when there is no
955
+ // `onAnimationEnd` and nothing to settle, so a handler that appears
956
+ // after mount needs the effect to run again. Keyed on presence, not
957
+ // identity: a changing handler is read through `onAnimationEndRef`.
958
+ const hasAnimationEnd = onAnimationEnd !== undefined
929
959
 
930
960
  // Stable ref to the live `safeToRemove` so the effect's settle-counter
931
961
  // closure can reach the latest <Presence> binding without retriggering.
@@ -943,6 +973,10 @@ export function createMotionComponent<C extends ComponentType<any>>(
943
973
  return
944
974
  }
945
975
 
976
+ // `done` doubles as the run token. When this effect re-runs, the
977
+ // cleanup below sets it, so the callbacks of the superseded animations
978
+ // — which Reanimated fires with `finished: false` as they are replaced
979
+ // — cannot drain this run's counter and release <Presence> early.
946
980
  let pending = 0
947
981
  let done = false
948
982
  const onSettle = () => {
@@ -1095,8 +1129,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
1095
1129
  if (isExiting && pending === 0) {
1096
1130
  safeToRemoveRef.current?.()
1097
1131
  }
1132
+ return () => {
1133
+ done = true
1134
+ }
1098
1135
  // eslint-disable-next-line react-hooks/exhaustive-deps
1099
- }, [baseSig, transitionSig])
1136
+ }, [baseSig, transitionSig, hasAnimationEnd])
1100
1137
 
1101
1138
  // Per-layer progress: when a sub-state activation flips, animate its
1102
1139
  // progress SV 0↔1 with the layer's own transition (or the parent
@@ -1162,7 +1199,7 @@ export function createMotionComponent<C extends ComponentType<any>>(
1162
1199
  if (!hasLayoutId) return undefined
1163
1200
  const out: SharedStyleSnapshot = {}
1164
1201
  let any = false
1165
- for (const key of activeKeysRef.current!) {
1202
+ for (const key of activeKeys) {
1166
1203
  if (!SHARED_STYLE_KEY_SET.has(key)) continue
1167
1204
  const v = sharedValues[key].value
1168
1205
  // Structured slots can't be carried, and a key nothing ever gave a
@@ -1186,10 +1223,11 @@ export function createMotionComponent<C extends ComponentType<any>>(
1186
1223
  const carry = sharedLayout.carry
1187
1224
 
1188
1225
  const animatedStyle = useAnimatedStyle(() => {
1189
- const activeKeys = activeKeysRef.current!
1190
- const hasTransform = hasTransformRef.current
1191
- const hasShadowOffset = hasShadowOffsetRef.current
1192
- const hasBoxShadow = hasBoxShadowRef.current
1226
+ const activeSet = activeSetSV.value
1227
+ const activeKeys = activeSet.keys
1228
+ const hasTransform = activeSet.hasTransform
1229
+ const hasShadowOffset = activeSet.hasShadowOffset
1230
+ const hasBoxShadow = activeSet.hasBoxShadow
1193
1231
  const out: Record<string, unknown> = {}
1194
1232
  const transform: Array<Record<string, unknown>> = []
1195
1233
  // shadow-offset reassembly buffers. The two synthetic axis SVs feed in
@@ -1448,7 +1486,7 @@ type SharedValueMap = Record<AnimatableKey, SharedValue<AnimatableSlotValue>>
1448
1486
  * The ESLint exhaustive-deps rule can't see through the loop, hence the
1449
1487
  * disable — the invariant it would check for us is asserted above instead.
1450
1488
  *
1451
- * Unused shared values are cheap; the worklet skips them via `activeKeysRef`.
1489
+ * Unused shared values are cheap; the worklet skips them via `activeSetSV`.
1452
1490
  * Color keys are seeded with the initial color string so Reanimated's value
1453
1491
  * setter recognizes the slot as a color from the first `withSpring` /
1454
1492
  * `withTiming` call.
@@ -1805,12 +1843,9 @@ function stepCountOf(v: AnimatableValue<number | string> | undefined): number {
1805
1843
  */
1806
1844
  function totalIterationsOf(cfg: TransitionConfig | undefined): number {
1807
1845
  if (!cfg || cfg.type === 'no-animation' || cfg.type === 'decay') return 1
1808
- const r = cfg.repeat
1809
- if (r === undefined) return 1
1810
- if (r === 'infinite') return Number.POSITIVE_INFINITY
1811
- if (typeof r === 'number') return r
1812
- if (r.count === 'infinite') return Number.POSITIVE_INFINITY
1813
- return r.count
1846
+ // Shares the resolver's count rule, so a `repeat: 0` that the resolver
1847
+ // treats as "run once" is also counted as one iteration here.
1848
+ return repeatIterationsOf(cfg.repeat)
1814
1849
  }
1815
1850
 
1816
1851
  /**
@@ -1880,16 +1915,23 @@ function resolveAnimateInput(
1880
1915
  variants: VariantsMap<unknown> | undefined,
1881
1916
  controllerKey: string | undefined,
1882
1917
  ): AnimateStyle<unknown> | undefined {
1918
+ if (controllerKey !== undefined && animate !== undefined) {
1919
+ warnOnce(
1920
+ 'controller-and-animate',
1921
+ '[inertia] Both `controller` and `animate` are set on the same ' +
1922
+ 'primitive. The controller drives the animation and `animate` is ' +
1923
+ 'ignored — remove one of the two.',
1924
+ )
1925
+ }
1883
1926
  if (controllerKey !== undefined && variants && controllerKey in variants) {
1884
1927
  return variants[controllerKey]
1885
1928
  }
1886
1929
  if (typeof animate === 'string') {
1887
1930
  if (variants && animate in variants) return variants[animate]
1888
- if (__DEV__) {
1889
- console.warn(
1890
- `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
1891
- )
1892
- }
1931
+ warnOnce(
1932
+ `missing-variant:${animate}`,
1933
+ `[inertia] animate="${animate}" but no matching variant. Did you forget to pass \`variants\`?`,
1934
+ )
1893
1935
  return undefined
1894
1936
  }
1895
1937
  return animate as AnimateStyle<unknown> | undefined
@@ -2047,6 +2089,12 @@ function useGestureHandlers(
2047
2089
  const hasFocused = gesture?.focused ? 1 : 0
2048
2090
  const hasFocusVisible = gesture?.focusVisible ? 1 : 0
2049
2091
  const hasHovered = gesture?.hovered ? 1 : 0
2092
+ // The web modality listeners attach on the first mount that tracks
2093
+ // `focusVisible` — see `focusVisibility.ts` for why mount time is early
2094
+ // enough and why import time is not an option.
2095
+ useEffect(() => {
2096
+ if (hasFocusVisible) installFocusVisibility()
2097
+ }, [hasFocusVisible])
2050
2098
  return useMemo(() => {
2051
2099
  if (!gesture) return {}
2052
2100
  const handlers: GestureHandlers = {}
@@ -9,6 +9,7 @@ import {
9
9
  useRef,
10
10
  useState,
11
11
  } from 'react'
12
+ import { warnOnce } from '../internal/warnOnce'
12
13
  import { PresenceContext, type PresenceContextValue } from './PresenceContext'
13
14
 
14
15
  interface RenderEntry {
@@ -38,11 +39,10 @@ export function Presence({ children }: { children: ReactNode }) {
38
39
  Children.forEach(children, (child) => {
39
40
  if (!isValidElement(child)) return
40
41
  if (child.key === null) {
41
- if (__DEV__) {
42
- console.warn(
43
- '[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
44
- )
45
- }
42
+ warnOnce(
43
+ 'presence-keyless-child',
44
+ '[inertia] <Presence> children must have a `key`. Skipping a keyless child.',
45
+ )
46
46
  return
47
47
  }
48
48
  out.push(child)
@@ -224,5 +224,3 @@ function PresenceItem({
224
224
  </PresenceContext.Provider>
225
225
  )
226
226
  }
227
-
228
- declare const __DEV__: boolean
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Clamp `value` to `[min, max]`. When `elastic > 0` the overshoot past a
3
+ * bound is scaled by `elastic` instead of hard-clamped, giving a rubber-band
4
+ * feel. `min` / `max` may be `undefined` to leave that side unbounded.
5
+ *
6
+ * Worklet, so it runs both inside a gesture-handler pan handler on the UI
7
+ * thread (`useDrag`) and from a JS-thread `PanResponder` callback
8
+ * (`useTouchDrag`).
9
+ *
10
+ * ```ts
11
+ * applyBounds(120, -100, 100, 0) // 100
12
+ * applyBounds(120, -100, 100, 0.5) // 110
13
+ * applyBounds(50, undefined, 100, 0) // 50
14
+ * ```
15
+ */
16
+ export function applyBounds(
17
+ value: number,
18
+ min: number | undefined,
19
+ max: number | undefined,
20
+ elastic: number,
21
+ ): number {
22
+ 'worklet'
23
+ if (min !== undefined && value < min) {
24
+ return elastic > 0 ? min + (value - min) * elastic : min
25
+ }
26
+ if (max !== undefined && value > max) {
27
+ return elastic > 0 ? max + (value - max) * elastic : max
28
+ }
29
+ return value
30
+ }
@@ -8,6 +8,7 @@
8
8
  * prefer `useDrag` from `@rootnative/inertia-gestures` — its UI-thread
9
9
  * release path is more precise.
10
10
  */
11
+ export { applyBounds } from './applyBounds'
11
12
  export { useTouchDrag } from './useTouchDrag'
12
13
  export type {
13
14
  TouchReleaseInfo,
@@ -1,15 +1,14 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, useRef } from 'react'
2
2
  import {
3
3
  PanResponder,
4
4
  type PanResponderGestureState,
5
5
  type PanResponderInstance,
6
6
  } from 'react-native'
7
- import {
8
- useAnimatedStyle,
9
- useSharedValue,
10
- type SharedValue,
11
- } from 'react-native-reanimated'
7
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
8
+ import type { useAnimatedStyle } from 'react-native-reanimated'
12
9
  import { buildReleaseAnimation } from '../transitions'
10
+ import { useTranslateStyle } from '../values/useTranslateStyle'
11
+ import { applyBounds } from './applyBounds'
13
12
  import type { TransitionConfig } from '../types'
14
13
 
15
14
  /**
@@ -166,23 +165,17 @@ export function useTouchDrag(
166
165
  const top = constraints?.top
167
166
  const bottom = constraints?.bottom
168
167
  const elasticCoef = elastic
169
- const { onDragStart, onDragEnd, onRelease } = options
168
+
169
+ // Callbacks are read through a ref at call time, so an inline arrow in
170
+ // `options` does not rebuild the PanResponder every render. PanResponder
171
+ // callbacks run on the JS thread, so a ref read is safe here.
172
+ const callbacksRef = useRef(options)
173
+ callbacksRef.current = options
170
174
 
171
175
  const responder = useMemo(
172
176
  () => buildResponder(),
173
177
  // 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
- ],
178
+ [lockX, lockY, left, right, top, bottom, elasticCoef],
186
179
  )
187
180
 
188
181
  // Hoisted out of the inline `useMemo` factory to keep the dep list readable
@@ -196,6 +189,7 @@ export function useTouchDrag(
196
189
  // `@rootnative/inertia-gestures` API (px/sec from gesture-handler).
197
190
  const vx = g.vx * 1000
198
191
  const vy = g.vy * 1000
192
+ const { onRelease, onDragEnd } = callbacksRef.current
199
193
  if (onRelease) {
200
194
  const result = onRelease({ x, y, velocity: { x: vx, y: vy } })
201
195
  if (result) {
@@ -228,7 +222,7 @@ export function useTouchDrag(
228
222
  startX.value = dragX.value
229
223
  startY.value = dragY.value
230
224
  isDragging.value = true
231
- if (onDragStart) onDragStart()
225
+ callbacksRef.current.onDragStart?.()
232
226
  },
233
227
  onPanResponderMove: (_e, g) => {
234
228
  if (lockX) {
@@ -253,9 +247,7 @@ export function useTouchDrag(
253
247
  })
254
248
  }
255
249
 
256
- const animatedStyle = useAnimatedStyle(() => ({
257
- transform: [{ translateX: dragX.value }, { translateY: dragY.value }],
258
- }))
250
+ const animatedStyle = useTranslateStyle(dragX, dragY)
259
251
 
260
252
  return {
261
253
  panHandlers: responder.panHandlers,
@@ -265,25 +257,3 @@ export function useTouchDrag(
265
257
  isDragging,
266
258
  }
267
259
  }
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
- }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Default `duration` for a `timing` transition that does not set one, in ms.
3
+ * Shared by the JS-thread resolver (`resolve.ts`) and the UI-thread builder
4
+ * (`runtime.ts`) so the two paths cannot drift apart.
5
+ */
6
+ export const DEFAULT_TIMING_DURATION = 250
7
+
8
+ /**
9
+ * Default `duration` for a `timing` layout transition (`layout` prop and
10
+ * `layoutId` shared-element legs), in ms. Layout moves cover more distance
11
+ * than a property tween, so the default is longer than
12
+ * `DEFAULT_TIMING_DURATION`.
13
+ */
14
+ export const DEFAULT_LAYOUT_DURATION = 300
@@ -2,7 +2,11 @@
2
2
  // dep); Reanimated's own re-export is deprecated.
3
3
  import { isWorkletFunction } from 'react-native-worklets'
4
4
  import { warnNonWorkletOnce } from '../internal/nonWorkletWarning'
5
- import { type EasingInput } from '../types'
5
+ import {
6
+ type EasingFunction,
7
+ type EasingFunctionFactory,
8
+ type EasingInput,
9
+ } from '../types'
6
10
 
7
11
  /**
8
12
  * Reanimated 3.9+ validates that easing functions used in nested-transition
@@ -33,7 +37,7 @@ export function ensureWorkletEasing(
33
37
  // Reanimated 4 `EasingFunctionFactory` — unwrap via `.factory()` before
34
38
  // checking worklet status, so the wrapped fn (not the factory wrapper)
35
39
  // ends up in the transition config.
36
- const fn = isEasingFactory(easing) ? easing.factory() : easing
40
+ const fn = unwrapEasingFactory(easing)
37
41
  if (isWorkletFunction(fn)) return fn
38
42
  warnNonWorkletOnce(
39
43
  'timing-easing',
@@ -46,13 +50,21 @@ export function ensureWorkletEasing(
46
50
  return wrapped
47
51
  }
48
52
 
49
- function isEasingFactory(
50
- value: EasingInput,
51
- ): value is { factory: () => (t: number) => number } {
52
- return (
53
- typeof value === 'object' &&
54
- value !== null &&
55
- 'factory' in value &&
56
- typeof (value as { factory: unknown }).factory === 'function'
57
- )
53
+ /**
54
+ * Return the easing function behind an `EasingInput`: a Reanimated 4
55
+ * `EasingFunctionFactory` is unwrapped through `.factory()`, a plain function
56
+ * passes through. Worklet, so `buildReleaseAnimation` can call it from a
57
+ * gesture `onEnd` handler on the UI thread.
58
+ */
59
+ export function unwrapEasingFactory(easing: EasingInput): EasingFunction {
60
+ 'worklet'
61
+ if (
62
+ typeof easing === 'object' &&
63
+ easing !== null &&
64
+ 'factory' in easing &&
65
+ typeof (easing as { factory: unknown }).factory === 'function'
66
+ ) {
67
+ return (easing as EasingFunctionFactory).factory()
68
+ }
69
+ return easing as EasingFunction
58
70
  }
@@ -1,9 +1,14 @@
1
1
  export {
2
2
  applyDelay,
3
+ applyRepeat,
4
+ normalizeRepeat,
5
+ repeatIterationsOf,
3
6
  resolveTransition,
4
7
  type AnimationCallback,
5
8
  type CallbackFactory,
9
+ type NormalizedRepeat,
6
10
  } from './resolve'
11
+ export { DEFAULT_LAYOUT_DURATION, DEFAULT_TIMING_DURATION } from './constants'
7
12
  export { resolveAnimatableValue } from './resolveSequence'
8
13
  export { cubicBezier } from './cubicBezier'
9
14
  export { ensureWorkletEasing } from './easing'
@@ -14,8 +14,6 @@ export const TRANSITION_CONFIG_KEYS = new Set([
14
14
  'friction',
15
15
  'mass',
16
16
  'velocity',
17
- 'restSpeedThreshold',
18
- 'restDisplacementThreshold',
19
17
  'duration',
20
18
  'easing',
21
19
  'delay',
@@ -6,8 +6,10 @@ import {
6
6
  withSpring,
7
7
  withTiming,
8
8
  } from 'react-native-reanimated'
9
+ import { DEFAULT_TIMING_DURATION } from './constants'
9
10
  import { ensureWorkletEasing } from './easing'
10
11
  import { springToReanimated } from './spring'
12
+ import { warnOnce } from '../internal/warnOnce'
11
13
  import {
12
14
  type DecayTransition,
13
15
  type RepeatConfig,
@@ -37,8 +39,6 @@ export type CallbackFactory = (
37
39
  step: number | undefined,
38
40
  ) => AnimationCallback | undefined
39
41
 
40
- const DEFAULT_TIMING_DURATION = 250
41
-
42
42
  function buildSpring(
43
43
  cfg: SpringTransition,
44
44
  toValue: number | string,
@@ -94,26 +94,101 @@ function buildOne(
94
94
  return buildSpring(cfg as SpringTransition, toValue, cb)
95
95
  }
96
96
 
97
+ /**
98
+ * Normalised repeat: `count` is the total number of iterations
99
+ * (`Number.POSITIVE_INFINITY` for `'infinite'`), `alternate` is the resolved
100
+ * flag, `explicitAlternate` says whether the caller set `alternate` by hand.
101
+ */
102
+ export interface NormalizedRepeat {
103
+ count: number
104
+ alternate: boolean
105
+ explicitAlternate: boolean
106
+ }
107
+
108
+ /**
109
+ * Reduce the three public `repeat` shapes to one record, or `undefined` when
110
+ * the animation runs once. A count below `1` is treated as "run once" and
111
+ * warns in dev: Reanimated's `withRepeat` reads `0` and negative counts as
112
+ * endless, so forwarding them would turn `repeat: 0` into an infinite loop.
113
+ */
114
+ export function normalizeRepeat(
115
+ repeat: RepeatConfig | undefined,
116
+ ): NormalizedRepeat | undefined {
117
+ if (repeat === undefined) return undefined
118
+ if (repeat === 'infinite') {
119
+ return {
120
+ count: Number.POSITIVE_INFINITY,
121
+ alternate: true,
122
+ explicitAlternate: false,
123
+ }
124
+ }
125
+ const rawCount = typeof repeat === 'number' ? repeat : repeat.count
126
+ const alternate = typeof repeat === 'number' ? true : repeat.alternate
127
+ if (rawCount === 'infinite') {
128
+ return {
129
+ count: Number.POSITIVE_INFINITY,
130
+ alternate: alternate ?? true,
131
+ explicitAlternate: alternate !== undefined,
132
+ }
133
+ }
134
+ if (!(rawCount >= 1)) {
135
+ warnOnce(
136
+ `repeat-count:${String(rawCount)}`,
137
+ `[inertia] repeat count ${String(rawCount)} is below 1 — the ` +
138
+ `animation runs once. Use \`repeat: 2\` or more to repeat, or ` +
139
+ `\`repeat: 'infinite'\`. (Reanimated reads a count of 0 as endless, ` +
140
+ `so it is not forwarded.)`,
141
+ )
142
+ return undefined
143
+ }
144
+ return {
145
+ count: rawCount,
146
+ alternate: alternate ?? true,
147
+ explicitAlternate: alternate !== undefined,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Total number of iterations an animation built from `repeat` runs, including
153
+ * the first pass. `1` when there is no repeat or the count is below 1;
154
+ * `Number.POSITIVE_INFINITY` for `'infinite'`.
155
+ */
156
+ export function repeatIterationsOf(repeat: RepeatConfig | undefined): number {
157
+ return normalizeRepeat(repeat)?.count ?? 1
158
+ }
159
+
97
160
  /**
98
161
  * Wrap an animation in `withRepeat` per the unified `repeat` shape:
99
162
  * - `number` → finite count, alternating direction
100
163
  * - `'infinite'` → endless, alternating direction
101
164
  * - `{ count, alternate }`→ explicit; `alternate` defaults to `true`
165
+ *
166
+ * Pass `{ sequence: true }` when `animation` is a `withSequence` result.
167
+ * Reanimated's `reverse` flag only swaps the wrapped animation's `toValue`,
168
+ * which a sequence ignores — it restarts at step 0 on every pass — so the
169
+ * flag is not forwarded for sequences. An explicit `alternate: true` on a
170
+ * sequence warns in dev; write the reverse steps into the sequence instead.
102
171
  */
103
172
  export function applyRepeat(
104
173
  animation: unknown,
105
174
  repeat: RepeatConfig | undefined,
175
+ options?: { sequence?: boolean },
106
176
  ) {
107
- if (repeat === undefined) return animation
108
- if (repeat === 'infinite') {
109
- return withRepeat(animation as never, -1, true)
110
- }
111
- if (typeof repeat === 'number') {
112
- return withRepeat(animation as never, repeat, true)
177
+ const r = normalizeRepeat(repeat)
178
+ if (r === undefined) return animation
179
+ const count = Number.isFinite(r.count) ? r.count : -1
180
+ if (options?.sequence) {
181
+ if (r.explicitAlternate && r.alternate) {
182
+ warnOnce(
183
+ 'repeat-sequence-alternate',
184
+ '[inertia] repeat.alternate has no effect on a sequence — Reanimated ' +
185
+ 'restarts a sequence at its first step on every pass. Append the ' +
186
+ 'reverse steps to the sequence to alternate.',
187
+ )
188
+ }
189
+ return withRepeat(animation as never, count, false)
113
190
  }
114
- const count = repeat.count === 'infinite' ? -1 : repeat.count
115
- const alternate = repeat.alternate ?? true
116
- return withRepeat(animation as never, count, alternate)
191
+ return withRepeat(animation as never, count, r.alternate)
117
192
  }
118
193
 
119
194
  /**