@tamagui/animations-react-native 2.7.7 → 3.0.0-0.canary.1791056644527

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/cjs/createAnimations.native.cjs +863 -0
  2. package/dist/cjs/createAnimations.native.js +814 -597
  3. package/dist/cjs/createAnimations.native.js.map +1 -1
  4. package/dist/cjs/index.cjs +19 -13
  5. package/dist/cjs/index.native.cjs +21 -0
  6. package/dist/cjs/index.native.js +12 -13
  7. package/dist/cjs/index.native.js.map +1 -1
  8. package/dist/cjs/polyfill.cjs +2 -2
  9. package/dist/cjs/polyfill.native.cjs +5 -0
  10. package/dist/cjs/polyfill.native.js +2 -1
  11. package/dist/cjs/polyfill.native.js.map +1 -1
  12. package/dist/cjs/types.cjs +16 -0
  13. package/dist/cjs/types.native.cjs +18 -0
  14. package/dist/cjs/types.native.js +20 -0
  15. package/dist/cjs/types.native.js.map +1 -0
  16. package/dist/esm/createAnimations.native.js +792 -567
  17. package/dist/esm/createAnimations.native.js.map +1 -1
  18. package/dist/esm/index.js +6 -2
  19. package/dist/esm/index.js.map +1 -1
  20. package/dist/esm/index.mjs +6 -2
  21. package/dist/esm/index.mjs.map +1 -1
  22. package/dist/esm/index.native.js +2 -2
  23. package/dist/esm/polyfill.mjs +2 -1
  24. package/dist/esm/polyfill.mjs.map +1 -1
  25. package/dist/esm/polyfill.native.js +2 -1
  26. package/dist/esm/polyfill.native.js.map +1 -1
  27. package/dist/esm/types.mjs +0 -0
  28. package/dist/esm/types.native.js +0 -0
  29. package/package.json +17 -11
  30. package/src/createAnimations.native.tsx +1351 -0
  31. package/src/index.native.ts +3 -0
  32. package/src/index.ts +20 -2
  33. package/src/types.ts +4 -0
  34. package/types/{createAnimations.d.ts → createAnimations.native.d.ts} +5 -14
  35. package/types/createAnimations.native.d.ts.map +11 -0
  36. package/types/index.d.ts +10 -2
  37. package/types/index.d.ts.map +2 -2
  38. package/types/index.native.d.ts +4 -0
  39. package/types/index.native.d.ts.map +11 -0
  40. package/types/types.d.ts +5 -0
  41. package/types/types.d.ts.map +11 -0
  42. package/dist/cjs/createAnimations.cjs +0 -566
  43. package/dist/esm/createAnimations.mjs +0 -524
  44. package/dist/esm/createAnimations.mjs.map +0 -1
  45. package/dist/esm/index.native.js.map +0 -1
  46. package/src/createAnimations.tsx +0 -831
  47. package/types/createAnimations.d.ts.map +0 -11
@@ -0,0 +1,1351 @@
1
+ import {
2
+ easingToBezier,
3
+ forAnimationState,
4
+ getTransitionForKey,
5
+ resolveTransition,
6
+ type AnimationsConfig,
7
+ type ResolvedEntry,
8
+ type ResolvedTransition,
9
+ } from '@tamagui/animation-helpers'
10
+ import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
11
+ import { ResetPresence, usePresence } from '@tamagui/use-presence'
12
+ import type {
13
+ AnimatedNumberStrategy,
14
+ AnimationDriverWithAnimatedNumbers,
15
+ NativeTextMetrics,
16
+ TransitionProp,
17
+ UniversalAnimatedNumber,
18
+ UseAnimatedNumberReaction,
19
+ UseAnimatedNumberStyle,
20
+ } from '@tamagui/web'
21
+ import { useEvent } from '@tamagui/web'
22
+ import { useThemeWithState } from '@tamagui/web/internal-runtime'
23
+ import React from 'react'
24
+ import {
25
+ Animated,
26
+ Easing,
27
+ processColor,
28
+ TextInput,
29
+ type ColorValue,
30
+ type Text,
31
+ type View,
32
+ } from 'react-native'
33
+
34
+ import type { CreateAnimationsOptions } from './types'
35
+
36
+ // detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys
37
+ const isFabric =
38
+ !isWeb && typeof global !== 'undefined' && !!global.__nativeFabricUIManager
39
+
40
+ // Helper to resolve dynamic theme values like {dynamic: {dark: "value", light: undefined}}
41
+ const resolveDynamicValue = (value: any, isDark: boolean): any => {
42
+ if (value && typeof value === 'object' && 'dynamic' in value) {
43
+ const dynamicValue = isDark ? value.dynamic.dark : value.dynamic.light
44
+ return dynamicValue
45
+ }
46
+ return value
47
+ }
48
+
49
+ /** what `getAnimationConfig` hands to `Animated.spring` / `Animated.timing` */
50
+ type AnimationConfig =
51
+ | ({ type: 'spring'; delay?: number } & Partial<
52
+ Pick<
53
+ Animated.SpringAnimationConfig,
54
+ 'damping' | 'mass' | 'overshootClamping' | 'stiffness' | 'velocity'
55
+ >
56
+ >)
57
+ | ({ type: 'timing'; delay?: number } & Partial<
58
+ Pick<Animated.TimingAnimationConfig, 'duration' | 'easing'>
59
+ >)
60
+
61
+ const animatedStyleKey = {
62
+ transform: true,
63
+ opacity: true,
64
+ }
65
+
66
+ const colorStyleKey = {
67
+ backgroundColor: true,
68
+ color: true,
69
+ borderColor: true,
70
+ borderLeftColor: true,
71
+ borderRightColor: true,
72
+ borderTopColor: true,
73
+ borderBottomColor: true,
74
+ }
75
+
76
+ // layout dimensions. the native animated module has no whitelist entry for any
77
+ // of them, so a node animating one runs its whole Animated graph on the JS
78
+ // driver (useNativeDriver:false): Fabric cannot mix native- and JS-driven
79
+ // values on one node.
80
+ const layoutStyleKey = {
81
+ height: true,
82
+ width: true,
83
+ minHeight: true,
84
+ maxHeight: true,
85
+ minWidth: true,
86
+ maxWidth: true,
87
+ }
88
+
89
+ // text metrics have no whitelist entry either, but unlike a height they sit on
90
+ // nearly every Text, so being covered by a broad `transition="medium"` must not
91
+ // be enough to take the node off the native driver: an opacity fade over text
92
+ // whose size is not changing would then run in JS. they join the Animated graph
93
+ // only while they are actually moving, and the node's driver follows them.
94
+ const textMetricStyleKey = {
95
+ fontSize: true,
96
+ lineHeight: true,
97
+ }
98
+
99
+ const jsDriverStyleKey = { ...layoutStyleKey, ...textMetricStyleKey }
100
+
101
+ function hasAnimatedLayoutKey(
102
+ style: Record<string, any>,
103
+ isDark: boolean,
104
+ resolved: ResolvedTransition
105
+ ) {
106
+ for (const key in layoutStyleKey) {
107
+ if (!getTransitionForKey(resolved, key)) continue
108
+ if (typeof resolveDynamicValue(style[key], isDark) === 'number') return true
109
+ }
110
+ return false
111
+ }
112
+
113
+ // A numeric authored lineHeight is a RATIO of the resolved font size, not a
114
+ // length. Core finalizes the destination to fontSize * ratio and reports the
115
+ // ratio back on `nativeTextMetrics`, so the painted leading has to be
116
+ // fontSize * ratio on EVERY frame, not only at the destination. This driver
117
+ // gets that exactly by never animating the leading itself: it animates the
118
+ // font size and the ratio, and paints their product as one Animated node.
119
+ // An absolute px leading, and any raw react-native style, keeps its own value
120
+ // and animates as an ordinary numeric key.
121
+ const getLeadingRatio = (metrics: { lineHeight?: unknown } | undefined | null) => {
122
+ const ratio = metrics?.lineHeight
123
+ return typeof ratio === 'number' && Number.isFinite(ratio) && ratio >= 0
124
+ ? ratio
125
+ : undefined
126
+ }
127
+
128
+ // the product needs both factors as numbers in the style it is painting into.
129
+ const paintsLeadingProduct = (
130
+ style: Record<string, any>,
131
+ ratio: number | undefined
132
+ ): ratio is number =>
133
+ ratio !== undefined &&
134
+ typeof style.fontSize === 'number' &&
135
+ typeof style.lineHeight === 'number'
136
+
137
+ // a leading multiplied off an ANCESTOR's live font size: this node has none of
138
+ // its own, core reports the ratio, and react-native inherits the size itself.
139
+ const inheritedFontSizeNode = (
140
+ style: Record<string, any>,
141
+ ratio: number | undefined,
142
+ inheritedText: { fontSize: unknown; driver: string } | null | undefined
143
+ ) =>
144
+ ratio !== undefined &&
145
+ typeof style.fontSize !== 'number' &&
146
+ inheritedText?.driver === 'react-native'
147
+ ? (inheritedText.fontSize as Animated.Value)
148
+ : undefined
149
+
150
+ // Only colors accepted by RN's own parser can enter interpolation. CSS-wide
151
+ // keywords, unresolved tokens, var()/calc(), and empty strings otherwise reach
152
+ // createInterpolationFromStringOutputRange / mapStringToNumericComponents and
153
+ // throw. Those values must be applied as static styles.
154
+ function isAnimatableColor(value: unknown): value is string {
155
+ return typeof value === 'string' && processColor(value as ColorValue) != null
156
+ }
157
+
158
+ // these style keys are costly to animate and only work with native driver on Fabric
159
+ const costlyToAnimateStyleKey = {
160
+ borderRadius: true,
161
+ borderTopLeftRadius: true,
162
+ borderTopRightRadius: true,
163
+ borderBottomLeftRadius: true,
164
+ borderBottomRightRadius: true,
165
+ borderWidth: true,
166
+ borderLeftWidth: true,
167
+ borderRightWidth: true,
168
+ borderTopWidth: true,
169
+ borderBottomWidth: true,
170
+ ...colorStyleKey,
171
+ }
172
+
173
+ export const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View
174
+ export const AnimatedText: Animated.AnimatedComponent<typeof Text> = Animated.Text
175
+ // a TextInput never inherits font size from an ancestor Text on native, so the
176
+ // binding hook has to hand it a host that accepts animated nodes of its own.
177
+ // built on first use: the compiler evaluates tamagui.config.ts against a
178
+ // react-native stub whose Animated cannot make one, and never binds any text.
179
+ let animatedTextInput: Animated.AnimatedComponent<typeof TextInput> | undefined
180
+
181
+ export function useAnimatedNumber(
182
+ initial: number
183
+ ): UniversalAnimatedNumber<Animated.Value> {
184
+ const state = React.useRef(
185
+ null as any as {
186
+ val: Animated.Value
187
+ composite: Animated.CompositeAnimation | null
188
+ strategy: AnimatedNumberStrategy
189
+ }
190
+ )
191
+ if (!state.current) {
192
+ state.current = {
193
+ composite: null,
194
+ val: new Animated.Value(initial),
195
+ strategy: { type: 'spring' },
196
+ }
197
+ }
198
+
199
+ return {
200
+ getInstance() {
201
+ return state.current.val
202
+ },
203
+ getValue() {
204
+ return state.current.val['_value']
205
+ },
206
+ stop() {
207
+ state.current.composite?.stop()
208
+ state.current.composite = null
209
+ },
210
+ setValue(next: number, { type, ...config } = { type: 'spring' }, onFinish) {
211
+ const val = state.current.val
212
+
213
+ const handleFinish = onFinish
214
+ ? ({ finished }) => (finished ? onFinish() : null)
215
+ : undefined
216
+
217
+ if (type === 'direct') {
218
+ state.current.composite?.stop()
219
+ state.current.composite = null
220
+ val.setValue(next)
221
+ // a direct set finishes the moment it lands. not calling back stranded
222
+ // everything waiting on it (sheet snap, presence completion).
223
+ onFinish?.()
224
+ } else if (type === 'spring') {
225
+ state.current.composite?.stop()
226
+ const composite = Animated.spring(val, {
227
+ ...config,
228
+ toValue: next,
229
+ useNativeDriver: isFabric,
230
+ })
231
+ composite.start(handleFinish)
232
+ state.current.composite = composite
233
+ } else {
234
+ state.current.composite?.stop()
235
+ const composite = Animated.timing(val, {
236
+ ...config,
237
+ toValue: next,
238
+ useNativeDriver: isFabric,
239
+ })
240
+ composite.start(handleFinish)
241
+ state.current.composite = composite
242
+ }
243
+ },
244
+ }
245
+ }
246
+
247
+ type RNAnimatedNum = UniversalAnimatedNumber<Animated.Value>
248
+
249
+ export const useAnimatedNumberReaction: UseAnimatedNumberReaction<RNAnimatedNum> = (
250
+ { value },
251
+ onValue
252
+ ) => {
253
+ const onChange = useEvent((current) => {
254
+ onValue(current.value)
255
+ })
256
+
257
+ React.useEffect(() => {
258
+ const id = value.getInstance().addListener(onChange)
259
+ return () => {
260
+ value.getInstance().removeListener(id)
261
+ }
262
+ }, [value, onChange])
263
+ }
264
+
265
+ export const useAnimatedNumberStyle: UseAnimatedNumberStyle<RNAnimatedNum> = (
266
+ value,
267
+ getStyle
268
+ ) => {
269
+ const instance = value.getInstance()
270
+ const animatedStyle = getStyle(instance)
271
+ const usesAnimatedNode = hasAnimatedNode(animatedStyle)
272
+ const [current, setCurrent] = React.useState(value.getValue())
273
+
274
+ // preserve the native animated-node path for direct mappings. callbacks
275
+ // that do arithmetic require numeric values, so drive those through the
276
+ // value listener and render the computed style.
277
+ React.useEffect(() => {
278
+ if (usesAnimatedNode) return
279
+
280
+ const id = instance.addListener(({ value: next }) => {
281
+ setCurrent(next)
282
+ })
283
+ return () => {
284
+ instance.removeListener(id)
285
+ }
286
+ }, [instance, usesAnimatedNode])
287
+
288
+ return usesAnimatedNode ? animatedStyle : getStyle(current)
289
+ }
290
+
291
+ function hasAnimatedNode(value: unknown): boolean {
292
+ if (!value || typeof value !== 'object') return false
293
+ if (typeof (value as any).__getValue === 'function') return true
294
+ if (Array.isArray(value)) return value.some(hasAnimatedNode)
295
+ return Object.values(value).some(hasAnimatedNode)
296
+ }
297
+
298
+ export const useAnimatedNumbersStyle = (
299
+ vals: RNAnimatedNum[],
300
+ getStyle: (...currentValues: any[]) => any
301
+ ): any => {
302
+ return getStyle(...vals.map((v) => v.getInstance()))
303
+ }
304
+
305
+ export function createAnimations<A extends AnimationsConfig>(
306
+ animations: A,
307
+ options?: CreateAnimationsOptions
308
+ ): AnimationDriverWithAnimatedNumbers<A> {
309
+ const nativeDriver = options?.useNativeDriver ?? isFabric
310
+
311
+ return {
312
+ inputStyle: 'value',
313
+ outputStyle: 'inline',
314
+ avoidReRenders: true,
315
+ animations,
316
+ needsCustomComponent: true,
317
+ View: AnimatedView,
318
+ Text: AnimatedText,
319
+ useAnimatedNumber,
320
+ useAnimatedNumberReaction,
321
+ useAnimatedNumberStyle,
322
+ useAnimatedNumbersStyle,
323
+ usePresence,
324
+ ResetPresence,
325
+
326
+ // binds a descendant that has no font size of its own to the ancestor's
327
+ // animated one. no clock here: the font size node is the ancestor's, and a
328
+ // ratio leading is a multiplication of it, so both land on the same frame.
329
+ useTextMetrics: ({ inheritedText, lineHeight }) => {
330
+ const node =
331
+ inheritedText?.driver === 'react-native'
332
+ ? (inheritedText.fontSize as Animated.Value)
333
+ : null
334
+ // an absolute or `normal` leading still needs the font size bound, it
335
+ // just keeps the leading the caller already resolved.
336
+ const ratio = typeof lineHeight === 'number' ? lineHeight : null
337
+ const style = React.useMemo(
338
+ () =>
339
+ node
340
+ ? ratio === null
341
+ ? { fontSize: node }
342
+ : { fontSize: node, lineHeight: Animated.multiply(node, ratio) }
343
+ : null,
344
+ [node, ratio]
345
+ )
346
+ const textChannel = inheritedText ?? null
347
+ if (!style) return { style: null, textChannel }
348
+ animatedTextInput ||= Animated.createAnimatedComponent(TextInput)
349
+ return { style, textChannel, Text: AnimatedText, TextInput: animatedTextInput }
350
+ },
351
+
352
+ useAnimations: ({
353
+ props,
354
+ onTransition,
355
+ style,
356
+ componentState,
357
+ presence,
358
+ stateRef,
359
+ styleState,
360
+ useStyleEmitter,
361
+ inheritedText,
362
+ }) => {
363
+ const isDisabled = isWeb && componentState.unmounted === true
364
+ const isExiting = presence?.[0] === false
365
+ const sendExitComplete = presence?.[1]
366
+ const onTransitionRef = React.useRef(onTransition)
367
+ onTransitionRef.current = onTransition
368
+ const emit = (
369
+ phase: 'start' | 'end',
370
+ cause: 'enter' | 'exit' | 'update',
371
+ finished?: boolean
372
+ ) => {
373
+ onTransitionRef.current?.(
374
+ phase === 'end' ? { phase, cause, finished } : { phase, cause }
375
+ )
376
+ }
377
+ // createComponent merges a colocated `transition` out of the active
378
+ // pseudo style (`enterStyle={{ opacity: 0, transition: '200ms' }}`), so
379
+ // this is the one that applies right now, not the base prop.
380
+ const effectiveTransition = (styleState?.effectiveTransition ?? props.transition) as
381
+ | TransitionProp
382
+ | null
383
+ | undefined
384
+
385
+ const [, themeState] = useThemeWithState({})
386
+ // Check scheme first, then fall back to checking theme name for 'dark'
387
+ const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')
388
+
389
+ /** store Animated value of each key e.g: color: AnimatedValue */
390
+ const animateStyles = React.useRef<Record<string, Animated.Value>>({})
391
+ // a ratio leading animates the RATIO, never the leading, and paints
392
+ // fontSize * ratio as one node. the ratio is not a style key, so it lives
393
+ // outside animateStyles (which the stale-key sweep and the emitter's
394
+ // structural-change check both treat as the rendered style's own keys).
395
+ const leadingRatioValue = React.useRef<Animated.Value | null>(null)
396
+ const leadingRatioRef = React.useRef<number | undefined>(undefined)
397
+ // whether the style React last committed paints the derived product. the
398
+ // emitter can only re-target existing Animated.Values, so a pass that
399
+ // disagrees with this has to force a commit to swap the node itself.
400
+ const paintsDerivedLeading = React.useRef(false)
401
+ // this subtree's live font size, published for descendants that inherit
402
+ // it. the channel's identity IS the node's, so a consumer that keeps the
403
+ // same channel keeps the same derived node.
404
+ const textChannelRef = React.useRef<{ fontSize: unknown; driver: string } | null>(
405
+ null
406
+ )
407
+ // core reports a numeric (ratio) leading on nativeTextMetrics; a px length
408
+ // and 'normal' are reported as themselves and stay independent keys.
409
+ leadingRatioRef.current = getLeadingRatio(styleState?.nativeTextMetrics)
410
+ // the text metrics the last pass painted, so the next one can tell a real
411
+ // change from a value that is simply present. a metric only joins the
412
+ // Animated graph while it is moving (see textMetricStyleKey), which is
413
+ // what keeps an ordinary opacity or transform animation over unchanged
414
+ // text on the native driver.
415
+ const paintedTextRef = React.useRef<{
416
+ fontSize?: unknown
417
+ lineHeight?: unknown
418
+ ratio?: number
419
+ }>({})
420
+ // a value already in the graph keeps moving until it arrives, so a
421
+ // re-render mid-flight never drops it back to a static style and snaps.
422
+ // the first pass has painted nothing yet, so nothing is moving there
423
+ // either: the metrics land as plain numbers and the node mounts on the
424
+ // native driver.
425
+ const movesTextValue = (key: 'fontSize' | 'lineHeight', target: unknown) => {
426
+ if (typeof target !== 'number') return false
427
+ const node = animateStyles.current[key]
428
+ const last = node ? (node['_value'] as unknown) : paintedTextRef.current[key]
429
+ return last !== undefined && last !== target
430
+ }
431
+ const movesLeadingRatio = (ratio: number | undefined) => {
432
+ if (ratio === undefined) return false
433
+ const node = leadingRatioValue.current
434
+ const last = node ? (node['_value'] as number) : paintedTextRef.current.ratio
435
+ return last !== undefined && last !== ratio
436
+ }
437
+ // the font size descendants read as this node's live size. it outlives
438
+ // any one animation: dropping it whenever the size came to rest would
439
+ // swap every descendant between Text and Animated.Text, remounting their
440
+ // subtrees, and a TextInput's focus and contents with them, every time an
441
+ // animation started or finished.
442
+ const fontSizeNode = React.useRef<Animated.Value | null>(null)
443
+ const animatedValueFor = (key: string) => {
444
+ const painted = animateStyles.current[key]
445
+ if (painted) return painted
446
+ if (key === 'fontSize' && fontSizeNode.current) return fontSizeNode.current
447
+ // a metric that was at rest is out of the graph, so the value it
448
+ // rejoins with has to start at what the last pass painted: a fresh
449
+ // Animated.Value starts AT its target, and would snap the metric.
450
+ if (textMetricStyleKey[key])
451
+ return new Animated.Value(paintedTextRef.current[key] as number)
452
+ return undefined
453
+ }
454
+ const leadingRatioValueFrom = () =>
455
+ leadingRatioValue.current ??
456
+ new Animated.Value(paintedTextRef.current.ratio as number)
457
+ const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])
458
+ const animationsState = React.useRef(
459
+ new WeakMap<
460
+ Animated.Value,
461
+ {
462
+ interpolation: Animated.AnimatedInterpolation<any>
463
+ current?: number | string | undefined
464
+ // only for colors
465
+ animateToValue?: number
466
+ }
467
+ >()
468
+ )
469
+ const pseudoActiveRef = React.useRef(false)
470
+
471
+ // exit cycle guards to prevent stale/duplicate completion
472
+ const exitCycleIdRef = React.useRef(0)
473
+ const exitCompletedRef = React.useRef(false)
474
+ const wasExitingRef = React.useRef(false)
475
+
476
+ // onTransition lifecycle bookkeeping
477
+ const enterStartedRef = React.useRef(false)
478
+ const exitStartedRef = React.useRef(false)
479
+ const updateInFlightRef = React.useRef(false)
480
+ const updateCycleIdRef = React.useRef(0)
481
+ const prevStyleSigRef = React.useRef<string | null>(null)
482
+
483
+ // detect transition into/out of exiting state
484
+ const justStartedExiting = isExiting && !wasExitingRef.current
485
+ const justStoppedExiting = !isExiting && wasExitingRef.current
486
+
487
+ // start new exit cycle only on transition INTO exiting
488
+ if (justStartedExiting) {
489
+ exitCycleIdRef.current++
490
+ exitCompletedRef.current = false
491
+ }
492
+ // invalidate pending callbacks when exit is canceled/interrupted
493
+ if (justStoppedExiting) {
494
+ exitCycleIdRef.current++
495
+ }
496
+
497
+ // Track if we just finished entering (transition from entering to not entering)
498
+ // must be declared before args array that uses justFinishedEntering
499
+ const isEntering = !!componentState.unmounted
500
+ const wasEnteringRef = React.useRef(isEntering)
501
+ const justFinishedEntering = wasEnteringRef.current && !isEntering
502
+ React.useEffect(() => {
503
+ wasEnteringRef.current = isEntering
504
+ })
505
+
506
+ const args = [
507
+ JSON.stringify(style),
508
+ JSON.stringify(effectiveTransition),
509
+ componentState,
510
+ isExiting,
511
+ !!onTransition,
512
+ isDark,
513
+ justFinishedEntering,
514
+ leadingRatioRef.current,
515
+ inheritedText,
516
+ ]
517
+
518
+ const res = React.useMemo(() => {
519
+ const runners: Function[] = []
520
+ const completions: Promise<void>[] = []
521
+
522
+ // Determine animation state for enter/exit transitions
523
+ // Use 'enter' if we're entering OR if we just finished entering
524
+ const animationState: 'enter' | 'exit' | 'default' = isExiting
525
+ ? 'exit'
526
+ : isEntering || justFinishedEntering
527
+ ? 'enter'
528
+ : 'default'
529
+
530
+ // which style keys animate at all is the transition's own decision, so
531
+ // a property list narrows this the same way it narrows css
532
+ const resolved = forAnimationState(
533
+ resolveTransition(effectiveTransition, { animations }),
534
+ animationState
535
+ )
536
+
537
+ const nonAnimatedStyle = {}
538
+ // the leading is derived only while something can actually move it.
539
+ // when neither key is covered by the transition both fall through to
540
+ // nonAnimatedStyle and land together in one commit, which is already
541
+ // coherent and keeps the node on the native driver.
542
+ const paintedRatio = leadingRatioRef.current
543
+ const paintsProduct = paintsLeadingProduct(style, paintedRatio)
544
+ const movesFontSize = movesTextValue('fontSize', style.fontSize)
545
+ const movesLeading = paintsProduct
546
+ ? movesLeadingRatio(paintedRatio)
547
+ : movesTextValue('lineHeight', style.lineHeight)
548
+ // a metric that has arrived leaves the graph here rather than in the
549
+ // sweep at the end of the pass, so the node goes back to the native
550
+ // driver in the same commit that stops moving it. the same three
551
+ // conditions as that sweep: an exit and a latched pseudo both still own
552
+ // the keys they are painting.
553
+ if (!isExiting && !isDisabled && !pseudoActiveRef.current) {
554
+ if (!movesFontSize) delete animateStyles.current.fontSize
555
+ if (!movesLeading) delete animateStyles.current.lineHeight
556
+ }
557
+
558
+ // a font size a transition can move is published to descendants whether
559
+ // or not it happens to be moving right now, so their host component
560
+ // does not change under them when it starts.
561
+ if (
562
+ isDisabled ||
563
+ typeof style.fontSize !== 'number' ||
564
+ !getTransitionForKey(resolved, 'fontSize')
565
+ ) {
566
+ fontSizeNode.current = null
567
+ } else if (!fontSizeNode.current) {
568
+ fontSizeNode.current = new Animated.Value(style.fontSize)
569
+ }
570
+
571
+ // track which animated keys/transforms the incoming style actually
572
+ // carries this pass, so entries that left the style can be dropped
573
+ // below (an Animated.Value that persisted forever would keep painting
574
+ // a stale pixel value, e.g. a released-to-auto accordion height)
575
+ const seenAnimateKeys = new Set<string>()
576
+ let sawTransform = false
577
+ let transformCount = 0
578
+
579
+ const derivesLeading =
580
+ !isDisabled &&
581
+ paintsProduct &&
582
+ (movesFontSize || movesLeading) &&
583
+ (!!getTransitionForKey(resolved, 'fontSize') ||
584
+ !!getTransitionForKey(resolved, 'lineHeight'))
585
+ // an inherited font size: core leaves this node's own fontSize out of
586
+ // the style and reports the ratio, so the leading is the ANCESTOR's
587
+ // live font size times this node's ratio. react-native inherits the
588
+ // size itself, and a length leading is inherited as a length.
589
+ const inheritedFontSize = isDisabled
590
+ ? undefined
591
+ : inheritedFontSizeNode(style, paintedRatio, inheritedText)
592
+
593
+ // animatedStyle owns every Animated.Value on the node. Fabric cannot mix
594
+ // native- and JS-driven values inside that shared graph, so one layout
595
+ // animation, one text metric on the move, or a leading multiplied off an
596
+ // ancestor's font size, makes the whole node use the JS driver. a metric
597
+ // another pass left in the graph counts too, or its next animation would
598
+ // start on a driver the node no longer runs on.
599
+ const useNativeDriverForNode =
600
+ nativeDriver &&
601
+ !hasAnimatedLayoutKey(style, isDark, resolved) &&
602
+ !movesFontSize &&
603
+ !movesLeading &&
604
+ !inheritedFontSize &&
605
+ !Object.keys(animateStyles.current).some((key) => jsDriverStyleKey[key])
606
+
607
+ for (const key in style) {
608
+ const rawVal = style[key]
609
+ // Resolve dynamic theme values from flat theme clauses.
610
+ const val = resolveDynamicValue(rawVal, isDark)
611
+ if (val === undefined) continue
612
+
613
+ if (isDisabled) {
614
+ continue
615
+ }
616
+
617
+ // fontSize and lineHeight are owned by the derived-leading block below
618
+ if (
619
+ (derivesLeading || inheritedFontSize) &&
620
+ (key === 'fontSize' || key === 'lineHeight')
621
+ ) {
622
+ // unless the size is the factor at rest: the product multiplies the
623
+ // number it paints, and that number has to be painted
624
+ if (key === 'fontSize' && derivesLeading && !movesFontSize) {
625
+ nonAnimatedStyle[key] = val
626
+ }
627
+ continue
628
+ }
629
+
630
+ // a text metric at rest stays out of the Animated graph entirely: it
631
+ // paints as a plain style, and the node keeps the native driver
632
+ if (
633
+ textMetricStyleKey[key] &&
634
+ !(key === 'fontSize' ? movesFontSize : movesLeading)
635
+ ) {
636
+ nonAnimatedStyle[key] = val
637
+ continue
638
+ }
639
+
640
+ if (
641
+ animatedStyleKey[key] == null &&
642
+ !costlyToAnimateStyleKey[key] &&
643
+ !jsDriverStyleKey[key]
644
+ ) {
645
+ nonAnimatedStyle[key] = val
646
+ continue
647
+ }
648
+
649
+ // `transform` is a container, not a property: its parts each resolve
650
+ // on their own below, and a part no entry covers gets `snapConfig`.
651
+ // the array cannot be split into animated and static halves here.
652
+ if (key !== 'transform' && !getTransitionForKey(resolved, key)) {
653
+ nonAnimatedStyle[key] = val
654
+ continue
655
+ }
656
+
657
+ // layout dimension keys only animate numbers — 'auto' (an open
658
+ // accordion at rest) and percent strings apply as static styles
659
+ if (jsDriverStyleKey[key] && typeof val !== 'number') {
660
+ nonAnimatedStyle[key] = val
661
+ continue
662
+ }
663
+
664
+ // unparseable colors crash RN
665
+ // interpolation — apply them as a static style instead
666
+ if (colorStyleKey[key] && !isAnimatableColor(val)) {
667
+ nonAnimatedStyle[key] = val
668
+ continue
669
+ }
670
+
671
+ if (key !== 'transform') {
672
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
673
+ seenAnimateKeys.add(key)
674
+ continue
675
+ }
676
+ // key: 'transform'
677
+ // for now just support one transform key
678
+ if (!val) continue
679
+ if (typeof val === 'string') {
680
+ console.warn(`Warning: Tamagui can't animate string transforms yet!`)
681
+ continue
682
+ }
683
+
684
+ sawTransform = true
685
+ for (const transform of val) {
686
+ if (!transform) continue
687
+ const index = transformCount++
688
+ // tkey: e.g: 'translateX'
689
+ const tkey = Object.keys(transform)[0]
690
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
691
+ animatedTranforms.current[index] = {
692
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
693
+ }
694
+ animatedTranforms.current = [...animatedTranforms.current]
695
+ }
696
+ }
697
+
698
+ // the derived leading: fontSize and the ratio each animate on their own
699
+ // resolved entry, and the painted leading is their product, so
700
+ // lineHeight is fontSize * ratio on every frame by construction rather
701
+ // than by two solvers happening to agree. a key the transition does not
702
+ // cover gets snapConfig here exactly as it would anywhere else, which
703
+ // makes `transition="fontSize 300ms"` move the leading with the size and
704
+ // `transition="lineHeight 300ms"` move the ratio over a size that has
705
+ // already arrived.
706
+ // the ratio is a factor exactly like the font size: a value of its own
707
+ // while it moves, the number it already paints while it does not
708
+ const ratioFactor = (): Animated.Value | number => {
709
+ if (!movesLeading) {
710
+ leadingRatioValue.current = null
711
+ return paintedRatio!
712
+ }
713
+ leadingRatioValue.current = update(
714
+ 'lineHeight',
715
+ leadingRatioValueFrom(),
716
+ paintedRatio!
717
+ )
718
+ return leadingRatioValue.current
719
+ }
720
+ let derivedLeading: ReturnType<typeof Animated.multiply> | undefined
721
+ if (derivesLeading) {
722
+ // a factor at rest multiplies as the plain number it already paints,
723
+ // so the product does not carry a value nothing is moving
724
+ let fontSizeFactor: Animated.Value | number = style.fontSize
725
+ if (movesFontSize) {
726
+ fontSizeFactor = update(
727
+ 'fontSize',
728
+ animatedValueFor('fontSize'),
729
+ style.fontSize
730
+ )
731
+ animateStyles.current.fontSize = fontSizeFactor
732
+ seenAnimateKeys.add('fontSize')
733
+ }
734
+ derivedLeading = Animated.multiply(fontSizeFactor, ratioFactor())
735
+ } else if (inheritedFontSize) {
736
+ derivedLeading = Animated.multiply(inheritedFontSize, ratioFactor())
737
+ } else if (!isDisabled) {
738
+ leadingRatioValue.current = null
739
+ }
740
+ paintsDerivedLeading.current = !!derivedLeading
741
+
742
+ // what descendants inherit from this node: its own animated font size
743
+ // when it has one, nothing when it has a font size of its own that is
744
+ // not animating (react-native inherits that statically), and otherwise
745
+ // whatever it inherited itself.
746
+ const ownFontSizeNode = fontSizeNode.current
747
+ const textChannel = ownFontSizeNode
748
+ ? textChannelRef.current?.fontSize === ownFontSizeNode
749
+ ? textChannelRef.current
750
+ : { fontSize: ownFontSizeNode, driver: 'react-native' }
751
+ : typeof style.fontSize === 'number'
752
+ ? null
753
+ : (inheritedText ?? null)
754
+ textChannelRef.current = textChannel
755
+
756
+ // drop stale Animated.Values whose keys left the incoming style, so the
757
+ // key genuinely leaves the rendered style object (a released height goes
758
+ // back to auto instead of staying pinned at its last pixel value). skip
759
+ // while exiting (presence still animates the leaving keys) and while
760
+ // disabled (the loop above intentionally skips every key). an active
761
+ // pseudo owns the current emitted style until its matching release.
762
+ if (!isExiting && !isDisabled && !pseudoActiveRef.current) {
763
+ for (const k in animateStyles.current) {
764
+ if (!seenAnimateKeys.has(k)) delete animateStyles.current[k]
765
+ }
766
+ if (!sawTransform) {
767
+ if (animatedTranforms.current.length) animatedTranforms.current = []
768
+ } else if (animatedTranforms.current.length > transformCount) {
769
+ animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)
770
+ }
771
+ }
772
+
773
+ const animatedTransformStyle =
774
+ animatedTranforms.current.length > 0
775
+ ? {
776
+ transform: animatedTranforms.current.map((r) => {
777
+ const key = Object.keys(r)[0]
778
+ const val =
779
+ animationsState.current!.get(r[key])?.interpolation || r[key]
780
+ return { [key]: val }
781
+ }),
782
+ }
783
+ : {}
784
+
785
+ const animatedStyle = {
786
+ ...Object.fromEntries(
787
+ Object.entries(animateStyles.current).map(([k, v]) => [
788
+ k,
789
+ animationsState.current!.get(v)?.interpolation || v,
790
+ ])
791
+ ),
792
+ ...(derivedLeading ? { lineHeight: derivedLeading } : null),
793
+ ...animatedTransformStyle,
794
+ }
795
+
796
+ if (!isDisabled) {
797
+ paintedTextRef.current = {
798
+ fontSize: style.fontSize,
799
+ lineHeight: style.lineHeight,
800
+ ratio: paintedRatio,
801
+ }
802
+ }
803
+
804
+ return {
805
+ runners,
806
+ completions,
807
+ textChannel,
808
+ style: [nonAnimatedStyle, animatedStyle],
809
+ }
810
+
811
+ function update(
812
+ key: string,
813
+ animated: Animated.Value | undefined,
814
+ valIn: string | number
815
+ ) {
816
+ const isColorStyleKey = colorStyleKey[key]
817
+ const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn)
818
+ let animateToValue = val
819
+ const value = animated || new Animated.Value(val)
820
+ const curInterpolation = animationsState.current.get(value)
821
+
822
+ let interpolateArgs: any
823
+ if (type) {
824
+ interpolateArgs = getInterpolated(
825
+ curInterpolation?.current ?? value['_value'],
826
+ val,
827
+ type
828
+ )
829
+ animationsState.current!.set(value, {
830
+ interpolation: value.interpolate(interpolateArgs),
831
+ current: val,
832
+ })
833
+ }
834
+
835
+ if (isColorStyleKey) {
836
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
837
+ interpolateArgs = getColorInterpolated(
838
+ curInterpolation?.current as string,
839
+ // valIn is the next color
840
+ valIn as string,
841
+ animateToValue
842
+ )
843
+ animationsState.current!.set(value, {
844
+ current: valIn,
845
+ interpolation: value.interpolate(interpolateArgs),
846
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
847
+ })
848
+ }
849
+
850
+ if (value) {
851
+ const animationConfig = getAnimationConfig(
852
+ key,
853
+ animations,
854
+ effectiveTransition,
855
+ animationState
856
+ )
857
+
858
+ let resolve
859
+ const promise = new Promise<void>((res) => {
860
+ resolve = res
861
+ })
862
+ completions.push(promise)
863
+
864
+ runners.push(() => {
865
+ value.stopAnimation()
866
+
867
+ // `delay` drives the sequence below, so it must not also ride
868
+ // along in the config or every delayed animation waits twice
869
+ const { type, delay, ...config } = animationConfig
870
+ const animation = Animated[type || 'spring'](value, {
871
+ toValue: animateToValue,
872
+ ...config,
873
+ useNativeDriver: useNativeDriverForNode,
874
+ })
875
+ const animation2 = delay
876
+ ? Animated.sequence([Animated.delay(delay), animation])
877
+ : animation
878
+
879
+ animation2.start(({ finished }) => {
880
+ // always resolve during exit (element is leaving anyway)
881
+ // for non-exit, only resolve on successful completion
882
+ if (finished || isExiting) {
883
+ resolve()
884
+ }
885
+ })
886
+ })
887
+ }
888
+
889
+ if (process.env.NODE_ENV === 'development') {
890
+ if (props['debug'] === 'verbose') {
891
+ // prettier-ignore
892
+ console.info(
893
+ ' 💠 animate',
894
+ key,
895
+ `from (${value['_value']}) to`,
896
+ valIn,
897
+ `(${val})`,
898
+ 'type',
899
+ type,
900
+ 'interpolate',
901
+ interpolateArgs
902
+ )
903
+ }
904
+ }
905
+ return value
906
+ }
907
+ }, args)
908
+
909
+ // track previous exiting state
910
+ React.useEffect(() => {
911
+ wasExitingRef.current = isExiting
912
+ })
913
+
914
+ // exit interrupted by a re-enter: report the exit as finished:false
915
+ useIsomorphicLayoutEffect(() => {
916
+ if (justStoppedExiting && exitStartedRef.current && !exitCompletedRef.current) {
917
+ exitStartedRef.current = false
918
+ emit('end', 'exit', false)
919
+ }
920
+ }, [justStoppedExiting])
921
+
922
+ useIsomorphicLayoutEffect(() => {
923
+ res.runners.forEach((r) => r())
924
+
925
+ // capture current cycle id
926
+ const cycleId = exitCycleIdRef.current
927
+
928
+ const cause: 'enter' | 'exit' | 'update' = isExiting
929
+ ? 'exit'
930
+ : isEntering || justFinishedEntering
931
+ ? 'enter'
932
+ : 'update'
933
+
934
+ // interruptions: an enter or update still in flight when exit begins is
935
+ // reported as finished:false (its own completion promise won't resolve
936
+ // because the animation was stopped, not finished).
937
+ if (cause === 'exit') {
938
+ if (enterStartedRef.current) {
939
+ enterStartedRef.current = false
940
+ emit('end', 'enter', false)
941
+ }
942
+ if (updateInFlightRef.current) {
943
+ updateInFlightRef.current = false
944
+ updateCycleIdRef.current++
945
+ emit('end', 'update', false)
946
+ }
947
+ }
948
+
949
+ // in-place update: a genuine style change while mounted (not entering or
950
+ // exiting). guard on the style signature so lifecycle-only re-renders
951
+ // don't register as updates.
952
+ if (cause === 'update') {
953
+ const sig = args[0] as string
954
+ if (prevStyleSigRef.current === null || prevStyleSigRef.current === sig) {
955
+ prevStyleSigRef.current = sig
956
+ return
957
+ }
958
+ prevStyleSigRef.current = sig
959
+ if (res.completions.length === 0) return
960
+ if (updateInFlightRef.current) {
961
+ // superseded before finishing
962
+ emit('end', 'update', false)
963
+ }
964
+ updateInFlightRef.current = true
965
+ const uid = ++updateCycleIdRef.current
966
+ emit('start', 'update')
967
+ Promise.all(res.completions).then(() => {
968
+ if (uid !== updateCycleIdRef.current) return
969
+ updateInFlightRef.current = false
970
+ emit('end', 'update', true)
971
+ })
972
+ return
973
+ }
974
+
975
+ // keep the update signature current while entering/exiting
976
+ prevStyleSigRef.current = args[0] as string
977
+
978
+ // handle zero-completion case immediately (enter/exit report a pair)
979
+ if (res.completions.length === 0) {
980
+ emit('start', cause)
981
+ emit('end', cause, true)
982
+ if (isExiting && !exitCompletedRef.current) {
983
+ exitCompletedRef.current = true
984
+ sendExitComplete?.()
985
+ }
986
+ return
987
+ }
988
+
989
+ // enter/exit start (once per cycle; re-runs continue the same animation)
990
+ if (cause === 'enter' && !enterStartedRef.current) {
991
+ enterStartedRef.current = true
992
+ emit('start', 'enter')
993
+ }
994
+ if (cause === 'exit' && !exitStartedRef.current) {
995
+ exitStartedRef.current = true
996
+ emit('start', 'exit')
997
+ }
998
+
999
+ Promise.all(res.completions).then(() => {
1000
+ // guard against stale cycle completion
1001
+ if (isExiting && cycleId !== exitCycleIdRef.current) return
1002
+ if (isExiting && exitCompletedRef.current) return
1003
+
1004
+ if (isExiting) {
1005
+ if (exitStartedRef.current) {
1006
+ exitStartedRef.current = false
1007
+ // exit 'end' fires immediately before presence safeToRemove
1008
+ emit('end', 'exit', true)
1009
+ }
1010
+ exitCompletedRef.current = true
1011
+ sendExitComplete?.()
1012
+ } else if (enterStartedRef.current) {
1013
+ enterStartedRef.current = false
1014
+ emit('end', 'enter', true)
1015
+ }
1016
+ })
1017
+ }, args)
1018
+
1019
+ // avoidReRenders: receive style changes imperatively from tamagui
1020
+ // and update Animated.Values directly without React re-renders
1021
+ // reuses the same update() + runner pattern as the useMemo path
1022
+ // the fourth argument is the emitted style's own nativeTextMetrics: the
1023
+ // emitter re-runs getSplitStyles outside render, so the ratio that
1024
+ // describes the style it hands over is never the ratio the last render
1025
+ // resolved (a pseudo can replace the leading with an absolute length).
1026
+ const onEmittedStyle = (
1027
+ nextStyle: Record<string, any>,
1028
+ emittedTransition: TransitionProp | null | undefined,
1029
+ pseudoActive?: boolean,
1030
+ nextMetrics?: NativeTextMetrics
1031
+ ) => {
1032
+ pseudoActiveRef.current = pseudoActive === true
1033
+ const runners: Function[] = []
1034
+ const seenAnimateKeys = new Set<string>()
1035
+ let transformCount = 0
1036
+ let animatedShapeChanged = false
1037
+ // the emitter runs on a mounted node, so `default` is the state, but
1038
+ // the transition is the one it was handed
1039
+ const emittedResolved = forAnimationState(
1040
+ resolveTransition(emittedTransition ?? effectiveTransition, { animations }),
1041
+ 'default'
1042
+ )
1043
+ const emittedRatio = getLeadingRatio(nextMetrics)
1044
+ const emitterPaintsProduct = paintsLeadingProduct(nextStyle, emittedRatio)
1045
+ // the same rule as the render path: a text metric joins the graph only
1046
+ // while it is moving, so a pseudo that only changes an opacity leaves
1047
+ // the node's font size out of it and keeps the native driver
1048
+ const emitterMovesFontSize = movesTextValue('fontSize', nextStyle.fontSize)
1049
+ const emitterMovesLeading = emitterPaintsProduct
1050
+ ? movesLeadingRatio(emittedRatio)
1051
+ : movesTextValue('lineHeight', nextStyle.lineHeight)
1052
+ // nextStyle is the complete style for this node, so the emitter makes
1053
+ // the same single driver decision as the render path. include the
1054
+ // currently rendered graph because its stale keys are not removed until
1055
+ // the structural-change commit below.
1056
+ const useNativeDriverForNode =
1057
+ nativeDriver &&
1058
+ !hasAnimatedLayoutKey(nextStyle, isDark, emittedResolved) &&
1059
+ !emitterMovesFontSize &&
1060
+ !emitterMovesLeading &&
1061
+ !inheritedFontSizeNode(nextStyle, emittedRatio, inheritedText) &&
1062
+ !Object.keys(animateStyles.current).some((key) => jsDriverStyleKey[key])
1063
+
1064
+ const emitterDerivesLeading =
1065
+ emitterPaintsProduct &&
1066
+ (emitterMovesFontSize || emitterMovesLeading) &&
1067
+ (!!getTransitionForKey(emittedResolved, 'fontSize') ||
1068
+ !!getTransitionForKey(emittedResolved, 'lineHeight'))
1069
+
1070
+ for (const key in nextStyle) {
1071
+ const rawVal = nextStyle[key]
1072
+ const val = resolveDynamicValue(rawVal, isDark)
1073
+ if (val === undefined) continue
1074
+
1075
+ if (emitterDerivesLeading && (key === 'fontSize' || key === 'lineHeight')) {
1076
+ continue
1077
+ }
1078
+
1079
+ // a metric at rest is not the emitter's to animate. it either already
1080
+ // paints as a plain style or the commit below hands it to one.
1081
+ if (
1082
+ textMetricStyleKey[key] &&
1083
+ !(key === 'fontSize' ? emitterMovesFontSize : emitterMovesLeading)
1084
+ ) {
1085
+ continue
1086
+ }
1087
+
1088
+ if (key === 'transform' && Array.isArray(val)) {
1089
+ for (const transform of val) {
1090
+ if (!transform) continue
1091
+ const index = transformCount++
1092
+ const tkey = Object.keys(transform)[0]
1093
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
1094
+ if (!currentTransform) animatedShapeChanged = true
1095
+ animatedTranforms.current[index] = {
1096
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
1097
+ }
1098
+ }
1099
+ } else if (
1100
+ animatedStyleKey[key] != null ||
1101
+ costlyToAnimateStyleKey[key] ||
1102
+ jsDriverStyleKey[key]
1103
+ ) {
1104
+ // layout keys only animate numbers ('auto'/percents are static);
1105
+ // unparseable themed colors can't be interpolated — skip both and
1106
+ // let the next render apply them statically
1107
+ if (jsDriverStyleKey[key] && typeof val !== 'number') continue
1108
+ if (colorStyleKey[key] && !isAnimatableColor(val)) continue
1109
+ if (!animateStyles.current[key]) animatedShapeChanged = true
1110
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
1111
+ seenAnimateKeys.add(key)
1112
+ }
1113
+ }
1114
+
1115
+ // the emitter can only re-target Animated.Values the committed style
1116
+ // already paints. re-targeting the font size and the ratio keeps the
1117
+ // product moving without a commit; a pass that disagrees with the
1118
+ // committed style about whether the leading IS a product (a pseudo that
1119
+ // overrides it with an absolute length, or the first pseudo pass on a
1120
+ // node whose render never derived) needs the node swapped, which only a
1121
+ // commit can do.
1122
+ if (emitterDerivesLeading) {
1123
+ if (emitterMovesFontSize) {
1124
+ if (!animateStyles.current.fontSize) animatedShapeChanged = true
1125
+ animateStyles.current.fontSize = update(
1126
+ 'fontSize',
1127
+ animatedValueFor('fontSize'),
1128
+ nextStyle.fontSize as number
1129
+ )
1130
+ seenAnimateKeys.add('fontSize')
1131
+ }
1132
+ // a factor at rest is left out: it multiplies as the number the
1133
+ // painted product already carries. giving one a value the committed
1134
+ // style does not paint is the shape change that commits.
1135
+ if (emitterMovesLeading) {
1136
+ if (!leadingRatioValue.current) animatedShapeChanged = true
1137
+ leadingRatioValue.current = update(
1138
+ 'lineHeight',
1139
+ leadingRatioValueFrom(),
1140
+ emittedRatio!
1141
+ )
1142
+ }
1143
+ }
1144
+ if (emitterDerivesLeading !== paintsDerivedLeading.current) {
1145
+ animatedShapeChanged = true
1146
+ }
1147
+
1148
+ // the emitter receives a complete style. keep the Animated style graph
1149
+ // equally complete, including a pseudo release that omits a pseudo-only
1150
+ // key. React Native needs a commit when that graph's shape changes.
1151
+ for (const key in animateStyles.current) {
1152
+ if (!seenAnimateKeys.has(key)) {
1153
+ delete animateStyles.current[key]
1154
+ animatedShapeChanged = true
1155
+ }
1156
+ }
1157
+ if (animatedTranforms.current.length > transformCount) {
1158
+ animatedTranforms.current = animatedTranforms.current.slice(0, transformCount)
1159
+ animatedShapeChanged = true
1160
+ }
1161
+
1162
+ paintedTextRef.current = {
1163
+ fontSize: nextStyle.fontSize,
1164
+ lineHeight: nextStyle.lineHeight,
1165
+ ratio: emittedRatio,
1166
+ }
1167
+
1168
+ // run the queued animations immediately
1169
+ runners.forEach((r) => r())
1170
+
1171
+ // pseudo state normally stays on the avoidReRenders path. adding or
1172
+ // removing a style key cannot be expressed by an existing Animated.Value,
1173
+ // so commit the pending state only for that structural change.
1174
+ if (animatedShapeChanged && stateRef.current.nextState) {
1175
+ stateRef.current.baseSetStateShallow?.(stateRef.current.nextState)
1176
+ }
1177
+
1178
+ function update(
1179
+ key: string,
1180
+ animated: Animated.Value | undefined,
1181
+ valIn: string | number
1182
+ ) {
1183
+ const isColor = colorStyleKey[key]
1184
+ const [numVal, type] = isColor ? [0, undefined] : getValue(valIn)
1185
+ let animateToValue = numVal
1186
+ const value = animated || new Animated.Value(numVal)
1187
+ const curInterpolation = animationsState.current.get(value)
1188
+
1189
+ if (type) {
1190
+ animationsState.current.set(value, {
1191
+ interpolation: value.interpolate(
1192
+ getInterpolated(
1193
+ curInterpolation?.current ?? value['_value'],
1194
+ numVal,
1195
+ type
1196
+ )
1197
+ ),
1198
+ current: numVal,
1199
+ })
1200
+ }
1201
+
1202
+ if (isColor) {
1203
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
1204
+ animationsState.current.set(value, {
1205
+ current: valIn,
1206
+ interpolation: value.interpolate(
1207
+ getColorInterpolated(
1208
+ curInterpolation?.current as string,
1209
+ valIn as string,
1210
+ animateToValue
1211
+ )
1212
+ ),
1213
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
1214
+ })
1215
+ }
1216
+
1217
+ // the emitter runs for pseudo-state changes on a mounted node, so
1218
+ // `default` is the state, but the transition is the one it was handed
1219
+ const animationConfig = getAnimationConfig(
1220
+ key,
1221
+ animations,
1222
+ emittedTransition ?? effectiveTransition,
1223
+ 'default'
1224
+ )
1225
+ runners.push(() => {
1226
+ value.stopAnimation()
1227
+ const { type, delay, ...config } = animationConfig
1228
+ const anim = Animated[type || 'spring'](value, {
1229
+ toValue: animateToValue,
1230
+ ...config,
1231
+ useNativeDriver: useNativeDriverForNode,
1232
+ })
1233
+ ;(delay ? Animated.sequence([Animated.delay(delay), anim]) : anim).start()
1234
+ })
1235
+
1236
+ return value
1237
+ }
1238
+ }
1239
+ useStyleEmitter?.(onEmittedStyle)
1240
+
1241
+ if (process.env.NODE_ENV === 'development') {
1242
+ if (props['debug'] === 'verbose') {
1243
+ console.info(`Animated`, { response: res, inputStyle: style, isExiting })
1244
+ }
1245
+ }
1246
+
1247
+ return res
1248
+ },
1249
+ }
1250
+ }
1251
+
1252
+ function getColorInterpolated(
1253
+ currentColor: string | undefined,
1254
+ nextColor: string,
1255
+ animateToValue: number
1256
+ ) {
1257
+ const inputRange = [0, 1]
1258
+ const outputRange = [currentColor ? currentColor : nextColor, nextColor]
1259
+ if (animateToValue === 0) {
1260
+ // because we are animating from value 1 to 0, we need to put target color at the beginning
1261
+ outputRange.reverse()
1262
+ }
1263
+ return {
1264
+ inputRange,
1265
+ outputRange,
1266
+ }
1267
+ }
1268
+
1269
+ function getInterpolated(current: number, next: number, postfix = 'deg') {
1270
+ if (next === current) {
1271
+ current = next - 0.000000001
1272
+ }
1273
+ const inputRange = [current, next]
1274
+ const outputRange = [`${current}${postfix}`, `${next}${postfix}`]
1275
+ if (next < current) {
1276
+ inputRange.reverse()
1277
+ outputRange.reverse()
1278
+ }
1279
+ return {
1280
+ inputRange,
1281
+ outputRange,
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * one resolved entry as a react-native Animated config.
1287
+ *
1288
+ * springs go in as stiffness/damping/mass, which is the parameterization RN
1289
+ * actually integrates. `bounciness`/`speed` and `tension`/`friction` are older
1290
+ * spellings of the same two numbers, so nothing is lost by not using them.
1291
+ */
1292
+ function entryToRN(entry: ResolvedEntry): AnimationConfig {
1293
+ const extra = entry.timing.kind === 'spring' ? entry.timing.extra : undefined
1294
+
1295
+ if (entry.timing.kind === 'spring') {
1296
+ return {
1297
+ type: 'spring',
1298
+ stiffness: entry.timing.stiffness,
1299
+ damping: entry.timing.damping,
1300
+ mass: entry.timing.mass,
1301
+ ...(typeof extra?.velocity === 'number' ? { velocity: extra.velocity } : null),
1302
+ ...(typeof extra?.overshootClamping === 'boolean'
1303
+ ? { overshootClamping: extra.overshootClamping }
1304
+ : null),
1305
+ ...(entry.delayMs ? { delay: entry.delayMs } : null),
1306
+ }
1307
+ }
1308
+
1309
+ const bezier = easingToBezier(entry.timing.easing)
1310
+ return {
1311
+ type: 'timing',
1312
+ duration: entry.timing.durationMs,
1313
+ // `linear()` and `steps()` have no bezier equivalent; RN's default easing
1314
+ // is the honest answer rather than a curve we made up
1315
+ ...(bezier
1316
+ ? { easing: Easing.bezier(bezier[0], bezier[1], bezier[2], bezier[3]) }
1317
+ : null),
1318
+ ...(entry.delayMs ? { delay: entry.delayMs } : null),
1319
+ }
1320
+ }
1321
+
1322
+ // a key the transition does not cover does not animate. snapping is what css
1323
+ // does for an unlisted property, so the drivers have to agree on it too.
1324
+ const snapConfig: AnimationConfig = { type: 'timing', duration: 0 }
1325
+
1326
+ function getAnimationConfig(
1327
+ key: string,
1328
+ animations: AnimationsConfig,
1329
+ transition?: TransitionProp | null,
1330
+ animationState: 'enter' | 'exit' | 'default' = 'default'
1331
+ ): AnimationConfig {
1332
+ const resolved = forAnimationState(
1333
+ resolveTransition(transition, { animations }),
1334
+ animationState
1335
+ )
1336
+ const entry = getTransitionForKey(resolved, key)
1337
+ return entry ? entryToRN(entry) : snapConfig
1338
+ }
1339
+
1340
+ function getValue(input: number | string, isColor = false) {
1341
+ if (typeof input !== 'string') {
1342
+ return [input] as const
1343
+ }
1344
+ // the unit is optional: unitless numbers reach here as strings (scale, and
1345
+ // any bare token value), and the number may be fractional. matching only
1346
+ // `[-0-9]+` followed by a required unit read "1.5deg" as 5 and gave NaN for
1347
+ // "0.95", and an Animated animation toward NaN never calls its completion
1348
+ // callback, which strands whatever waits on it.
1349
+ const [_, number, after] = input.match(/(-?(?:\d+\.?\d*|\.\d+))(deg|%|px)?/) ?? []
1350
+ return [+number, after] as const
1351
+ }