import React, { useEffect, useRef, useState, useCallback } from 'react'; import { View, type ViewStyle, type StyleProp } from 'react-native'; import type { AnimateProps, CubicBezier, SingleTransition, Transition, TransitionEndEvent, TransformOrigin, TransformPerspective, } from './types'; /** Identity values used as defaults for animate/initialAnimate. */ const IDENTITY: Required< Omit< AnimateProps, 'scale' | 'backgroundColor' | 'borderColor' | 'shadowColor' | 'shadowOffset' > > & { shadowOffset: { width: number; height: number } } = { opacity: 1, translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, rotate: 0, rotateX: 0, rotateY: 0, borderRadius: 0, borderWidth: 0, shadowOpacity: 0, shadowRadius: 0, shadowOffset: { width: 0, height: 0 }, elevation: 0, }; /** Preset easing curves as cubic bezier control points. */ const EASING_PRESETS: Record = { linear: [0, 0, 1, 1], easeIn: [0.42, 0, 1, 1], easeOut: [0, 0, 0.58, 1], easeInOut: [0.42, 0, 0.58, 1], }; // --------------------------------------------------------------------------- // Spring simulation → CSS linear() easing // --------------------------------------------------------------------------- /** Simulate a damped spring from 0 → 1 and return settling duration + sample points. */ function simulateSpring( damping: number, stiffness: number, mass: number, ): { durationMs: number; points: number[] } { const dt = 1 / 120; // 120 Hz simulation const maxTime = 10; // 10s safety cap let x = 0; let v = 0; const samples: number[] = [0]; let step = 0; while (step * dt < maxTime) { const a = (-stiffness * (x - 1) - damping * v) / mass; v += a * dt; x += v * dt; step++; // Downsample to ~60 fps (every 2nd sample) if (step % 2 === 0) { samples.push(Math.round(x * 10000) / 10000); } // Settled? if (Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.001) break; } // Ensure last point is exactly 1 samples[samples.length - 1] = 1; return { durationMs: Math.round(step * dt * 1000), points: samples, }; } /** Cache for computed spring easing strings (keyed by damping-stiffness-mass). */ const springCache = new Map(); function getSpringEasing( damping: number, stiffness: number, mass: number, ): { duration: number; easing: string } { const key = `${damping}-${stiffness}-${mass}`; let cached = springCache.get(key); if (cached) return cached; const { durationMs, points } = simulateSpring(damping, stiffness, mass); const easing = `linear(${points.join(', ')})`; cached = { duration: durationMs, easing }; springCache.set(key, cached); return cached; } /** Detect CSS linear() support (lazy, cached). */ let linearSupported: boolean | null = null; function supportsLinearEasing(): boolean { if (linearSupported != null) return linearSupported; try { const el = document.createElement('div'); el.style.transitionTimingFunction = 'linear(0, 1)'; linearSupported = el.style.transitionTimingFunction !== ''; } catch { linearSupported = false; } return linearSupported; } const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'; export type EaseViewProps = { animate?: AnimateProps; initialAnimate?: AnimateProps; transition?: Transition; onTransitionEnd?: (event: TransitionEndEvent) => void; /** No-op on web. */ useHardwareLayer?: boolean; transformOrigin?: TransformOrigin; /** * Distance of the camera from the z=0 plane for 3D transforms (rotateX, rotateY). * Higher values produce a flatter, more telephoto look; lower values exaggerate * perspective. @default 1280 */ transformPerspective?: TransformPerspective; style?: StyleProp; children?: React.ReactNode; }; function resolveAnimateValues( props: AnimateProps | undefined, ): typeof IDENTITY & { backgroundColor?: string; borderColor?: string; shadowColor?: string; } { return { ...IDENTITY, ...props, scaleX: props?.scaleX ?? props?.scale ?? IDENTITY.scaleX, scaleY: props?.scaleY ?? props?.scale ?? IDENTITY.scaleY, rotateX: props?.rotateX ?? IDENTITY.rotateX, rotateY: props?.rotateY ?? IDENTITY.rotateY, shadowOffset: { width: props?.shadowOffset?.width ?? 0, height: props?.shadowOffset?.height ?? 0, }, backgroundColor: props?.backgroundColor as string | undefined, borderColor: props?.borderColor as string | undefined, shadowColor: props?.shadowColor as string | undefined, }; } function buildTransform( vals: ReturnType, perspective: number | false, ): string { const parts: string[] = []; if (perspective !== false) { parts.push(`perspective(${perspective}px)`); } if (vals.translateX !== 0 || vals.translateY !== 0) { parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`); } if (vals.scaleX !== 1 || vals.scaleY !== 1) { parts.push( vals.scaleX === vals.scaleY ? `scale(${vals.scaleX})` : `scale(${vals.scaleX}, ${vals.scaleY})`, ); } if (vals.rotate !== 0) { parts.push(`rotate(${vals.rotate}deg)`); } if (vals.rotateX !== 0) { parts.push(`rotateX(${vals.rotateX}deg)`); } if (vals.rotateY !== 0) { parts.push(`rotateY(${vals.rotateY}deg)`); } return parts.length > 0 ? parts.join(' ') : 'none'; } /** Returns true if the transition is a SingleTransition (has a `type` field). */ function isSingleTransition(t: Transition): t is SingleTransition { return 'type' in t; } /** Resolve a single config into CSS-ready duration/easing. */ function resolveConfigForCss(config: SingleTransition | undefined): { duration: number; easing: string; type: string; } { if (!config || config.type === 'none') { return { duration: 0, easing: 'linear', type: config?.type ?? 'timing' }; } return { duration: resolveDuration(config), easing: resolveEasing(config), type: config.type, }; } /** CSS property names for each category. */ const CSS_PROP_MAP = { opacity: 'opacity', transform: 'transform', borderRadius: 'border-radius', backgroundColor: 'background-color', borderWidth: 'border-width', borderColor: 'border-color', boxShadow: 'box-shadow', } as const; type CategoryKey = keyof typeof CSS_PROP_MAP; /** Resolve transition prop into per-category CSS configs. */ function resolvePerCategoryConfigs( transition: Transition | undefined, ): Record { if (!transition) { const def = resolveConfigForCss(undefined); return { opacity: def, transform: def, borderRadius: def, backgroundColor: def, borderWidth: def, borderColor: def, boxShadow: def, }; } if (isSingleTransition(transition)) { const def = resolveConfigForCss(transition); return { opacity: def, transform: def, borderRadius: def, backgroundColor: def, borderWidth: def, borderColor: def, boxShadow: def, }; } // TransitionMap const defaultConfig = resolveConfigForCss(transition.default); const borderConfig = transition.border ? resolveConfigForCss(transition.border) : defaultConfig; const shadowConfig = transition.shadow ? resolveConfigForCss(transition.shadow) : defaultConfig; return { opacity: transition.opacity ? resolveConfigForCss(transition.opacity) : defaultConfig, transform: transition.transform ? resolveConfigForCss(transition.transform) : defaultConfig, borderRadius: transition.borderRadius ? resolveConfigForCss(transition.borderRadius) : defaultConfig, backgroundColor: transition.backgroundColor ? resolveConfigForCss(transition.backgroundColor) : defaultConfig, borderWidth: borderConfig, borderColor: borderConfig, boxShadow: shadowConfig, }; } function resolveEasing(transition: SingleTransition | undefined): string { if (!transition || transition.type === 'none') { return 'linear'; } if (transition.type === 'spring') { const d = transition.damping ?? 15; const s = transition.stiffness ?? 120; const m = transition.mass ?? 1; if (supportsLinearEasing()) { return getSpringEasing(d, s, m).easing; } return SPRING_FALLBACK_EASING; } // timing const easing = transition.easing ?? 'easeInOut'; const bezier: CubicBezier = Array.isArray(easing) ? easing : EASING_PRESETS[easing]!; return `cubic-bezier(${bezier[0]}, ${bezier[1]}, ${bezier[2]}, ${bezier[3]})`; } function resolveDuration(transition: SingleTransition | undefined): number { if (!transition) return 300; if (transition.type === 'timing') return transition.duration ?? 300; if (transition.type === 'none') return 0; // Spring: use simulation-derived duration (incorporates stiffness) const d = transition.damping ?? 15; const s = transition.stiffness ?? 120; const m = transition.mass ?? 1; return getSpringEasing(d, s, m).duration; } /** Counter for unique keyframe names. */ let keyframeCounter = 0; export function EaseView({ animate, initialAnimate, transition, onTransitionEnd, useHardwareLayer: _useHardwareLayer, transformOrigin, transformPerspective = 1280, style, children, }: EaseViewProps) { const resolved = resolveAnimateValues(animate); const uses3D = animate?.rotateX != null || animate?.rotateY != null || initialAnimate?.rotateX != null || initialAnimate?.rotateY != null; const hasInitial = initialAnimate != null; const [mounted, setMounted] = useState(!hasInitial); // On web, View ref gives us the underlying DOM element. const viewRef = useRef>(null); const animationNameRef = useRef(null); const getElement = useCallback( () => viewRef.current as unknown as HTMLElement | null, [], ); // For initialAnimate: render initial values first, then animate on mount. useEffect(() => { if (hasInitial) { getElement()?.getBoundingClientRect(); setMounted(true); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const displayValues = !mounted && hasInitial ? resolveAnimateValues(initialAnimate) : resolved; const categoryConfigs = resolvePerCategoryConfigs(transition); // For loop mode, use the default/single transition config const singleTransition = transition && isSingleTransition(transition) ? transition : transition && !isSingleTransition(transition) ? transition.default : undefined; const loopMode = singleTransition?.type === 'timing' ? singleTransition.loop : undefined; const loopDuration = resolveDuration(singleTransition); const loopEasing = resolveEasing(singleTransition); const originX = ((transformOrigin?.x ?? 0.5) * 100).toFixed(1); const originY = ((transformOrigin?.y ?? 0.5) * 100).toFixed(1); const transitionCss = !mounted && hasInitial ? 'none' : (Object.keys(CSS_PROP_MAP) as CategoryKey[]) .filter((key) => { const cfg = categoryConfigs[key]; return cfg.type !== 'none' && cfg.duration > 0; }) .map((key) => { const cfg = categoryConfigs[key]; return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${cfg.easing}`; }) .join(', ') || 'none'; // Apply CSS transition/animation properties imperatively (not in RN style spec). useEffect(() => { const el = getElement(); if (!el) return; if (!loopMode) { el.style.transition = transitionCss; } el.style.transformOrigin = `${originX}% ${originY}%`; }); // Handle transitionend event via DOM listener. useEffect(() => { const el = getElement(); if (!el || !onTransitionEnd) return; const handler = (e: TransitionEvent) => { if (e.target !== e.currentTarget) return; if (e.propertyName !== 'opacity' && e.propertyName !== 'transform') return; onTransitionEnd({ finished: true }); }; el.addEventListener('transitionend', handler); return () => el.removeEventListener('transitionend', handler); }, [onTransitionEnd, getElement]); // Handle loop animations via CSS @keyframes. useEffect(() => { const el = getElement(); if (!loopMode || !el) { if (animationNameRef.current) { const cleanEl = getElement(); if (cleanEl) cleanEl.style.animation = ''; animationNameRef.current = null; } return; } const fromValues = initialAnimate ? resolveAnimateValues(initialAnimate) : resolveAnimateValues(undefined); const toValues = resolveAnimateValues(animate); const fromTransform = buildTransform( fromValues, uses3D && transformPerspective, ); const toTransform = buildTransform( toValues, uses3D && transformPerspective, ); const name = `ease-loop-${++keyframeCounter}`; animationNameRef.current = name; // Only include border-radius/background-color in keyframes when explicitly // set by the user, to avoid overriding values from the style prop. const hasBorderRadius = initialAnimate?.borderRadius != null || animate?.borderRadius != null; const hasBgColor = initialAnimate?.backgroundColor != null || animate?.backgroundColor != null; const fromBlock = [ `opacity: ${fromValues.opacity}`, `transform: ${fromTransform}`, hasBorderRadius ? `border-radius: ${fromValues.borderRadius}px` : '', hasBgColor && fromValues.backgroundColor ? `background-color: ${fromValues.backgroundColor}` : '', ] .filter(Boolean) .join('; '); const toBlock = [ `opacity: ${toValues.opacity}`, `transform: ${toTransform}`, hasBorderRadius ? `border-radius: ${toValues.borderRadius}px` : '', hasBgColor && toValues.backgroundColor ? `background-color: ${toValues.backgroundColor}` : '', ] .filter(Boolean) .join('; '); const keyframes = `@keyframes ${name} { from { ${fromBlock} } to { ${toBlock} } }`; const styleEl = document.createElement('style'); styleEl.textContent = keyframes; document.head.appendChild(styleEl); const direction = loopMode === 'reverse' ? 'alternate' : 'normal'; el.style.transition = 'none'; el.style.animation = `${name} ${loopDuration}ms ${loopEasing} infinite ${direction}`; return () => { styleEl.remove(); el.style.animation = ''; animationNameRef.current = null; }; }, [ loopMode, animate, initialAnimate, loopDuration, loopEasing, getElement, uses3D, transformPerspective, ]); // Build animated style using RN transform array format. // react-native-web converts these to CSS transform strings. const animatedStyle: ViewStyle = { opacity: displayValues.opacity, transform: [ ...(uses3D ? [{ perspective: transformPerspective }] : []), ...(displayValues.translateX !== 0 ? [{ translateX: displayValues.translateX }] : []), ...(displayValues.translateY !== 0 ? [{ translateY: displayValues.translateY }] : []), ...(displayValues.scaleX !== 1 ? [{ scaleX: displayValues.scaleX }] : []), ...(displayValues.scaleY !== 1 ? [{ scaleY: displayValues.scaleY }] : []), ...(displayValues.rotate !== 0 ? [{ rotate: `${displayValues.rotate}deg` }] : []), ...(uses3D ? [{ rotateX: `${displayValues.rotateX}deg` }] : []), ...(uses3D ? [{ rotateY: `${displayValues.rotateY}deg` }] : []), ], ...(displayValues.borderRadius > 0 ? { borderRadius: displayValues.borderRadius } : {}), ...(displayValues.backgroundColor ? { backgroundColor: displayValues.backgroundColor } : {}), ...(displayValues.borderWidth > 0 ? { borderWidth: displayValues.borderWidth } : {}), ...(displayValues.borderColor ? { borderColor: displayValues.borderColor } : {}), ...(displayValues.shadowOpacity > 0 ? { shadowColor: displayValues.shadowColor ?? 'black', shadowOpacity: displayValues.shadowOpacity, shadowRadius: displayValues.shadowRadius, shadowOffset: displayValues.shadowOffset, } : {}), }; return ( {children} ); }