@tamagui/animations-react-native 2.4.6 → 2.5.0
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/dist/cjs/createAnimations.cjs +84 -11
- package/dist/cjs/createAnimations.native.js +91 -15
- package/dist/cjs/createAnimations.native.js.map +1 -1
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/polyfill.native.js.map +1 -1
- package/dist/esm/createAnimations.mjs +84 -11
- package/dist/esm/createAnimations.mjs.map +1 -1
- package/dist/esm/createAnimations.native.js +91 -15
- package/dist/esm/createAnimations.native.js.map +1 -1
- package/package.json +12 -7
- package/src/createAnimations.tsx +150 -9
- package/types/createAnimations.d.ts.map +2 -2
package/src/createAnimations.tsx
CHANGED
|
@@ -63,6 +63,41 @@ const colorStyleKey = {
|
|
|
63
63
|
borderBottomColor: true,
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// layout dimension keys. these must run on the JS driver (useNativeDriver:false)
|
|
67
|
+
// because the native animated module can't drive layout props.
|
|
68
|
+
const layoutStyleKey = {
|
|
69
|
+
height: true,
|
|
70
|
+
width: true,
|
|
71
|
+
minHeight: true,
|
|
72
|
+
maxHeight: true,
|
|
73
|
+
minWidth: true,
|
|
74
|
+
maxWidth: true,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasAnimatedLayoutKey(
|
|
78
|
+
style: Record<string, any>,
|
|
79
|
+
isDark: boolean,
|
|
80
|
+
animateOnly?: string[]
|
|
81
|
+
) {
|
|
82
|
+
for (const key in layoutStyleKey) {
|
|
83
|
+
if (animateOnly && !animateOnly.includes(key)) continue
|
|
84
|
+
if (typeof resolveDynamicValue(style[key], isDark) === 'number') return true
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// a color string that RN's color interpolation can actually parse. var(...),
|
|
90
|
+
// calc(...) and empty strings reach createInterpolationFromStringOutputRange /
|
|
91
|
+
// mapStringToNumericComponents and throw ('.map of null' / 'outputRange must
|
|
92
|
+
// contain color or value with numeric component'). those must be applied as a
|
|
93
|
+
// static style instead of entering interpolation.
|
|
94
|
+
function isAnimatableColor(value: unknown): value is string {
|
|
95
|
+
if (typeof value !== 'string') return false
|
|
96
|
+
if (value === '') return false
|
|
97
|
+
if (value.includes('var(') || value.includes('calc(')) return false
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
66
101
|
// these style keys are costly to animate and only work with native driver on Fabric
|
|
67
102
|
const costlyToAnimateStyleKey = {
|
|
68
103
|
borderRadius: true,
|
|
@@ -206,6 +241,7 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
206
241
|
style,
|
|
207
242
|
componentState,
|
|
208
243
|
presence,
|
|
244
|
+
stateRef,
|
|
209
245
|
useStyleEmitter,
|
|
210
246
|
}) => {
|
|
211
247
|
const isDisabled = isWeb && componentState.unmounted === true
|
|
@@ -229,6 +265,7 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
229
265
|
}
|
|
230
266
|
>()
|
|
231
267
|
)
|
|
268
|
+
const pseudoActiveRef = React.useRef(false)
|
|
232
269
|
|
|
233
270
|
// exit cycle guards to prevent stale/duplicate completion
|
|
234
271
|
const exitCycleIdRef = React.useRef(0)
|
|
@@ -284,6 +321,24 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
284
321
|
: 'default'
|
|
285
322
|
|
|
286
323
|
const nonAnimatedStyle = {}
|
|
324
|
+
// animatedStyle owns every Animated.Value on the node. Fabric cannot mix
|
|
325
|
+
// native- and JS-driven values inside that shared graph, so one layout
|
|
326
|
+
// animation makes the whole node use the JS driver.
|
|
327
|
+
const useNativeDriverForNode =
|
|
328
|
+
nativeDriver &&
|
|
329
|
+
!hasAnimatedLayoutKey(
|
|
330
|
+
style,
|
|
331
|
+
isDark,
|
|
332
|
+
hasTransitionOnly ? animateOnly : undefined
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// track which animated keys/transforms the incoming style actually
|
|
336
|
+
// carries this pass, so entries that left the style can be dropped
|
|
337
|
+
// below (an Animated.Value that persisted forever would keep painting
|
|
338
|
+
// a stale pixel value, e.g. a released-to-auto accordion height)
|
|
339
|
+
const seenAnimateKeys = new Set<string>()
|
|
340
|
+
let sawTransform = false
|
|
341
|
+
let transformCount = 0
|
|
287
342
|
|
|
288
343
|
for (const key in style) {
|
|
289
344
|
const rawVal = style[key]
|
|
@@ -295,7 +350,11 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
295
350
|
continue
|
|
296
351
|
}
|
|
297
352
|
|
|
298
|
-
if (
|
|
353
|
+
if (
|
|
354
|
+
animatedStyleKey[key] == null &&
|
|
355
|
+
!costlyToAnimateStyleKey[key] &&
|
|
356
|
+
!layoutStyleKey[key]
|
|
357
|
+
) {
|
|
299
358
|
nonAnimatedStyle[key] = val
|
|
300
359
|
continue
|
|
301
360
|
}
|
|
@@ -305,8 +364,23 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
305
364
|
continue
|
|
306
365
|
}
|
|
307
366
|
|
|
367
|
+
// layout dimension keys only animate numbers — 'auto' (an open
|
|
368
|
+
// accordion at rest) and percent strings apply as static styles
|
|
369
|
+
if (layoutStyleKey[key] && typeof val !== 'number') {
|
|
370
|
+
nonAnimatedStyle[key] = val
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// unparseable themed colors (var(), calc(), empty) crash RN
|
|
375
|
+
// interpolation — apply them as a static style instead
|
|
376
|
+
if (colorStyleKey[key] && !isAnimatableColor(val)) {
|
|
377
|
+
nonAnimatedStyle[key] = val
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
|
|
308
381
|
if (key !== 'transform') {
|
|
309
382
|
animateStyles.current[key] = update(key, animateStyles.current[key], val)
|
|
383
|
+
seenAnimateKeys.add(key)
|
|
310
384
|
continue
|
|
311
385
|
}
|
|
312
386
|
// key: 'transform'
|
|
@@ -317,8 +391,10 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
317
391
|
continue
|
|
318
392
|
}
|
|
319
393
|
|
|
320
|
-
|
|
394
|
+
sawTransform = true
|
|
395
|
+
for (const transform of val) {
|
|
321
396
|
if (!transform) continue
|
|
397
|
+
const index = transformCount++
|
|
322
398
|
// tkey: e.g: 'translateX'
|
|
323
399
|
const tkey = Object.keys(transform)[0]
|
|
324
400
|
const currentTransform = animatedTranforms.current[index]?.[tkey]
|
|
@@ -329,6 +405,23 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
329
405
|
}
|
|
330
406
|
}
|
|
331
407
|
|
|
408
|
+
// drop stale Animated.Values whose keys left the incoming style, so the
|
|
409
|
+
// key genuinely leaves the rendered style object (a released height goes
|
|
410
|
+
// back to auto instead of staying pinned at its last pixel value). skip
|
|
411
|
+
// while exiting (presence still animates the leaving keys) and while
|
|
412
|
+
// disabled (the loop above intentionally skips every key). an active
|
|
413
|
+
// pseudo owns the current emitted style until its matching release.
|
|
414
|
+
if (!isExiting && !isDisabled && !pseudoActiveRef.current) {
|
|
415
|
+
for (const k in animateStyles.current) {
|
|
416
|
+
if (!seenAnimateKeys.has(k)) delete animateStyles.current[k]
|
|
417
|
+
}
|
|
418
|
+
if (!sawTransform) {
|
|
419
|
+
if (animatedTranforms.current.length) animatedTranforms.current = []
|
|
420
|
+
} else if (animatedTranforms.current.length > transformCount) {
|
|
421
|
+
animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
332
425
|
const animatedTransformStyle =
|
|
333
426
|
animatedTranforms.current.length > 0
|
|
334
427
|
? {
|
|
@@ -416,8 +509,8 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
416
509
|
function getAnimation() {
|
|
417
510
|
return Animated[animationConfig.type || 'spring'](value, {
|
|
418
511
|
toValue: animateToValue,
|
|
419
|
-
useNativeDriver: nativeDriver,
|
|
420
512
|
...animationConfig,
|
|
513
|
+
useNativeDriver: useNativeDriverForNode,
|
|
421
514
|
})
|
|
422
515
|
}
|
|
423
516
|
|
|
@@ -500,28 +593,76 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
500
593
|
// avoidReRenders: receive style changes imperatively from tamagui
|
|
501
594
|
// and update Animated.Values directly without React re-renders
|
|
502
595
|
// reuses the same update() + runner pattern as the useMemo path
|
|
503
|
-
useStyleEmitter?.((nextStyle) => {
|
|
596
|
+
useStyleEmitter?.((nextStyle, _effectiveTransition, pseudoActive) => {
|
|
597
|
+
pseudoActiveRef.current = pseudoActive === true
|
|
598
|
+
const runners: Function[] = []
|
|
599
|
+
const seenAnimateKeys = new Set<string>()
|
|
600
|
+
let transformCount = 0
|
|
601
|
+
let animatedShapeChanged = false
|
|
602
|
+
// nextStyle is the complete style for this node, so the emitter makes
|
|
603
|
+
// the same single driver decision as the render path. include the
|
|
604
|
+
// currently rendered graph because its stale keys are not removed until
|
|
605
|
+
// the structural-change commit below.
|
|
606
|
+
const useNativeDriverForNode =
|
|
607
|
+
nativeDriver &&
|
|
608
|
+
!hasAnimatedLayoutKey(nextStyle, isDark) &&
|
|
609
|
+
!Object.keys(animateStyles.current).some((key) => layoutStyleKey[key])
|
|
610
|
+
|
|
504
611
|
for (const key in nextStyle) {
|
|
505
612
|
const rawVal = nextStyle[key]
|
|
506
613
|
const val = resolveDynamicValue(rawVal, isDark)
|
|
507
614
|
if (val === undefined) continue
|
|
508
615
|
|
|
509
616
|
if (key === 'transform' && Array.isArray(val)) {
|
|
510
|
-
for (const
|
|
617
|
+
for (const transform of val) {
|
|
511
618
|
if (!transform) continue
|
|
619
|
+
const index = transformCount++
|
|
512
620
|
const tkey = Object.keys(transform)[0]
|
|
513
621
|
const currentTransform = animatedTranforms.current[index]?.[tkey]
|
|
622
|
+
if (!currentTransform) animatedShapeChanged = true
|
|
514
623
|
animatedTranforms.current[index] = {
|
|
515
624
|
[tkey]: update(tkey, currentTransform, transform[tkey]),
|
|
516
625
|
}
|
|
517
626
|
}
|
|
518
|
-
} else if (
|
|
627
|
+
} else if (
|
|
628
|
+
animatedStyleKey[key] != null ||
|
|
629
|
+
costlyToAnimateStyleKey[key] ||
|
|
630
|
+
layoutStyleKey[key]
|
|
631
|
+
) {
|
|
632
|
+
// layout keys only animate numbers ('auto'/percents are static);
|
|
633
|
+
// unparseable themed colors can't be interpolated — skip both and
|
|
634
|
+
// let the next render apply them statically
|
|
635
|
+
if (layoutStyleKey[key] && typeof val !== 'number') continue
|
|
636
|
+
if (colorStyleKey[key] && !isAnimatableColor(val)) continue
|
|
637
|
+
if (!animateStyles.current[key]) animatedShapeChanged = true
|
|
519
638
|
animateStyles.current[key] = update(key, animateStyles.current[key], val)
|
|
639
|
+
seenAnimateKeys.add(key)
|
|
520
640
|
}
|
|
521
641
|
}
|
|
522
642
|
|
|
643
|
+
// the emitter receives a complete style. keep the Animated style graph
|
|
644
|
+
// equally complete, including a pseudo release that omits a pseudo-only
|
|
645
|
+
// key. React Native needs a commit when that graph's shape changes.
|
|
646
|
+
for (const key in animateStyles.current) {
|
|
647
|
+
if (!seenAnimateKeys.has(key)) {
|
|
648
|
+
delete animateStyles.current[key]
|
|
649
|
+
animatedShapeChanged = true
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (animatedTranforms.current.length > transformCount) {
|
|
653
|
+
animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)
|
|
654
|
+
animatedShapeChanged = true
|
|
655
|
+
}
|
|
656
|
+
|
|
523
657
|
// run the queued animations immediately
|
|
524
|
-
|
|
658
|
+
runners.forEach((r) => r())
|
|
659
|
+
|
|
660
|
+
// pseudo state normally stays on the avoidReRenders path. adding or
|
|
661
|
+
// removing a style key cannot be expressed by an existing Animated.Value,
|
|
662
|
+
// so commit the pending state only for that structural change.
|
|
663
|
+
if (animatedShapeChanged && stateRef.current.nextState) {
|
|
664
|
+
stateRef.current.baseSetStateShallow?.(stateRef.current.nextState)
|
|
665
|
+
}
|
|
525
666
|
|
|
526
667
|
function update(
|
|
527
668
|
key: string,
|
|
@@ -568,12 +709,12 @@ export function createAnimations<A extends AnimationsConfig>(
|
|
|
568
709
|
props.transition,
|
|
569
710
|
'default'
|
|
570
711
|
)
|
|
571
|
-
|
|
712
|
+
runners.push(() => {
|
|
572
713
|
value.stopAnimation()
|
|
573
714
|
const anim = Animated[animationConfig.type || 'spring'](value, {
|
|
574
715
|
toValue: animateToValue,
|
|
575
|
-
useNativeDriver: nativeDriver,
|
|
576
716
|
...animationConfig,
|
|
717
|
+
useNativeDriver: useNativeDriverForNode,
|
|
577
718
|
})
|
|
578
719
|
;(animationConfig.delay
|
|
579
720
|
? Animated.sequence([Animated.delay(animationConfig.delay), anim])
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"mappings": "AAGA,cAEE,iBAEA,yBACA,2BACA,8BACK;AAGP,SAAS,eAAe,WAAW,YAAY;KAe1C,iBAAiB,6BAA6B,aAAa,KAAI;KAE/D,eAAe;CAAE,OAAO;IAAa,QACxC,KACE,SAAS,uBACP,UACA,eACA,YACA,aACA,SACA,sBACA,UACA,cACA,YACA;KAID,eAAe;CAAE,MAAM;IAAa,QAAQ,SAAS;KAErD,kBAAkB,eAAe;
|
|
2
|
+
"mappings": "AAGA,cAEE,iBAEA,yBACA,2BACA,8BACK;AAGP,SAAS,eAAe,WAAW,YAAY;KAe1C,iBAAiB,6BAA6B,aAAa,KAAI;KAE/D,eAAe;CAAE,OAAO;IAAa,QACxC,KACE,SAAS,uBACP,UACA,eACA,YACA,aACA,SACA,sBACA,UACA,cACA,YACA;KAID,eAAe;CAAE,MAAM;IAAa,QAAQ,SAAS;KAErD,kBAAkB,eAAe;KAmEjC,0BAA0B;CAE7B;;AAGF,OAAO,cAAM,cAAc,SAAS,yBAAyB;AAC7D,OAAO,cAAM,cAAc,SAAS,yBAAyB;AAE7D,OAAO,iBAAS,kBACd,kBACC,wBAAwB,SAAS;KA2D/B,gBAAgB,wBAAwB,SAAS;AAEtD,OAAO,cAAM,2BAA2B,0BAA0B;AAgBlE,OAAO,cAAM,wBAAwB,uBAAuB;AAO5D,OAAO,cAAM,0BACX,MAAM,iBACN,WAAW,GAAG;AAKhB,OAAO,iBAAS,iBAAiB,UAAU,kBACzC,YAAY,GACZ,UAAU,0BACT,gBAAgB",
|
|
3
3
|
"names": [],
|
|
4
4
|
"sources": [
|
|
5
5
|
"src/createAnimations.tsx"
|
|
6
6
|
],
|
|
7
7
|
"version": 3,
|
|
8
8
|
"sourcesContent": [
|
|
9
|
-
"import { getEffectiveAnimation, normalizeTransition } from '@tamagui/animation-helpers'\nimport { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'\nimport { ResetPresence, usePresence } from '@tamagui/use-presence'\nimport type {\n AnimatedNumberStrategy,\n AnimationDriver,\n TransitionProp,\n UniversalAnimatedNumber,\n UseAnimatedNumberReaction,\n UseAnimatedNumberStyle,\n} from '@tamagui/web'\nimport { useEvent, useThemeWithState } from '@tamagui/web'\nimport React from 'react'\nimport { Animated, type Text, type View } from 'react-native'\n\n// detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys\nconst isFabric =\n !isWeb && typeof global !== 'undefined' && !!global.__nativeFabricUIManager\n\n// Helper to resolve dynamic theme values like {dynamic: {dark: \"value\", light: undefined}}\nconst resolveDynamicValue = (value: any, isDark: boolean): any => {\n if (value && typeof value === 'object' && 'dynamic' in value) {\n const dynamicValue = isDark ? value.dynamic.dark : value.dynamic.light\n return dynamicValue\n }\n return value\n}\n\ntype AnimationsConfig<A extends object = any> = { [Key in keyof A]: AnimationConfig }\n\ntype SpringConfig = { type?: 'spring' } & Partial<\n Pick<\n Animated.SpringAnimationConfig,\n | 'delay'\n | 'bounciness'\n | 'damping'\n | 'friction'\n | 'mass'\n | 'overshootClamping'\n | 'speed'\n | 'stiffness'\n | 'tension'\n | 'velocity'\n >\n>\n\ntype TimingConfig = { type: 'timing' } & Partial<Animated.TimingAnimationConfig>\n\ntype AnimationConfig = SpringConfig | TimingConfig\n\nconst animatedStyleKey = {\n transform: true,\n opacity: true,\n}\n\nconst colorStyleKey = {\n backgroundColor: true,\n color: true,\n borderColor: true,\n borderLeftColor: true,\n borderRightColor: true,\n borderTopColor: true,\n borderBottomColor: true,\n}\n\n// these style keys are costly to animate and only work with native driver on Fabric\nconst costlyToAnimateStyleKey = {\n borderRadius: true,\n borderTopLeftRadius: true,\n borderTopRightRadius: true,\n borderBottomLeftRadius: true,\n borderBottomRightRadius: true,\n borderWidth: true,\n borderLeftWidth: true,\n borderRightWidth: true,\n borderTopWidth: true,\n borderBottomWidth: true,\n ...colorStyleKey,\n}\n\ntype CreateAnimationsOptions = {\n // override native driver detection (default: auto-detect Fabric)\n useNativeDriver?: boolean\n}\n\nexport const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View\nexport const AnimatedText: Animated.AnimatedComponent<typeof Text> = Animated.Text\n\nexport function useAnimatedNumber(\n initial: number\n): UniversalAnimatedNumber<Animated.Value> {\n const state = React.useRef(\n null as any as {\n val: Animated.Value\n composite: Animated.CompositeAnimation | null\n strategy: AnimatedNumberStrategy\n }\n )\n if (!state.current) {\n state.current = {\n composite: null,\n val: new Animated.Value(initial),\n strategy: { type: 'spring' },\n }\n }\n\n return {\n getInstance() {\n return state.current.val\n },\n getValue() {\n return state.current.val['_value']\n },\n stop() {\n state.current.composite?.stop()\n state.current.composite = null\n },\n setValue(next: number, { type, ...config } = { type: 'spring' }, onFinish) {\n const val = state.current.val\n\n const handleFinish = onFinish\n ? ({ finished }) => (finished ? onFinish() : null)\n : undefined\n\n if (type === 'direct') {\n val.setValue(next)\n } else if (type === 'spring') {\n state.current.composite?.stop()\n const composite = Animated.spring(val, {\n ...config,\n toValue: next,\n useNativeDriver: isFabric,\n })\n composite.start(handleFinish)\n state.current.composite = composite\n } else {\n state.current.composite?.stop()\n const composite = Animated.timing(val, {\n ...config,\n toValue: next,\n useNativeDriver: isFabric,\n })\n composite.start(handleFinish)\n state.current.composite = composite\n }\n },\n }\n}\n\ntype RNAnimatedNum = UniversalAnimatedNumber<Animated.Value>\n\nexport const useAnimatedNumberReaction: UseAnimatedNumberReaction<RNAnimatedNum> = (\n { value },\n onValue\n) => {\n const onChange = useEvent((current) => {\n onValue(current.value)\n })\n\n React.useEffect(() => {\n const id = value.getInstance().addListener(onChange)\n return () => {\n value.getInstance().removeListener(id)\n }\n }, [value, onChange])\n}\n\nexport const useAnimatedNumberStyle: UseAnimatedNumberStyle<RNAnimatedNum> = (\n value,\n getStyle\n) => {\n return getStyle(value.getInstance())\n}\n\nexport const useAnimatedNumbersStyle = (\n vals: RNAnimatedNum[],\n getStyle: (...currentValues: any[]) => any\n): any => {\n return getStyle(...vals.map((v) => v.getInstance()))\n}\n\nexport function createAnimations<A extends AnimationsConfig>(\n animations: A,\n options?: CreateAnimationsOptions\n): AnimationDriver<A> {\n const nativeDriver = options?.useNativeDriver ?? isFabric\n\n return {\n isReactNative: true,\n inputStyle: 'value',\n outputStyle: 'inline',\n avoidReRenders: true,\n animations,\n needsCustomComponent: true,\n View: AnimatedView,\n Text: AnimatedText,\n useAnimatedNumber,\n useAnimatedNumberReaction,\n useAnimatedNumberStyle,\n useAnimatedNumbersStyle,\n usePresence,\n ResetPresence,\n useAnimations: ({\n props,\n onDidAnimate,\n style,\n componentState,\n presence,\n useStyleEmitter,\n }) => {\n const isDisabled = isWeb && componentState.unmounted === true\n const isExiting = presence?.[0] === false\n const sendExitComplete = presence?.[1]\n const [, themeState] = useThemeWithState({})\n // Check scheme first, then fall back to checking theme name for 'dark'\n const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')\n\n /** store Animated value of each key e.g: color: AnimatedValue */\n const animateStyles = React.useRef<Record<string, Animated.Value>>({})\n const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])\n const animationsState = React.useRef(\n new WeakMap<\n Animated.Value,\n {\n interpolation: Animated.AnimatedInterpolation<any>\n current?: number | string | undefined\n // only for colors\n animateToValue?: number\n }\n >()\n )\n\n // exit cycle guards to prevent stale/duplicate completion\n const exitCycleIdRef = React.useRef(0)\n const exitCompletedRef = React.useRef(false)\n const wasExitingRef = React.useRef(false)\n\n // detect transition into/out of exiting state\n const justStartedExiting = isExiting && !wasExitingRef.current\n const justStoppedExiting = !isExiting && wasExitingRef.current\n\n // start new exit cycle only on transition INTO exiting\n if (justStartedExiting) {\n exitCycleIdRef.current++\n exitCompletedRef.current = false\n }\n // invalidate pending callbacks when exit is canceled/interrupted\n if (justStoppedExiting) {\n exitCycleIdRef.current++\n }\n\n const animateOnly = (props.animateOnly as string[]) || []\n const hasTransitionOnly = !!props.animateOnly\n\n // Track if we just finished entering (transition from entering to not entering)\n // must be declared before args array that uses justFinishedEntering\n const isEntering = !!componentState.unmounted\n const wasEnteringRef = React.useRef(isEntering)\n const justFinishedEntering = wasEnteringRef.current && !isEntering\n React.useEffect(() => {\n wasEnteringRef.current = isEntering\n })\n\n const args = [\n JSON.stringify(style),\n componentState,\n isExiting,\n !!onDidAnimate,\n isDark,\n justFinishedEntering,\n hasTransitionOnly,\n ]\n\n const res = React.useMemo(() => {\n const runners: Function[] = []\n const completions: Promise<void>[] = []\n\n // Determine animation state for enter/exit transitions\n // Use 'enter' if we're entering OR if we just finished entering\n const animationState: 'enter' | 'exit' | 'default' = isExiting\n ? 'exit'\n : isEntering || justFinishedEntering\n ? 'enter'\n : 'default'\n\n const nonAnimatedStyle = {}\n\n for (const key in style) {\n const rawVal = style[key]\n // Resolve dynamic theme values (like $theme-dark)\n const val = resolveDynamicValue(rawVal, isDark)\n if (val === undefined) continue\n\n if (isDisabled) {\n continue\n }\n\n if (animatedStyleKey[key] == null && !costlyToAnimateStyleKey[key]) {\n nonAnimatedStyle[key] = val\n continue\n }\n\n if (hasTransitionOnly && !animateOnly.includes(key)) {\n nonAnimatedStyle[key] = val\n continue\n }\n\n if (key !== 'transform') {\n animateStyles.current[key] = update(key, animateStyles.current[key], val)\n continue\n }\n // key: 'transform'\n // for now just support one transform key\n if (!val) continue\n if (typeof val === 'string') {\n console.warn(`Warning: Tamagui can't animate string transforms yet!`)\n continue\n }\n\n for (const [index, transform] of val.entries()) {\n if (!transform) continue\n // tkey: e.g: 'translateX'\n const tkey = Object.keys(transform)[0]\n const currentTransform = animatedTranforms.current[index]?.[tkey]\n animatedTranforms.current[index] = {\n [tkey]: update(tkey, currentTransform, transform[tkey]),\n }\n animatedTranforms.current = [...animatedTranforms.current]\n }\n }\n\n const animatedTransformStyle =\n animatedTranforms.current.length > 0\n ? {\n transform: animatedTranforms.current.map((r) => {\n const key = Object.keys(r)[0]\n const val =\n animationsState.current!.get(r[key])?.interpolation || r[key]\n return { [key]: val }\n }),\n }\n : {}\n\n const animatedStyle = {\n ...Object.fromEntries(\n Object.entries(animateStyles.current).map(([k, v]) => [\n k,\n animationsState.current!.get(v)?.interpolation || v,\n ])\n ),\n ...animatedTransformStyle,\n }\n\n return {\n runners,\n completions,\n style: [nonAnimatedStyle, animatedStyle],\n }\n\n function update(\n key: string,\n animated: Animated.Value | undefined,\n valIn: string | number\n ) {\n const isColorStyleKey = colorStyleKey[key]\n const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn)\n let animateToValue = val\n const value = animated || new Animated.Value(val)\n const curInterpolation = animationsState.current.get(value)\n\n let interpolateArgs: any\n if (type) {\n interpolateArgs = getInterpolated(\n curInterpolation?.current ?? value['_value'],\n val,\n type\n )\n animationsState.current!.set(value, {\n interpolation: value.interpolate(interpolateArgs),\n current: val,\n })\n }\n\n if (isColorStyleKey) {\n animateToValue = curInterpolation?.animateToValue ? 0 : 1\n interpolateArgs = getColorInterpolated(\n curInterpolation?.current as string,\n // valIn is the next color\n valIn as string,\n animateToValue\n )\n animationsState.current!.set(value, {\n current: valIn,\n interpolation: value.interpolate(interpolateArgs),\n animateToValue: curInterpolation?.animateToValue ? 0 : 1,\n })\n }\n\n if (value) {\n const animationConfig = getAnimationConfig(\n key,\n animations,\n props.transition,\n animationState\n )\n\n let resolve\n const promise = new Promise<void>((res) => {\n resolve = res\n })\n completions.push(promise)\n\n runners.push(() => {\n value.stopAnimation()\n\n function getAnimation() {\n return Animated[animationConfig.type || 'spring'](value, {\n toValue: animateToValue,\n useNativeDriver: nativeDriver,\n ...animationConfig,\n })\n }\n\n const animation = animationConfig.delay\n ? Animated.sequence([\n Animated.delay(animationConfig.delay),\n getAnimation(),\n ])\n : getAnimation()\n\n animation.start(({ finished }) => {\n // always resolve during exit (element is leaving anyway)\n // for non-exit, only resolve on successful completion\n if (finished || isExiting) {\n resolve()\n }\n })\n })\n }\n\n if (process.env.NODE_ENV === 'development') {\n if (props['debug'] === 'verbose') {\n // prettier-ignore\n console.info(\n ' 💠 animate',\n key,\n `from (${value['_value']}) to`,\n valIn,\n `(${val})`,\n 'type',\n type,\n 'interpolate',\n interpolateArgs\n )\n }\n }\n return value\n }\n }, args)\n\n // track previous exiting state\n React.useEffect(() => {\n wasExitingRef.current = isExiting\n })\n\n useIsomorphicLayoutEffect(() => {\n res.runners.forEach((r) => r())\n\n // capture current cycle id\n const cycleId = exitCycleIdRef.current\n\n // handle zero-completion case immediately\n if (res.completions.length === 0) {\n onDidAnimate?.()\n if (isExiting && !exitCompletedRef.current) {\n exitCompletedRef.current = true\n sendExitComplete?.()\n }\n return\n }\n\n let cancel = false\n Promise.all(res.completions).then(() => {\n if (cancel) return\n // guard against stale cycle completion\n if (isExiting && cycleId !== exitCycleIdRef.current) return\n if (isExiting && exitCompletedRef.current) return\n\n onDidAnimate?.()\n if (isExiting) {\n exitCompletedRef.current = true\n sendExitComplete?.()\n }\n })\n return () => {\n cancel = true\n }\n }, args)\n\n // avoidReRenders: receive style changes imperatively from tamagui\n // and update Animated.Values directly without React re-renders\n // reuses the same update() + runner pattern as the useMemo path\n useStyleEmitter?.((nextStyle) => {\n for (const key in nextStyle) {\n const rawVal = nextStyle[key]\n const val = resolveDynamicValue(rawVal, isDark)\n if (val === undefined) continue\n\n if (key === 'transform' && Array.isArray(val)) {\n for (const [index, transform] of val.entries()) {\n if (!transform) continue\n const tkey = Object.keys(transform)[0]\n const currentTransform = animatedTranforms.current[index]?.[tkey]\n animatedTranforms.current[index] = {\n [tkey]: update(tkey, currentTransform, transform[tkey]),\n }\n }\n } else if (animatedStyleKey[key] != null || costlyToAnimateStyleKey[key]) {\n animateStyles.current[key] = update(key, animateStyles.current[key], val)\n }\n }\n\n // run the queued animations immediately\n res.runners.forEach((r) => r())\n\n function update(\n key: string,\n animated: Animated.Value | undefined,\n valIn: string | number\n ) {\n const isColor = colorStyleKey[key]\n const [numVal, type] = isColor ? [0, undefined] : getValue(valIn)\n let animateToValue = numVal\n const value = animated || new Animated.Value(numVal)\n const curInterpolation = animationsState.current.get(value)\n\n if (type) {\n animationsState.current.set(value, {\n interpolation: value.interpolate(\n getInterpolated(\n curInterpolation?.current ?? value['_value'],\n numVal,\n type\n )\n ),\n current: numVal,\n })\n }\n\n if (isColor) {\n animateToValue = curInterpolation?.animateToValue ? 0 : 1\n animationsState.current.set(value, {\n current: valIn,\n interpolation: value.interpolate(\n getColorInterpolated(\n curInterpolation?.current as string,\n valIn as string,\n animateToValue\n )\n ),\n animateToValue: curInterpolation?.animateToValue ? 0 : 1,\n })\n }\n\n const animationConfig = getAnimationConfig(\n key,\n animations,\n props.transition,\n 'default'\n )\n res.runners.push(() => {\n value.stopAnimation()\n const anim = Animated[animationConfig.type || 'spring'](value, {\n toValue: animateToValue,\n useNativeDriver: nativeDriver,\n ...animationConfig,\n })\n ;(animationConfig.delay\n ? Animated.sequence([Animated.delay(animationConfig.delay), anim])\n : anim\n ).start()\n })\n\n return value\n }\n })\n\n if (process.env.NODE_ENV === 'development') {\n if (props['debug'] === 'verbose') {\n console.info(`Animated`, { response: res, inputStyle: style, isExiting })\n }\n }\n\n return res\n },\n }\n}\n\nfunction getColorInterpolated(\n currentColor: string | undefined,\n nextColor: string,\n animateToValue: number\n) {\n const inputRange = [0, 1]\n const outputRange = [currentColor ? currentColor : nextColor, nextColor]\n if (animateToValue === 0) {\n // because we are animating from value 1 to 0, we need to put target color at the beginning\n outputRange.reverse()\n }\n return {\n inputRange,\n outputRange,\n }\n}\n\nfunction getInterpolated(current: number, next: number, postfix = 'deg') {\n if (next === current) {\n current = next - 0.000000001\n }\n const inputRange = [current, next]\n const outputRange = [`${current}${postfix}`, `${next}${postfix}`]\n if (next < current) {\n inputRange.reverse()\n outputRange.reverse()\n }\n return {\n inputRange,\n outputRange,\n }\n}\n\nfunction getAnimationConfig(\n key: string,\n animations: AnimationsConfig,\n transition?: TransitionProp,\n animationState: 'enter' | 'exit' | 'default' = 'default'\n): AnimationConfig {\n const normalized = normalizeTransition(transition)\n const shortKey = transformShorthands[key]\n\n // Check for property-specific animation\n const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey]\n\n let animationType: string | null = null\n let extraConf: any = {}\n\n if (typeof propAnimation === 'string') {\n // Direct animation name: { x: 'quick' }\n animationType = propAnimation\n } else if (propAnimation && typeof propAnimation === 'object') {\n // Config object: { x: { type: 'quick', delay: 100 } }\n // Use effective animation based on state if no explicit type in config\n animationType =\n propAnimation.type || getEffectiveAnimation(normalized, animationState)\n extraConf = propAnimation\n } else {\n // Fall back to effective animation based on state (enter/exit/default)\n animationType = getEffectiveAnimation(normalized, animationState)\n }\n\n // Apply global delay if no property-specific delay\n if (normalized.delay && !extraConf.delay) {\n extraConf = { ...extraConf, delay: normalized.delay }\n }\n\n const found = animationType ? animations[animationType] : {}\n return {\n ...found,\n // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]})\n ...normalized.config,\n // Property-specific config takes highest precedence\n ...extraConf,\n }\n}\n\n// try both combos\nconst transformShorthands = {\n x: 'translateX',\n y: 'translateY',\n translateX: 'x',\n translateY: 'y',\n}\n\nfunction getValue(input: number | string, isColor = false) {\n if (typeof input !== 'string') {\n return [input] as const\n }\n const [_, number, after] = input.match(/([-0-9]+)(deg|%|px)/) ?? []\n return [+number, after] as const\n}\n"
|
|
9
|
+
"import { getEffectiveAnimation, normalizeTransition } from '@tamagui/animation-helpers'\nimport { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'\nimport { ResetPresence, usePresence } from '@tamagui/use-presence'\nimport type {\n AnimatedNumberStrategy,\n AnimationDriver,\n TransitionProp,\n UniversalAnimatedNumber,\n UseAnimatedNumberReaction,\n UseAnimatedNumberStyle,\n} from '@tamagui/web'\nimport { useEvent, useThemeWithState } from '@tamagui/web'\nimport React from 'react'\nimport { Animated, type Text, type View } from 'react-native'\n\n// detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys\nconst isFabric =\n !isWeb && typeof global !== 'undefined' && !!global.__nativeFabricUIManager\n\n// Helper to resolve dynamic theme values like {dynamic: {dark: \"value\", light: undefined}}\nconst resolveDynamicValue = (value: any, isDark: boolean): any => {\n if (value && typeof value === 'object' && 'dynamic' in value) {\n const dynamicValue = isDark ? value.dynamic.dark : value.dynamic.light\n return dynamicValue\n }\n return value\n}\n\ntype AnimationsConfig<A extends object = any> = { [Key in keyof A]: AnimationConfig }\n\ntype SpringConfig = { type?: 'spring' } & Partial<\n Pick<\n Animated.SpringAnimationConfig,\n | 'delay'\n | 'bounciness'\n | 'damping'\n | 'friction'\n | 'mass'\n | 'overshootClamping'\n | 'speed'\n | 'stiffness'\n | 'tension'\n | 'velocity'\n >\n>\n\ntype TimingConfig = { type: 'timing' } & Partial<Animated.TimingAnimationConfig>\n\ntype AnimationConfig = SpringConfig | TimingConfig\n\nconst animatedStyleKey = {\n transform: true,\n opacity: true,\n}\n\nconst colorStyleKey = {\n backgroundColor: true,\n color: true,\n borderColor: true,\n borderLeftColor: true,\n borderRightColor: true,\n borderTopColor: true,\n borderBottomColor: true,\n}\n\n// layout dimension keys. these must run on the JS driver (useNativeDriver:false)\n// because the native animated module can't drive layout props.\nconst layoutStyleKey = {\n height: true,\n width: true,\n minHeight: true,\n maxHeight: true,\n minWidth: true,\n maxWidth: true,\n}\n\nfunction hasAnimatedLayoutKey(\n style: Record<string, any>,\n isDark: boolean,\n animateOnly?: string[]\n) {\n for (const key in layoutStyleKey) {\n if (animateOnly && !animateOnly.includes(key)) continue\n if (typeof resolveDynamicValue(style[key], isDark) === 'number') return true\n }\n return false\n}\n\n// a color string that RN's color interpolation can actually parse. var(...),\n// calc(...) and empty strings reach createInterpolationFromStringOutputRange /\n// mapStringToNumericComponents and throw ('.map of null' / 'outputRange must\n// contain color or value with numeric component'). those must be applied as a\n// static style instead of entering interpolation.\nfunction isAnimatableColor(value: unknown): value is string {\n if (typeof value !== 'string') return false\n if (value === '') return false\n if (value.includes('var(') || value.includes('calc(')) return false\n return true\n}\n\n// these style keys are costly to animate and only work with native driver on Fabric\nconst costlyToAnimateStyleKey = {\n borderRadius: true,\n borderTopLeftRadius: true,\n borderTopRightRadius: true,\n borderBottomLeftRadius: true,\n borderBottomRightRadius: true,\n borderWidth: true,\n borderLeftWidth: true,\n borderRightWidth: true,\n borderTopWidth: true,\n borderBottomWidth: true,\n ...colorStyleKey,\n}\n\ntype CreateAnimationsOptions = {\n // override native driver detection (default: auto-detect Fabric)\n useNativeDriver?: boolean\n}\n\nexport const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View\nexport const AnimatedText: Animated.AnimatedComponent<typeof Text> = Animated.Text\n\nexport function useAnimatedNumber(\n initial: number\n): UniversalAnimatedNumber<Animated.Value> {\n const state = React.useRef(\n null as any as {\n val: Animated.Value\n composite: Animated.CompositeAnimation | null\n strategy: AnimatedNumberStrategy\n }\n )\n if (!state.current) {\n state.current = {\n composite: null,\n val: new Animated.Value(initial),\n strategy: { type: 'spring' },\n }\n }\n\n return {\n getInstance() {\n return state.current.val\n },\n getValue() {\n return state.current.val['_value']\n },\n stop() {\n state.current.composite?.stop()\n state.current.composite = null\n },\n setValue(next: number, { type, ...config } = { type: 'spring' }, onFinish) {\n const val = state.current.val\n\n const handleFinish = onFinish\n ? ({ finished }) => (finished ? onFinish() : null)\n : undefined\n\n if (type === 'direct') {\n val.setValue(next)\n } else if (type === 'spring') {\n state.current.composite?.stop()\n const composite = Animated.spring(val, {\n ...config,\n toValue: next,\n useNativeDriver: isFabric,\n })\n composite.start(handleFinish)\n state.current.composite = composite\n } else {\n state.current.composite?.stop()\n const composite = Animated.timing(val, {\n ...config,\n toValue: next,\n useNativeDriver: isFabric,\n })\n composite.start(handleFinish)\n state.current.composite = composite\n }\n },\n }\n}\n\ntype RNAnimatedNum = UniversalAnimatedNumber<Animated.Value>\n\nexport const useAnimatedNumberReaction: UseAnimatedNumberReaction<RNAnimatedNum> = (\n { value },\n onValue\n) => {\n const onChange = useEvent((current) => {\n onValue(current.value)\n })\n\n React.useEffect(() => {\n const id = value.getInstance().addListener(onChange)\n return () => {\n value.getInstance().removeListener(id)\n }\n }, [value, onChange])\n}\n\nexport const useAnimatedNumberStyle: UseAnimatedNumberStyle<RNAnimatedNum> = (\n value,\n getStyle\n) => {\n return getStyle(value.getInstance())\n}\n\nexport const useAnimatedNumbersStyle = (\n vals: RNAnimatedNum[],\n getStyle: (...currentValues: any[]) => any\n): any => {\n return getStyle(...vals.map((v) => v.getInstance()))\n}\n\nexport function createAnimations<A extends AnimationsConfig>(\n animations: A,\n options?: CreateAnimationsOptions\n): AnimationDriver<A> {\n const nativeDriver = options?.useNativeDriver ?? isFabric\n\n return {\n isReactNative: true,\n inputStyle: 'value',\n outputStyle: 'inline',\n avoidReRenders: true,\n animations,\n needsCustomComponent: true,\n View: AnimatedView,\n Text: AnimatedText,\n useAnimatedNumber,\n useAnimatedNumberReaction,\n useAnimatedNumberStyle,\n useAnimatedNumbersStyle,\n usePresence,\n ResetPresence,\n useAnimations: ({\n props,\n onDidAnimate,\n style,\n componentState,\n presence,\n stateRef,\n useStyleEmitter,\n }) => {\n const isDisabled = isWeb && componentState.unmounted === true\n const isExiting = presence?.[0] === false\n const sendExitComplete = presence?.[1]\n const [, themeState] = useThemeWithState({})\n // Check scheme first, then fall back to checking theme name for 'dark'\n const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')\n\n /** store Animated value of each key e.g: color: AnimatedValue */\n const animateStyles = React.useRef<Record<string, Animated.Value>>({})\n const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])\n const animationsState = React.useRef(\n new WeakMap<\n Animated.Value,\n {\n interpolation: Animated.AnimatedInterpolation<any>\n current?: number | string | undefined\n // only for colors\n animateToValue?: number\n }\n >()\n )\n const pseudoActiveRef = React.useRef(false)\n\n // exit cycle guards to prevent stale/duplicate completion\n const exitCycleIdRef = React.useRef(0)\n const exitCompletedRef = React.useRef(false)\n const wasExitingRef = React.useRef(false)\n\n // detect transition into/out of exiting state\n const justStartedExiting = isExiting && !wasExitingRef.current\n const justStoppedExiting = !isExiting && wasExitingRef.current\n\n // start new exit cycle only on transition INTO exiting\n if (justStartedExiting) {\n exitCycleIdRef.current++\n exitCompletedRef.current = false\n }\n // invalidate pending callbacks when exit is canceled/interrupted\n if (justStoppedExiting) {\n exitCycleIdRef.current++\n }\n\n const animateOnly = (props.animateOnly as string[]) || []\n const hasTransitionOnly = !!props.animateOnly\n\n // Track if we just finished entering (transition from entering to not entering)\n // must be declared before args array that uses justFinishedEntering\n const isEntering = !!componentState.unmounted\n const wasEnteringRef = React.useRef(isEntering)\n const justFinishedEntering = wasEnteringRef.current && !isEntering\n React.useEffect(() => {\n wasEnteringRef.current = isEntering\n })\n\n const args = [\n JSON.stringify(style),\n componentState,\n isExiting,\n !!onDidAnimate,\n isDark,\n justFinishedEntering,\n hasTransitionOnly,\n ]\n\n const res = React.useMemo(() => {\n const runners: Function[] = []\n const completions: Promise<void>[] = []\n\n // Determine animation state for enter/exit transitions\n // Use 'enter' if we're entering OR if we just finished entering\n const animationState: 'enter' | 'exit' | 'default' = isExiting\n ? 'exit'\n : isEntering || justFinishedEntering\n ? 'enter'\n : 'default'\n\n const nonAnimatedStyle = {}\n // animatedStyle owns every Animated.Value on the node. Fabric cannot mix\n // native- and JS-driven values inside that shared graph, so one layout\n // animation makes the whole node use the JS driver.\n const useNativeDriverForNode =\n nativeDriver &&\n !hasAnimatedLayoutKey(\n style,\n isDark,\n hasTransitionOnly ? animateOnly : undefined\n )\n\n // track which animated keys/transforms the incoming style actually\n // carries this pass, so entries that left the style can be dropped\n // below (an Animated.Value that persisted forever would keep painting\n // a stale pixel value, e.g. a released-to-auto accordion height)\n const seenAnimateKeys = new Set<string>()\n let sawTransform = false\n let transformCount = 0\n\n for (const key in style) {\n const rawVal = style[key]\n // Resolve dynamic theme values (like $theme-dark)\n const val = resolveDynamicValue(rawVal, isDark)\n if (val === undefined) continue\n\n if (isDisabled) {\n continue\n }\n\n if (\n animatedStyleKey[key] == null &&\n !costlyToAnimateStyleKey[key] &&\n !layoutStyleKey[key]\n ) {\n nonAnimatedStyle[key] = val\n continue\n }\n\n if (hasTransitionOnly && !animateOnly.includes(key)) {\n nonAnimatedStyle[key] = val\n continue\n }\n\n // layout dimension keys only animate numbers — 'auto' (an open\n // accordion at rest) and percent strings apply as static styles\n if (layoutStyleKey[key] && typeof val !== 'number') {\n nonAnimatedStyle[key] = val\n continue\n }\n\n // unparseable themed colors (var(), calc(), empty) crash RN\n // interpolation — apply them as a static style instead\n if (colorStyleKey[key] && !isAnimatableColor(val)) {\n nonAnimatedStyle[key] = val\n continue\n }\n\n if (key !== 'transform') {\n animateStyles.current[key] = update(key, animateStyles.current[key], val)\n seenAnimateKeys.add(key)\n continue\n }\n // key: 'transform'\n // for now just support one transform key\n if (!val) continue\n if (typeof val === 'string') {\n console.warn(`Warning: Tamagui can't animate string transforms yet!`)\n continue\n }\n\n sawTransform = true\n for (const transform of val) {\n if (!transform) continue\n const index = transformCount++\n // tkey: e.g: 'translateX'\n const tkey = Object.keys(transform)[0]\n const currentTransform = animatedTranforms.current[index]?.[tkey]\n animatedTranforms.current[index] = {\n [tkey]: update(tkey, currentTransform, transform[tkey]),\n }\n animatedTranforms.current = [...animatedTranforms.current]\n }\n }\n\n // drop stale Animated.Values whose keys left the incoming style, so the\n // key genuinely leaves the rendered style object (a released height goes\n // back to auto instead of staying pinned at its last pixel value). skip\n // while exiting (presence still animates the leaving keys) and while\n // disabled (the loop above intentionally skips every key). an active\n // pseudo owns the current emitted style until its matching release.\n if (!isExiting && !isDisabled && !pseudoActiveRef.current) {\n for (const k in animateStyles.current) {\n if (!seenAnimateKeys.has(k)) delete animateStyles.current[k]\n }\n if (!sawTransform) {\n if (animatedTranforms.current.length) animatedTranforms.current = []\n } else if (animatedTranforms.current.length > transformCount) {\n animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)\n }\n }\n\n const animatedTransformStyle =\n animatedTranforms.current.length > 0\n ? {\n transform: animatedTranforms.current.map((r) => {\n const key = Object.keys(r)[0]\n const val =\n animationsState.current!.get(r[key])?.interpolation || r[key]\n return { [key]: val }\n }),\n }\n : {}\n\n const animatedStyle = {\n ...Object.fromEntries(\n Object.entries(animateStyles.current).map(([k, v]) => [\n k,\n animationsState.current!.get(v)?.interpolation || v,\n ])\n ),\n ...animatedTransformStyle,\n }\n\n return {\n runners,\n completions,\n style: [nonAnimatedStyle, animatedStyle],\n }\n\n function update(\n key: string,\n animated: Animated.Value | undefined,\n valIn: string | number\n ) {\n const isColorStyleKey = colorStyleKey[key]\n const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn)\n let animateToValue = val\n const value = animated || new Animated.Value(val)\n const curInterpolation = animationsState.current.get(value)\n\n let interpolateArgs: any\n if (type) {\n interpolateArgs = getInterpolated(\n curInterpolation?.current ?? value['_value'],\n val,\n type\n )\n animationsState.current!.set(value, {\n interpolation: value.interpolate(interpolateArgs),\n current: val,\n })\n }\n\n if (isColorStyleKey) {\n animateToValue = curInterpolation?.animateToValue ? 0 : 1\n interpolateArgs = getColorInterpolated(\n curInterpolation?.current as string,\n // valIn is the next color\n valIn as string,\n animateToValue\n )\n animationsState.current!.set(value, {\n current: valIn,\n interpolation: value.interpolate(interpolateArgs),\n animateToValue: curInterpolation?.animateToValue ? 0 : 1,\n })\n }\n\n if (value) {\n const animationConfig = getAnimationConfig(\n key,\n animations,\n props.transition,\n animationState\n )\n\n let resolve\n const promise = new Promise<void>((res) => {\n resolve = res\n })\n completions.push(promise)\n\n runners.push(() => {\n value.stopAnimation()\n\n function getAnimation() {\n return Animated[animationConfig.type || 'spring'](value, {\n toValue: animateToValue,\n ...animationConfig,\n useNativeDriver: useNativeDriverForNode,\n })\n }\n\n const animation = animationConfig.delay\n ? Animated.sequence([\n Animated.delay(animationConfig.delay),\n getAnimation(),\n ])\n : getAnimation()\n\n animation.start(({ finished }) => {\n // always resolve during exit (element is leaving anyway)\n // for non-exit, only resolve on successful completion\n if (finished || isExiting) {\n resolve()\n }\n })\n })\n }\n\n if (process.env.NODE_ENV === 'development') {\n if (props['debug'] === 'verbose') {\n // prettier-ignore\n console.info(\n ' 💠 animate',\n key,\n `from (${value['_value']}) to`,\n valIn,\n `(${val})`,\n 'type',\n type,\n 'interpolate',\n interpolateArgs\n )\n }\n }\n return value\n }\n }, args)\n\n // track previous exiting state\n React.useEffect(() => {\n wasExitingRef.current = isExiting\n })\n\n useIsomorphicLayoutEffect(() => {\n res.runners.forEach((r) => r())\n\n // capture current cycle id\n const cycleId = exitCycleIdRef.current\n\n // handle zero-completion case immediately\n if (res.completions.length === 0) {\n onDidAnimate?.()\n if (isExiting && !exitCompletedRef.current) {\n exitCompletedRef.current = true\n sendExitComplete?.()\n }\n return\n }\n\n let cancel = false\n Promise.all(res.completions).then(() => {\n if (cancel) return\n // guard against stale cycle completion\n if (isExiting && cycleId !== exitCycleIdRef.current) return\n if (isExiting && exitCompletedRef.current) return\n\n onDidAnimate?.()\n if (isExiting) {\n exitCompletedRef.current = true\n sendExitComplete?.()\n }\n })\n return () => {\n cancel = true\n }\n }, args)\n\n // avoidReRenders: receive style changes imperatively from tamagui\n // and update Animated.Values directly without React re-renders\n // reuses the same update() + runner pattern as the useMemo path\n useStyleEmitter?.((nextStyle, _effectiveTransition, pseudoActive) => {\n pseudoActiveRef.current = pseudoActive === true\n const runners: Function[] = []\n const seenAnimateKeys = new Set<string>()\n let transformCount = 0\n let animatedShapeChanged = false\n // nextStyle is the complete style for this node, so the emitter makes\n // the same single driver decision as the render path. include the\n // currently rendered graph because its stale keys are not removed until\n // the structural-change commit below.\n const useNativeDriverForNode =\n nativeDriver &&\n !hasAnimatedLayoutKey(nextStyle, isDark) &&\n !Object.keys(animateStyles.current).some((key) => layoutStyleKey[key])\n\n for (const key in nextStyle) {\n const rawVal = nextStyle[key]\n const val = resolveDynamicValue(rawVal, isDark)\n if (val === undefined) continue\n\n if (key === 'transform' && Array.isArray(val)) {\n for (const transform of val) {\n if (!transform) continue\n const index = transformCount++\n const tkey = Object.keys(transform)[0]\n const currentTransform = animatedTranforms.current[index]?.[tkey]\n if (!currentTransform) animatedShapeChanged = true\n animatedTranforms.current[index] = {\n [tkey]: update(tkey, currentTransform, transform[tkey]),\n }\n }\n } else if (\n animatedStyleKey[key] != null ||\n costlyToAnimateStyleKey[key] ||\n layoutStyleKey[key]\n ) {\n // layout keys only animate numbers ('auto'/percents are static);\n // unparseable themed colors can't be interpolated — skip both and\n // let the next render apply them statically\n if (layoutStyleKey[key] && typeof val !== 'number') continue\n if (colorStyleKey[key] && !isAnimatableColor(val)) continue\n if (!animateStyles.current[key]) animatedShapeChanged = true\n animateStyles.current[key] = update(key, animateStyles.current[key], val)\n seenAnimateKeys.add(key)\n }\n }\n\n // the emitter receives a complete style. keep the Animated style graph\n // equally complete, including a pseudo release that omits a pseudo-only\n // key. React Native needs a commit when that graph's shape changes.\n for (const key in animateStyles.current) {\n if (!seenAnimateKeys.has(key)) {\n delete animateStyles.current[key]\n animatedShapeChanged = true\n }\n }\n if (animatedTranforms.current.length > transformCount) {\n animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)\n animatedShapeChanged = true\n }\n\n // run the queued animations immediately\n runners.forEach((r) => r())\n\n // pseudo state normally stays on the avoidReRenders path. adding or\n // removing a style key cannot be expressed by an existing Animated.Value,\n // so commit the pending state only for that structural change.\n if (animatedShapeChanged && stateRef.current.nextState) {\n stateRef.current.baseSetStateShallow?.(stateRef.current.nextState)\n }\n\n function update(\n key: string,\n animated: Animated.Value | undefined,\n valIn: string | number\n ) {\n const isColor = colorStyleKey[key]\n const [numVal, type] = isColor ? [0, undefined] : getValue(valIn)\n let animateToValue = numVal\n const value = animated || new Animated.Value(numVal)\n const curInterpolation = animationsState.current.get(value)\n\n if (type) {\n animationsState.current.set(value, {\n interpolation: value.interpolate(\n getInterpolated(\n curInterpolation?.current ?? value['_value'],\n numVal,\n type\n )\n ),\n current: numVal,\n })\n }\n\n if (isColor) {\n animateToValue = curInterpolation?.animateToValue ? 0 : 1\n animationsState.current.set(value, {\n current: valIn,\n interpolation: value.interpolate(\n getColorInterpolated(\n curInterpolation?.current as string,\n valIn as string,\n animateToValue\n )\n ),\n animateToValue: curInterpolation?.animateToValue ? 0 : 1,\n })\n }\n\n const animationConfig = getAnimationConfig(\n key,\n animations,\n props.transition,\n 'default'\n )\n runners.push(() => {\n value.stopAnimation()\n const anim = Animated[animationConfig.type || 'spring'](value, {\n toValue: animateToValue,\n ...animationConfig,\n useNativeDriver: useNativeDriverForNode,\n })\n ;(animationConfig.delay\n ? Animated.sequence([Animated.delay(animationConfig.delay), anim])\n : anim\n ).start()\n })\n\n return value\n }\n })\n\n if (process.env.NODE_ENV === 'development') {\n if (props['debug'] === 'verbose') {\n console.info(`Animated`, { response: res, inputStyle: style, isExiting })\n }\n }\n\n return res\n },\n }\n}\n\nfunction getColorInterpolated(\n currentColor: string | undefined,\n nextColor: string,\n animateToValue: number\n) {\n const inputRange = [0, 1]\n const outputRange = [currentColor ? currentColor : nextColor, nextColor]\n if (animateToValue === 0) {\n // because we are animating from value 1 to 0, we need to put target color at the beginning\n outputRange.reverse()\n }\n return {\n inputRange,\n outputRange,\n }\n}\n\nfunction getInterpolated(current: number, next: number, postfix = 'deg') {\n if (next === current) {\n current = next - 0.000000001\n }\n const inputRange = [current, next]\n const outputRange = [`${current}${postfix}`, `${next}${postfix}`]\n if (next < current) {\n inputRange.reverse()\n outputRange.reverse()\n }\n return {\n inputRange,\n outputRange,\n }\n}\n\nfunction getAnimationConfig(\n key: string,\n animations: AnimationsConfig,\n transition?: TransitionProp,\n animationState: 'enter' | 'exit' | 'default' = 'default'\n): AnimationConfig {\n const normalized = normalizeTransition(transition)\n const shortKey = transformShorthands[key]\n\n // Check for property-specific animation\n const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey]\n\n let animationType: string | null = null\n let extraConf: any = {}\n\n if (typeof propAnimation === 'string') {\n // Direct animation name: { x: 'quick' }\n animationType = propAnimation\n } else if (propAnimation && typeof propAnimation === 'object') {\n // Config object: { x: { type: 'quick', delay: 100 } }\n // Use effective animation based on state if no explicit type in config\n animationType =\n propAnimation.type || getEffectiveAnimation(normalized, animationState)\n extraConf = propAnimation\n } else {\n // Fall back to effective animation based on state (enter/exit/default)\n animationType = getEffectiveAnimation(normalized, animationState)\n }\n\n // Apply global delay if no property-specific delay\n if (normalized.delay && !extraConf.delay) {\n extraConf = { ...extraConf, delay: normalized.delay }\n }\n\n const found = animationType ? animations[animationType] : {}\n return {\n ...found,\n // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]})\n ...normalized.config,\n // Property-specific config takes highest precedence\n ...extraConf,\n }\n}\n\n// try both combos\nconst transformShorthands = {\n x: 'translateX',\n y: 'translateY',\n translateX: 'x',\n translateY: 'y',\n}\n\nfunction getValue(input: number | string, isColor = false) {\n if (typeof input !== 'string') {\n return [input] as const\n }\n const [_, number, after] = input.match(/([-0-9]+)(deg|%|px)/) ?? []\n return [+number, after] as const\n}\n"
|
|
10
10
|
]
|
|
11
11
|
}
|