@onlynative/inertia 0.0.1-alpha.3 → 0.0.1-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -5
- package/dist/index.d.mts +259 -3
- package/dist/index.d.ts +259 -3
- package/dist/index.js +1711 -118
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1709 -122
- package/dist/index.mjs.map +1 -1
- package/dist/motion/Image.d.mts +1 -1
- package/dist/motion/Image.d.ts +1 -1
- package/dist/motion/Image.js +1502 -64
- package/dist/motion/Image.js.map +1 -1
- package/dist/motion/Image.mjs +1504 -66
- package/dist/motion/Image.mjs.map +1 -1
- package/dist/motion/Pressable.d.mts +1 -1
- package/dist/motion/Pressable.d.ts +1 -1
- package/dist/motion/Pressable.js +1502 -64
- package/dist/motion/Pressable.js.map +1 -1
- package/dist/motion/Pressable.mjs +1504 -66
- package/dist/motion/Pressable.mjs.map +1 -1
- package/dist/motion/ScrollView.d.mts +1 -1
- package/dist/motion/ScrollView.d.ts +1 -1
- package/dist/motion/ScrollView.js +1502 -64
- package/dist/motion/ScrollView.js.map +1 -1
- package/dist/motion/ScrollView.mjs +1504 -66
- package/dist/motion/ScrollView.mjs.map +1 -1
- package/dist/motion/Text.d.mts +1 -1
- package/dist/motion/Text.d.ts +1 -1
- package/dist/motion/Text.js +1502 -64
- package/dist/motion/Text.js.map +1 -1
- package/dist/motion/Text.mjs +1504 -66
- package/dist/motion/Text.mjs.map +1 -1
- package/dist/motion/View.d.mts +1 -1
- package/dist/motion/View.d.ts +1 -1
- package/dist/motion/View.js +1502 -64
- package/dist/motion/View.js.map +1 -1
- package/dist/motion/View.mjs +1504 -66
- package/dist/motion/View.mjs.map +1 -1
- package/dist/{types-DAhX3fC2.d.mts → types-CjztO3RW.d.mts} +49 -4
- package/dist/{types-DAhX3fC2.d.ts → types-CjztO3RW.d.ts} +49 -4
- package/llms.txt +29 -4
- package/package.json +1 -1
- package/src/__type-tests__/animate.test-d.tsx +88 -0
- package/src/index.ts +16 -1
- package/src/layout/index.ts +1 -0
- package/src/layout/resolveLayout.ts +54 -0
- package/src/motion/createMotionComponent.tsx +38 -60
- package/src/transitions/easing.ts +3 -1
- package/src/transitions/index.ts +3 -0
- package/src/transitions/keys.ts +32 -0
- package/src/transitions/resolve.ts +1 -24
- package/src/transitions/sig.ts +40 -0
- package/src/transitions/spring.ts +41 -0
- package/src/types.ts +52 -2
- package/src/values/index.ts +14 -0
- package/src/values/useAnimation.ts +69 -0
- package/src/values/useGesture.ts +144 -0
- package/src/values/useMotionValue.ts +33 -0
- package/src/values/useScroll.ts +72 -0
- package/src/values/useSpring.ts +93 -0
- 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
|
-
|
|
104
|
-
|
|
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.
|
package/src/values/index.ts
CHANGED
|
@@ -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
|
+
}
|