@onlynative/inertia 0.0.1-alpha.3 → 0.0.1-alpha.4

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 (60) hide show
  1. package/README.md +19 -5
  2. package/dist/index.d.mts +259 -3
  3. package/dist/index.d.ts +259 -3
  4. package/dist/index.js +1711 -118
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1709 -122
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/motion/Image.d.mts +1 -1
  9. package/dist/motion/Image.d.ts +1 -1
  10. package/dist/motion/Image.js +1502 -64
  11. package/dist/motion/Image.js.map +1 -1
  12. package/dist/motion/Image.mjs +1504 -66
  13. package/dist/motion/Image.mjs.map +1 -1
  14. package/dist/motion/Pressable.d.mts +1 -1
  15. package/dist/motion/Pressable.d.ts +1 -1
  16. package/dist/motion/Pressable.js +1502 -64
  17. package/dist/motion/Pressable.js.map +1 -1
  18. package/dist/motion/Pressable.mjs +1504 -66
  19. package/dist/motion/Pressable.mjs.map +1 -1
  20. package/dist/motion/ScrollView.d.mts +1 -1
  21. package/dist/motion/ScrollView.d.ts +1 -1
  22. package/dist/motion/ScrollView.js +1502 -64
  23. package/dist/motion/ScrollView.js.map +1 -1
  24. package/dist/motion/ScrollView.mjs +1504 -66
  25. package/dist/motion/ScrollView.mjs.map +1 -1
  26. package/dist/motion/Text.d.mts +1 -1
  27. package/dist/motion/Text.d.ts +1 -1
  28. package/dist/motion/Text.js +1502 -64
  29. package/dist/motion/Text.js.map +1 -1
  30. package/dist/motion/Text.mjs +1504 -66
  31. package/dist/motion/Text.mjs.map +1 -1
  32. package/dist/motion/View.d.mts +1 -1
  33. package/dist/motion/View.d.ts +1 -1
  34. package/dist/motion/View.js +1502 -64
  35. package/dist/motion/View.js.map +1 -1
  36. package/dist/motion/View.mjs +1504 -66
  37. package/dist/motion/View.mjs.map +1 -1
  38. package/dist/{types-DAhX3fC2.d.mts → types-CjztO3RW.d.mts} +49 -4
  39. package/dist/{types-DAhX3fC2.d.ts → types-CjztO3RW.d.ts} +49 -4
  40. package/llms.txt +29 -4
  41. package/package.json +1 -1
  42. package/src/__type-tests__/animate.test-d.tsx +88 -0
  43. package/src/index.ts +16 -1
  44. package/src/layout/index.ts +1 -0
  45. package/src/layout/resolveLayout.ts +54 -0
  46. package/src/motion/createMotionComponent.tsx +38 -60
  47. package/src/transitions/easing.ts +3 -1
  48. package/src/transitions/index.ts +3 -0
  49. package/src/transitions/keys.ts +32 -0
  50. package/src/transitions/resolve.ts +1 -24
  51. package/src/transitions/sig.ts +40 -0
  52. package/src/transitions/spring.ts +41 -0
  53. package/src/types.ts +52 -2
  54. package/src/values/index.ts +14 -0
  55. package/src/values/useAnimation.ts +69 -0
  56. package/src/values/useGesture.ts +144 -0
  57. package/src/values/useMotionValue.ts +33 -0
  58. package/src/values/useScroll.ts +72 -0
  59. package/src/values/useSpring.ts +93 -0
  60. package/src/values/useTransform.ts +132 -0
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Stable string signature for an arbitrary value — used as a dep-array
3
+ * member so a fresh object literal each render doesn't re-fire an effect
4
+ * unless something structurally changed. Functions serialize as `null`
5
+ * (their identity isn't useful in a sig); `undefined` collapses to an empty
6
+ * string so omitted props compare equal across renders.
7
+ */
8
+ export function stableSig(value: unknown): string {
9
+ if (value === undefined) return ''
10
+ try {
11
+ return stableStringify(value)
12
+ } catch {
13
+ return String(value)
14
+ }
15
+ }
16
+
17
+ /**
18
+ * JSON.stringify with keys sorted at every level so a sig is invariant under
19
+ * property-declaration order. Functions and `undefined` both serialize as
20
+ * `null` — we accept the latter's information loss (rare in practice) in
21
+ * exchange for not crashing on circular function-bearing graphs.
22
+ */
23
+ function stableStringify(v: unknown): string {
24
+ if (v === null || typeof v !== 'object') {
25
+ if (typeof v === 'function' || v === undefined) return 'null'
26
+ return JSON.stringify(v)
27
+ }
28
+ if (Array.isArray(v)) {
29
+ return '[' + v.map(stableStringify).join(',') + ']'
30
+ }
31
+ const obj = v as Record<string, unknown>
32
+ const keys = Object.keys(obj).sort()
33
+ return (
34
+ '{' +
35
+ keys
36
+ .map((k) => JSON.stringify(k) + ':' + stableStringify(obj[k]))
37
+ .join(',') +
38
+ '}'
39
+ )
40
+ }
@@ -0,0 +1,41 @@
1
+ import { type SpringTransition } from '../types'
2
+
3
+ /**
4
+ * Default spring physics, expressed in react-spring vocabulary.
5
+ *
6
+ * `tension: 170` / `friction: 26` / `mass: 1` was picked over Reanimated's
7
+ * raw `stiffness: 100` / `damping: 10` default because the raw default
8
+ * overshoots noticeably for the small (~100px) translates that dominate
9
+ * UI work — buttons, sheets, popovers. These numbers settle in ~350ms with
10
+ * a single, almost-imperceptible overshoot, which matches the perceptual
11
+ * target the rest of the library is tuned against.
12
+ */
13
+ export const DEFAULT_SPRING: Required<
14
+ Pick<SpringTransition, 'tension' | 'friction' | 'mass'>
15
+ > = {
16
+ tension: 170,
17
+ friction: 26,
18
+ mass: 1,
19
+ }
20
+
21
+ /**
22
+ * Convert public react-spring vocabulary (`tension` / `friction` / `mass`)
23
+ * to Reanimated's raw `stiffness` / `damping` / `mass`. This is the single
24
+ * place the mapping lives; resolvers, value hooks, and any future surface
25
+ * that needs a Reanimated spring config import from here.
26
+ *
27
+ * The mapping is identity (tension ≡ stiffness, friction ≡ damping) — the
28
+ * names differ but the underlying physics constants are the same. We don't
29
+ * surface the raw names publicly because the react-spring vocabulary is
30
+ * what designers and prior-art consumers expect.
31
+ */
32
+ export function springToReanimated(t: SpringTransition) {
33
+ return {
34
+ stiffness: t.tension ?? DEFAULT_SPRING.tension,
35
+ damping: t.friction ?? DEFAULT_SPRING.friction,
36
+ mass: t.mass ?? DEFAULT_SPRING.mass,
37
+ velocity: t.velocity,
38
+ restSpeedThreshold: t.restSpeedThreshold,
39
+ restDisplacementThreshold: t.restDisplacementThreshold,
40
+ }
41
+ }
package/src/types.ts CHANGED
@@ -95,13 +95,43 @@ export type Transition<S> =
95
95
  | TransitionConfig
96
96
  | (PerPropertyTransition<S> & GestureLayerTransitions)
97
97
 
98
+ /**
99
+ * Transform shorthands that Inertia exposes on `animate` but that don't
100
+ * appear on RN's typed ViewStyle as top-level keys. RN keeps `scale`,
101
+ * `rotate`, `rotateX`, and `rotateY` inside the `transform` array; only
102
+ * `scaleX`/`scaleY` and `translateX`/`translateY` are surfaced as
103
+ * (deprecated) top-level shortcuts. Inertia's runtime treats these as
104
+ * transform-group keys (see `TRANSFORM_KEYS` in `createMotionComponent`),
105
+ * so they're documented as first-class animatables in `CLAUDE.md` and must
106
+ * be reachable from `animate` without dropping into the `transform: [...]`
107
+ * array form. Rotation values are degrees as numbers — the runtime appends
108
+ * `'deg'` before handing the transform to Reanimated.
109
+ */
110
+ type AnimatableTransformExtras = {
111
+ scale?: AnimatableValue<number>
112
+ rotate?: AnimatableValue<number>
113
+ rotateX?: AnimatableValue<number>
114
+ rotateY?: AnimatableValue<number>
115
+ }
116
+
98
117
  /**
99
118
  * The animation state shape inferred from the underlying component's style
100
119
  * prop. We narrow to the value side of `style` so consumers see ViewStyle on
101
120
  * `Motion.View`, TextStyle on `Motion.Text`, etc. — no shared union.
121
+ *
122
+ * Some components (notably `Pressable`) type `style` as a union of
123
+ * `StyleProp<T>` and a callback `(state) => StyleProp<T>`. If we infer `S`
124
+ * directly from `StyleProp<infer S>`, the callback branch widens `S` to
125
+ * `unknown`, which collapses the animate map to `| {}` and silently
126
+ * accepts any key. Excluding functions first keeps inference tight.
102
127
  */
103
- export type AnimateStyle<C> = C extends { style?: StyleProp<infer S> }
104
- ? { [K in keyof S]?: AnimatableValue<S[K]> }
128
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
129
+ type _StyleValue<T> = Exclude<T, (...args: any[]) => any>
130
+
131
+ export type AnimateStyle<C> = C extends { style?: infer Raw }
132
+ ? _StyleValue<Raw> extends StyleProp<infer S>
133
+ ? { [K in keyof S]?: AnimatableValue<S[K]> } & AnimatableTransformExtras
134
+ : never
105
135
  : never
106
136
 
107
137
  export interface AnimationCallbackInfo<S> {
@@ -231,6 +261,26 @@ export interface MotionProps<C> {
231
261
  * precedence over the top-level transition.
232
262
  */
233
263
  transition?: Transition<AnimateStyle<C>>
264
+ /**
265
+ * Auto-layout animation. When the component's position or size changes
266
+ * because of a parent layout change (a flex sibling growing, a list
267
+ * reordering, a column toggling its width), interpolate between the old
268
+ * and new layout instead of snapping.
269
+ *
270
+ * - `true` — animate with the library's default spring.
271
+ * - `TransitionConfig` — spring (react-spring vocab) or timing config; the
272
+ * resolver bridges to Reanimated's `LinearTransition` builder.
273
+ * - omitted / `false` — no layout animation (default).
274
+ *
275
+ * Only `'spring'` / `'timing'` / `'no-animation'` map to layout transitions
276
+ * — decay is downgraded to spring (no clear target). Reduced motion gates
277
+ * the prop the same way it gates `animate`.
278
+ *
279
+ * `layoutId` for shared element transitions across screens is deferred:
280
+ * Reanimated 4 dropped the underlying `sharedTransitionTag` API and a
281
+ * Inertia-side measure-based registry is the in-flight design.
282
+ */
283
+ layout?: boolean | TransitionConfig
234
284
  /**
235
285
  * Fired once per logical animation completion. See `AnimationCallbackInfo`
236
286
  * for the payload shape — transform parents fire once, not per axis.
@@ -1 +1,15 @@
1
+ export { useAnimation } from './useAnimation'
2
+ export {
3
+ useGesture,
4
+ type UseGestureHandlers,
5
+ type UseGestureResult,
6
+ } from './useGesture'
7
+ export { useMotionValue } from './useMotionValue'
8
+ export { useSpring } from './useSpring'
9
+ export {
10
+ useTransform,
11
+ type ExtrapolationMode,
12
+ type UseTransformOptions,
13
+ } from './useTransform'
14
+ export { useScroll, type UseScrollResult } from './useScroll'
1
15
  export { useVariants } from './useVariants'
@@ -0,0 +1,69 @@
1
+ import { useEffect } from 'react'
2
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
3
+ import { useShouldReduceMotion } from '../config'
4
+ import { resolveTransition, stableSig } from '../transitions'
5
+ import { type TransitionConfig } from '../types'
6
+
7
+ /**
8
+ * Drive a `SharedValue<number>` toward `target` with **any** transition shape
9
+ * — spring, timing, decay, or no-animation. The general-purpose value-layer
10
+ * hook: reach for it when you need raw `useSharedValue + useEffect + withX`
11
+ * outside the declarative `animate` flow.
12
+ *
13
+ * Re-runs whenever `target` changes shape (`target` is in the dep array) or
14
+ * the transition signature changes (kept stable via JSON-style hashing).
15
+ * Reduced motion (via `<MotionConfig reducedMotion>`) collapses the
16
+ * transition to `no-animation` so the value snaps instead of interpolating.
17
+ *
18
+ * **Spring shorthand.** Prefer [`useSpring`](./useSpring) when you only want
19
+ * spring physics — it accepts the same `tension`/`friction`/`mass` config and
20
+ * also supports a `SharedValue<number>` as the target (UI-thread reactive
21
+ * source). `useAnimation` is JS-thread-driven only.
22
+ *
23
+ * **Loops.** Repeat is part of `TransitionConfig` and flows through
24
+ * untouched — `useAnimation(1, { type: 'timing', duration: 1800, repeat: {
25
+ * count: 'infinite', alternate: false } })` produces an indeterminate-style
26
+ * progress driver.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * // Toggle progress (Switch / Checkbox / Radio).
31
+ * const progress = useAnimation(isChecked ? 1 : 0, {
32
+ * type: 'spring',
33
+ * tension: 380,
34
+ * friction: 33,
35
+ * })
36
+ *
37
+ * // Float a TextField label when the value becomes non-empty.
38
+ * const floated = useAnimation(hasValue ? 1 : 0, {
39
+ * type: 'timing',
40
+ * duration: 150,
41
+ * })
42
+ *
43
+ * // Indeterminate progress slider (loops forever, snaps back).
44
+ * const slide = useAnimation(1, {
45
+ * type: 'timing',
46
+ * duration: 1800,
47
+ * repeat: { count: 'infinite', alternate: false },
48
+ * })
49
+ * ```
50
+ */
51
+ export function useAnimation(
52
+ target: number,
53
+ transition?: TransitionConfig,
54
+ ): SharedValue<number> {
55
+ const output = useSharedValue<number>(target)
56
+ const shouldReduceMotion = useShouldReduceMotion()
57
+ const cfgSig = stableSig(transition)
58
+
59
+ useEffect(() => {
60
+ const cfg = shouldReduceMotion
61
+ ? ({ type: 'no-animation' } as const)
62
+ : (transition ?? ({ type: 'spring' } as const))
63
+ output.value = resolveTransition(cfg, target) as never
64
+ // `output` is identity-stable per hook instance.
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [target, cfgSig, shouldReduceMotion])
67
+
68
+ return output
69
+ }
@@ -0,0 +1,144 @@
1
+ import { useCallback, useMemo } from 'react'
2
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
3
+ import { useShouldReduceMotion } from '../config'
4
+ import { isFocusVisible } from '../gestures'
5
+ import { isTopLevelTransition, resolveTransition } from '../transitions'
6
+ import { type GestureLayerTransitions, type TransitionConfig } from '../types'
7
+
8
+ type LayerName = 'pressed' | 'focused' | 'focusVisible' | 'hovered'
9
+
10
+ /**
11
+ * Handler bag returned by `useGesture`. Spread on a `Pressable` to drive the
12
+ * shared values returned alongside.
13
+ *
14
+ * Hover handlers use `Pressable`'s own `onHoverIn` / `onHoverOut` names (web
15
+ * only — no-ops on native). `onFocus` consults `isFocusVisible()` before
16
+ * raising the keyboard-only `focusVisible` layer; `focused` always raises.
17
+ */
18
+ export interface UseGestureHandlers {
19
+ onPressIn: () => void
20
+ onPressOut: () => void
21
+ onHoverIn: () => void
22
+ onHoverOut: () => void
23
+ onFocus: () => void
24
+ onBlur: () => void
25
+ }
26
+
27
+ export interface UseGestureResult {
28
+ /** 0↔1 progress for the pressed layer. */
29
+ pressed: SharedValue<number>
30
+ /** 0↔1 progress for the focused layer (any focus modality). */
31
+ focused: SharedValue<number>
32
+ /** 0↔1 progress for the focusVisible layer (keyboard focus only). */
33
+ focusVisible: SharedValue<number>
34
+ /** 0↔1 progress for the hovered layer (web only — stays at 0 on native). */
35
+ hovered: SharedValue<number>
36
+ /** Handlers to spread on the receiving `Pressable`. */
37
+ handlers: UseGestureHandlers
38
+ }
39
+
40
+ /**
41
+ * Build a gesture-layer controller. The hook-form of the `gesture` prop —
42
+ * reach for it when you need to drive multiple animated views from the same
43
+ * gesture state (a focus ring + state-layer halo + content tint all on one
44
+ * Pressable), which the prop-form's "animate the receiver's own style" model
45
+ * can't express.
46
+ *
47
+ * Returns four 0↔1 shared values (one per layer) and a handler bag to spread
48
+ * on a `Pressable`. The shared values are stable across renders — feed them
49
+ * into any number of `useAnimatedStyle` blocks anywhere in the tree.
50
+ *
51
+ * Transitions follow the same shape as the `gesture` prop's accompanying
52
+ * `transition`: pass a single `TransitionConfig` to use for every layer, or a
53
+ * `GestureLayerTransitions` map to give each layer its own. Layers without an
54
+ * explicit transition fall back to the library default spring.
55
+ *
56
+ * Reduced motion (via `<MotionConfig reducedMotion>`) collapses every
57
+ * transition to `no-animation` so state changes snap instead of interpolating
58
+ * — same behaviour the gesture prop applies.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * import { useAnimatedStyle } from 'react-native-reanimated'
63
+ * import { useGesture } from '@onlynative/inertia'
64
+ *
65
+ * function Card() {
66
+ * const { pressed, focused, hovered, handlers } = useGesture({
67
+ * pressed: { type: 'timing', duration: 100 },
68
+ * hovered: { type: 'timing', duration: 150 },
69
+ * focused: { type: 'timing', duration: 200 },
70
+ * })
71
+ *
72
+ * const ringStyle = useAnimatedStyle(() => ({ opacity: focused.value }))
73
+ * const haloStyle = useAnimatedStyle(() => ({
74
+ * opacity: Math.max(
75
+ * hovered.value * 0.08,
76
+ * focused.value * 0.10,
77
+ * pressed.value * 0.10,
78
+ * ),
79
+ * }))
80
+ *
81
+ * return (
82
+ * <Pressable {...handlers}>
83
+ * <Animated.View style={ringStyle} />
84
+ * <Animated.View style={haloStyle} />
85
+ * </Pressable>
86
+ * )
87
+ * }
88
+ * ```
89
+ */
90
+ export function useGesture(
91
+ transition?: TransitionConfig | GestureLayerTransitions,
92
+ ): UseGestureResult {
93
+ const pressed = useSharedValue(0)
94
+ const focused = useSharedValue(0)
95
+ const focusVisible = useSharedValue(0)
96
+ const hovered = useSharedValue(0)
97
+ const shouldReduceMotion = useShouldReduceMotion()
98
+
99
+ const setLayer = useCallback(
100
+ (sv: SharedValue<number>, layer: LayerName, target: 0 | 1) => {
101
+ const cfg = shouldReduceMotion
102
+ ? ({ type: 'no-animation' } as const)
103
+ : (layerTransition(layer, transition) ?? ({ type: 'spring' } as const))
104
+ sv.value = resolveTransition(cfg, target) as never
105
+ },
106
+ // The transition is intentionally read on every call rather than cooked
107
+ // into the dep array — a fresh literal each render would otherwise
108
+ // rebuild the handler bag and break composing consumers that key off
109
+ // handler identity. `transition` is read inside the callback closure;
110
+ // shared values are stable so the only dep that matters is the reduce-
111
+ // motion flag.
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ [shouldReduceMotion],
114
+ )
115
+
116
+ const handlers = useMemo<UseGestureHandlers>(
117
+ () => ({
118
+ onPressIn: () => setLayer(pressed, 'pressed', 1),
119
+ onPressOut: () => setLayer(pressed, 'pressed', 0),
120
+ onHoverIn: () => setLayer(hovered, 'hovered', 1),
121
+ onHoverOut: () => setLayer(hovered, 'hovered', 0),
122
+ onFocus: () => {
123
+ setLayer(focused, 'focused', 1)
124
+ if (isFocusVisible()) setLayer(focusVisible, 'focusVisible', 1)
125
+ },
126
+ onBlur: () => {
127
+ setLayer(focused, 'focused', 0)
128
+ setLayer(focusVisible, 'focusVisible', 0)
129
+ },
130
+ }),
131
+ [setLayer, pressed, focused, focusVisible, hovered],
132
+ )
133
+
134
+ return { pressed, focused, focusVisible, hovered, handlers }
135
+ }
136
+
137
+ function layerTransition(
138
+ layer: LayerName,
139
+ transition: TransitionConfig | GestureLayerTransitions | undefined,
140
+ ): TransitionConfig | undefined {
141
+ if (!transition) return undefined
142
+ if (isTopLevelTransition(transition)) return transition
143
+ return (transition as GestureLayerTransitions)[layer]
144
+ }
@@ -0,0 +1,33 @@
1
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
2
+
3
+ /**
4
+ * Create an animatable value owned by JS but readable from worklets.
5
+ *
6
+ * This is the escape-hatch primitive that the rest of the value-layer hooks
7
+ * (`useSpring`, `useTransform`, `useScroll`) compose against. It is a thin
8
+ * pass-through over Reanimated's `useSharedValue`: a `SharedValue<T>` with
9
+ * `.value` for direct reads/writes (UI-thread reads in worklets, JS-thread
10
+ * writes from event handlers / effects).
11
+ *
12
+ * We intentionally do not introduce a `MotionValue` wrapper class around the
13
+ * shared value. The simplest object that interops with `useAnimatedStyle`,
14
+ * `useDerivedValue`, and every other Reanimated API _is_ the shared value
15
+ * itself; adding a `{ get, set, value }` shell would force consumers to
16
+ * unwrap it at every Reanimated boundary and break worklet capture.
17
+ *
18
+ * Worklet read:
19
+ * ```ts
20
+ * const x = useMotionValue(0)
21
+ * useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }))
22
+ * ```
23
+ *
24
+ * JS write:
25
+ * ```ts
26
+ * onPress={() => { x.value = 100 }}
27
+ * ```
28
+ */
29
+ export function useMotionValue<T extends number | string>(
30
+ initial: T,
31
+ ): SharedValue<T> {
32
+ return useSharedValue<T>(initial)
33
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ useAnimatedScrollHandler,
3
+ useSharedValue,
4
+ type SharedValue,
5
+ } from 'react-native-reanimated'
6
+ import { type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'
7
+
8
+ export interface UseScrollResult {
9
+ /** Horizontal scroll offset in points. */
10
+ scrollX: SharedValue<number>
11
+ /** Vertical scroll offset in points. */
12
+ scrollY: SharedValue<number>
13
+ /**
14
+ * Handler to pass to a `Motion.ScrollView`'s `onScroll` prop (or any other
15
+ * Reanimated `Animated.ScrollView`). The handler is opaque to JS — it runs
16
+ * as a worklet — but the type narrows to the same shape RN's native
17
+ * `onScroll` prop expects so it composes cleanly.
18
+ */
19
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void
20
+ }
21
+
22
+ /**
23
+ * Track the scroll offset of a `Motion.ScrollView` as shared values.
24
+ *
25
+ * ```tsx
26
+ * const { scrollY, onScroll } = useScroll()
27
+ * const headerOpacity = useTransform(scrollY, [0, 100], [1, 0])
28
+ *
29
+ * return (
30
+ * <>
31
+ * <Motion.View animate={{ opacity: headerOpacity }} />
32
+ * <Motion.ScrollView onScroll={onScroll} scrollEventThrottle={16}>
33
+ * …
34
+ * </Motion.ScrollView>
35
+ * </>
36
+ * )
37
+ * ```
38
+ *
39
+ * Scroll events fire on the UI thread, so `scrollX` / `scrollY` are safe to
40
+ * read from any worklet (`useAnimatedStyle`, `useDerivedValue`,
41
+ * `useTransform`) without a JS-thread bounce.
42
+ *
43
+ * Remember to set `scrollEventThrottle={16}` on the `ScrollView` for 60Hz
44
+ * updates — RN's default is to dispatch on every event, which on iOS still
45
+ * means one per frame, but Android benefits from the explicit cap.
46
+ */
47
+ export function useScroll(): UseScrollResult {
48
+ const scrollX = useSharedValue(0)
49
+ const scrollY = useSharedValue(0)
50
+
51
+ const handler = useAnimatedScrollHandler({
52
+ onScroll: (event) => {
53
+ 'worklet'
54
+ scrollX.value = event.contentOffset.x
55
+ scrollY.value = event.contentOffset.y
56
+ },
57
+ })
58
+
59
+ // `useAnimatedScrollHandler` returns an opaque worklet bag whose JS-side
60
+ // type is `(event) => void` but actually carries native event-handler
61
+ // wiring. Cast through `unknown` because the public RN `onScroll` type
62
+ // wants a `NativeSyntheticEvent`-taking function and Reanimated's handler
63
+ // is structurally compatible — the cast is to satisfy the consumer's
64
+ // prop type at the call site.
65
+ return {
66
+ scrollX,
67
+ scrollY,
68
+ onScroll: handler as unknown as (
69
+ event: NativeSyntheticEvent<NativeScrollEvent>,
70
+ ) => void,
71
+ }
72
+ }
@@ -0,0 +1,93 @@
1
+ import { useEffect, useMemo } from 'react'
2
+ import {
3
+ useAnimatedReaction,
4
+ useSharedValue,
5
+ withSpring,
6
+ type SharedValue,
7
+ } from 'react-native-reanimated'
8
+ import { springToReanimated } from '../transitions/spring'
9
+ import { type SpringTransition } from '../types'
10
+
11
+ /**
12
+ * Animate a shared value toward `target` with spring physics, using the
13
+ * library's react-spring vocabulary (`tension` / `friction` / `mass`).
14
+ *
15
+ * `target` may be a plain number or a `SharedValue<number>`. The plain-number
16
+ * path drives the spring from a JS `useEffect`, so the animation re-runs on
17
+ * every render where `target` changes. The shared-value path drives the
18
+ * spring from a Reanimated reaction on the UI thread, so values produced by
19
+ * gestures, scroll handlers, or other worklets flow through without bouncing
20
+ * back to JS.
21
+ *
22
+ * Both call sites end up at the same `withSpring` invocation; the split is
23
+ * just about which thread observes the source change.
24
+ */
25
+ export function useSpring(
26
+ target: number | SharedValue<number>,
27
+ config?: SpringTransition,
28
+ ): SharedValue<number> {
29
+ // Reanimated config is rebuilt only when the public config object changes
30
+ // shape. The worklet path reads this from JS-thread closure capture, which
31
+ // is fine: it's the resolved config that's invariant across UI-thread
32
+ // ticks, not a JS-thread reference that would go stale.
33
+ const reanimConfig = useMemo(
34
+ () => springToReanimated(config ?? {}),
35
+ [
36
+ config?.tension,
37
+ config?.friction,
38
+ config?.mass,
39
+ config?.velocity,
40
+ config?.restSpeedThreshold,
41
+ config?.restDisplacementThreshold,
42
+ ],
43
+ )
44
+
45
+ const isSharedTarget = isSharedValue(target)
46
+ const initial = isSharedTarget ? target.value : (target as number)
47
+ const output = useSharedValue<number>(initial)
48
+
49
+ // Plain-number path. The reaction below is a no-op when `target` is a
50
+ // number, so this effect carries the change. Reading `target` directly in
51
+ // the dep array means React drives the schedule; we don't have to babysit
52
+ // a stale closure.
53
+ useEffect(() => {
54
+ if (isSharedTarget) return
55
+ output.value = withSpring(target as number, reanimConfig)
56
+ // `output` is identity-stable per hook instance (Reanimated guarantee).
57
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
+ }, [isSharedTarget, target, reanimConfig])
59
+
60
+ // SharedValue path. `useAnimatedReaction` runs the prepare worklet whenever
61
+ // its returned value changes; we read `.value` off the target SV and pipe
62
+ // it through `withSpring` on the UI thread. When the target is a plain
63
+ // number we never declare a source so the reaction is inert (returns
64
+ // `null`, never fires `react`).
65
+ useAnimatedReaction(
66
+ () => {
67
+ 'worklet'
68
+ if (!isSharedTarget) return null
69
+ return (target as SharedValue<number>).value
70
+ },
71
+ (next, prev) => {
72
+ 'worklet'
73
+ if (next === null || next === prev) return
74
+ output.value = withSpring(next, reanimConfig)
75
+ },
76
+ [isSharedTarget, reanimConfig],
77
+ )
78
+
79
+ return output
80
+ }
81
+
82
+ function isSharedValue(v: unknown): v is SharedValue<number> {
83
+ // SharedValues are plain objects with a single `value` accessor — there is
84
+ // no public constructor or instanceof check. Reading `'value' in v` on any
85
+ // POJO would also pass, but the hook's call site already narrows the type;
86
+ // this guard exists to dispatch between the two implementation paths, not
87
+ // to validate untrusted input.
88
+ return (
89
+ typeof v === 'object' &&
90
+ v !== null &&
91
+ 'value' in (v as Record<string, unknown>)
92
+ )
93
+ }