import { getEffectiveAnimation, normalizeTransition } from '@tamagui/animation-helpers' import { isWeb, useIsomorphicLayoutEffect } from '@tamagui/constants' import { ResetPresence, usePresence } from '@tamagui/use-presence' import type { AnimatedNumberStrategy, AnimationDriver, TransitionProp, UniversalAnimatedNumber, UseAnimatedNumberReaction, UseAnimatedNumberStyle, } from '@tamagui/web' import { useEvent, useThemeWithState } from '@tamagui/web' import React from 'react' import { Animated, type Text, type View } from 'react-native' // detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys const isFabric = !isWeb && typeof global !== 'undefined' && !!global.__nativeFabricUIManager // Helper to resolve dynamic theme values like {dynamic: {dark: "value", light: undefined}} const resolveDynamicValue = (value: any, isDark: boolean): any => { if (value && typeof value === 'object' && 'dynamic' in value) { const dynamicValue = isDark ? value.dynamic.dark : value.dynamic.light return dynamicValue } return value } type AnimationsConfig = { [Key in keyof A]: AnimationConfig } type SpringConfig = { type?: 'spring' } & Partial< Pick< Animated.SpringAnimationConfig, | 'delay' | 'bounciness' | 'damping' | 'friction' | 'mass' | 'overshootClamping' | 'speed' | 'stiffness' | 'tension' | 'velocity' > > type TimingConfig = { type: 'timing' } & Partial type AnimationConfig = SpringConfig | TimingConfig const animatedStyleKey = { transform: true, opacity: true, } const colorStyleKey = { backgroundColor: true, color: true, borderColor: true, borderLeftColor: true, borderRightColor: true, borderTopColor: true, borderBottomColor: true, } // these style keys are costly to animate and only work with native driver on Fabric const costlyToAnimateStyleKey = { borderRadius: true, borderTopLeftRadius: true, borderTopRightRadius: true, borderBottomLeftRadius: true, borderBottomRightRadius: true, borderWidth: true, borderLeftWidth: true, borderRightWidth: true, borderTopWidth: true, borderBottomWidth: true, ...colorStyleKey, } type CreateAnimationsOptions = { // override native driver detection (default: auto-detect Fabric) useNativeDriver?: boolean } export const AnimatedView: Animated.AnimatedComponent = Animated.View export const AnimatedText: Animated.AnimatedComponent = Animated.Text export function useAnimatedNumber( initial: number ): UniversalAnimatedNumber { const state = React.useRef( null as any as { val: Animated.Value composite: Animated.CompositeAnimation | null strategy: AnimatedNumberStrategy } ) if (!state.current) { state.current = { composite: null, val: new Animated.Value(initial), strategy: { type: 'spring' }, } } return { getInstance() { return state.current.val }, getValue() { return state.current.val['_value'] }, stop() { state.current.composite?.stop() state.current.composite = null }, setValue(next: number, { type, ...config } = { type: 'spring' }, onFinish) { const val = state.current.val const handleFinish = onFinish ? ({ finished }) => (finished ? onFinish() : null) : undefined if (type === 'direct') { val.setValue(next) } else if (type === 'spring') { state.current.composite?.stop() const composite = Animated.spring(val, { ...config, toValue: next, useNativeDriver: isFabric, }) composite.start(handleFinish) state.current.composite = composite } else { state.current.composite?.stop() const composite = Animated.timing(val, { ...config, toValue: next, useNativeDriver: isFabric, }) composite.start(handleFinish) state.current.composite = composite } }, } } type RNAnimatedNum = UniversalAnimatedNumber export const useAnimatedNumberReaction: UseAnimatedNumberReaction = ( { value }, onValue ) => { const onChange = useEvent((current) => { onValue(current.value) }) React.useEffect(() => { const id = value.getInstance().addListener(onChange) return () => { value.getInstance().removeListener(id) } }, [value, onChange]) } export const useAnimatedNumberStyle: UseAnimatedNumberStyle = ( value, getStyle ) => { return getStyle(value.getInstance()) } export const useAnimatedNumbersStyle = ( vals: RNAnimatedNum[], getStyle: (...currentValues: any[]) => any ): any => { return getStyle(...vals.map((v) => v.getInstance())) } export function createAnimations( animations: A, options?: CreateAnimationsOptions ): AnimationDriver { const nativeDriver = options?.useNativeDriver ?? isFabric return { isReactNative: true, inputStyle: 'value', outputStyle: 'inline', avoidReRenders: true, animations, needsCustomComponent: true, View: AnimatedView, Text: AnimatedText, useAnimatedNumber, useAnimatedNumberReaction, useAnimatedNumberStyle, useAnimatedNumbersStyle, usePresence, ResetPresence, useAnimations: ({ props, onDidAnimate, style, componentState, presence, useStyleEmitter, }) => { const isDisabled = isWeb && componentState.unmounted === true const isExiting = presence?.[0] === false const sendExitComplete = presence?.[1] const [, themeState] = useThemeWithState({}) // Check scheme first, then fall back to checking theme name for 'dark' const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark') /** store Animated value of each key e.g: color: AnimatedValue */ const animateStyles = React.useRef>({}) const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([]) const animationsState = React.useRef( new WeakMap< Animated.Value, { interpolation: Animated.AnimatedInterpolation current?: number | string | undefined // only for colors animateToValue?: number } >() ) // exit cycle guards to prevent stale/duplicate completion const exitCycleIdRef = React.useRef(0) const exitCompletedRef = React.useRef(false) const wasExitingRef = React.useRef(false) // detect transition into/out of exiting state const justStartedExiting = isExiting && !wasExitingRef.current const justStoppedExiting = !isExiting && wasExitingRef.current // start new exit cycle only on transition INTO exiting if (justStartedExiting) { exitCycleIdRef.current++ exitCompletedRef.current = false } // invalidate pending callbacks when exit is canceled/interrupted if (justStoppedExiting) { exitCycleIdRef.current++ } const animateOnly = (props.animateOnly as string[]) || [] const hasTransitionOnly = !!props.animateOnly // Track if we just finished entering (transition from entering to not entering) // must be declared before args array that uses justFinishedEntering const isEntering = !!componentState.unmounted const wasEnteringRef = React.useRef(isEntering) const justFinishedEntering = wasEnteringRef.current && !isEntering React.useEffect(() => { wasEnteringRef.current = isEntering }) const args = [ JSON.stringify(style), componentState, isExiting, !!onDidAnimate, isDark, justFinishedEntering, hasTransitionOnly, ] const res = React.useMemo(() => { const runners: Function[] = [] const completions: Promise[] = [] // Determine animation state for enter/exit transitions // Use 'enter' if we're entering OR if we just finished entering const animationState: 'enter' | 'exit' | 'default' = isExiting ? 'exit' : isEntering || justFinishedEntering ? 'enter' : 'default' const nonAnimatedStyle = {} for (const key in style) { const rawVal = style[key] // Resolve dynamic theme values (like $theme-dark) const val = resolveDynamicValue(rawVal, isDark) if (val === undefined) continue if (isDisabled) { continue } if (animatedStyleKey[key] == null && !costlyToAnimateStyleKey[key]) { nonAnimatedStyle[key] = val continue } if (hasTransitionOnly && !animateOnly.includes(key)) { nonAnimatedStyle[key] = val continue } if (key !== 'transform') { animateStyles.current[key] = update(key, animateStyles.current[key], val) continue } // key: 'transform' // for now just support one transform key if (!val) continue if (typeof val === 'string') { console.warn(`Warning: Tamagui can't animate string transforms yet!`) continue } for (const [index, transform] of val.entries()) { if (!transform) continue // tkey: e.g: 'translateX' const tkey = Object.keys(transform)[0] const currentTransform = animatedTranforms.current[index]?.[tkey] animatedTranforms.current[index] = { [tkey]: update(tkey, currentTransform, transform[tkey]), } animatedTranforms.current = [...animatedTranforms.current] } } const animatedTransformStyle = animatedTranforms.current.length > 0 ? { transform: animatedTranforms.current.map((r) => { const key = Object.keys(r)[0] const val = animationsState.current!.get(r[key])?.interpolation || r[key] return { [key]: val } }), } : {} const animatedStyle = { ...Object.fromEntries( Object.entries(animateStyles.current).map(([k, v]) => [ k, animationsState.current!.get(v)?.interpolation || v, ]) ), ...animatedTransformStyle, } return { runners, completions, style: [nonAnimatedStyle, animatedStyle], } function update( key: string, animated: Animated.Value | undefined, valIn: string | number ) { const isColorStyleKey = colorStyleKey[key] const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn) let animateToValue = val const value = animated || new Animated.Value(val) const curInterpolation = animationsState.current.get(value) let interpolateArgs: any if (type) { interpolateArgs = getInterpolated( curInterpolation?.current ?? value['_value'], val, type ) animationsState.current!.set(value, { interpolation: value.interpolate(interpolateArgs), current: val, }) } if (isColorStyleKey) { animateToValue = curInterpolation?.animateToValue ? 0 : 1 interpolateArgs = getColorInterpolated( curInterpolation?.current as string, // valIn is the next color valIn as string, animateToValue ) animationsState.current!.set(value, { current: valIn, interpolation: value.interpolate(interpolateArgs), animateToValue: curInterpolation?.animateToValue ? 0 : 1, }) } if (value) { const animationConfig = getAnimationConfig( key, animations, props.transition, animationState ) let resolve const promise = new Promise((res) => { resolve = res }) completions.push(promise) runners.push(() => { value.stopAnimation() function getAnimation() { return Animated[animationConfig.type || 'spring'](value, { toValue: animateToValue, useNativeDriver: nativeDriver, ...animationConfig, }) } const animation = animationConfig.delay ? Animated.sequence([ Animated.delay(animationConfig.delay), getAnimation(), ]) : getAnimation() animation.start(({ finished }) => { // always resolve during exit (element is leaving anyway) // for non-exit, only resolve on successful completion if (finished || isExiting) { resolve() } }) }) } if (process.env.NODE_ENV === 'development') { if (props['debug'] === 'verbose') { // prettier-ignore console.info( ' 💠 animate', key, `from (${value['_value']}) to`, valIn, `(${val})`, 'type', type, 'interpolate', interpolateArgs ) } } return value } }, args) // track previous exiting state React.useEffect(() => { wasExitingRef.current = isExiting }) useIsomorphicLayoutEffect(() => { res.runners.forEach((r) => r()) // capture current cycle id const cycleId = exitCycleIdRef.current // handle zero-completion case immediately if (res.completions.length === 0) { onDidAnimate?.() if (isExiting && !exitCompletedRef.current) { exitCompletedRef.current = true sendExitComplete?.() } return } let cancel = false Promise.all(res.completions).then(() => { if (cancel) return // guard against stale cycle completion if (isExiting && cycleId !== exitCycleIdRef.current) return if (isExiting && exitCompletedRef.current) return onDidAnimate?.() if (isExiting) { exitCompletedRef.current = true sendExitComplete?.() } }) return () => { cancel = true } }, args) // avoidReRenders: receive style changes imperatively from tamagui // and update Animated.Values directly without React re-renders // reuses the same update() + runner pattern as the useMemo path useStyleEmitter?.((nextStyle) => { for (const key in nextStyle) { const rawVal = nextStyle[key] const val = resolveDynamicValue(rawVal, isDark) if (val === undefined) continue if (key === 'transform' && Array.isArray(val)) { for (const [index, transform] of val.entries()) { if (!transform) continue const tkey = Object.keys(transform)[0] const currentTransform = animatedTranforms.current[index]?.[tkey] animatedTranforms.current[index] = { [tkey]: update(tkey, currentTransform, transform[tkey]), } } } else if (animatedStyleKey[key] != null || costlyToAnimateStyleKey[key]) { animateStyles.current[key] = update(key, animateStyles.current[key], val) } } // run the queued animations immediately res.runners.forEach((r) => r()) function update( key: string, animated: Animated.Value | undefined, valIn: string | number ) { const isColor = colorStyleKey[key] const [numVal, type] = isColor ? [0, undefined] : getValue(valIn) let animateToValue = numVal const value = animated || new Animated.Value(numVal) const curInterpolation = animationsState.current.get(value) if (type) { animationsState.current.set(value, { interpolation: value.interpolate( getInterpolated( curInterpolation?.current ?? value['_value'], numVal, type ) ), current: numVal, }) } if (isColor) { animateToValue = curInterpolation?.animateToValue ? 0 : 1 animationsState.current.set(value, { current: valIn, interpolation: value.interpolate( getColorInterpolated( curInterpolation?.current as string, valIn as string, animateToValue ) ), animateToValue: curInterpolation?.animateToValue ? 0 : 1, }) } const animationConfig = getAnimationConfig( key, animations, props.transition, 'default' ) res.runners.push(() => { value.stopAnimation() const anim = Animated[animationConfig.type || 'spring'](value, { toValue: animateToValue, useNativeDriver: nativeDriver, ...animationConfig, }) ;(animationConfig.delay ? Animated.sequence([Animated.delay(animationConfig.delay), anim]) : anim ).start() }) return value } }) if (process.env.NODE_ENV === 'development') { if (props['debug'] === 'verbose') { console.info(`Animated`, { response: res, inputStyle: style, isExiting }) } } return res }, } } function getColorInterpolated( currentColor: string | undefined, nextColor: string, animateToValue: number ) { const inputRange = [0, 1] const outputRange = [currentColor ? currentColor : nextColor, nextColor] if (animateToValue === 0) { // because we are animating from value 1 to 0, we need to put target color at the beginning outputRange.reverse() } return { inputRange, outputRange, } } function getInterpolated(current: number, next: number, postfix = 'deg') { if (next === current) { current = next - 0.000000001 } const inputRange = [current, next] const outputRange = [`${current}${postfix}`, `${next}${postfix}`] if (next < current) { inputRange.reverse() outputRange.reverse() } return { inputRange, outputRange, } } function getAnimationConfig( key: string, animations: AnimationsConfig, transition?: TransitionProp, animationState: 'enter' | 'exit' | 'default' = 'default' ): AnimationConfig { const normalized = normalizeTransition(transition) const shortKey = transformShorthands[key] // Check for property-specific animation const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey] let animationType: string | null = null let extraConf: any = {} if (typeof propAnimation === 'string') { // Direct animation name: { x: 'quick' } animationType = propAnimation } else if (propAnimation && typeof propAnimation === 'object') { // Config object: { x: { type: 'quick', delay: 100 } } // Use effective animation based on state if no explicit type in config animationType = propAnimation.type || getEffectiveAnimation(normalized, animationState) extraConf = propAnimation } else { // Fall back to effective animation based on state (enter/exit/default) animationType = getEffectiveAnimation(normalized, animationState) } // Apply global delay if no property-specific delay if (normalized.delay && !extraConf.delay) { extraConf = { ...extraConf, delay: normalized.delay } } const found = animationType ? animations[animationType] : {} return { ...found, // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]}) ...normalized.config, // Property-specific config takes highest precedence ...extraConf, } } // try both combos const transformShorthands = { x: 'translateX', y: 'translateY', translateX: 'x', translateY: 'y', } function getValue(input: number | string, isColor = false) { if (typeof input !== 'string') { return [input] as const } const [_, number, after] = input.match(/([-0-9]+)(deg|%|px)/) ?? [] return [+number, after] as const }