@rootnative/inertia 0.0.1 → 0.0.3
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/CHANGELOG.md +36 -2
- package/dist/{chunk-TDSO63CJ.js → chunk-3XTVY34H.js} +2 -2
- package/dist/{chunk-NXDJZD6A.mjs → chunk-46P57VMY.mjs} +1 -1
- package/dist/{chunk-7UDYEFBU.js → chunk-BP3Y2SHQ.js} +477 -298
- package/dist/{chunk-6SMPIOIC.mjs → chunk-BQQTHG2V.mjs} +1 -1
- package/dist/{chunk-DWCLIBYO.mjs → chunk-CSODMRJ7.mjs} +478 -299
- package/dist/chunk-FNVFV4EY.js +8 -0
- package/dist/chunk-FWQOXA43.js +8 -0
- package/dist/chunk-KBP4LR75.js +8 -0
- package/dist/{chunk-ALRHDFZE.mjs → chunk-O22NXXCZ.mjs} +1 -1
- package/dist/{chunk-CWLFUYIY.mjs → chunk-OQV66TBQ.mjs} +1 -1
- package/dist/{chunk-JVBXPF2G.mjs → chunk-SGUHE5CX.mjs} +1 -1
- package/dist/{chunk-2HYD2ZBK.js → chunk-W5MC3P4N.js} +2 -2
- package/dist/index.d.mts +231 -43
- package/dist/index.d.ts +231 -43
- package/dist/index.js +212 -23
- package/dist/index.mjs +204 -18
- package/dist/motion/Image.js +3 -3
- package/dist/motion/Image.mjs +2 -2
- package/dist/motion/Pressable.js +3 -3
- package/dist/motion/Pressable.mjs +2 -2
- package/dist/motion/ScrollView.js +3 -3
- package/dist/motion/ScrollView.mjs +2 -2
- package/dist/motion/Text.js +3 -3
- package/dist/motion/Text.mjs +2 -2
- package/dist/motion/View.js +3 -3
- package/dist/motion/View.mjs +2 -2
- package/llms.txt +5 -0
- package/package.json +1 -1
- package/src/index.ts +10 -0
- package/src/layout/index.ts +1 -0
- package/src/layout/sharedRegistry.ts +51 -2
- package/src/motion/createMotionComponent.tsx +781 -502
- package/src/presence/Presence.tsx +73 -10
- package/src/values/index.ts +13 -0
- package/src/values/useAnimation.ts +15 -1
- package/src/values/useAnimator.ts +83 -0
- package/src/values/useColorCascade.ts +125 -0
- package/src/values/useInterpolatedStyle.ts +319 -0
- package/src/values/useMotionValue.ts +20 -2
- package/src/values/useSpring.ts +12 -0
- package/dist/chunk-3UTJJ4A3.js +0 -8
- package/dist/chunk-4QGXK6TF.js +0 -8
- package/dist/chunk-Z7HIOFKQ.js +0 -8
|
@@ -63,6 +63,20 @@ export function Presence({ children }: { children: ReactNode }) {
|
|
|
63
63
|
// synchronously alongside the setState call.
|
|
64
64
|
const prevIncomingRef = useRef<ReactElement[]>(incoming)
|
|
65
65
|
|
|
66
|
+
// Render order from the previous pass, *including* entries that were already
|
|
67
|
+
// exiting. An exiting child is by definition absent from `incoming`, so this
|
|
68
|
+
// is the only record of where it sat among its siblings.
|
|
69
|
+
const orderRef = useRef<Key[]>([])
|
|
70
|
+
|
|
71
|
+
// The exiting map this render should actually render with. On the render
|
|
72
|
+
// that detects a departure, `exiting` state is still the pre-departure map —
|
|
73
|
+
// `setExiting` below schedules the update but doesn't apply it here. Ordering
|
|
74
|
+
// has to see the departure immediately: if it doesn't, the key is missing
|
|
75
|
+
// from `orderRef` on the *next* render too, and the walk below (which only
|
|
76
|
+
// visits keys it remembers) would drop the child entirely instead of just
|
|
77
|
+
// misplacing it.
|
|
78
|
+
let pendingExiting: Map<Key, ReactElement> | null = null
|
|
79
|
+
|
|
66
80
|
if (prevIncomingRef.current !== incoming) {
|
|
67
81
|
const prev = prevIncomingRef.current
|
|
68
82
|
prevIncomingRef.current = incoming
|
|
@@ -91,9 +105,14 @@ export function Presence({ children }: { children: ReactNode }) {
|
|
|
91
105
|
}
|
|
92
106
|
}
|
|
93
107
|
|
|
94
|
-
if (next)
|
|
108
|
+
if (next) {
|
|
109
|
+
pendingExiting = next
|
|
110
|
+
setExiting(next)
|
|
111
|
+
}
|
|
95
112
|
}
|
|
96
113
|
|
|
114
|
+
const activeExiting = pendingExiting ?? exiting
|
|
115
|
+
|
|
97
116
|
const handleRemove = useCallback((key: Key) => {
|
|
98
117
|
setExiting((prev) => {
|
|
99
118
|
if (!prev.has(key)) return prev
|
|
@@ -107,20 +126,64 @@ export function Presence({ children }: { children: ReactNode }) {
|
|
|
107
126
|
// one array (rather than two `.map` calls inside a fragment) ensures React
|
|
108
127
|
// reconciles by `key` across positions — when an entry moves from
|
|
109
128
|
// present-list to exiting-list, the component instance persists.
|
|
110
|
-
|
|
129
|
+
//
|
|
130
|
+
// Exiting entries are spliced back in at the position they held, not
|
|
131
|
+
// appended. React reconciles this array by key, so appending *moves* the
|
|
132
|
+
// node: removing the middle of `a, b, c` rendered `a, c, b` and the
|
|
133
|
+
// departing row visibly jumped to the end before it had finished animating
|
|
134
|
+
// out. Absolutely-positioned overlays (popovers, sheets) never showed it;
|
|
135
|
+
// any list or column did.
|
|
136
|
+
const byKey = new Map<Key, ReactElement>()
|
|
137
|
+
const presentKeys = new Set<Key>()
|
|
138
|
+
const order: Key[] = []
|
|
111
139
|
for (const el of incoming) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
140
|
+
const key = el.key as Key
|
|
141
|
+
byKey.set(key, el)
|
|
142
|
+
presentKeys.add(key)
|
|
143
|
+
order.push(key)
|
|
117
144
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
145
|
+
|
|
146
|
+
// Walk the remembered order so that several adjacent departures keep their
|
|
147
|
+
// relative order, and anchor each one immediately after the nearest
|
|
148
|
+
// preceding sibling that is still rendered. No surviving predecessor means
|
|
149
|
+
// it was at the front, so it goes back to the front.
|
|
150
|
+
const prevOrder = orderRef.current
|
|
151
|
+
for (let i = 0; i < prevOrder.length; i++) {
|
|
152
|
+
const key = prevOrder[i]!
|
|
153
|
+
const departing = activeExiting.get(key)
|
|
154
|
+
if (!departing || byKey.has(key)) continue
|
|
155
|
+
let insertAt = 0
|
|
156
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
157
|
+
const anchor = order.indexOf(prevOrder[j]!)
|
|
158
|
+
if (anchor !== -1) {
|
|
159
|
+
insertAt = anchor + 1
|
|
160
|
+
break
|
|
161
|
+
}
|
|
121
162
|
}
|
|
163
|
+
byKey.set(key, departing)
|
|
164
|
+
order.splice(insertAt, 0, key)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Safety net: an exiting child the remembered order never saw still has to
|
|
168
|
+
// render, or it would unmount with no exit animation at all. Appending is
|
|
169
|
+
// the old (wrong-position) behaviour, which is strictly better than dropping
|
|
170
|
+
// it — in practice `activeExiting` keeps this loop empty.
|
|
171
|
+
for (const [key, el] of activeExiting) {
|
|
172
|
+
if (byKey.has(key)) continue
|
|
173
|
+
byKey.set(key, el)
|
|
174
|
+
order.push(key)
|
|
122
175
|
}
|
|
123
176
|
|
|
177
|
+
orderRef.current = order
|
|
178
|
+
|
|
179
|
+
// A live `incoming` entry always wins: a key that returns mid-exit is
|
|
180
|
+
// present again, and the same instance interrupts back toward `animate`.
|
|
181
|
+
const renderList: RenderEntry[] = order.map((key) => ({
|
|
182
|
+
key,
|
|
183
|
+
element: byKey.get(key)!,
|
|
184
|
+
isPresent: presentKeys.has(key),
|
|
185
|
+
}))
|
|
186
|
+
|
|
124
187
|
return (
|
|
125
188
|
<>
|
|
126
189
|
{renderList.map(({ key, element, isPresent }) => (
|
package/src/values/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { useAnimation } from './useAnimation'
|
|
2
|
+
export { useAnimator, type Animator } from './useAnimator'
|
|
2
3
|
export { useBooleanSpring } from './useBooleanSpring'
|
|
4
|
+
export {
|
|
5
|
+
useColorCascade,
|
|
6
|
+
type ColorCascadeLayer,
|
|
7
|
+
type UseColorCascadeOptions,
|
|
8
|
+
} from './useColorCascade'
|
|
3
9
|
export {
|
|
4
10
|
useColorTransition,
|
|
5
11
|
type ColorStyleKey,
|
|
@@ -10,6 +16,13 @@ export {
|
|
|
10
16
|
type UseGestureHandlers,
|
|
11
17
|
type UseGestureResult,
|
|
12
18
|
} from './useGesture'
|
|
19
|
+
export {
|
|
20
|
+
useInterpolatedStyle,
|
|
21
|
+
type InterpolatedStyleMap,
|
|
22
|
+
type NumericStyleKey,
|
|
23
|
+
type TransformKey,
|
|
24
|
+
type UseInterpolatedStyleOptions,
|
|
25
|
+
} from './useInterpolatedStyle'
|
|
13
26
|
export { useMotionValue } from './useMotionValue'
|
|
14
27
|
export { useSpring } from './useSpring'
|
|
15
28
|
export {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { useEffect } from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
cancelAnimation,
|
|
4
|
+
useSharedValue,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
} from 'react-native-reanimated'
|
|
3
7
|
import {
|
|
4
8
|
resolveNamedTransition,
|
|
5
9
|
useNamedTransitions,
|
|
@@ -71,5 +75,15 @@ export function useAnimation(
|
|
|
71
75
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
72
76
|
}, [target, cfgSig, shouldReduceMotion])
|
|
73
77
|
|
|
78
|
+
// Cancel the in-flight animation on unmount so an infinite-repeat or
|
|
79
|
+
// still-settling `withX` doesn't keep ticking against an orphaned value.
|
|
80
|
+
// `output` is identity-stable per hook instance and owned here.
|
|
81
|
+
useEffect(
|
|
82
|
+
() => () => cancelAnimation(output),
|
|
83
|
+
// `output` is identity-stable per hook instance (Reanimated guarantee).
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
[],
|
|
86
|
+
)
|
|
87
|
+
|
|
74
88
|
return output
|
|
75
89
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react'
|
|
2
|
+
import { type SharedValue } from 'react-native-reanimated'
|
|
3
|
+
import {
|
|
4
|
+
resolveNamedTransition,
|
|
5
|
+
useNamedTransitions,
|
|
6
|
+
useShouldReduceMotion,
|
|
7
|
+
} from '../config'
|
|
8
|
+
import { resolveTransition } from '../transitions'
|
|
9
|
+
import { type TransitionInput } from '../types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Imperative setter that drives a `SharedValue<number>` toward `to`, resolving
|
|
13
|
+
* the transition through the **same context** the declarative surface uses. It
|
|
14
|
+
* is the imperative escape hatch that closes the two footguns of writing
|
|
15
|
+
* `value.value = resolveTransition(config, to)` by hand from an event handler:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Named transitions resolve.** A `TransitionName` registered on the
|
|
18
|
+
* nearest `<MotionConfig transitions>` works here just as it does on the
|
|
19
|
+
* `transition` prop or in `useAnimation`. Raw `resolveTransition` can't
|
|
20
|
+
* reach the registry (names resolve via context), so imperative call sites
|
|
21
|
+
* otherwise rebuild configs the provider already owns.
|
|
22
|
+
* 2. **Reduced motion is respected.** Writes route through the same
|
|
23
|
+
* `no-animation` downgrade `useAnimation` applies under
|
|
24
|
+
* `<MotionConfig reducedMotion>`. Hand-rolled `resolveTransition` writes
|
|
25
|
+
* silently bypass that setting — a correctness bug this hook fixes.
|
|
26
|
+
*
|
|
27
|
+
* The returned callback is identity-stable for the lifetime of the component —
|
|
28
|
+
* it reads the registry and the reduced-motion flag out of refs at call time,
|
|
29
|
+
* so neither a new `<MotionConfig transitions>` map nor a reduced-motion change
|
|
30
|
+
* gives it a new identity. Drop it straight into memoized handlers or a
|
|
31
|
+
* `useCallback` dependency list without churning them.
|
|
32
|
+
*
|
|
33
|
+
* This is not a new animation API — it starts animations in Inertia's existing
|
|
34
|
+
* transition vocabulary, so it does not conflict with the "no imperative-only
|
|
35
|
+
* APIs that bypass the declarative surface" scope rule. It is the hooks-layer
|
|
36
|
+
* equivalent of `useMotionValue` + `resolveTransition`, minus the footguns.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const hovered = useMotionValue(0)
|
|
41
|
+
* const animate = useAnimator()
|
|
42
|
+
*
|
|
43
|
+
* const onHoverIn = () => animate(hovered, 1, 'state-hover')
|
|
44
|
+
* const onHoverOut = () => animate(hovered, 0, 'state-hover')
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* // Inline config works too; default is spring when omitted.
|
|
50
|
+
* animate(progress, 1, { type: 'timing', duration: 150 })
|
|
51
|
+
* animate(progress, 0) // spring
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export type Animator = (
|
|
55
|
+
value: SharedValue<number>,
|
|
56
|
+
to: number,
|
|
57
|
+
transition?: TransitionInput,
|
|
58
|
+
) => void
|
|
59
|
+
|
|
60
|
+
export function useAnimator(): Animator {
|
|
61
|
+
const registry = useNamedTransitions()
|
|
62
|
+
const shouldReduceMotion = useShouldReduceMotion()
|
|
63
|
+
|
|
64
|
+
// Latest context values behind refs, so the callback below can close over
|
|
65
|
+
// nothing that changes. Depending on them directly would hand back a new
|
|
66
|
+
// identity whenever a provider re-published its registry or the OS
|
|
67
|
+
// reduced-motion flag flipped — which breaks the documented contract that
|
|
68
|
+
// this is safe to drop into a memoized handler. Reading at call time is also
|
|
69
|
+
// strictly more correct: the write always resolves against the registry that
|
|
70
|
+
// is current *when the event fires*, not the one captured at render.
|
|
71
|
+
const registryRef = useRef(registry)
|
|
72
|
+
registryRef.current = registry
|
|
73
|
+
const reduceMotionRef = useRef(shouldReduceMotion)
|
|
74
|
+
reduceMotionRef.current = shouldReduceMotion
|
|
75
|
+
|
|
76
|
+
return useCallback((value, to, transition) => {
|
|
77
|
+
const resolved = resolveNamedTransition(transition, registryRef.current)
|
|
78
|
+
const cfg = reduceMotionRef.current
|
|
79
|
+
? ({ type: 'no-animation' } as const)
|
|
80
|
+
: (resolved ?? ({ type: 'spring' } as const))
|
|
81
|
+
value.value = resolveTransition(cfg, to) as never
|
|
82
|
+
}, [])
|
|
83
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useMemo, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
interpolateColor,
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
} from 'react-native-reanimated'
|
|
7
|
+
import type { ColorStyleKey } from './useColorTransition'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One layer in a color cascade: its own `progress` shared value (0→1) and the
|
|
11
|
+
* color it blends toward as that progress rises. Layers are ordered lowest
|
|
12
|
+
* priority first; a later layer wins over an earlier one at equal progress.
|
|
13
|
+
*/
|
|
14
|
+
export interface ColorCascadeLayer {
|
|
15
|
+
/** 0→1 driver for this layer. Drive it upstream (spring / boolean / gesture). */
|
|
16
|
+
progress: SharedValue<number>
|
|
17
|
+
/** The color this layer blends toward as `progress` moves 0→1. */
|
|
18
|
+
color: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseColorCascadeOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Which style slot the composited color is emitted under. Defaults to
|
|
24
|
+
* `backgroundColor` — identical to `useColorTransition`. Override for ring
|
|
25
|
+
* colors (`borderColor`), text (`color`), image tints (`tintColor`), etc.
|
|
26
|
+
*/
|
|
27
|
+
key?: ColorStyleKey
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Priority-ordered layered color crossfade: each layer owns an independent
|
|
32
|
+
* `progress` value and blends the accumulated color below it toward its own
|
|
33
|
+
* color as that progress moves 0→1. Later layers win over earlier ones — the
|
|
34
|
+
* array is priority order, **lowest first** (matching the `gesture` prop's
|
|
35
|
+
* fixed-priority cascade, Decision 5).
|
|
36
|
+
*
|
|
37
|
+
* Equivalent to the hand-chained nested-`interpolateColor` shape
|
|
38
|
+
* `focus(error(hover(rest)))`, collapsed into one hook and one worklet:
|
|
39
|
+
*
|
|
40
|
+
* ```tsx
|
|
41
|
+
* const borderStyle = useColorCascade(
|
|
42
|
+
* colors.border,
|
|
43
|
+
* [
|
|
44
|
+
* { progress: hovered, color: colors.borderHover },
|
|
45
|
+
* { progress: errored, color: colors.borderError },
|
|
46
|
+
* { progress: focused, color: colors.borderFocus },
|
|
47
|
+
* ],
|
|
48
|
+
* { key: 'borderColor' },
|
|
49
|
+
* )
|
|
50
|
+
*
|
|
51
|
+
* return <Motion.View style={[styles.field, borderStyle]} />
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* This is a pure interpolator — it does not animate on its own. Drive each
|
|
55
|
+
* layer's `progress` upstream with a `useSpring`, `useBooleanSpring`, gesture
|
|
56
|
+
* progress, or anything else producing a 0→1 shared value.
|
|
57
|
+
*
|
|
58
|
+
* For the single-layer case (`rest` ⇄ one active color), reach for
|
|
59
|
+
* [`useColorTransition`](./useColorTransition) — it is the fast path and this
|
|
60
|
+
* hook is not a replacement for it. For a mixed numeric + color cascade, or
|
|
61
|
+
* function-valued layers, drop to a hand-rolled `useAnimatedStyle`.
|
|
62
|
+
*
|
|
63
|
+
* The layer chain is resolved once on the JS thread and kept identity-stable,
|
|
64
|
+
* so a fresh-but-equal `layers` array each render produces no new UI-thread
|
|
65
|
+
* closure (CLAUDE.md principle 8). Changing a colour, the `key`, the base
|
|
66
|
+
* `rest`, the layer count, or **which shared value drives a layer** all rewire
|
|
67
|
+
* the worklet as you'd expect.
|
|
68
|
+
*/
|
|
69
|
+
export function useColorCascade(
|
|
70
|
+
rest: string,
|
|
71
|
+
layers: readonly ColorCascadeLayer[],
|
|
72
|
+
options?: UseColorCascadeOptions,
|
|
73
|
+
): ReturnType<typeof useAnimatedStyle> {
|
|
74
|
+
const key = options?.key ?? 'backgroundColor'
|
|
75
|
+
|
|
76
|
+
// Resolve the layer chain into two flat arrays the worklet closes over — the
|
|
77
|
+
// static colors and the live progress shared values. Both must keep a stable
|
|
78
|
+
// identity across renders where nothing really changed, or Reanimated sees a
|
|
79
|
+
// fresh closure dependency and rebuilds the UI-thread worklet every render
|
|
80
|
+
// (CLAUDE.md principle 8).
|
|
81
|
+
//
|
|
82
|
+
// Colors key off a structural signature.
|
|
83
|
+
const sig = `${key}|${rest}|${layers.length}|${layers
|
|
84
|
+
.map((l) => l.color)
|
|
85
|
+
.join(',')}`
|
|
86
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
+
const colors = useMemo(() => layers.map((l) => l.color), [sig])
|
|
88
|
+
|
|
89
|
+
// Progress values can't go in that signature — they're objects, not
|
|
90
|
+
// stringifiable. But they can't be excluded from change detection either:
|
|
91
|
+
// swapping *which* shared value drives a layer while its colour stays the
|
|
92
|
+
// same has to rewire the worklet. (It previously didn't: the cascade kept
|
|
93
|
+
// reading the old SV forever, silently.) So compare references directly and
|
|
94
|
+
// rebuild the array only when one actually differs — an equal-but-fresh
|
|
95
|
+
// `layers` literal still yields the same reference and no new worklet.
|
|
96
|
+
const progressRef = useRef<readonly SharedValue<number>[]>([])
|
|
97
|
+
const prevProgress = progressRef.current
|
|
98
|
+
let progressChanged = prevProgress.length !== layers.length
|
|
99
|
+
if (!progressChanged) {
|
|
100
|
+
for (let i = 0; i < layers.length; i++) {
|
|
101
|
+
if (prevProgress[i] !== layers[i]!.progress) {
|
|
102
|
+
progressChanged = true
|
|
103
|
+
break
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (progressChanged) progressRef.current = layers.map((l) => l.progress)
|
|
108
|
+
const progressValues = progressRef.current
|
|
109
|
+
|
|
110
|
+
return useAnimatedStyle(() => {
|
|
111
|
+
'worklet'
|
|
112
|
+
// Fold the layers bottom-up: each layer blends the accumulated color below
|
|
113
|
+
// it toward its own color as its progress rises, so a higher-priority
|
|
114
|
+
// layer at full progress overrides everything beneath it.
|
|
115
|
+
let acc = rest
|
|
116
|
+
for (let i = 0; i < colors.length; i++) {
|
|
117
|
+
acc = interpolateColor(
|
|
118
|
+
progressValues[i]!.value,
|
|
119
|
+
[0, 1],
|
|
120
|
+
[acc, colors[i]!],
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return { [key]: acc }
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Extrapolation,
|
|
4
|
+
interpolate,
|
|
5
|
+
interpolateColor,
|
|
6
|
+
useAnimatedStyle,
|
|
7
|
+
type SharedValue,
|
|
8
|
+
} from 'react-native-reanimated'
|
|
9
|
+
import type { ColorStyleKey } from './useColorTransition'
|
|
10
|
+
import type { ExtrapolationMode } from './useTransform'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Numeric style keys `useInterpolatedStyle` can emit directly (not lifted into
|
|
14
|
+
* the transform array). Mirrors the flat numeric surface of the `animate`
|
|
15
|
+
* prop.
|
|
16
|
+
*/
|
|
17
|
+
export type NumericStyleKey =
|
|
18
|
+
| 'opacity'
|
|
19
|
+
| 'width'
|
|
20
|
+
| 'height'
|
|
21
|
+
| 'borderRadius'
|
|
22
|
+
| 'shadowOpacity'
|
|
23
|
+
| 'shadowRadius'
|
|
24
|
+
| 'elevation'
|
|
25
|
+
| 'top'
|
|
26
|
+
| 'left'
|
|
27
|
+
| 'right'
|
|
28
|
+
| 'bottom'
|
|
29
|
+
| 'fontSize'
|
|
30
|
+
| 'lineHeight'
|
|
31
|
+
| 'letterSpacing'
|
|
32
|
+
| 'borderWidth'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Transform keys, lifted into a `transform: [...]` array in the order they
|
|
36
|
+
* appear in the map — the same key-order convention the `animate` prop uses.
|
|
37
|
+
* `rotate` / `rotateX` / `rotateY` take numeric degrees and emit
|
|
38
|
+
* `'<n>deg'` strings.
|
|
39
|
+
*/
|
|
40
|
+
export type TransformKey =
|
|
41
|
+
| 'translateX'
|
|
42
|
+
| 'translateY'
|
|
43
|
+
| 'scale'
|
|
44
|
+
| 'scaleX'
|
|
45
|
+
| 'scaleY'
|
|
46
|
+
| 'rotate'
|
|
47
|
+
| 'rotateX'
|
|
48
|
+
| 'rotateY'
|
|
49
|
+
|
|
50
|
+
const TRANSFORM_KEYS = new Set<string>([
|
|
51
|
+
'translateX',
|
|
52
|
+
'translateY',
|
|
53
|
+
'scale',
|
|
54
|
+
'scaleX',
|
|
55
|
+
'scaleY',
|
|
56
|
+
'rotate',
|
|
57
|
+
'rotateX',
|
|
58
|
+
'rotateY',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
const ROTATION_KEYS = new Set<string>(['rotate', 'rotateX', 'rotateY'])
|
|
62
|
+
|
|
63
|
+
const COLOR_KEYS = new Set<string>([
|
|
64
|
+
'backgroundColor',
|
|
65
|
+
'color',
|
|
66
|
+
'borderColor',
|
|
67
|
+
'borderTopColor',
|
|
68
|
+
'borderRightColor',
|
|
69
|
+
'borderBottomColor',
|
|
70
|
+
'borderLeftColor',
|
|
71
|
+
'tintColor',
|
|
72
|
+
'shadowColor',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Interpolation map: each entry maps `progress` onto an output range for one
|
|
77
|
+
* style or transform key. Numeric / transform keys take number stops; color
|
|
78
|
+
* keys take color-string stops. Mixing stop types per key is a compile error.
|
|
79
|
+
*/
|
|
80
|
+
export type InterpolatedStyleMap = {
|
|
81
|
+
[K in NumericStyleKey | TransformKey]?: readonly number[]
|
|
82
|
+
} & {
|
|
83
|
+
[K in ColorStyleKey]?: readonly string[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface UseInterpolatedStyleOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Input range mapped onto every key's output range. Defaults to `[0, 1]`
|
|
89
|
+
* for 2-stop outputs, and to evenly-spaced stops across `[0, 1]` for
|
|
90
|
+
* longer outputs. When provided, it applies to all keys; a key whose
|
|
91
|
+
* output length differs from `inputRange.length` throws in dev.
|
|
92
|
+
*/
|
|
93
|
+
inputRange?: readonly number[]
|
|
94
|
+
/**
|
|
95
|
+
* Edge behavior outside the input range. Defaults to `'clamp'`, matching
|
|
96
|
+
* `useColorTransition`. Applies to numeric keys; color interpolation
|
|
97
|
+
* always clamps (Reanimated's `interpolateColor` has no extrapolation
|
|
98
|
+
* option).
|
|
99
|
+
*/
|
|
100
|
+
extrapolate?: ExtrapolationMode
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One key's pre-resolved plan; the worklet consumes these flat records. */
|
|
104
|
+
interface NumericEntry {
|
|
105
|
+
kind: 'numeric'
|
|
106
|
+
key: string
|
|
107
|
+
input: number[]
|
|
108
|
+
output: number[]
|
|
109
|
+
}
|
|
110
|
+
interface RotationEntry {
|
|
111
|
+
kind: 'rotation'
|
|
112
|
+
key: string
|
|
113
|
+
input: number[]
|
|
114
|
+
output: number[]
|
|
115
|
+
}
|
|
116
|
+
interface TransformNumericEntry {
|
|
117
|
+
kind: 'transform-numeric'
|
|
118
|
+
key: string
|
|
119
|
+
input: number[]
|
|
120
|
+
output: number[]
|
|
121
|
+
}
|
|
122
|
+
interface ColorEntry {
|
|
123
|
+
kind: 'color'
|
|
124
|
+
key: string
|
|
125
|
+
input: number[]
|
|
126
|
+
output: string[]
|
|
127
|
+
}
|
|
128
|
+
type Entry = NumericEntry | RotationEntry | TransformNumericEntry | ColorEntry
|
|
129
|
+
|
|
130
|
+
function evenlySpaced(count: number): number[] {
|
|
131
|
+
if (count <= 1) return [0]
|
|
132
|
+
const out: number[] = []
|
|
133
|
+
for (let i = 0; i < count; i++) out.push(i / (count - 1))
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mapExtrapolation(mode: ExtrapolationMode | undefined): Extrapolation {
|
|
138
|
+
if (mode === 'identity') return Extrapolation.IDENTITY
|
|
139
|
+
if (mode === 'extend') return Extrapolation.EXTEND
|
|
140
|
+
return Extrapolation.CLAMP
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Order-preserving structural signature of the map + options. Unlike
|
|
145
|
+
* `stableSig` (which sorts keys), this walks `map` in insertion order because
|
|
146
|
+
* transform lifting depends on it.
|
|
147
|
+
*/
|
|
148
|
+
function buildSignature(
|
|
149
|
+
map: InterpolatedStyleMap,
|
|
150
|
+
options: UseInterpolatedStyleOptions | undefined,
|
|
151
|
+
): string {
|
|
152
|
+
let sig = ''
|
|
153
|
+
for (const key of Object.keys(map)) {
|
|
154
|
+
const output = (map as Record<string, readonly (number | string)[]>)[key]
|
|
155
|
+
sig += `${key}:${JSON.stringify(output)}|`
|
|
156
|
+
}
|
|
157
|
+
sig += `#ir:${JSON.stringify(options?.inputRange)}|ex:${options?.extrapolate ?? ''}`
|
|
158
|
+
return sig
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve the map into flat per-key plans the worklet consumes. Walks `map`
|
|
163
|
+
* in insertion order so transform axes emit in author order.
|
|
164
|
+
*/
|
|
165
|
+
function buildEntries(
|
|
166
|
+
map: InterpolatedStyleMap,
|
|
167
|
+
options: UseInterpolatedStyleOptions | undefined,
|
|
168
|
+
): Entry[] {
|
|
169
|
+
const explicitInput = options?.inputRange
|
|
170
|
+
const entries: Entry[] = []
|
|
171
|
+
for (const key of Object.keys(map) as (keyof InterpolatedStyleMap)[]) {
|
|
172
|
+
const output = map[key] as readonly (number | string)[] | undefined
|
|
173
|
+
if (output === undefined || output.length === 0) continue
|
|
174
|
+
|
|
175
|
+
const input = explicitInput
|
|
176
|
+
? (explicitInput as number[])
|
|
177
|
+
: output.length === 2
|
|
178
|
+
? [0, 1]
|
|
179
|
+
: evenlySpaced(output.length)
|
|
180
|
+
|
|
181
|
+
if (__DEV__ && explicitInput && explicitInput.length !== output.length) {
|
|
182
|
+
console.warn(
|
|
183
|
+
`[inertia] useInterpolatedStyle: inputRange has ${explicitInput.length} stops but the "${String(
|
|
184
|
+
key,
|
|
185
|
+
)}" output has ${output.length}. They must match — interpolation results are undefined otherwise.`,
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isColor =
|
|
190
|
+
COLOR_KEYS.has(key as string) && typeof output[0] === 'string'
|
|
191
|
+
if (isColor) {
|
|
192
|
+
entries.push({
|
|
193
|
+
kind: 'color',
|
|
194
|
+
key: key as string,
|
|
195
|
+
input,
|
|
196
|
+
output: output as string[],
|
|
197
|
+
})
|
|
198
|
+
} else if (ROTATION_KEYS.has(key as string)) {
|
|
199
|
+
entries.push({
|
|
200
|
+
kind: 'rotation',
|
|
201
|
+
key: key as string,
|
|
202
|
+
input,
|
|
203
|
+
output: output as number[],
|
|
204
|
+
})
|
|
205
|
+
} else if (TRANSFORM_KEYS.has(key as string)) {
|
|
206
|
+
entries.push({
|
|
207
|
+
kind: 'transform-numeric',
|
|
208
|
+
key: key as string,
|
|
209
|
+
input,
|
|
210
|
+
output: output as number[],
|
|
211
|
+
})
|
|
212
|
+
} else {
|
|
213
|
+
entries.push({
|
|
214
|
+
kind: 'numeric',
|
|
215
|
+
key: key as string,
|
|
216
|
+
input,
|
|
217
|
+
output: output as number[],
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return entries
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Map one `progress` shared value onto N style props via `interpolate` /
|
|
226
|
+
* `interpolateColor`, returning an animated style fragment that composes in a
|
|
227
|
+
* style array on any Reanimated-aware host (`Motion.*`, a hand-rolled
|
|
228
|
+
* `Animated.View`). The style-fragment counterpart to `useTransform`'s
|
|
229
|
+
* output-range form, in the same family as `useColorTransition` / `useShadow`.
|
|
230
|
+
*
|
|
231
|
+
* ```tsx
|
|
232
|
+
* const collapseStyle = useInterpolatedStyle(collapseProgress, {
|
|
233
|
+
* height: [expandedHeight, collapsedHeight],
|
|
234
|
+
* fontSize: [expanded.fontSize, collapsed.fontSize],
|
|
235
|
+
* })
|
|
236
|
+
*
|
|
237
|
+
* const labelStyle = useInterpolatedStyle(floatProgress, {
|
|
238
|
+
* translateY: [restingOffset, 0],
|
|
239
|
+
* scale: [restingScale, 1],
|
|
240
|
+
* })
|
|
241
|
+
*
|
|
242
|
+
* return <Motion.View style={[base, collapseStyle, labelStyle]} />
|
|
243
|
+
* ```
|
|
244
|
+
*
|
|
245
|
+
* This is a pure interpolator — it does not animate on its own. Drive
|
|
246
|
+
* `progress` upstream with a `useSpring`, `useBooleanSpring`, gesture
|
|
247
|
+
* progress, or scroll-derived `useTransform`.
|
|
248
|
+
*
|
|
249
|
+
* - Numeric / transform keys route through `interpolate`; color-string stops
|
|
250
|
+
* on a color key route through `interpolateColor` (a multi-stop
|
|
251
|
+
* `useColorTransition` without touching that hook).
|
|
252
|
+
* - Transform keys (`translateX`, `scale`, `rotate`, …) are lifted into a
|
|
253
|
+
* single `transform` array in the order they appear in the map. `rotate*`
|
|
254
|
+
* keys take numeric degrees and emit `'<n>deg'` strings, consistent with
|
|
255
|
+
* the `animate` surface.
|
|
256
|
+
* - `options.inputRange` defaults to `[0, 1]` for 2-stop outputs and to
|
|
257
|
+
* evenly-spaced stops otherwise. `options.extrapolate` defaults to
|
|
258
|
+
* `'clamp'`.
|
|
259
|
+
*
|
|
260
|
+
* For function-valued entries or multi-source composition, drop to a
|
|
261
|
+
* hand-rolled `useAnimatedStyle` (or `useTransform`'s worklet form) — this
|
|
262
|
+
* hook stays fully declarative and hashable so unchanged maps produce zero
|
|
263
|
+
* new UI-thread closures.
|
|
264
|
+
*/
|
|
265
|
+
export function useInterpolatedStyle(
|
|
266
|
+
progress: SharedValue<number>,
|
|
267
|
+
map: InterpolatedStyleMap,
|
|
268
|
+
options?: UseInterpolatedStyleOptions,
|
|
269
|
+
): ReturnType<typeof useAnimatedStyle> {
|
|
270
|
+
const extrapolate = mapExtrapolation(options?.extrapolate)
|
|
271
|
+
|
|
272
|
+
// Order-preserving signature: the map's key order is load-bearing (transform
|
|
273
|
+
// lifting emits axes in author order), so `stableSig` (which sorts keys) is
|
|
274
|
+
// wrong here — sign the ordered key/output pairs plus the options directly.
|
|
275
|
+
const sig = buildSignature(map, options)
|
|
276
|
+
|
|
277
|
+
// Resolve every key's plan once on the JS thread so the worklet body only
|
|
278
|
+
// consumes flat arrays — consistent with the JS-thread resolver principle
|
|
279
|
+
// that keeps `Object.keys`-style walks off the UI thread (CLAUDE.md
|
|
280
|
+
// principle 8). Memoized on `sig` so a fresh-but-equal map literal each
|
|
281
|
+
// render yields the same `entries` reference — Reanimated then sees an
|
|
282
|
+
// unchanged closure dependency and does not rebuild the UI-thread worklet.
|
|
283
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
284
|
+
const entries = useMemo<Entry[]>(() => buildEntries(map, options), [sig])
|
|
285
|
+
|
|
286
|
+
return useAnimatedStyle(() => {
|
|
287
|
+
'worklet'
|
|
288
|
+
const out: Record<string, unknown> = {}
|
|
289
|
+
const transform: Record<string, unknown>[] = []
|
|
290
|
+
for (const e of entries) {
|
|
291
|
+
if (e.kind === 'color') {
|
|
292
|
+
out[e.key] = interpolateColor(progress.value, e.input, e.output)
|
|
293
|
+
} else if (e.kind === 'numeric') {
|
|
294
|
+
out[e.key] = interpolate(progress.value, e.input, e.output, {
|
|
295
|
+
extrapolateLeft: extrapolate,
|
|
296
|
+
extrapolateRight: extrapolate,
|
|
297
|
+
})
|
|
298
|
+
} else if (e.kind === 'transform-numeric') {
|
|
299
|
+
transform.push({
|
|
300
|
+
[e.key]: interpolate(progress.value, e.input, e.output, {
|
|
301
|
+
extrapolateLeft: extrapolate,
|
|
302
|
+
extrapolateRight: extrapolate,
|
|
303
|
+
}),
|
|
304
|
+
})
|
|
305
|
+
} else {
|
|
306
|
+
// rotation — emit a deg string
|
|
307
|
+
const deg = interpolate(progress.value, e.input, e.output, {
|
|
308
|
+
extrapolateLeft: extrapolate,
|
|
309
|
+
extrapolateRight: extrapolate,
|
|
310
|
+
})
|
|
311
|
+
transform.push({ [e.key]: `${deg}deg` })
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (transform.length > 0) out.transform = transform
|
|
315
|
+
return out
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
declare const __DEV__: boolean
|