@tamagui/animations-react-native 3.0.0-beta.922.1 → 3.0.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.
@@ -1,9 +1,18 @@
1
- import { getEffectiveAnimation, normalizeTransition } from '@tamagui/animation-helpers'
1
+ import {
2
+ easingToBezier,
3
+ forAnimationState,
4
+ getTransitionForKey,
5
+ resolveTransition,
6
+ type AnimationsConfig,
7
+ type ResolvedEntry,
8
+ type ResolvedTransition,
9
+ } from '@tamagui/animation-helpers'
2
10
  import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants'
3
11
  import { ResetPresence, usePresence } from '@tamagui/use-presence'
4
12
  import type {
5
13
  AnimatedNumberStrategy,
6
14
  AnimationDriverWithAnimatedNumbers,
15
+ NativeTextMetrics,
7
16
  TransitionProp,
8
17
  UniversalAnimatedNumber,
9
18
  UseAnimatedNumberReaction,
@@ -12,13 +21,9 @@ import type {
12
21
  import { useEvent } from '@tamagui/web'
13
22
  import { useThemeWithState } from '@tamagui/web/internal-runtime'
14
23
  import React from 'react'
15
- import {
16
- Animated,
17
- processColor,
18
- type ColorValue,
19
- type Text,
20
- type View,
21
- } from 'react-native'
24
+ import { Animated, Easing, processColor, TextInput, type ColorValue } from 'react-native'
25
+
26
+ import type { CreateAnimationsOptions } from './types'
22
27
 
23
28
  // detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys
24
29
  const isFabric =
@@ -33,27 +38,17 @@ const resolveDynamicValue = (value: any, isDark: boolean): any => {
33
38
  return value
34
39
  }
35
40
 
36
- type AnimationsConfig<A extends object = any> = { [Key in keyof A]: AnimationConfig }
37
-
38
- type SpringConfig = { type?: 'spring' } & Partial<
39
- Pick<
40
- Animated.SpringAnimationConfig,
41
- | 'delay'
42
- | 'bounciness'
43
- | 'damping'
44
- | 'friction'
45
- | 'mass'
46
- | 'overshootClamping'
47
- | 'speed'
48
- | 'stiffness'
49
- | 'tension'
50
- | 'velocity'
51
- >
52
- >
53
-
54
- type TimingConfig = { type: 'timing' } & Partial<Animated.TimingAnimationConfig>
55
-
56
- type AnimationConfig = SpringConfig | TimingConfig
41
+ /** what `getAnimationConfig` hands to `Animated.spring` / `Animated.timing` */
42
+ type AnimationConfig =
43
+ | ({ type: 'spring'; delay?: number } & Partial<
44
+ Pick<
45
+ Animated.SpringAnimationConfig,
46
+ 'damping' | 'mass' | 'overshootClamping' | 'stiffness' | 'velocity'
47
+ >
48
+ >)
49
+ | ({ type: 'timing'; delay?: number } & Partial<
50
+ Pick<Animated.TimingAnimationConfig, 'duration' | 'easing'>
51
+ >)
57
52
 
58
53
  const animatedStyleKey = {
59
54
  transform: true,
@@ -70,8 +65,10 @@ const colorStyleKey = {
70
65
  borderBottomColor: true,
71
66
  }
72
67
 
73
- // layout dimension keys. these must run on the JS driver (useNativeDriver:false)
74
- // because the native animated module can't drive layout props.
68
+ // layout dimensions. the native animated module has no whitelist entry for any
69
+ // of them, so a node animating one runs its whole Animated graph on the JS
70
+ // driver (useNativeDriver:false): Fabric cannot mix native- and JS-driven
71
+ // values on one node.
75
72
  const layoutStyleKey = {
76
73
  height: true,
77
74
  width: true,
@@ -81,18 +78,67 @@ const layoutStyleKey = {
81
78
  maxWidth: true,
82
79
  }
83
80
 
81
+ // text metrics have no whitelist entry either, but unlike a height they sit on
82
+ // nearly every Text, so being covered by a broad `transition="medium"` must not
83
+ // be enough to take the node off the native driver: an opacity fade over text
84
+ // whose size is not changing would then run in JS. they join the Animated graph
85
+ // only while they are actually moving, and the node's driver follows them.
86
+ const textMetricStyleKey = {
87
+ fontSize: true,
88
+ lineHeight: true,
89
+ }
90
+
91
+ const jsDriverStyleKey = { ...layoutStyleKey, ...textMetricStyleKey }
92
+
84
93
  function hasAnimatedLayoutKey(
85
94
  style: Record<string, any>,
86
95
  isDark: boolean,
87
- animateOnly?: string[]
96
+ resolved: ResolvedTransition
88
97
  ) {
89
98
  for (const key in layoutStyleKey) {
90
- if (animateOnly && !animateOnly.includes(key)) continue
99
+ if (!getTransitionForKey(resolved, key)) continue
91
100
  if (typeof resolveDynamicValue(style[key], isDark) === 'number') return true
92
101
  }
93
102
  return false
94
103
  }
95
104
 
105
+ // A numeric authored lineHeight is a RATIO of the resolved font size, not a
106
+ // length. Core finalizes the destination to fontSize * ratio and reports the
107
+ // ratio back on `nativeTextMetrics`, so the painted leading has to be
108
+ // fontSize * ratio on EVERY frame, not only at the destination. This driver
109
+ // gets that exactly by never animating the leading itself: it animates the
110
+ // font size and the ratio, and paints their product as one Animated node.
111
+ // An absolute px leading, and any raw react-native style, keeps its own value
112
+ // and animates as an ordinary numeric key.
113
+ const getLeadingRatio = (metrics: { lineHeight?: unknown } | undefined | null) => {
114
+ const ratio = metrics?.lineHeight
115
+ return typeof ratio === 'number' && Number.isFinite(ratio) && ratio >= 0
116
+ ? ratio
117
+ : undefined
118
+ }
119
+
120
+ // the product needs both factors as numbers in the style it is painting into.
121
+ const paintsLeadingProduct = (
122
+ style: Record<string, any>,
123
+ ratio: number | undefined
124
+ ): ratio is number =>
125
+ ratio !== undefined &&
126
+ typeof style.fontSize === 'number' &&
127
+ typeof style.lineHeight === 'number'
128
+
129
+ // a leading multiplied off an ANCESTOR's live font size: this node has none of
130
+ // its own, core reports the ratio, and react-native inherits the size itself.
131
+ const inheritedFontSizeNode = (
132
+ style: Record<string, any>,
133
+ ratio: number | undefined,
134
+ inheritedText: { fontSize: unknown; driver: string } | null | undefined
135
+ ) =>
136
+ ratio !== undefined &&
137
+ typeof style.fontSize !== 'number' &&
138
+ inheritedText?.driver === 'react-native'
139
+ ? (inheritedText.fontSize as Animated.Value)
140
+ : undefined
141
+
96
142
  // Only colors accepted by RN's own parser can enter interpolation. CSS-wide
97
143
  // keywords, unresolved tokens, var()/calc(), and empty strings otherwise reach
98
144
  // createInterpolationFromStringOutputRange / mapStringToNumericComponents and
@@ -116,13 +162,15 @@ const costlyToAnimateStyleKey = {
116
162
  ...colorStyleKey,
117
163
  }
118
164
 
119
- type CreateAnimationsOptions = {
120
- // override native driver detection (default: auto-detect Fabric)
121
- useNativeDriver?: boolean
122
- }
123
-
124
- export const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View
125
- export const AnimatedText: Animated.AnimatedComponent<typeof Text> = Animated.Text
165
+ export const AnimatedView: typeof Animated.View = Animated.View
166
+ export const AnimatedText: typeof Animated.Text = Animated.Text
167
+ // a TextInput never inherits font size from an ancestor Text on native, so the
168
+ // binding hook has to hand it a host that accepts animated nodes of its own.
169
+ // built on first use: the compiler evaluates tamagui.config.ts against a
170
+ // react-native stub whose Animated cannot make one, and never binds any text.
171
+ let animatedTextInput:
172
+ | ReturnType<typeof Animated.createAnimatedComponent<typeof TextInput>>
173
+ | undefined
126
174
 
127
175
  export function useAnimatedNumber(
128
176
  initial: number
@@ -161,7 +209,12 @@ export function useAnimatedNumber(
161
209
  : undefined
162
210
 
163
211
  if (type === 'direct') {
212
+ state.current.composite?.stop()
213
+ state.current.composite = null
164
214
  val.setValue(next)
215
+ // a direct set finishes the moment it lands. not calling back stranded
216
+ // everything waiting on it (sheet snap, presence completion).
217
+ onFinish?.()
165
218
  } else if (type === 'spring') {
166
219
  state.current.composite?.stop()
167
220
  const composite = Animated.spring(val, {
@@ -263,6 +316,33 @@ export function createAnimations<A extends AnimationsConfig>(
263
316
  useAnimatedNumbersStyle,
264
317
  usePresence,
265
318
  ResetPresence,
319
+
320
+ // binds a descendant that has no font size of its own to the ancestor's
321
+ // animated one. no clock here: the font size node is the ancestor's, and a
322
+ // ratio leading is a multiplication of it, so both land on the same frame.
323
+ useTextMetrics: ({ inheritedText, lineHeight }) => {
324
+ const node =
325
+ inheritedText?.driver === 'react-native'
326
+ ? (inheritedText.fontSize as Animated.Value)
327
+ : null
328
+ // an absolute or `normal` leading still needs the font size bound, it
329
+ // just keeps the leading the caller already resolved.
330
+ const ratio = typeof lineHeight === 'number' ? lineHeight : null
331
+ const style = React.useMemo(
332
+ () =>
333
+ node
334
+ ? ratio === null
335
+ ? { fontSize: node }
336
+ : { fontSize: node, lineHeight: Animated.multiply(node, ratio) }
337
+ : null,
338
+ [node, ratio]
339
+ )
340
+ const textChannel = inheritedText ?? null
341
+ if (!style) return { style: null, textChannel }
342
+ animatedTextInput ||= Animated.createAnimatedComponent(TextInput)
343
+ return { style, textChannel, Text: AnimatedText, TextInput: animatedTextInput }
344
+ },
345
+
266
346
  useAnimations: ({
267
347
  props,
268
348
  onTransition,
@@ -270,7 +350,9 @@ export function createAnimations<A extends AnimationsConfig>(
270
350
  componentState,
271
351
  presence,
272
352
  stateRef,
353
+ styleState,
273
354
  useStyleEmitter,
355
+ inheritedText,
274
356
  }) => {
275
357
  const isDisabled = isWeb && componentState.unmounted === true
276
358
  const isExiting = presence?.[0] === false
@@ -286,12 +368,86 @@ export function createAnimations<A extends AnimationsConfig>(
286
368
  phase === 'end' ? { phase, cause, finished } : { phase, cause }
287
369
  )
288
370
  }
371
+ // createComponent merges a colocated `transition` out of the active
372
+ // pseudo style (`enterStyle={{ opacity: 0, transition: '200ms' }}`), so
373
+ // this is the one that applies right now, not the base prop.
374
+ const effectiveTransition = (styleState?.effectiveTransition ?? props.transition) as
375
+ | TransitionProp
376
+ | null
377
+ | undefined
378
+
289
379
  const [, themeState] = useThemeWithState({})
290
380
  // Check scheme first, then fall back to checking theme name for 'dark'
291
381
  const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')
292
382
 
293
383
  /** store Animated value of each key e.g: color: AnimatedValue */
294
384
  const animateStyles = React.useRef<Record<string, Animated.Value>>({})
385
+ // a ratio leading animates the RATIO, never the leading, and paints
386
+ // fontSize * ratio as one node. the ratio is not a style key, so it lives
387
+ // outside animateStyles (which the stale-key sweep and the emitter's
388
+ // structural-change check both treat as the rendered style's own keys).
389
+ const leadingRatioValue = React.useRef<Animated.Value | null>(null)
390
+ const leadingRatioRef = React.useRef<number | undefined>(undefined)
391
+ // whether the style React last committed paints the derived product. the
392
+ // emitter can only re-target existing Animated.Values, so a pass that
393
+ // disagrees with this has to force a commit to swap the node itself.
394
+ const paintsDerivedLeading = React.useRef(false)
395
+ // this subtree's live font size, published for descendants that inherit
396
+ // it. the channel's identity IS the node's, so a consumer that keeps the
397
+ // same channel keeps the same derived node.
398
+ const textChannelRef = React.useRef<{ fontSize: unknown; driver: string } | null>(
399
+ null
400
+ )
401
+ // core reports a numeric (ratio) leading on nativeTextMetrics; a px length
402
+ // and 'normal' are reported as themselves and stay independent keys.
403
+ leadingRatioRef.current = getLeadingRatio(styleState?.nativeTextMetrics)
404
+ // the text metrics the last pass painted, so the next one can tell a real
405
+ // change from a value that is simply present. a metric only joins the
406
+ // Animated graph while it is moving (see textMetricStyleKey), which is
407
+ // what keeps an ordinary opacity or transform animation over unchanged
408
+ // text on the native driver.
409
+ const paintedTextRef = React.useRef<{
410
+ fontSize?: unknown
411
+ lineHeight?: unknown
412
+ ratio?: number
413
+ }>({})
414
+ // a value already in the graph keeps moving until it arrives, so a
415
+ // re-render mid-flight never drops it back to a static style and snaps.
416
+ // the first pass has painted nothing yet, so nothing is moving there
417
+ // either: the metrics land as plain numbers and the node mounts on the
418
+ // native driver.
419
+ const movesTextValue = (key: 'fontSize' | 'lineHeight', target: unknown) => {
420
+ if (typeof target !== 'number') return false
421
+ const node = animateStyles.current[key]
422
+ const last = node ? (node['_value'] as unknown) : paintedTextRef.current[key]
423
+ return last !== undefined && last !== target
424
+ }
425
+ const movesLeadingRatio = (ratio: number | undefined) => {
426
+ if (ratio === undefined) return false
427
+ const node = leadingRatioValue.current
428
+ const last = node ? (node['_value'] as number) : paintedTextRef.current.ratio
429
+ return last !== undefined && last !== ratio
430
+ }
431
+ // the font size descendants read as this node's live size. it outlives
432
+ // any one animation: dropping it whenever the size came to rest would
433
+ // swap every descendant between Text and Animated.Text, remounting their
434
+ // subtrees, and a TextInput's focus and contents with them, every time an
435
+ // animation started or finished.
436
+ const fontSizeNode = React.useRef<Animated.Value | null>(null)
437
+ const animatedValueFor = (key: string) => {
438
+ const painted = animateStyles.current[key]
439
+ if (painted) return painted
440
+ if (key === 'fontSize' && fontSizeNode.current) return fontSizeNode.current
441
+ // a metric that was at rest is out of the graph, so the value it
442
+ // rejoins with has to start at what the last pass painted: a fresh
443
+ // Animated.Value starts AT its target, and would snap the metric.
444
+ if (textMetricStyleKey[key])
445
+ return new Animated.Value(paintedTextRef.current[key] as number)
446
+ return undefined
447
+ }
448
+ const leadingRatioValueFrom = () =>
449
+ leadingRatioValue.current ??
450
+ new Animated.Value(paintedTextRef.current.ratio as number)
295
451
  const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])
296
452
  const animationsState = React.useRef(
297
453
  new WeakMap<
@@ -332,9 +488,6 @@ export function createAnimations<A extends AnimationsConfig>(
332
488
  exitCycleIdRef.current++
333
489
  }
334
490
 
335
- const animateOnly = (props.animateOnly as string[]) || []
336
- const hasTransitionOnly = !!props.animateOnly
337
-
338
491
  // Track if we just finished entering (transition from entering to not entering)
339
492
  // must be declared before args array that uses justFinishedEntering
340
493
  const isEntering = !!componentState.unmounted
@@ -346,12 +499,14 @@ export function createAnimations<A extends AnimationsConfig>(
346
499
 
347
500
  const args = [
348
501
  JSON.stringify(style),
502
+ JSON.stringify(effectiveTransition),
349
503
  componentState,
350
504
  isExiting,
351
505
  !!onTransition,
352
506
  isDark,
353
507
  justFinishedEntering,
354
- hasTransitionOnly,
508
+ leadingRatioRef.current,
509
+ inheritedText,
355
510
  ]
356
511
 
357
512
  const res = React.useMemo(() => {
@@ -366,17 +521,46 @@ export function createAnimations<A extends AnimationsConfig>(
366
521
  ? 'enter'
367
522
  : 'default'
368
523
 
524
+ // which style keys animate at all is the transition's own decision, so
525
+ // a property list narrows this the same way it narrows css
526
+ const resolved = forAnimationState(
527
+ resolveTransition(effectiveTransition, { animations }),
528
+ animationState
529
+ )
530
+
369
531
  const nonAnimatedStyle = {}
370
- // animatedStyle owns every Animated.Value on the node. Fabric cannot mix
371
- // native- and JS-driven values inside that shared graph, so one layout
372
- // animation makes the whole node use the JS driver.
373
- const useNativeDriverForNode =
374
- nativeDriver &&
375
- !hasAnimatedLayoutKey(
376
- style,
377
- isDark,
378
- hasTransitionOnly ? animateOnly : undefined
379
- )
532
+ // the leading is derived only while something can actually move it.
533
+ // when neither key is covered by the transition both fall through to
534
+ // nonAnimatedStyle and land together in one commit, which is already
535
+ // coherent and keeps the node on the native driver.
536
+ const paintedRatio = leadingRatioRef.current
537
+ const paintsProduct = paintsLeadingProduct(style, paintedRatio)
538
+ const movesFontSize = movesTextValue('fontSize', style.fontSize)
539
+ const movesLeading = paintsProduct
540
+ ? movesLeadingRatio(paintedRatio)
541
+ : movesTextValue('lineHeight', style.lineHeight)
542
+ // a metric that has arrived leaves the graph here rather than in the
543
+ // sweep at the end of the pass, so the node goes back to the native
544
+ // driver in the same commit that stops moving it. the same three
545
+ // conditions as that sweep: an exit and a latched pseudo both still own
546
+ // the keys they are painting.
547
+ if (!isExiting && !isDisabled && !pseudoActiveRef.current) {
548
+ if (!movesFontSize) delete animateStyles.current.fontSize
549
+ if (!movesLeading) delete animateStyles.current.lineHeight
550
+ }
551
+
552
+ // a font size a transition can move is published to descendants whether
553
+ // or not it happens to be moving right now, so their host component
554
+ // does not change under them when it starts.
555
+ if (
556
+ isDisabled ||
557
+ typeof style.fontSize !== 'number' ||
558
+ !getTransitionForKey(resolved, 'fontSize')
559
+ ) {
560
+ fontSizeNode.current = null
561
+ } else if (!fontSizeNode.current) {
562
+ fontSizeNode.current = new Animated.Value(style.fontSize)
563
+ }
380
564
 
381
565
  // track which animated keys/transforms the incoming style actually
382
566
  // carries this pass, so entries that left the style can be dropped
@@ -386,6 +570,34 @@ export function createAnimations<A extends AnimationsConfig>(
386
570
  let sawTransform = false
387
571
  let transformCount = 0
388
572
 
573
+ const derivesLeading =
574
+ !isDisabled &&
575
+ paintsProduct &&
576
+ (movesFontSize || movesLeading) &&
577
+ (!!getTransitionForKey(resolved, 'fontSize') ||
578
+ !!getTransitionForKey(resolved, 'lineHeight'))
579
+ // an inherited font size: core leaves this node's own fontSize out of
580
+ // the style and reports the ratio, so the leading is the ANCESTOR's
581
+ // live font size times this node's ratio. react-native inherits the
582
+ // size itself, and a length leading is inherited as a length.
583
+ const inheritedFontSize = isDisabled
584
+ ? undefined
585
+ : inheritedFontSizeNode(style, paintedRatio, inheritedText)
586
+
587
+ // animatedStyle owns every Animated.Value on the node. Fabric cannot mix
588
+ // native- and JS-driven values inside that shared graph, so one layout
589
+ // animation, one text metric on the move, or a leading multiplied off an
590
+ // ancestor's font size, makes the whole node use the JS driver. a metric
591
+ // another pass left in the graph counts too, or its next animation would
592
+ // start on a driver the node no longer runs on.
593
+ const useNativeDriverForNode =
594
+ nativeDriver &&
595
+ !hasAnimatedLayoutKey(style, isDark, resolved) &&
596
+ !movesFontSize &&
597
+ !movesLeading &&
598
+ !inheritedFontSize &&
599
+ !Object.keys(animateStyles.current).some((key) => jsDriverStyleKey[key])
600
+
389
601
  for (const key in style) {
390
602
  const rawVal = style[key]
391
603
  // Resolve dynamic theme values from flat theme clauses.
@@ -396,23 +608,49 @@ export function createAnimations<A extends AnimationsConfig>(
396
608
  continue
397
609
  }
398
610
 
611
+ // fontSize and lineHeight are owned by the derived-leading block below
612
+ if (
613
+ (derivesLeading || inheritedFontSize) &&
614
+ (key === 'fontSize' || key === 'lineHeight')
615
+ ) {
616
+ // unless the size is the factor at rest: the product multiplies the
617
+ // number it paints, and that number has to be painted
618
+ if (key === 'fontSize' && derivesLeading && !movesFontSize) {
619
+ nonAnimatedStyle[key] = val
620
+ }
621
+ continue
622
+ }
623
+
624
+ // a text metric at rest stays out of the Animated graph entirely: it
625
+ // paints as a plain style, and the node keeps the native driver
626
+ if (
627
+ textMetricStyleKey[key] &&
628
+ !(key === 'fontSize' ? movesFontSize : movesLeading)
629
+ ) {
630
+ nonAnimatedStyle[key] = val
631
+ continue
632
+ }
633
+
399
634
  if (
400
635
  animatedStyleKey[key] == null &&
401
636
  !costlyToAnimateStyleKey[key] &&
402
- !layoutStyleKey[key]
637
+ !jsDriverStyleKey[key]
403
638
  ) {
404
639
  nonAnimatedStyle[key] = val
405
640
  continue
406
641
  }
407
642
 
408
- if (hasTransitionOnly && !animateOnly.includes(key)) {
643
+ // `transform` is a container, not a property: its parts each resolve
644
+ // on their own below, and a part no entry covers gets `snapConfig`.
645
+ // the array cannot be split into animated and static halves here.
646
+ if (key !== 'transform' && !getTransitionForKey(resolved, key)) {
409
647
  nonAnimatedStyle[key] = val
410
648
  continue
411
649
  }
412
650
 
413
651
  // layout dimension keys only animate numbers — 'auto' (an open
414
652
  // accordion at rest) and percent strings apply as static styles
415
- if (layoutStyleKey[key] && typeof val !== 'number') {
653
+ if (jsDriverStyleKey[key] && typeof val !== 'number') {
416
654
  nonAnimatedStyle[key] = val
417
655
  continue
418
656
  }
@@ -425,7 +663,7 @@ export function createAnimations<A extends AnimationsConfig>(
425
663
  }
426
664
 
427
665
  if (key !== 'transform') {
428
- animateStyles.current[key] = update(key, animateStyles.current[key], val)
666
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
429
667
  seenAnimateKeys.add(key)
430
668
  continue
431
669
  }
@@ -451,6 +689,64 @@ export function createAnimations<A extends AnimationsConfig>(
451
689
  }
452
690
  }
453
691
 
692
+ // the derived leading: fontSize and the ratio each animate on their own
693
+ // resolved entry, and the painted leading is their product, so
694
+ // lineHeight is fontSize * ratio on every frame by construction rather
695
+ // than by two solvers happening to agree. a key the transition does not
696
+ // cover gets snapConfig here exactly as it would anywhere else, which
697
+ // makes `transition="fontSize 300ms"` move the leading with the size and
698
+ // `transition="lineHeight 300ms"` move the ratio over a size that has
699
+ // already arrived.
700
+ // the ratio is a factor exactly like the font size: a value of its own
701
+ // while it moves, the number it already paints while it does not
702
+ const ratioFactor = (): Animated.Value | number => {
703
+ if (!movesLeading) {
704
+ leadingRatioValue.current = null
705
+ return paintedRatio!
706
+ }
707
+ leadingRatioValue.current = update(
708
+ 'lineHeight',
709
+ leadingRatioValueFrom(),
710
+ paintedRatio!
711
+ )
712
+ return leadingRatioValue.current
713
+ }
714
+ let derivedLeading: ReturnType<typeof Animated.multiply> | undefined
715
+ if (derivesLeading) {
716
+ // a factor at rest multiplies as the plain number it already paints,
717
+ // so the product does not carry a value nothing is moving
718
+ let fontSizeFactor: Animated.Value | number = style.fontSize
719
+ if (movesFontSize) {
720
+ fontSizeFactor = update(
721
+ 'fontSize',
722
+ animatedValueFor('fontSize'),
723
+ style.fontSize
724
+ )
725
+ animateStyles.current.fontSize = fontSizeFactor
726
+ seenAnimateKeys.add('fontSize')
727
+ }
728
+ derivedLeading = Animated.multiply(fontSizeFactor, ratioFactor())
729
+ } else if (inheritedFontSize) {
730
+ derivedLeading = Animated.multiply(inheritedFontSize, ratioFactor())
731
+ } else if (!isDisabled) {
732
+ leadingRatioValue.current = null
733
+ }
734
+ paintsDerivedLeading.current = !!derivedLeading
735
+
736
+ // what descendants inherit from this node: its own animated font size
737
+ // when it has one, nothing when it has a font size of its own that is
738
+ // not animating (react-native inherits that statically), and otherwise
739
+ // whatever it inherited itself.
740
+ const ownFontSizeNode = fontSizeNode.current
741
+ const textChannel = ownFontSizeNode
742
+ ? textChannelRef.current?.fontSize === ownFontSizeNode
743
+ ? textChannelRef.current
744
+ : { fontSize: ownFontSizeNode, driver: 'react-native' }
745
+ : typeof style.fontSize === 'number'
746
+ ? null
747
+ : (inheritedText ?? null)
748
+ textChannelRef.current = textChannel
749
+
454
750
  // drop stale Animated.Values whose keys left the incoming style, so the
455
751
  // key genuinely leaves the rendered style object (a released height goes
456
752
  // back to auto instead of staying pinned at its last pixel value). skip
@@ -487,12 +783,22 @@ export function createAnimations<A extends AnimationsConfig>(
487
783
  animationsState.current!.get(v)?.interpolation || v,
488
784
  ])
489
785
  ),
786
+ ...(derivedLeading ? { lineHeight: derivedLeading } : null),
490
787
  ...animatedTransformStyle,
491
788
  }
492
789
 
790
+ if (!isDisabled) {
791
+ paintedTextRef.current = {
792
+ fontSize: style.fontSize,
793
+ lineHeight: style.lineHeight,
794
+ ratio: paintedRatio,
795
+ }
796
+ }
797
+
493
798
  return {
494
799
  runners,
495
800
  completions,
801
+ textChannel,
496
802
  style: [nonAnimatedStyle, animatedStyle],
497
803
  }
498
804
 
@@ -539,7 +845,7 @@ export function createAnimations<A extends AnimationsConfig>(
539
845
  const animationConfig = getAnimationConfig(
540
846
  key,
541
847
  animations,
542
- props.transition,
848
+ effectiveTransition,
543
849
  animationState
544
850
  )
545
851
 
@@ -552,22 +858,19 @@ export function createAnimations<A extends AnimationsConfig>(
552
858
  runners.push(() => {
553
859
  value.stopAnimation()
554
860
 
555
- function getAnimation() {
556
- return Animated[animationConfig.type || 'spring'](value, {
557
- toValue: animateToValue,
558
- ...animationConfig,
559
- useNativeDriver: useNativeDriverForNode,
560
- })
561
- }
562
-
563
- const animation = animationConfig.delay
564
- ? Animated.sequence([
565
- Animated.delay(animationConfig.delay),
566
- getAnimation(),
567
- ])
568
- : getAnimation()
861
+ // `delay` drives the sequence below, so it must not also ride
862
+ // along in the config or every delayed animation waits twice
863
+ const { type, delay, ...config } = animationConfig
864
+ const animation = Animated[type || 'spring'](value, {
865
+ toValue: animateToValue,
866
+ ...config,
867
+ useNativeDriver: useNativeDriverForNode,
868
+ })
869
+ const animation2 = delay
870
+ ? Animated.sequence([Animated.delay(delay), animation])
871
+ : animation
569
872
 
570
- animation.start(({ finished }) => {
873
+ animation2.start(({ finished }) => {
571
874
  // always resolve during exit (element is leaving anyway)
572
875
  // for non-exit, only resolve on successful completion
573
876
  if (finished || isExiting) {
@@ -710,26 +1013,72 @@ export function createAnimations<A extends AnimationsConfig>(
710
1013
  // avoidReRenders: receive style changes imperatively from tamagui
711
1014
  // and update Animated.Values directly without React re-renders
712
1015
  // reuses the same update() + runner pattern as the useMemo path
713
- useStyleEmitter?.((nextStyle, _effectiveTransition, pseudoActive) => {
1016
+ // the fourth argument is the emitted style's own nativeTextMetrics: the
1017
+ // emitter re-runs getSplitStyles outside render, so the ratio that
1018
+ // describes the style it hands over is never the ratio the last render
1019
+ // resolved (a pseudo can replace the leading with an absolute length).
1020
+ const onEmittedStyle = (
1021
+ nextStyle: Record<string, any>,
1022
+ emittedTransition: TransitionProp | null | undefined,
1023
+ pseudoActive?: boolean,
1024
+ nextMetrics?: NativeTextMetrics
1025
+ ) => {
714
1026
  pseudoActiveRef.current = pseudoActive === true
715
1027
  const runners: Function[] = []
716
1028
  const seenAnimateKeys = new Set<string>()
717
1029
  let transformCount = 0
718
1030
  let animatedShapeChanged = false
1031
+ // the emitter runs on a mounted node, so `default` is the state, but
1032
+ // the transition is the one it was handed
1033
+ const emittedResolved = forAnimationState(
1034
+ resolveTransition(emittedTransition ?? effectiveTransition, { animations }),
1035
+ 'default'
1036
+ )
1037
+ const emittedRatio = getLeadingRatio(nextMetrics)
1038
+ const emitterPaintsProduct = paintsLeadingProduct(nextStyle, emittedRatio)
1039
+ // the same rule as the render path: a text metric joins the graph only
1040
+ // while it is moving, so a pseudo that only changes an opacity leaves
1041
+ // the node's font size out of it and keeps the native driver
1042
+ const emitterMovesFontSize = movesTextValue('fontSize', nextStyle.fontSize)
1043
+ const emitterMovesLeading = emitterPaintsProduct
1044
+ ? movesLeadingRatio(emittedRatio)
1045
+ : movesTextValue('lineHeight', nextStyle.lineHeight)
719
1046
  // nextStyle is the complete style for this node, so the emitter makes
720
1047
  // the same single driver decision as the render path. include the
721
1048
  // currently rendered graph because its stale keys are not removed until
722
1049
  // the structural-change commit below.
723
1050
  const useNativeDriverForNode =
724
1051
  nativeDriver &&
725
- !hasAnimatedLayoutKey(nextStyle, isDark) &&
726
- !Object.keys(animateStyles.current).some((key) => layoutStyleKey[key])
1052
+ !hasAnimatedLayoutKey(nextStyle, isDark, emittedResolved) &&
1053
+ !emitterMovesFontSize &&
1054
+ !emitterMovesLeading &&
1055
+ !inheritedFontSizeNode(nextStyle, emittedRatio, inheritedText) &&
1056
+ !Object.keys(animateStyles.current).some((key) => jsDriverStyleKey[key])
1057
+
1058
+ const emitterDerivesLeading =
1059
+ emitterPaintsProduct &&
1060
+ (emitterMovesFontSize || emitterMovesLeading) &&
1061
+ (!!getTransitionForKey(emittedResolved, 'fontSize') ||
1062
+ !!getTransitionForKey(emittedResolved, 'lineHeight'))
727
1063
 
728
1064
  for (const key in nextStyle) {
729
1065
  const rawVal = nextStyle[key]
730
1066
  const val = resolveDynamicValue(rawVal, isDark)
731
1067
  if (val === undefined) continue
732
1068
 
1069
+ if (emitterDerivesLeading && (key === 'fontSize' || key === 'lineHeight')) {
1070
+ continue
1071
+ }
1072
+
1073
+ // a metric at rest is not the emitter's to animate. it either already
1074
+ // paints as a plain style or the commit below hands it to one.
1075
+ if (
1076
+ textMetricStyleKey[key] &&
1077
+ !(key === 'fontSize' ? emitterMovesFontSize : emitterMovesLeading)
1078
+ ) {
1079
+ continue
1080
+ }
1081
+
733
1082
  if (key === 'transform' && Array.isArray(val)) {
734
1083
  for (const transform of val) {
735
1084
  if (!transform) continue
@@ -744,19 +1093,52 @@ export function createAnimations<A extends AnimationsConfig>(
744
1093
  } else if (
745
1094
  animatedStyleKey[key] != null ||
746
1095
  costlyToAnimateStyleKey[key] ||
747
- layoutStyleKey[key]
1096
+ jsDriverStyleKey[key]
748
1097
  ) {
749
1098
  // layout keys only animate numbers ('auto'/percents are static);
750
1099
  // unparseable themed colors can't be interpolated — skip both and
751
1100
  // let the next render apply them statically
752
- if (layoutStyleKey[key] && typeof val !== 'number') continue
1101
+ if (jsDriverStyleKey[key] && typeof val !== 'number') continue
753
1102
  if (colorStyleKey[key] && !isAnimatableColor(val)) continue
754
1103
  if (!animateStyles.current[key]) animatedShapeChanged = true
755
- animateStyles.current[key] = update(key, animateStyles.current[key], val)
1104
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
756
1105
  seenAnimateKeys.add(key)
757
1106
  }
758
1107
  }
759
1108
 
1109
+ // the emitter can only re-target Animated.Values the committed style
1110
+ // already paints. re-targeting the font size and the ratio keeps the
1111
+ // product moving without a commit; a pass that disagrees with the
1112
+ // committed style about whether the leading IS a product (a pseudo that
1113
+ // overrides it with an absolute length, or the first pseudo pass on a
1114
+ // node whose render never derived) needs the node swapped, which only a
1115
+ // commit can do.
1116
+ if (emitterDerivesLeading) {
1117
+ if (emitterMovesFontSize) {
1118
+ if (!animateStyles.current.fontSize) animatedShapeChanged = true
1119
+ animateStyles.current.fontSize = update(
1120
+ 'fontSize',
1121
+ animatedValueFor('fontSize'),
1122
+ nextStyle.fontSize as number
1123
+ )
1124
+ seenAnimateKeys.add('fontSize')
1125
+ }
1126
+ // a factor at rest is left out: it multiplies as the number the
1127
+ // painted product already carries. giving one a value the committed
1128
+ // style does not paint is the shape change that commits.
1129
+ if (emitterMovesLeading) {
1130
+ if (!leadingRatioValue.current) animatedShapeChanged = true
1131
+ leadingRatioValue.current = update(
1132
+ 'lineHeight',
1133
+ leadingRatioValueFrom(),
1134
+ emittedRatio!
1135
+ )
1136
+ }
1137
+ }
1138
+ if (emitterDerivesLeading !== paintsDerivedLeading.current) {
1139
+ animatedShapeChanged = true
1140
+ }
1141
+
760
1142
  // the emitter receives a complete style. keep the Animated style graph
761
1143
  // equally complete, including a pseudo release that omits a pseudo-only
762
1144
  // key. React Native needs a commit when that graph's shape changes.
@@ -771,6 +1153,12 @@ export function createAnimations<A extends AnimationsConfig>(
771
1153
  animatedShapeChanged = true
772
1154
  }
773
1155
 
1156
+ paintedTextRef.current = {
1157
+ fontSize: nextStyle.fontSize,
1158
+ lineHeight: nextStyle.lineHeight,
1159
+ ratio: emittedRatio,
1160
+ }
1161
+
774
1162
  // run the queued animations immediately
775
1163
  runners.forEach((r) => r())
776
1164
 
@@ -820,28 +1208,29 @@ export function createAnimations<A extends AnimationsConfig>(
820
1208
  })
821
1209
  }
822
1210
 
1211
+ // the emitter runs for pseudo-state changes on a mounted node, so
1212
+ // `default` is the state, but the transition is the one it was handed
823
1213
  const animationConfig = getAnimationConfig(
824
1214
  key,
825
1215
  animations,
826
- props.transition,
1216
+ emittedTransition ?? effectiveTransition,
827
1217
  'default'
828
1218
  )
829
1219
  runners.push(() => {
830
1220
  value.stopAnimation()
831
- const anim = Animated[animationConfig.type || 'spring'](value, {
1221
+ const { type, delay, ...config } = animationConfig
1222
+ const anim = Animated[type || 'spring'](value, {
832
1223
  toValue: animateToValue,
833
- ...animationConfig,
1224
+ ...config,
834
1225
  useNativeDriver: useNativeDriverForNode,
835
1226
  })
836
- ;(animationConfig.delay
837
- ? Animated.sequence([Animated.delay(animationConfig.delay), anim])
838
- : anim
839
- ).start()
1227
+ ;(delay ? Animated.sequence([Animated.delay(delay), anim]) : anim).start()
840
1228
  })
841
1229
 
842
1230
  return value
843
1231
  }
844
- })
1232
+ }
1233
+ useStyleEmitter?.(onEmittedStyle)
845
1234
 
846
1235
  if (process.env.NODE_ENV === 'development') {
847
1236
  if (props['debug'] === 'verbose') {
@@ -887,56 +1276,59 @@ function getInterpolated(current: number, next: number, postfix = 'deg') {
887
1276
  }
888
1277
  }
889
1278
 
890
- function getAnimationConfig(
891
- key: string,
892
- animations: AnimationsConfig,
893
- transition?: TransitionProp,
894
- animationState: 'enter' | 'exit' | 'default' = 'default'
895
- ): AnimationConfig {
896
- const normalized = normalizeTransition(transition)
897
- const shortKey = transformShorthands[key]
898
-
899
- // Check for property-specific animation
900
- const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey]
901
-
902
- let animationType: string | null = null
903
- let extraConf: any = {}
904
-
905
- if (typeof propAnimation === 'string') {
906
- // Direct animation name: { x: 'quick' }
907
- animationType = propAnimation
908
- } else if (propAnimation && typeof propAnimation === 'object') {
909
- // Config object: { x: { type: 'quick', delay: 100 } }
910
- // Use effective animation based on state if no explicit type in config
911
- animationType =
912
- propAnimation.type || getEffectiveAnimation(normalized, animationState)
913
- extraConf = propAnimation
914
- } else {
915
- // Fall back to effective animation based on state (enter/exit/default)
916
- animationType = getEffectiveAnimation(normalized, animationState)
917
- }
918
-
919
- // Apply global delay if no property-specific delay
920
- if (normalized.delay && !extraConf.delay) {
921
- extraConf = { ...extraConf, delay: normalized.delay }
1279
+ /**
1280
+ * one resolved entry as a react-native Animated config.
1281
+ *
1282
+ * springs go in as stiffness/damping/mass, which is the parameterization RN
1283
+ * actually integrates. `bounciness`/`speed` and `tension`/`friction` are older
1284
+ * spellings of the same two numbers, so nothing is lost by not using them.
1285
+ */
1286
+ function entryToRN(entry: ResolvedEntry): AnimationConfig {
1287
+ const extra = entry.timing.kind === 'spring' ? entry.timing.extra : undefined
1288
+
1289
+ if (entry.timing.kind === 'spring') {
1290
+ return {
1291
+ type: 'spring',
1292
+ stiffness: entry.timing.stiffness,
1293
+ damping: entry.timing.damping,
1294
+ mass: entry.timing.mass,
1295
+ ...(typeof extra?.velocity === 'number' ? { velocity: extra.velocity } : null),
1296
+ ...(typeof extra?.overshootClamping === 'boolean'
1297
+ ? { overshootClamping: extra.overshootClamping }
1298
+ : null),
1299
+ ...(entry.delayMs ? { delay: entry.delayMs } : null),
1300
+ }
922
1301
  }
923
1302
 
924
- const found = animationType ? animations[animationType] : {}
1303
+ const bezier = easingToBezier(entry.timing.easing)
925
1304
  return {
926
- ...found,
927
- // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]})
928
- ...normalized.config,
929
- // Property-specific config takes highest precedence
930
- ...extraConf,
1305
+ type: 'timing',
1306
+ duration: entry.timing.durationMs,
1307
+ // `linear()` and `steps()` have no bezier equivalent; RN's default easing
1308
+ // is the honest answer rather than a curve we made up
1309
+ ...(bezier
1310
+ ? { easing: Easing.bezier(bezier[0], bezier[1], bezier[2], bezier[3]) }
1311
+ : null),
1312
+ ...(entry.delayMs ? { delay: entry.delayMs } : null),
931
1313
  }
932
1314
  }
933
1315
 
934
- // try both combos
935
- const transformShorthands = {
936
- x: 'translateX',
937
- y: 'translateY',
938
- translateX: 'x',
939
- translateY: 'y',
1316
+ // a key the transition does not cover does not animate. snapping is what css
1317
+ // does for an unlisted property, so the drivers have to agree on it too.
1318
+ const snapConfig: AnimationConfig = { type: 'timing', duration: 0 }
1319
+
1320
+ function getAnimationConfig(
1321
+ key: string,
1322
+ animations: AnimationsConfig,
1323
+ transition?: TransitionProp | null,
1324
+ animationState: 'enter' | 'exit' | 'default' = 'default'
1325
+ ): AnimationConfig {
1326
+ const resolved = forAnimationState(
1327
+ resolveTransition(transition, { animations }),
1328
+ animationState
1329
+ )
1330
+ const entry = getTransitionForKey(resolved, key)
1331
+ return entry ? entryToRN(entry) : snapConfig
940
1332
  }
941
1333
 
942
1334
  function getValue(input: number | string, isColor = false) {