@tamagui/animations-react-native 3.0.0-beta.1312.1 → 3.0.0-beta.1341.1

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.
@@ -12,6 +12,7 @@ import { ResetPresence, usePresence } from '@tamagui/use-presence'
12
12
  import type {
13
13
  AnimatedNumberStrategy,
14
14
  AnimationDriverWithAnimatedNumbers,
15
+ NativeTextMetrics,
15
16
  TransitionProp,
16
17
  UniversalAnimatedNumber,
17
18
  UseAnimatedNumberReaction,
@@ -24,6 +25,7 @@ import {
24
25
  Animated,
25
26
  Easing,
26
27
  processColor,
28
+ TextInput,
27
29
  type ColorValue,
28
30
  type Text,
29
31
  type View,
@@ -71,8 +73,10 @@ const colorStyleKey = {
71
73
  borderBottomColor: true,
72
74
  }
73
75
 
74
- // layout dimension keys. these must run on the JS driver (useNativeDriver:false)
75
- // because the native animated module can't drive layout props.
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.
76
80
  const layoutStyleKey = {
77
81
  height: true,
78
82
  width: true,
@@ -82,6 +86,18 @@ const layoutStyleKey = {
82
86
  maxWidth: true,
83
87
  }
84
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
+
85
101
  function hasAnimatedLayoutKey(
86
102
  style: Record<string, any>,
87
103
  isDark: boolean,
@@ -94,6 +110,43 @@ function hasAnimatedLayoutKey(
94
110
  return false
95
111
  }
96
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
+
97
150
  // Only colors accepted by RN's own parser can enter interpolation. CSS-wide
98
151
  // keywords, unresolved tokens, var()/calc(), and empty strings otherwise reach
99
152
  // createInterpolationFromStringOutputRange / mapStringToNumericComponents and
@@ -119,6 +172,11 @@ const costlyToAnimateStyleKey = {
119
172
 
120
173
  export const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View
121
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
122
180
 
123
181
  export function useAnimatedNumber(
124
182
  initial: number
@@ -264,6 +322,33 @@ export function createAnimations<A extends AnimationsConfig>(
264
322
  useAnimatedNumbersStyle,
265
323
  usePresence,
266
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
+
267
352
  useAnimations: ({
268
353
  props,
269
354
  onTransition,
@@ -273,6 +358,7 @@ export function createAnimations<A extends AnimationsConfig>(
273
358
  stateRef,
274
359
  styleState,
275
360
  useStyleEmitter,
361
+ inheritedText,
276
362
  }) => {
277
363
  const isDisabled = isWeb && componentState.unmounted === true
278
364
  const isExiting = presence?.[0] === false
@@ -302,6 +388,72 @@ export function createAnimations<A extends AnimationsConfig>(
302
388
 
303
389
  /** store Animated value of each key e.g: color: AnimatedValue */
304
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)
305
457
  const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])
306
458
  const animationsState = React.useRef(
307
459
  new WeakMap<
@@ -359,6 +511,8 @@ export function createAnimations<A extends AnimationsConfig>(
359
511
  !!onTransition,
360
512
  isDark,
361
513
  justFinishedEntering,
514
+ leadingRatioRef.current,
515
+ inheritedText,
362
516
  ]
363
517
 
364
518
  const res = React.useMemo(() => {
@@ -381,11 +535,38 @@ export function createAnimations<A extends AnimationsConfig>(
381
535
  )
382
536
 
383
537
  const nonAnimatedStyle = {}
384
- // animatedStyle owns every Animated.Value on the node. Fabric cannot mix
385
- // native- and JS-driven values inside that shared graph, so one layout
386
- // animation makes the whole node use the JS driver.
387
- const useNativeDriverForNode =
388
- nativeDriver && !hasAnimatedLayoutKey(style, isDark, resolved)
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
+ }
389
570
 
390
571
  // track which animated keys/transforms the incoming style actually
391
572
  // carries this pass, so entries that left the style can be dropped
@@ -395,6 +576,34 @@ export function createAnimations<A extends AnimationsConfig>(
395
576
  let sawTransform = false
396
577
  let transformCount = 0
397
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
+
398
607
  for (const key in style) {
399
608
  const rawVal = style[key]
400
609
  // Resolve dynamic theme values from flat theme clauses.
@@ -405,10 +614,33 @@ export function createAnimations<A extends AnimationsConfig>(
405
614
  continue
406
615
  }
407
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
+
408
640
  if (
409
641
  animatedStyleKey[key] == null &&
410
642
  !costlyToAnimateStyleKey[key] &&
411
- !layoutStyleKey[key]
643
+ !jsDriverStyleKey[key]
412
644
  ) {
413
645
  nonAnimatedStyle[key] = val
414
646
  continue
@@ -424,7 +656,7 @@ export function createAnimations<A extends AnimationsConfig>(
424
656
 
425
657
  // layout dimension keys only animate numbers — 'auto' (an open
426
658
  // accordion at rest) and percent strings apply as static styles
427
- if (layoutStyleKey[key] && typeof val !== 'number') {
659
+ if (jsDriverStyleKey[key] && typeof val !== 'number') {
428
660
  nonAnimatedStyle[key] = val
429
661
  continue
430
662
  }
@@ -437,7 +669,7 @@ export function createAnimations<A extends AnimationsConfig>(
437
669
  }
438
670
 
439
671
  if (key !== 'transform') {
440
- animateStyles.current[key] = update(key, animateStyles.current[key], val)
672
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
441
673
  seenAnimateKeys.add(key)
442
674
  continue
443
675
  }
@@ -463,6 +695,64 @@ export function createAnimations<A extends AnimationsConfig>(
463
695
  }
464
696
  }
465
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
+
466
756
  // drop stale Animated.Values whose keys left the incoming style, so the
467
757
  // key genuinely leaves the rendered style object (a released height goes
468
758
  // back to auto instead of staying pinned at its last pixel value). skip
@@ -499,12 +789,22 @@ export function createAnimations<A extends AnimationsConfig>(
499
789
  animationsState.current!.get(v)?.interpolation || v,
500
790
  ])
501
791
  ),
792
+ ...(derivedLeading ? { lineHeight: derivedLeading } : null),
502
793
  ...animatedTransformStyle,
503
794
  }
504
795
 
796
+ if (!isDisabled) {
797
+ paintedTextRef.current = {
798
+ fontSize: style.fontSize,
799
+ lineHeight: style.lineHeight,
800
+ ratio: paintedRatio,
801
+ }
802
+ }
803
+
505
804
  return {
506
805
  runners,
507
806
  completions,
807
+ textChannel,
508
808
  style: [nonAnimatedStyle, animatedStyle],
509
809
  }
510
810
 
@@ -719,7 +1019,16 @@ export function createAnimations<A extends AnimationsConfig>(
719
1019
  // avoidReRenders: receive style changes imperatively from tamagui
720
1020
  // and update Animated.Values directly without React re-renders
721
1021
  // reuses the same update() + runner pattern as the useMemo path
722
- useStyleEmitter?.((nextStyle, emittedTransition, pseudoActive) => {
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
+ ) => {
723
1032
  pseudoActiveRef.current = pseudoActive === true
724
1033
  const runners: Function[] = []
725
1034
  const seenAnimateKeys = new Set<string>()
@@ -731,6 +1040,15 @@ export function createAnimations<A extends AnimationsConfig>(
731
1040
  resolveTransition(emittedTransition ?? effectiveTransition, { animations }),
732
1041
  'default'
733
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)
734
1052
  // nextStyle is the complete style for this node, so the emitter makes
735
1053
  // the same single driver decision as the render path. include the
736
1054
  // currently rendered graph because its stale keys are not removed until
@@ -738,13 +1056,35 @@ export function createAnimations<A extends AnimationsConfig>(
738
1056
  const useNativeDriverForNode =
739
1057
  nativeDriver &&
740
1058
  !hasAnimatedLayoutKey(nextStyle, isDark, emittedResolved) &&
741
- !Object.keys(animateStyles.current).some((key) => layoutStyleKey[key])
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'))
742
1069
 
743
1070
  for (const key in nextStyle) {
744
1071
  const rawVal = nextStyle[key]
745
1072
  const val = resolveDynamicValue(rawVal, isDark)
746
1073
  if (val === undefined) continue
747
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
+
748
1088
  if (key === 'transform' && Array.isArray(val)) {
749
1089
  for (const transform of val) {
750
1090
  if (!transform) continue
@@ -759,19 +1099,52 @@ export function createAnimations<A extends AnimationsConfig>(
759
1099
  } else if (
760
1100
  animatedStyleKey[key] != null ||
761
1101
  costlyToAnimateStyleKey[key] ||
762
- layoutStyleKey[key]
1102
+ jsDriverStyleKey[key]
763
1103
  ) {
764
1104
  // layout keys only animate numbers ('auto'/percents are static);
765
1105
  // unparseable themed colors can't be interpolated — skip both and
766
1106
  // let the next render apply them statically
767
- if (layoutStyleKey[key] && typeof val !== 'number') continue
1107
+ if (jsDriverStyleKey[key] && typeof val !== 'number') continue
768
1108
  if (colorStyleKey[key] && !isAnimatableColor(val)) continue
769
1109
  if (!animateStyles.current[key]) animatedShapeChanged = true
770
- animateStyles.current[key] = update(key, animateStyles.current[key], val)
1110
+ animateStyles.current[key] = update(key, animatedValueFor(key), val)
771
1111
  seenAnimateKeys.add(key)
772
1112
  }
773
1113
  }
774
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
+
775
1148
  // the emitter receives a complete style. keep the Animated style graph
776
1149
  // equally complete, including a pseudo release that omits a pseudo-only
777
1150
  // key. React Native needs a commit when that graph's shape changes.
@@ -786,6 +1159,12 @@ export function createAnimations<A extends AnimationsConfig>(
786
1159
  animatedShapeChanged = true
787
1160
  }
788
1161
 
1162
+ paintedTextRef.current = {
1163
+ fontSize: nextStyle.fontSize,
1164
+ lineHeight: nextStyle.lineHeight,
1165
+ ratio: emittedRatio,
1166
+ }
1167
+
789
1168
  // run the queued animations immediately
790
1169
  runners.forEach((r) => r())
791
1170
 
@@ -856,7 +1235,8 @@ export function createAnimations<A extends AnimationsConfig>(
856
1235
 
857
1236
  return value
858
1237
  }
859
- })
1238
+ }
1239
+ useStyleEmitter?.(onEmittedStyle)
860
1240
 
861
1241
  if (process.env.NODE_ENV === 'development') {
862
1242
  if (props['debug'] === 'verbose') {