@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
|
@@ -7,13 +7,14 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
} from 'react'
|
|
9
9
|
import Animated, {
|
|
10
|
+
cancelAnimation,
|
|
10
11
|
interpolateColor,
|
|
11
12
|
runOnJS,
|
|
12
13
|
useAnimatedStyle,
|
|
13
14
|
useSharedValue,
|
|
14
15
|
type SharedValue,
|
|
15
16
|
} from 'react-native-reanimated'
|
|
16
|
-
import { type LayoutChangeEvent } from 'react-native'
|
|
17
|
+
import { StyleSheet, type LayoutChangeEvent } from 'react-native'
|
|
17
18
|
import {
|
|
18
19
|
lookupNamedTransition,
|
|
19
20
|
resolveNamedTransitionProp,
|
|
@@ -233,48 +234,22 @@ export function createMotionComponent<C extends ComponentType<any>>(
|
|
|
233
234
|
|
|
234
235
|
type Props = React.ComponentProps<C> & MotionProps<React.ComponentProps<C>>
|
|
235
236
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
style?: unknown
|
|
253
|
-
layout?: LayoutProp | string
|
|
254
|
-
layoutId?: string
|
|
255
|
-
onLayout?: (event: LayoutChangeEvent) => void
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Resolve registered transition names (from the nearest <MotionConfig
|
|
259
|
-
// transitions>) into concrete configs before anything downstream touches
|
|
260
|
-
// the props. Resolution is JS-thread and identity-preserving when no
|
|
261
|
-
// names are present; when names resolve, the registry entries are stable
|
|
262
|
-
// objects, so every signature-keyed memo below stays warm.
|
|
263
|
-
const namedTransitions = useNamedTransitions()
|
|
264
|
-
const transition = resolveNamedTransitionProp(
|
|
265
|
-
transitionProp as Transition<Record<string, unknown>> | undefined,
|
|
266
|
-
namedTransitions,
|
|
267
|
-
)
|
|
268
|
-
const layout: LayoutProp =
|
|
269
|
-
typeof layoutProp === 'string'
|
|
270
|
-
? lookupNamedTransition(layoutProp, namedTransitions)
|
|
271
|
-
: layoutProp
|
|
272
|
-
|
|
273
|
-
// Function-form `style={(state) => ...}` is the Pressable render-prop API.
|
|
274
|
-
// Inertia drives press/focus state through `gesture.*` and merges its own
|
|
275
|
-
// animated style; a function passed here lands inside a style array where
|
|
276
|
-
// the underlying component never invokes it, so the resulting styles are
|
|
277
|
-
// silently dropped. Throw loudly in dev rather than ship the footgun.
|
|
237
|
+
// Plain-host fast path. When an instance carries none of the animation-
|
|
238
|
+
// driving props, it needs no shared values, no `useAnimatedStyle` worklet,
|
|
239
|
+
// no gesture state, and no layout wiring — it is just the underlying
|
|
240
|
+
// `Animated.createAnimatedComponent(Component)` with `style`/`ref`/`onLayout`
|
|
241
|
+
// forwarded through. Rendering that directly (instead of the full animated
|
|
242
|
+
// body) keeps a prop-less `Motion.View` a zero-cost pass-through: same host,
|
|
243
|
+
// no per-render animation allocations (Principle 3 — one host concept, no
|
|
244
|
+
// separate "plain" alias). Because `PlainHost` and `MotionAnimated` are
|
|
245
|
+
// distinct component types, React keeps each one's hook list consistent; an
|
|
246
|
+
// instance that gains (or loses) an animation prop after mount crosses the
|
|
247
|
+
// boundary and remounts, which is the correct behavior for that rare edge.
|
|
248
|
+
const PlainHost = forwardRef<unknown, Props>(function PlainHost(props, ref) {
|
|
249
|
+
const { style, ...rest } = props as Props & { style?: unknown }
|
|
250
|
+
|
|
251
|
+
// Same dev guard as the animated body: a `style` function is the Pressable
|
|
252
|
+
// render-prop API, which Inertia doesn't support (see the animated path).
|
|
278
253
|
if (__DEV__ && typeof style === 'function') {
|
|
279
254
|
throw new Error(
|
|
280
255
|
'[inertia] `style` must be a style object or array of style objects, ' +
|
|
@@ -284,499 +259,683 @@ export function createMotionComponent<C extends ComponentType<any>>(
|
|
|
284
259
|
)
|
|
285
260
|
}
|
|
286
261
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
262
|
+
// Presence coordination. A prop-less child inside <Presence> has no exit
|
|
263
|
+
// animation, so it must signal `safeToRemove` immediately once it starts
|
|
264
|
+
// exiting — otherwise it lingers in the snapshot forever. This is a context
|
|
265
|
+
// read plus an unmount-scoped effect: no shared values, no worklet, no
|
|
266
|
+
// per-render allocation. `null` when there is no <Presence> ancestor.
|
|
290
267
|
const presence = usePresence()
|
|
291
268
|
const isExiting = presence !== null && presence.isPresent === false
|
|
269
|
+
const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
|
|
270
|
+
safeToRemoveRef.current = presence?.safeToRemove
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (isExiting) safeToRemoveRef.current?.()
|
|
273
|
+
}, [isExiting])
|
|
292
274
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
// Pin the latest `onAnimationEnd` in a ref so the worklet callback always
|
|
301
|
-
// dispatches against the current closure without re-resolving the
|
|
302
|
-
// animation graph. Worklets can read refs via `runOnJS`.
|
|
303
|
-
const onAnimationEndRef = useRef(onAnimationEnd)
|
|
304
|
-
onAnimationEndRef.current = onAnimationEnd
|
|
305
|
-
|
|
306
|
-
// Resolve `animate` against `variants` / `controller`. The controller's
|
|
307
|
-
// `current` wins when both are set (typed contract: don't mix
|
|
308
|
-
// `controller` and `animate` — controller drives the animation in that
|
|
309
|
-
// mode). When `animate` is a string and `variants` exist, look it up.
|
|
310
|
-
const variantKey = useControllerKey(controller)
|
|
311
|
-
const resolvedAnimate = resolveAnimateInput(
|
|
312
|
-
animate as AnimateStyle<unknown> | string | undefined,
|
|
313
|
-
variants as VariantsMap<unknown> | undefined,
|
|
314
|
-
variantKey,
|
|
275
|
+
return (
|
|
276
|
+
<AnimatedComponent
|
|
277
|
+
ref={ref as never}
|
|
278
|
+
{...(rest as object)}
|
|
279
|
+
style={style}
|
|
280
|
+
/>
|
|
315
281
|
)
|
|
282
|
+
})
|
|
283
|
+
PlainHost.displayName = `MotionPlain(${Component.displayName ?? Component.name ?? 'Component'})`
|
|
284
|
+
|
|
285
|
+
const MotionAnimated = forwardRef<unknown, Props>(
|
|
286
|
+
function MotionAnimated(props, ref) {
|
|
287
|
+
const {
|
|
288
|
+
initial,
|
|
289
|
+
animate,
|
|
290
|
+
exit,
|
|
291
|
+
transition: transitionProp,
|
|
292
|
+
variants,
|
|
293
|
+
controller,
|
|
294
|
+
gesture,
|
|
295
|
+
layout: layoutProp,
|
|
296
|
+
layoutId,
|
|
297
|
+
onAnimationEnd,
|
|
298
|
+
style,
|
|
299
|
+
onLayout: userOnLayout,
|
|
300
|
+
...rest
|
|
301
|
+
} = props as Props & {
|
|
302
|
+
style?: unknown
|
|
303
|
+
layout?: LayoutProp | string
|
|
304
|
+
layoutId?: string
|
|
305
|
+
onLayout?: (event: LayoutChangeEvent) => void
|
|
306
|
+
}
|
|
316
307
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// render silently dropped the new key (its SV updated, but the
|
|
345
|
-
// worklet — which iterates this set — never read it).
|
|
346
|
-
//
|
|
347
|
-
// Growing-only keeps the worklet stable: the `activeKeysRef.current` array
|
|
348
|
-
// identity only changes on the renders that actually add a key, so the
|
|
349
|
-
// `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
|
|
350
|
-
// expansion without churning frame-to-frame.
|
|
351
|
-
const touched = new Set<AnimatableKey>()
|
|
352
|
-
collectTouchedKeys(touched, animateRecord)
|
|
353
|
-
if (initialRecord) collectTouchedKeys(touched, initialRecord)
|
|
354
|
-
if (variants) {
|
|
355
|
-
for (const variant of Object.values(variants) as object[]) {
|
|
356
|
-
if (!variant) continue
|
|
357
|
-
collectTouchedKeys(touched, variant as Record<string, unknown>)
|
|
308
|
+
// Resolve registered transition names (from the nearest <MotionConfig
|
|
309
|
+
// transitions>) into concrete configs before anything downstream touches
|
|
310
|
+
// the props. Resolution is JS-thread and identity-preserving when no
|
|
311
|
+
// names are present; when names resolve, the registry entries are stable
|
|
312
|
+
// objects, so every signature-keyed memo below stays warm.
|
|
313
|
+
const namedTransitions = useNamedTransitions()
|
|
314
|
+
const transition = resolveNamedTransitionProp(
|
|
315
|
+
transitionProp as Transition<Record<string, unknown>> | undefined,
|
|
316
|
+
namedTransitions,
|
|
317
|
+
)
|
|
318
|
+
const layout: LayoutProp =
|
|
319
|
+
typeof layoutProp === 'string'
|
|
320
|
+
? lookupNamedTransition(layoutProp, namedTransitions)
|
|
321
|
+
: layoutProp
|
|
322
|
+
|
|
323
|
+
// Function-form `style={(state) => ...}` is the Pressable render-prop API.
|
|
324
|
+
// Inertia drives press/focus state through `gesture.*` and merges its own
|
|
325
|
+
// animated style; a function passed here lands inside a style array where
|
|
326
|
+
// the underlying component never invokes it, so the resulting styles are
|
|
327
|
+
// silently dropped. Throw loudly in dev rather than ship the footgun.
|
|
328
|
+
if (__DEV__ && typeof style === 'function') {
|
|
329
|
+
throw new Error(
|
|
330
|
+
'[inertia] `style` must be a style object or array of style objects, ' +
|
|
331
|
+
'not a function. The function-form `style={(state) => ...}` Pressable ' +
|
|
332
|
+
'API is not supported — use `gesture.pressed` (or `gesture.focused`, ' +
|
|
333
|
+
'etc.) to drive state-dependent styling instead.',
|
|
334
|
+
)
|
|
358
335
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
336
|
+
|
|
337
|
+
// <Presence> contract: when an ancestor flips `isPresent` to false the
|
|
338
|
+
// child stays rendered until `safeToRemove` is called, giving the exit
|
|
339
|
+
// animation time to play. `null` when there is no <Presence> ancestor.
|
|
340
|
+
const presence = usePresence()
|
|
341
|
+
const isExiting = presence !== null && presence.isPresent === false
|
|
342
|
+
|
|
343
|
+
// Resolved reduced-motion preference for this subtree. When true, every
|
|
344
|
+
// per-key transition is replaced with `no-animation` below, so values
|
|
345
|
+
// snap to target without interpolation. In 'user' mode the OS setting is
|
|
346
|
+
// read via Reanimated's `useReducedMotion`, which captures the value once
|
|
347
|
+
// at app start — a runtime toggle takes effect on the next launch.
|
|
348
|
+
const shouldReduceMotion = useShouldReduceMotion()
|
|
349
|
+
|
|
350
|
+
// Pin the latest `onAnimationEnd` in a ref so the worklet callback always
|
|
351
|
+
// dispatches against the current closure without re-resolving the
|
|
352
|
+
// animation graph. Worklets can read refs via `runOnJS`.
|
|
353
|
+
const onAnimationEndRef = useRef(onAnimationEnd)
|
|
354
|
+
onAnimationEndRef.current = onAnimationEnd
|
|
355
|
+
|
|
356
|
+
// Resolve `animate` against `variants` / `controller`. The controller's
|
|
357
|
+
// `current` wins when both are set (typed contract: don't mix
|
|
358
|
+
// `controller` and `animate` — controller drives the animation in that
|
|
359
|
+
// mode). When `animate` is a string and `variants` exist, look it up.
|
|
360
|
+
const variantKey = useControllerKey(controller)
|
|
361
|
+
const resolvedAnimate = resolveAnimateInput(
|
|
362
|
+
animate as AnimateStyle<unknown> | string | undefined,
|
|
363
|
+
variants as VariantsMap<unknown> | undefined,
|
|
364
|
+
variantKey,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const animateRecord = (resolvedAnimate ?? {}) as InternalAnimateRecord
|
|
368
|
+
const initialRecord =
|
|
369
|
+
initial && initial !== false
|
|
370
|
+
? (initial as InternalInitialRecord)
|
|
371
|
+
: undefined
|
|
372
|
+
const exitRecord = exit ? (exit as InternalAnimateRecord) : undefined
|
|
373
|
+
|
|
374
|
+
// Gesture sub-state activation tracked as JS state. Activation flips drive
|
|
375
|
+
// the per-layer progress shared values (0↔1); they intentionally do NOT
|
|
376
|
+
// re-run the value-driving effect — gesture sub-state targets live on the
|
|
377
|
+
// worklet's composition chain, not on the base `animate` SV.
|
|
378
|
+
const [pressed, setPressed] = useState(false)
|
|
379
|
+
const [focused, setFocused] = useState(false)
|
|
380
|
+
const [focusVisible, setFocusVisible] = useState(false)
|
|
381
|
+
const [hovered, setHovered] = useState(false)
|
|
382
|
+
|
|
383
|
+
// The set of keys this instance animates is a *monotonically growing*
|
|
384
|
+
// union, recomputed every render and expanded when a render introduces a
|
|
385
|
+
// key not seen before. It never shrinks. Two requirements meet here:
|
|
386
|
+
//
|
|
387
|
+
// 1. Variants and gesture sub-states contribute the union across *all*
|
|
388
|
+
// their branches up front — a key touched by any variant must be
|
|
389
|
+
// active so the worklet picks it up when the controller transitions
|
|
390
|
+
// to a branch the base `animate` never mentions.
|
|
391
|
+
// 2. A literal `animate` object is reactive: a parent that changes
|
|
392
|
+
// `animate={{ opacity: 1 }}` to `animate={{ opacity: 1, scale: 2 }}`
|
|
393
|
+
// after mount must get `scale` animating. Freezing the set at first
|
|
394
|
+
// render silently dropped the new key (its SV updated, but the
|
|
395
|
+
// worklet — which iterates this set — never read it).
|
|
396
|
+
//
|
|
397
|
+
// Growing-only keeps the worklet stable: the `activeKeysRef.current` array
|
|
398
|
+
// identity only changes on the renders that actually add a key, so the
|
|
399
|
+
// `useAnimatedStyle` worklet (which reads `.current` each frame) sees the
|
|
400
|
+
// expansion without churning frame-to-frame.
|
|
401
|
+
const touched = new Set<AnimatableKey>()
|
|
402
|
+
collectTouchedKeys(touched, animateRecord)
|
|
403
|
+
if (initialRecord) collectTouchedKeys(touched, initialRecord)
|
|
404
|
+
if (variants) {
|
|
405
|
+
for (const variant of Object.values(variants) as object[]) {
|
|
406
|
+
if (!variant) continue
|
|
407
|
+
collectTouchedKeys(touched, variant as Record<string, unknown>)
|
|
408
|
+
}
|
|
369
409
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const prevActive = activeKeysRef.current
|
|
380
|
-
let grew = prevActive === null
|
|
381
|
-
if (!grew && prevActive) {
|
|
382
|
-
for (const k of touched) {
|
|
383
|
-
if (!prevActive.includes(k)) {
|
|
384
|
-
grew = true
|
|
385
|
-
break
|
|
410
|
+
if (gesture) {
|
|
411
|
+
for (const subState of [
|
|
412
|
+
gesture.pressed,
|
|
413
|
+
gesture.focused,
|
|
414
|
+
gesture.focusVisible,
|
|
415
|
+
gesture.hovered,
|
|
416
|
+
] as Array<object | undefined>) {
|
|
417
|
+
if (!subState) continue
|
|
418
|
+
collectTouchedKeys(touched, subState as Record<string, unknown>)
|
|
386
419
|
}
|
|
387
420
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
)
|
|
399
|
-
|
|
421
|
+
if (exitRecord) collectTouchedKeys(touched, exitRecord)
|
|
422
|
+
|
|
423
|
+
const activeKeysRef = useRef<readonly AnimatableKey[] | null>(null)
|
|
424
|
+
const hasTransformRef = useRef<boolean>(false)
|
|
425
|
+
const hasShadowOffsetRef = useRef<boolean>(false)
|
|
426
|
+
// Expand the active set only when this render touched a key we haven't
|
|
427
|
+
// recorded yet. When nothing new appears we keep the existing array
|
|
428
|
+
// identity so the worklet's captured ref doesn't see a fresh value.
|
|
429
|
+
const prevActive = activeKeysRef.current
|
|
430
|
+
let grew = prevActive === null
|
|
431
|
+
if (!grew && prevActive) {
|
|
432
|
+
for (const k of touched) {
|
|
433
|
+
if (!prevActive.includes(k)) {
|
|
434
|
+
grew = true
|
|
435
|
+
break
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (grew) {
|
|
440
|
+
const merged = new Set<AnimatableKey>(prevActive ?? [])
|
|
441
|
+
for (const k of touched) merged.add(k)
|
|
442
|
+
activeKeysRef.current = ALL_KEYS.filter((k) => merged.has(k))
|
|
443
|
+
hasTransformRef.current = activeKeysRef.current.some((k) =>
|
|
444
|
+
TRANSFORM_KEY_SET.has(k),
|
|
445
|
+
)
|
|
446
|
+
hasShadowOffsetRef.current = activeKeysRef.current.some((k) =>
|
|
447
|
+
SHADOW_OFFSET_KEY_SET.has(k),
|
|
448
|
+
)
|
|
449
|
+
}
|
|
400
450
|
|
|
401
|
-
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
451
|
+
// Which keys a record actually drives *right now*. Everything else in
|
|
452
|
+
// the active set is there only because a gesture sub-state, an `exit`
|
|
453
|
+
// target, or a variant branch that isn't current mentions it. Those must
|
|
454
|
+
// rest at whatever the static `style` says — the animated style merges
|
|
455
|
+
// AFTER `style`, so resting them at `DEFAULT_RESTING` silently stomps it
|
|
456
|
+
// (`borderColor` → 'transparent', `width` → 0, and so on for every key
|
|
457
|
+
// whose default isn't an identity). Transforms and `opacity` hid this
|
|
458
|
+
// bug for a long time: their defaults happen to be no-ops.
|
|
459
|
+
const drivenNow = new Set<AnimatableKey>()
|
|
460
|
+
collectTouchedKeys(drivenNow, animateRecord)
|
|
461
|
+
if (initialRecord) collectTouchedKeys(drivenNow, initialRecord)
|
|
462
|
+
if (isExiting && exitRecord) collectTouchedKeys(drivenNow, exitRecord)
|
|
463
|
+
|
|
464
|
+
// Monotonic, like the active set. Once a record has driven a key we stop
|
|
465
|
+
// syncing it to the style, so transitioning to a variant that doesn't
|
|
466
|
+
// mention the key leaves it where the previous variant put it — the
|
|
467
|
+
// existing semantic — instead of snapping back to the style value.
|
|
468
|
+
const everDrivenRef = useRef<Set<AnimatableKey>>(new Set())
|
|
469
|
+
for (const k of drivenNow) everDrivenRef.current.add(k)
|
|
470
|
+
|
|
471
|
+
// Resting values pulled off the static `style`, for active keys nothing
|
|
472
|
+
// has driven. Gated so the common `animate`-only instance never flattens
|
|
473
|
+
// a style it has no use for.
|
|
474
|
+
const activeKeys = activeKeysRef.current!
|
|
475
|
+
const everDriven = everDrivenRef.current
|
|
476
|
+
const styleResting: Partial<Record<AnimatableKey, number | string>> = {}
|
|
477
|
+
if (activeKeys.some((k) => !everDriven.has(k))) {
|
|
478
|
+
const flat = StyleSheet.flatten(style as never) as
|
|
479
|
+
| Record<string, unknown>
|
|
480
|
+
| undefined
|
|
481
|
+
if (flat) {
|
|
482
|
+
for (const key of activeKeys) {
|
|
483
|
+
if (everDriven.has(key)) continue
|
|
484
|
+
const v = styleValueFor(flat, key)
|
|
485
|
+
if (v !== undefined) styleResting[key] = v
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const sharedValues = useAnimatableSharedValues((key) => {
|
|
491
|
+
// Shadow offset synthetics seed from the corresponding axis on the
|
|
492
|
+
// `shadowOffset: { width, height }` source — the consumer doesn't write
|
|
493
|
+
// `shadowOffsetWidth` / `shadowOffsetHeight` directly. Fall back to the
|
|
494
|
+
// static style, then the generic resting default, when neither initial
|
|
495
|
+
// nor animate touched it.
|
|
496
|
+
if (SHADOW_OFFSET_KEY_SET.has(key)) {
|
|
497
|
+
const axis = shadowOffsetAxisFor(key as ShadowOffsetKey)
|
|
498
|
+
if (initial === false) {
|
|
499
|
+
return (
|
|
500
|
+
shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
|
|
501
|
+
styleResting[key] ??
|
|
502
|
+
DEFAULT_RESTING[key]
|
|
503
|
+
)
|
|
504
|
+
}
|
|
409
505
|
return (
|
|
506
|
+
shadowOffsetAxisValue(
|
|
507
|
+
initialRecord?.shadowOffset as
|
|
508
|
+
| { width?: number; height?: number }
|
|
509
|
+
| undefined,
|
|
510
|
+
axis,
|
|
511
|
+
) ??
|
|
410
512
|
shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
|
|
513
|
+
styleResting[key] ??
|
|
411
514
|
DEFAULT_RESTING[key]
|
|
412
515
|
)
|
|
413
516
|
}
|
|
517
|
+
if (initial === false) {
|
|
518
|
+
const a = animateRecord[key]
|
|
519
|
+
return restValue(a) ?? styleResting[key] ?? DEFAULT_RESTING[key]
|
|
520
|
+
}
|
|
414
521
|
return (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
| undefined,
|
|
419
|
-
axis,
|
|
420
|
-
) ??
|
|
421
|
-
shadowOffsetAxisValue(animateRecord.shadowOffset, axis) ??
|
|
522
|
+
initialRecord?.[key] ??
|
|
523
|
+
restValue(animateRecord[key]) ??
|
|
524
|
+
styleResting[key] ??
|
|
422
525
|
DEFAULT_RESTING[key]
|
|
423
526
|
)
|
|
424
|
-
}
|
|
425
|
-
if (initial === false) {
|
|
426
|
-
const a = animateRecord[key]
|
|
427
|
-
return restValue(a) ?? DEFAULT_RESTING[key]
|
|
428
|
-
}
|
|
429
|
-
return (
|
|
430
|
-
initialRecord?.[key] ??
|
|
431
|
-
restValue(animateRecord[key]) ??
|
|
432
|
-
DEFAULT_RESTING[key]
|
|
433
|
-
)
|
|
434
|
-
})
|
|
435
|
-
|
|
436
|
-
// One progress SV per gesture layer, allocated unconditionally for hook
|
|
437
|
-
// stability. Each layer's progress animates 0↔1 with its own transition
|
|
438
|
-
// when its activation flips; the worklet reads them when compositing.
|
|
439
|
-
// Initial value is 0 — even if a sub-state is somehow active on mount,
|
|
440
|
-
// the activation effect below will animate it to 1 on the next tick.
|
|
441
|
-
const pressedProgress = useSharedValue(0)
|
|
442
|
-
const focusedProgress = useSharedValue(0)
|
|
443
|
-
const focusVisibleProgress = useSharedValue(0)
|
|
444
|
-
const hoveredProgress = useSharedValue(0)
|
|
445
|
-
|
|
446
|
-
// Mirror gesture targets into a UI-runtime-resident shared value so the
|
|
447
|
-
// animated-style worklet can read the latest layer values without having
|
|
448
|
-
// to capture `gesture` directly (which would re-register the worklet on
|
|
449
|
-
// every render where the consumer passes a fresh literal). The signature
|
|
450
|
-
// dependency means we only push to the SV when targets actually change —
|
|
451
|
-
// the SV ref itself is stable across renders.
|
|
452
|
-
//
|
|
453
|
-
// The resolved value is a layer-keyed map of primitive endpoints (numbers
|
|
454
|
-
// or color strings); sequence/`{ to }` step shapes on a sub-state collapse
|
|
455
|
-
// to their final endpoint via `targetEndValue` because a gesture layer
|
|
456
|
-
// describes a steady target, not a keyframe sequence.
|
|
457
|
-
const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
|
|
458
|
-
resolveGestureLayers(gesture),
|
|
459
|
-
)
|
|
460
|
-
const gestureTargetsSig = stableSig(gesture)
|
|
461
|
-
useEffect(() => {
|
|
462
|
-
gestureSV.value = resolveGestureLayers(gesture)
|
|
463
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
464
|
-
}, [gestureTargetsSig])
|
|
465
|
-
|
|
466
|
-
// The base record drives the per-key shared values. Gesture sub-state
|
|
467
|
-
// targets are intentionally NOT merged here — they layer on top in the
|
|
468
|
-
// worklet. Exit values still take precedence over `animate` while exiting
|
|
469
|
-
// because the base SV is what <Presence> waits on to settle.
|
|
470
|
-
const baseRecord =
|
|
471
|
-
isExiting && exitRecord
|
|
472
|
-
? { ...animateRecord, ...exitRecord }
|
|
473
|
-
: animateRecord
|
|
474
|
-
const baseSig =
|
|
475
|
-
stableSig(baseRecord) +
|
|
476
|
-
(isExiting ? '|exit' : '') +
|
|
477
|
-
(shouldReduceMotion ? '|rm' : '')
|
|
478
|
-
const transitionSig = stableSig(transition)
|
|
479
|
-
|
|
480
|
-
// Stable ref to the live `safeToRemove` so the effect's settle-counter
|
|
481
|
-
// closure can reach the latest <Presence> binding without retriggering.
|
|
482
|
-
const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
|
|
483
|
-
safeToRemoveRef.current = presence?.safeToRemove
|
|
527
|
+
})
|
|
484
528
|
|
|
485
|
-
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
529
|
+
// Keep never-driven keys tracking the *live* static style. The seed above
|
|
530
|
+
// only runs at mount, but the animated style emits these keys on every
|
|
531
|
+
// frame — so without this a `style` that changes afterwards (a theme
|
|
532
|
+
// swap, a conditional colour) would stay invisible for any key some
|
|
533
|
+
// gesture / exit / variant branch happens to mention. Direct assignment,
|
|
534
|
+
// not an animation: this is a static value, not a target.
|
|
535
|
+
const styleRestingSig = stableSig(styleResting)
|
|
536
|
+
useEffect(() => {
|
|
537
|
+
for (const key of activeKeysRef.current!) {
|
|
538
|
+
if (everDrivenRef.current.has(key)) continue
|
|
539
|
+
sharedValues[key].value = (styleResting[key] ??
|
|
540
|
+
DEFAULT_RESTING[key]) as never
|
|
541
|
+
}
|
|
542
|
+
// `styleResting` is rebuilt each render; its signature is the real dep.
|
|
543
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
544
|
+
}, [styleRestingSig])
|
|
545
|
+
|
|
546
|
+
// One progress SV per gesture layer, allocated unconditionally for hook
|
|
547
|
+
// stability. Each layer's progress animates 0↔1 with its own transition
|
|
548
|
+
// when its activation flips; the worklet reads them when compositing.
|
|
549
|
+
// Initial value is 0 — even if a sub-state is somehow active on mount,
|
|
550
|
+
// the activation effect below will animate it to 1 on the next tick.
|
|
551
|
+
const pressedProgress = useSharedValue(0)
|
|
552
|
+
const focusedProgress = useSharedValue(0)
|
|
553
|
+
const focusVisibleProgress = useSharedValue(0)
|
|
554
|
+
const hoveredProgress = useSharedValue(0)
|
|
555
|
+
|
|
556
|
+
// Cancel any in-flight gesture-layer springs on unmount, matching the
|
|
557
|
+
// per-key guard in `useAnimatableSharedValues`. Each SV is identity-stable.
|
|
558
|
+
useEffect(
|
|
559
|
+
() => () => {
|
|
560
|
+
cancelAnimation(pressedProgress)
|
|
561
|
+
cancelAnimation(focusedProgress)
|
|
562
|
+
cancelAnimation(focusVisibleProgress)
|
|
563
|
+
cancelAnimation(hoveredProgress)
|
|
564
|
+
},
|
|
565
|
+
// The progress SVs are identity-stable per hook instance.
|
|
566
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
567
|
+
[],
|
|
568
|
+
)
|
|
492
569
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
570
|
+
// Mirror gesture targets into a UI-runtime-resident shared value so the
|
|
571
|
+
// animated-style worklet can read the latest layer values without having
|
|
572
|
+
// to capture `gesture` directly (which would re-register the worklet on
|
|
573
|
+
// every render where the consumer passes a fresh literal). The signature
|
|
574
|
+
// dependency means we only push to the SV when targets actually change —
|
|
575
|
+
// the SV ref itself is stable across renders.
|
|
576
|
+
//
|
|
577
|
+
// The resolved value is a layer-keyed map of primitive endpoints (numbers
|
|
578
|
+
// or color strings); sequence/`{ to }` step shapes on a sub-state collapse
|
|
579
|
+
// to their final endpoint via `targetEndValue` because a gesture layer
|
|
580
|
+
// describes a steady target, not a keyframe sequence.
|
|
581
|
+
const gestureSV = useSharedValue<ResolvedGestureLayers | null>(
|
|
582
|
+
resolveGestureLayers(gesture),
|
|
583
|
+
)
|
|
584
|
+
const gestureTargetsSig = stableSig(gesture)
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
gestureSV.value = resolveGestureLayers(gesture)
|
|
587
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
588
|
+
}, [gestureTargetsSig])
|
|
589
|
+
|
|
590
|
+
// The base record drives the per-key shared values. Gesture sub-state
|
|
591
|
+
// targets are intentionally NOT merged here — they layer on top in the
|
|
592
|
+
// worklet. Exit values still take precedence over `animate` while exiting
|
|
593
|
+
// because the base SV is what <Presence> waits on to settle.
|
|
594
|
+
const baseRecord =
|
|
595
|
+
isExiting && exitRecord
|
|
596
|
+
? { ...animateRecord, ...exitRecord }
|
|
597
|
+
: animateRecord
|
|
598
|
+
const baseSig =
|
|
599
|
+
stableSig(baseRecord) +
|
|
600
|
+
(isExiting ? '|exit' : '') +
|
|
601
|
+
(shouldReduceMotion ? '|rm' : '')
|
|
602
|
+
const transitionSig = stableSig(transition)
|
|
603
|
+
|
|
604
|
+
// Stable ref to the live `safeToRemove` so the effect's settle-counter
|
|
605
|
+
// closure can reach the latest <Presence> binding without retriggering.
|
|
606
|
+
const safeToRemoveRef = useRef<(() => void) | undefined>(undefined)
|
|
607
|
+
safeToRemoveRef.current = presence?.safeToRemove
|
|
608
|
+
|
|
609
|
+
useEffect(() => {
|
|
610
|
+
// Exit fast-path: nothing to animate (or no exit prop), tell <Presence>
|
|
611
|
+
// immediately so the unmount isn't gated on a phantom animation.
|
|
612
|
+
if (
|
|
613
|
+
isExiting &&
|
|
614
|
+
(!exitRecord || Object.keys(exitRecord).length === 0)
|
|
615
|
+
) {
|
|
616
|
+
safeToRemoveRef.current?.()
|
|
617
|
+
return
|
|
501
618
|
}
|
|
502
|
-
}
|
|
503
619
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
620
|
+
let pending = 0
|
|
621
|
+
let done = false
|
|
622
|
+
const onSettle = () => {
|
|
623
|
+
if (done) return
|
|
624
|
+
pending--
|
|
625
|
+
if (pending <= 0) {
|
|
626
|
+
done = true
|
|
627
|
+
if (isExiting) safeToRemoveRef.current?.()
|
|
628
|
+
}
|
|
512
629
|
}
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
// animate / exit record never has `shadowOffsetWidth` etc. on it
|
|
521
|
-
// directly. The synthetic transition follows the same `shadowOffset`
|
|
522
|
-
// top-level transition entry (no per-axis split).
|
|
523
|
-
const target: AnimatableValue<number | string> | undefined =
|
|
524
|
-
SHADOW_OFFSET_KEY_SET.has(key)
|
|
525
|
-
? shadowOffsetAxisValue(
|
|
526
|
-
baseRecord.shadowOffset,
|
|
527
|
-
shadowOffsetAxisFor(key as ShadowOffsetKey),
|
|
528
|
-
)
|
|
529
|
-
: baseRecord[key]
|
|
530
|
-
if (target === undefined) continue
|
|
531
|
-
// Reduced-motion overrides every per-key transition (and any nested
|
|
532
|
-
// sequence-step transition) with `no-animation`, which the resolver
|
|
533
|
-
// turns into a direct value assignment. Sequences still iterate but
|
|
534
|
-
// each step settles instantly, which matches the "snap to final
|
|
630
|
+
|
|
631
|
+
// Per-key transition resolution, hoisted out of the loop below so the
|
|
632
|
+
// transform-group count can ask about a key's config before the loop
|
|
633
|
+
// reaches it. Reduced motion overrides every per-key transition (and
|
|
634
|
+
// any nested sequence-step transition) with `no-animation`, which the
|
|
635
|
+
// resolver turns into a direct value assignment. Sequences still
|
|
636
|
+
// iterate but each step settles instantly, matching the "snap to final
|
|
535
637
|
// state" expectation.
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const factory = makeKeyCallbackFactory(
|
|
546
|
-
key,
|
|
547
|
-
sharedValues[key],
|
|
548
|
-
targetEndValue(target),
|
|
549
|
-
onAnimationEndRef,
|
|
550
|
-
{
|
|
551
|
-
stepCount: stepCountOf(target),
|
|
552
|
-
totalIterations: totalIterationsOf(cfg),
|
|
553
|
-
},
|
|
554
|
-
isExiting ? onSettle : undefined,
|
|
555
|
-
TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
|
|
556
|
-
)
|
|
557
|
-
sharedValues[key].value = resolveAnimatableValue(
|
|
558
|
-
target,
|
|
559
|
-
cfg,
|
|
560
|
-
factory,
|
|
561
|
-
) as never
|
|
562
|
-
}
|
|
638
|
+
const configFor = (key: AnimatableKey): TransitionConfig | undefined =>
|
|
639
|
+
shouldReduceMotion
|
|
640
|
+
? ({ type: 'no-animation' } as const)
|
|
641
|
+
: transitionFor(
|
|
642
|
+
SHADOW_OFFSET_KEY_SET.has(key)
|
|
643
|
+
? ('shadowOffset' as keyof typeof baseRecord)
|
|
644
|
+
: key,
|
|
645
|
+
transition,
|
|
646
|
+
)
|
|
563
647
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
648
|
+
// Count transform axes participating in this effect run so the factory
|
|
649
|
+
// can coalesce their terminal callbacks into a single transform-group
|
|
650
|
+
// event. `undefined` when no transform axis is animating, which lets
|
|
651
|
+
// the factory skip the coalescing branch entirely.
|
|
652
|
+
//
|
|
653
|
+
// Endless axes are excluded. A `repeat: 'infinite'` axis never reaches
|
|
654
|
+
// the terminal `'animation'` phase, so counting it would pin
|
|
655
|
+
// `remaining` permanently above zero — a sibling axis with a finite
|
|
656
|
+
// transition would settle, decrement, and have its completion silently
|
|
657
|
+
// swallowed. An axis that never finishes simply isn't part of the group
|
|
658
|
+
// that does.
|
|
659
|
+
let transformPending = 0
|
|
660
|
+
for (const k of ALL_KEYS) {
|
|
661
|
+
if (!TRANSFORM_KEY_SET.has(k) || baseRecord[k] === undefined) continue
|
|
662
|
+
if (!reachesTerminalPhase(configFor(k))) continue
|
|
663
|
+
transformPending++
|
|
664
|
+
}
|
|
665
|
+
const transformGroup: TransformGroup | undefined =
|
|
666
|
+
transformPending > 0 ? { remaining: transformPending } : undefined
|
|
667
|
+
|
|
668
|
+
for (const key of ALL_KEYS) {
|
|
669
|
+
// Shadow offset synthetics read their target from the nested
|
|
670
|
+
// `shadowOffset: { width, height }` source on `baseRecord` — the
|
|
671
|
+
// animate / exit record never has `shadowOffsetWidth` etc. on it
|
|
672
|
+
// directly. The synthetic transition follows the same `shadowOffset`
|
|
673
|
+
// top-level transition entry (no per-axis split).
|
|
674
|
+
const target: AnimatableValue<number | string> | undefined =
|
|
675
|
+
SHADOW_OFFSET_KEY_SET.has(key)
|
|
676
|
+
? shadowOffsetAxisValue(
|
|
677
|
+
baseRecord.shadowOffset,
|
|
678
|
+
shadowOffsetAxisFor(key as ShadowOffsetKey),
|
|
679
|
+
)
|
|
680
|
+
: baseRecord[key]
|
|
681
|
+
if (target === undefined) continue
|
|
682
|
+
const cfg = configFor(key)
|
|
683
|
+
// <Presence> waits on this counter before unmounting, so only count
|
|
684
|
+
// keys that can actually settle. An endless exit animation (a
|
|
685
|
+
// `repeat: 'infinite'` transition inherited by `exit`, e.g. a pulsing
|
|
686
|
+
// element inside <Presence>) would otherwise never call
|
|
687
|
+
// `safeToRemove` and the child would stay mounted forever. Finite
|
|
688
|
+
// keys still gate the unmount; if every exit key is endless, the
|
|
689
|
+
// post-loop `pending === 0` release fires immediately.
|
|
690
|
+
if (isExiting && reachesTerminalPhase(cfg)) pending++
|
|
691
|
+
const factory = makeKeyCallbackFactory(
|
|
692
|
+
key,
|
|
693
|
+
sharedValues[key],
|
|
694
|
+
targetEndValue(target),
|
|
695
|
+
onAnimationEndRef,
|
|
696
|
+
{
|
|
697
|
+
stepCount: stepCountOf(target),
|
|
698
|
+
totalIterations: totalIterationsOf(cfg),
|
|
699
|
+
},
|
|
700
|
+
isExiting ? onSettle : undefined,
|
|
701
|
+
TRANSFORM_KEY_SET.has(key) ? transformGroup : undefined,
|
|
702
|
+
)
|
|
703
|
+
sharedValues[key].value = resolveAnimatableValue(
|
|
704
|
+
target,
|
|
705
|
+
cfg,
|
|
706
|
+
factory,
|
|
707
|
+
) as never
|
|
708
|
+
}
|
|
619
709
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
// multiple `translateX` entries sum, multiple `scaleX` entries multiply,
|
|
626
|
-
// which is exactly the FLIP semantic.
|
|
627
|
-
const sharedLayout = useSharedLayout({
|
|
628
|
-
layoutId,
|
|
629
|
-
userRef: ref,
|
|
630
|
-
transition: isTopLevelTransition(transition) ? transition : undefined,
|
|
631
|
-
shouldReduceMotion,
|
|
632
|
-
userOnLayout,
|
|
633
|
-
})
|
|
634
|
-
const flip = sharedLayout.flip
|
|
635
|
-
const hasLayoutId = layoutId !== undefined
|
|
636
|
-
|
|
637
|
-
const animatedStyle = useAnimatedStyle(() => {
|
|
638
|
-
const activeKeys = activeKeysRef.current!
|
|
639
|
-
const hasTransform = hasTransformRef.current
|
|
640
|
-
const hasShadowOffset = hasShadowOffsetRef.current
|
|
641
|
-
const out: Record<string, unknown> = {}
|
|
642
|
-
const transform: Array<Record<string, unknown>> = []
|
|
643
|
-
// shadow-offset reassembly buffers. The two synthetic axis SVs feed in
|
|
644
|
-
// here and the recomposed `{ width, height }` object lands on `out`
|
|
645
|
-
// after the loop so RN gets a single `shadowOffset` style prop.
|
|
646
|
-
let shadowOffsetW = 0
|
|
647
|
-
let shadowOffsetH = 0
|
|
648
|
-
|
|
649
|
-
// Read each progress SV exactly once so the chain below sees a coherent
|
|
650
|
-
// snapshot for this frame. Reading them on the UI thread is cheap.
|
|
651
|
-
const ph = hoveredProgress.value
|
|
652
|
-
const pf = focusedProgress.value
|
|
653
|
-
const pfv = focusVisibleProgress.value
|
|
654
|
-
const pp = pressedProgress.value
|
|
655
|
-
|
|
656
|
-
const layers = gestureSV.value
|
|
657
|
-
// Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
|
|
658
|
-
// `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
|
|
659
|
-
// worklet closure tracker would otherwise pick those up as captured
|
|
660
|
-
// dependencies and re-register the worklet on every activation flip.
|
|
661
|
-
const hoveredLayer = layers ? layers.hovered : null
|
|
662
|
-
const focusedLayer = layers ? layers.focused : null
|
|
663
|
-
const focusVisibleLayer = layers ? layers.focusVisible : null
|
|
664
|
-
const pressedLayer = layers ? layers.pressed : null
|
|
665
|
-
|
|
666
|
-
for (const key of activeKeys) {
|
|
667
|
-
let v = sharedValues[key].value
|
|
668
|
-
const isColor = COLOR_KEY_SET.has(key)
|
|
669
|
-
|
|
670
|
-
// Composite gesture layers in priority order (lowest first). Each
|
|
671
|
-
// active layer pulls the value toward its pre-resolved primitive
|
|
672
|
-
// endpoint by `progress`; numeric keys lerp, color keys go through
|
|
673
|
-
// Reanimated's RGBA `interpolateColor`. We skip layers with progress
|
|
674
|
-
// 0 to avoid an `interpolateColor(0, ...)` call that would parse the
|
|
675
|
-
// target color string for no visible effect.
|
|
676
|
-
if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
|
|
677
|
-
const t = hoveredLayer[key]
|
|
678
|
-
v = isColor
|
|
679
|
-
? interpolateColor(ph, [0, 1], [v as string, t as string])
|
|
680
|
-
: (v as number) + ((t as number) - (v as number)) * ph
|
|
710
|
+
// No exit-targeted keys (only `animate` keys present, no `exit`)
|
|
711
|
+
// → release immediately rather than wait for animations that aren't
|
|
712
|
+
// headed toward an exit value.
|
|
713
|
+
if (isExiting && pending === 0) {
|
|
714
|
+
safeToRemoveRef.current?.()
|
|
681
715
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
716
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
717
|
+
}, [baseSig, transitionSig])
|
|
718
|
+
|
|
719
|
+
// Per-layer progress: when a sub-state activation flips, animate its
|
|
720
|
+
// progress SV 0↔1 with the layer's own transition (or the parent
|
|
721
|
+
// transition / library default, in priority order). On exit we snap every
|
|
722
|
+
// layer to 0 instantly so the unmount-bound base SV isn't fighting a
|
|
723
|
+
// stale layer contribution mid-fade.
|
|
724
|
+
//
|
|
725
|
+
// The `declared` flag short-circuits the effect when the consumer hasn't
|
|
726
|
+
// wired the corresponding sub-state — so a Motion primitive without a
|
|
727
|
+
// `gesture` prop (or with only some sub-states declared) makes zero extra
|
|
728
|
+
// `withSpring` / `withTiming` calls on mount.
|
|
729
|
+
useGestureLayerProgress(
|
|
730
|
+
pressedProgress,
|
|
731
|
+
pressed,
|
|
732
|
+
gesture?.pressed != null,
|
|
733
|
+
'pressed',
|
|
734
|
+
transition,
|
|
735
|
+
isExiting,
|
|
736
|
+
shouldReduceMotion,
|
|
737
|
+
)
|
|
738
|
+
useGestureLayerProgress(
|
|
739
|
+
focusedProgress,
|
|
740
|
+
focused,
|
|
741
|
+
gesture?.focused != null,
|
|
742
|
+
'focused',
|
|
743
|
+
transition,
|
|
744
|
+
isExiting,
|
|
745
|
+
shouldReduceMotion,
|
|
746
|
+
)
|
|
747
|
+
useGestureLayerProgress(
|
|
748
|
+
focusVisibleProgress,
|
|
749
|
+
focusVisible,
|
|
750
|
+
gesture?.focusVisible != null,
|
|
751
|
+
'focusVisible',
|
|
752
|
+
transition,
|
|
753
|
+
isExiting,
|
|
754
|
+
shouldReduceMotion,
|
|
755
|
+
)
|
|
756
|
+
useGestureLayerProgress(
|
|
757
|
+
hoveredProgress,
|
|
758
|
+
hovered,
|
|
759
|
+
gesture?.hovered != null,
|
|
760
|
+
'hovered',
|
|
761
|
+
transition,
|
|
762
|
+
isExiting,
|
|
763
|
+
shouldReduceMotion,
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
// Shared-element transition wiring. `useSharedLayout` allocates FLIP
|
|
767
|
+
// shared values (identity at rest), measures via the merged `onLayout`,
|
|
768
|
+
// and on first-mount snaps the FLIP transform to a source rect popped
|
|
769
|
+
// from the registry. The worklet below appends those entries to the
|
|
770
|
+
// transform array so they compose with the user's animate transforms —
|
|
771
|
+
// multiple `translateX` entries sum, multiple `scaleX` entries multiply,
|
|
772
|
+
// which is exactly the FLIP semantic.
|
|
773
|
+
const sharedLayout = useSharedLayout({
|
|
774
|
+
layoutId,
|
|
775
|
+
userRef: ref,
|
|
776
|
+
transition: isTopLevelTransition(transition) ? transition : undefined,
|
|
777
|
+
shouldReduceMotion,
|
|
778
|
+
userOnLayout,
|
|
779
|
+
})
|
|
780
|
+
const flip = sharedLayout.flip
|
|
781
|
+
const hasLayoutId = layoutId !== undefined
|
|
782
|
+
|
|
783
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
784
|
+
const activeKeys = activeKeysRef.current!
|
|
785
|
+
const hasTransform = hasTransformRef.current
|
|
786
|
+
const hasShadowOffset = hasShadowOffsetRef.current
|
|
787
|
+
const out: Record<string, unknown> = {}
|
|
788
|
+
const transform: Array<Record<string, unknown>> = []
|
|
789
|
+
// shadow-offset reassembly buffers. The two synthetic axis SVs feed in
|
|
790
|
+
// here and the recomposed `{ width, height }` object lands on `out`
|
|
791
|
+
// after the loop so RN gets a single `shadowOffset` style prop.
|
|
792
|
+
let shadowOffsetW = 0
|
|
793
|
+
let shadowOffsetH = 0
|
|
794
|
+
|
|
795
|
+
// Read each progress SV exactly once so the chain below sees a coherent
|
|
796
|
+
// snapshot for this frame. Reading them on the UI thread is cheap.
|
|
797
|
+
const ph = hoveredProgress.value
|
|
798
|
+
const pf = focusedProgress.value
|
|
799
|
+
const pfv = focusVisibleProgress.value
|
|
800
|
+
const pp = pressedProgress.value
|
|
801
|
+
|
|
802
|
+
const layers = gestureSV.value
|
|
803
|
+
// Locals are suffixed `Layer` so they don't shadow the outer `pressed` /
|
|
804
|
+
// `focused` / `focusVisible` / `hovered` JS-state booleans — Reanimated's
|
|
805
|
+
// worklet closure tracker would otherwise pick those up as captured
|
|
806
|
+
// dependencies and re-register the worklet on every activation flip.
|
|
807
|
+
const hoveredLayer = layers ? layers.hovered : null
|
|
808
|
+
const focusedLayer = layers ? layers.focused : null
|
|
809
|
+
const focusVisibleLayer = layers ? layers.focusVisible : null
|
|
810
|
+
const pressedLayer = layers ? layers.pressed : null
|
|
811
|
+
|
|
812
|
+
for (const key of activeKeys) {
|
|
813
|
+
let v = sharedValues[key].value
|
|
814
|
+
const isColor = COLOR_KEY_SET.has(key)
|
|
815
|
+
|
|
816
|
+
// Composite gesture layers in priority order (lowest first). Each
|
|
817
|
+
// active layer pulls the value toward its pre-resolved primitive
|
|
818
|
+
// endpoint by `progress`; numeric keys lerp, color keys go through
|
|
819
|
+
// Reanimated's RGBA `interpolateColor`. We skip layers with progress
|
|
820
|
+
// 0 to avoid an `interpolateColor(0, ...)` call that would parse the
|
|
821
|
+
// target color string for no visible effect.
|
|
822
|
+
if (hoveredLayer && ph > 0 && hoveredLayer[key] !== undefined) {
|
|
823
|
+
const t = hoveredLayer[key]
|
|
824
|
+
v = isColor
|
|
825
|
+
? interpolateColor(ph, [0, 1], [v as string, t as string])
|
|
826
|
+
: (v as number) + ((t as number) - (v as number)) * ph
|
|
827
|
+
}
|
|
828
|
+
if (focusedLayer && pf > 0 && focusedLayer[key] !== undefined) {
|
|
829
|
+
const t = focusedLayer[key]
|
|
830
|
+
v = isColor
|
|
831
|
+
? interpolateColor(pf, [0, 1], [v as string, t as string])
|
|
832
|
+
: (v as number) + ((t as number) - (v as number)) * pf
|
|
833
|
+
}
|
|
834
|
+
if (
|
|
835
|
+
focusVisibleLayer &&
|
|
836
|
+
pfv > 0 &&
|
|
837
|
+
focusVisibleLayer[key] !== undefined
|
|
838
|
+
) {
|
|
839
|
+
const t = focusVisibleLayer[key]
|
|
840
|
+
v = isColor
|
|
841
|
+
? interpolateColor(pfv, [0, 1], [v as string, t as string])
|
|
842
|
+
: (v as number) + ((t as number) - (v as number)) * pfv
|
|
843
|
+
}
|
|
844
|
+
if (pressedLayer && pp > 0 && pressedLayer[key] !== undefined) {
|
|
845
|
+
const t = pressedLayer[key]
|
|
846
|
+
v = isColor
|
|
847
|
+
? interpolateColor(pp, [0, 1], [v as string, t as string])
|
|
848
|
+
: (v as number) + ((t as number) - (v as number)) * pp
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (TRANSFORM_KEY_SET.has(key)) {
|
|
852
|
+
transform.push(
|
|
853
|
+
ROTATION_KEYS.has(key) ? { [key]: `${v}deg` } : { [key]: v },
|
|
854
|
+
)
|
|
855
|
+
} else if (key === 'shadowOffsetWidth') {
|
|
856
|
+
shadowOffsetW = v as number
|
|
857
|
+
} else if (key === 'shadowOffsetHeight') {
|
|
858
|
+
shadowOffsetH = v as number
|
|
859
|
+
} else {
|
|
860
|
+
out[key] = v
|
|
861
|
+
}
|
|
687
862
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
)
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
863
|
+
// Shared-element FLIP transforms append after the user's transform
|
|
864
|
+
// entries so they compose multiplicatively in the same `transform`
|
|
865
|
+
// array — separate style entries with `transform` keys would
|
|
866
|
+
// last-write-wins, which is what we explicitly avoid here. At rest
|
|
867
|
+
// (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
|
|
868
|
+
// when no shared-element transition is active.
|
|
869
|
+
if (hasLayoutId) {
|
|
870
|
+
transform.push({ translateX: flip.dx.value })
|
|
871
|
+
transform.push({ translateY: flip.dy.value })
|
|
872
|
+
transform.push({ scaleX: flip.sx.value })
|
|
873
|
+
transform.push({ scaleY: flip.sy.value })
|
|
697
874
|
}
|
|
698
|
-
if (
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
? interpolateColor(pp, [0, 1], [v as string, t as string])
|
|
702
|
-
: (v as number) + ((t as number) - (v as number)) * pp
|
|
875
|
+
if (hasTransform || hasLayoutId) out.transform = transform
|
|
876
|
+
if (hasShadowOffset) {
|
|
877
|
+
out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
|
|
703
878
|
}
|
|
879
|
+
return out
|
|
880
|
+
})
|
|
704
881
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
// Shared-element FLIP transforms append after the user's transform
|
|
718
|
-
// entries so they compose multiplicatively in the same `transform`
|
|
719
|
-
// array — separate style entries with `transform` keys would
|
|
720
|
-
// last-write-wins, which is what we explicitly avoid here. At rest
|
|
721
|
-
// (dx, dy, sx, sy) = (0, 0, 1, 1) so the contribution is a no-op
|
|
722
|
-
// when no shared-element transition is active.
|
|
723
|
-
if (hasLayoutId) {
|
|
724
|
-
transform.push({ translateX: flip.dx.value })
|
|
725
|
-
transform.push({ translateY: flip.dy.value })
|
|
726
|
-
transform.push({ scaleX: flip.sx.value })
|
|
727
|
-
transform.push({ scaleY: flip.sy.value })
|
|
728
|
-
}
|
|
729
|
-
if (hasTransform || hasLayoutId) out.transform = transform
|
|
730
|
-
if (hasShadowOffset) {
|
|
731
|
-
out.shadowOffset = { width: shadowOffsetW, height: shadowOffsetH }
|
|
732
|
-
}
|
|
733
|
-
return out
|
|
734
|
-
})
|
|
735
|
-
|
|
736
|
-
// Exiting children are tap-deaf: the next press should fall through to
|
|
737
|
-
// whatever is underneath, not re-trigger a soon-to-unmount node. This is
|
|
738
|
-
// the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
|
|
739
|
-
// `pointerEvents` as a prop in favor of the style key, so we merge it
|
|
740
|
-
// alongside the animated style instead of spreading as a prop.
|
|
741
|
-
const mergedStyle = useMemo(
|
|
742
|
-
() =>
|
|
743
|
-
(isExiting
|
|
744
|
-
? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
|
|
745
|
-
: [style, animatedStyle]) as unknown,
|
|
746
|
-
[style, animatedStyle, isExiting],
|
|
747
|
-
)
|
|
882
|
+
// Exiting children are tap-deaf: the next press should fall through to
|
|
883
|
+
// whatever is underneath, not re-trigger a soon-to-unmount node. This is
|
|
884
|
+
// the moti #297 fix and a v0.1 acceptance criterion. RN 0.71+ deprecates
|
|
885
|
+
// `pointerEvents` as a prop in favor of the style key, so we merge it
|
|
886
|
+
// alongside the animated style instead of spreading as a prop.
|
|
887
|
+
const mergedStyle = useMemo(
|
|
888
|
+
() =>
|
|
889
|
+
(isExiting
|
|
890
|
+
? [style, animatedStyle, EXITING_POINTER_EVENTS_STYLE]
|
|
891
|
+
: [style, animatedStyle]) as unknown,
|
|
892
|
+
[style, animatedStyle, isExiting],
|
|
893
|
+
)
|
|
748
894
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
895
|
+
const gestureHandlers = useGestureHandlers(
|
|
896
|
+
gesture,
|
|
897
|
+
rest as Record<string, unknown>,
|
|
898
|
+
setPressed,
|
|
899
|
+
setFocused,
|
|
900
|
+
setFocusVisible,
|
|
901
|
+
setHovered,
|
|
902
|
+
)
|
|
757
903
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
904
|
+
// Resolve the `layout` prop into a Reanimated `LinearTransition` builder.
|
|
905
|
+
// Memoized on the value's stable signature so a fresh `layout={true}` or
|
|
906
|
+
// `layout={{ ... }}` literal each render doesn't rebuild the builder. When
|
|
907
|
+
// reduced motion is active we pass `undefined` — see `resolveLayout` for
|
|
908
|
+
// why we don't pass a duration-0 builder instead.
|
|
909
|
+
const layoutSig = stableSig(layout)
|
|
910
|
+
const layoutTransition = useMemo(
|
|
911
|
+
() =>
|
|
912
|
+
shouldReduceMotion ? undefined : resolveLayoutTransition(layout),
|
|
913
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
914
|
+
[layoutSig, shouldReduceMotion],
|
|
915
|
+
)
|
|
769
916
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
917
|
+
return (
|
|
918
|
+
<AnimatedComponent
|
|
919
|
+
ref={sharedLayout.setRef as never}
|
|
920
|
+
{...(rest as object)}
|
|
921
|
+
{...gestureHandlers}
|
|
922
|
+
onLayout={sharedLayout.onLayout}
|
|
923
|
+
layout={layoutTransition}
|
|
924
|
+
style={mergedStyle}
|
|
925
|
+
/>
|
|
926
|
+
)
|
|
927
|
+
},
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
MotionAnimated.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
|
|
931
|
+
|
|
932
|
+
// Dispatch: route prop-less instances to the zero-cost `PlainHost`, and
|
|
933
|
+
// anything carrying an animation prop to the full `MotionAnimated` body.
|
|
934
|
+
const Motion = forwardRef<unknown, Props>(function Motion(props, ref) {
|
|
935
|
+
if (hasMotionProps(props as Record<string, unknown>)) {
|
|
936
|
+
return <MotionAnimated ref={ref} {...props} />
|
|
937
|
+
}
|
|
938
|
+
return <PlainHost ref={ref} {...props} />
|
|
780
939
|
})
|
|
781
940
|
|
|
782
941
|
Motion.displayName = `Motion(${Component.displayName ?? Component.name ?? 'Component'})`
|
|
@@ -784,6 +943,32 @@ export function createMotionComponent<C extends ComponentType<any>>(
|
|
|
784
943
|
return Motion as unknown as MotionComponent<C>
|
|
785
944
|
}
|
|
786
945
|
|
|
946
|
+
/**
|
|
947
|
+
* The props that make a `Motion.*` instance actually animate. If none are
|
|
948
|
+
* present, the instance is a plain animated host (see `PlainHost`). A `style`
|
|
949
|
+
* function is intentionally excluded — it throws in dev and is not an
|
|
950
|
+
* animation driver; a plain host forwards a static `style` object untouched.
|
|
951
|
+
*/
|
|
952
|
+
const MOTION_PROP_KEYS = [
|
|
953
|
+
'initial',
|
|
954
|
+
'animate',
|
|
955
|
+
'exit',
|
|
956
|
+
'transition',
|
|
957
|
+
'variants',
|
|
958
|
+
'controller',
|
|
959
|
+
'gesture',
|
|
960
|
+
'layout',
|
|
961
|
+
'layoutId',
|
|
962
|
+
'onAnimationEnd',
|
|
963
|
+
] as const
|
|
964
|
+
|
|
965
|
+
function hasMotionProps(props: Record<string, unknown>): boolean {
|
|
966
|
+
for (const key of MOTION_PROP_KEYS) {
|
|
967
|
+
if (props[key] !== undefined) return true
|
|
968
|
+
}
|
|
969
|
+
return false
|
|
970
|
+
}
|
|
971
|
+
|
|
787
972
|
type SharedValueMap = Record<AnimatableKey, SharedValue<number | string>>
|
|
788
973
|
|
|
789
974
|
/**
|
|
@@ -863,6 +1048,23 @@ function useAnimatableSharedValues(
|
|
|
863
1048
|
shadowOffsetHeight,
|
|
864
1049
|
}
|
|
865
1050
|
}
|
|
1051
|
+
|
|
1052
|
+
// Cancel every in-flight per-key animation when the primitive unmounts, so
|
|
1053
|
+
// a mid-flight (or infinite-repeat) `withSpring` / `withTiming` doesn't keep
|
|
1054
|
+
// ticking its worklet against orphaned shared values. This mirrors the
|
|
1055
|
+
// value-layer hooks' unmount guard (`useMotionValue` / `useSpring` /
|
|
1056
|
+
// `useAnimation`). The SV map is identity-stable per instance.
|
|
1057
|
+
const map = ref.current
|
|
1058
|
+
useEffect(
|
|
1059
|
+
() => () => {
|
|
1060
|
+
for (const key in map) {
|
|
1061
|
+
cancelAnimation(map[key as AnimatableKey])
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
// `map` is identity-stable per hook instance.
|
|
1065
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1066
|
+
[],
|
|
1067
|
+
)
|
|
866
1068
|
return ref.current
|
|
867
1069
|
}
|
|
868
1070
|
|
|
@@ -1023,6 +1225,67 @@ function shadowOffsetAxisValue(
|
|
|
1023
1225
|
return source?.[axis]
|
|
1024
1226
|
}
|
|
1025
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* Read an animatable key's resting value out of a **flattened style object**.
|
|
1230
|
+
*
|
|
1231
|
+
* Used for keys that are in the active set but that no record drives — they
|
|
1232
|
+
* have to rest wherever the static `style` put them, because the animated
|
|
1233
|
+
* style merges after `style` and would otherwise override it.
|
|
1234
|
+
*
|
|
1235
|
+
* Three shapes need unwrapping: transform keys live inside the `transform`
|
|
1236
|
+
* array rather than at the top level, `shadowOffset*` synthetics decompose
|
|
1237
|
+
* from the nested object, and rotations are unit-suffixed strings on a style
|
|
1238
|
+
* but plain degrees in our shared values.
|
|
1239
|
+
*
|
|
1240
|
+
* Type-narrow deliberately: only numbers are accepted for numeric slots, so a
|
|
1241
|
+
* `width: '100%'` is left to `DEFAULT_RESTING` rather than seeded into a slot
|
|
1242
|
+
* that a later `withTiming` would try to interpolate as a number.
|
|
1243
|
+
*/
|
|
1244
|
+
function styleValueFor(
|
|
1245
|
+
flat: Record<string, unknown>,
|
|
1246
|
+
key: AnimatableKey,
|
|
1247
|
+
): number | string | undefined {
|
|
1248
|
+
if (SHADOW_OFFSET_KEY_SET.has(key)) {
|
|
1249
|
+
return shadowOffsetAxisValue(
|
|
1250
|
+
flat.shadowOffset as { width?: number; height?: number } | undefined,
|
|
1251
|
+
shadowOffsetAxisFor(key as ShadowOffsetKey),
|
|
1252
|
+
)
|
|
1253
|
+
}
|
|
1254
|
+
if (TRANSFORM_KEY_SET.has(key)) {
|
|
1255
|
+
const list = flat.transform
|
|
1256
|
+
if (!Array.isArray(list)) return undefined
|
|
1257
|
+
for (const entry of list) {
|
|
1258
|
+
if (!entry || typeof entry !== 'object') continue
|
|
1259
|
+
const v = (entry as Record<string, unknown>)[key]
|
|
1260
|
+
if (v === undefined) continue
|
|
1261
|
+
if (ROTATION_KEYS.has(key)) return parseAngleDegrees(v)
|
|
1262
|
+
return typeof v === 'number' ? v : undefined
|
|
1263
|
+
}
|
|
1264
|
+
return undefined
|
|
1265
|
+
}
|
|
1266
|
+
const v = flat[key]
|
|
1267
|
+
if (COLOR_KEY_SET.has(key)) {
|
|
1268
|
+
return typeof v === 'string' || typeof v === 'number' ? v : undefined
|
|
1269
|
+
}
|
|
1270
|
+
return typeof v === 'number' ? v : undefined
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Style rotations are unit-suffixed strings (`'45deg'`, `'0.5rad'`); the shared
|
|
1275
|
+
* value behind a rotation key holds plain degrees. Anything we can't convert
|
|
1276
|
+
* confidently returns `undefined` so the caller falls back to the resting
|
|
1277
|
+
* default instead of guessing at a unit.
|
|
1278
|
+
*/
|
|
1279
|
+
function parseAngleDegrees(v: unknown): number | undefined {
|
|
1280
|
+
if (typeof v === 'number') return v
|
|
1281
|
+
if (typeof v !== 'string') return undefined
|
|
1282
|
+
const deg = /^(-?\d*\.?\d+)deg$/.exec(v)
|
|
1283
|
+
if (deg) return Number(deg[1])
|
|
1284
|
+
const rad = /^(-?\d*\.?\d+)rad$/.exec(v)
|
|
1285
|
+
if (rad) return (Number(rad[1]) * 180) / Math.PI
|
|
1286
|
+
return undefined
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1026
1289
|
/**
|
|
1027
1290
|
* Populate `touched` with the `AnimatableKey`s mentioned in `record`. Direct
|
|
1028
1291
|
* matches (e.g. `opacity`, `width`) come from the key iteration; the nested
|
|
@@ -1067,6 +1330,22 @@ function totalIterationsOf(cfg: TransitionConfig | undefined): number {
|
|
|
1067
1330
|
return r.count
|
|
1068
1331
|
}
|
|
1069
1332
|
|
|
1333
|
+
/**
|
|
1334
|
+
* Whether an animation built from `cfg` will ever reach its terminal
|
|
1335
|
+
* `'animation'` phase. Everything finite does; `repeat: 'infinite'` never
|
|
1336
|
+
* does, because `dispatch` only promotes a callback to the terminal phase once
|
|
1337
|
+
* `iteration >= totalIterations - 1`, and that comparison is unreachable
|
|
1338
|
+
* against `Infinity`.
|
|
1339
|
+
*
|
|
1340
|
+
* Anything counting pending completions must exclude endless animations, or
|
|
1341
|
+
* the counter never drains: one endless transform axis swallowed every sibling
|
|
1342
|
+
* axis's `onAnimationEnd`, and one endless `exit` key left a `<Presence>` child
|
|
1343
|
+
* mounted forever because `safeToRemove` was gated behind it.
|
|
1344
|
+
*/
|
|
1345
|
+
function reachesTerminalPhase(cfg: TransitionConfig | undefined): boolean {
|
|
1346
|
+
return Number.isFinite(totalIterationsOf(cfg))
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1070
1349
|
/**
|
|
1071
1350
|
* Pull a single end-value out of an `AnimatableValue` for the
|
|
1072
1351
|
* `AnimationCallbackInfo.target` field. Plain numbers/strings come through;
|