/** * Keeps a chart's placeholder on screen a beat past the data arriving, and * dissolves it while the real marks grow in. * * A skeleton cut at the exact frame `status` flips leaves the plot empty: the * placeholder is gone and the reveal has not started, so there is a blank frame * between two states that were both supposed to show something. Outliving the * status change by the length of the fade is what closes that gap, and it is * why the loading state reads as *becoming* the chart rather than as being * replaced by it. * * The fade runs on the UI thread; the unmount is scheduled back once, at the * end, so nothing re-renders per frame. * * ```tsx * const { mounted, opacity } = useSkeletonHandoff(status === 'loading'); * const animatedProps = useAnimatedProps(() => ({ opacity: opacity.value })); * if (!mounted) return null; * return {…}; * ``` */ import { useEffect, useState } from 'react'; import { runOnJS, useReducedMotion, useSharedValue, withTiming, type SharedValue, } from 'react-native-reanimated'; /** Milliseconds for a placeholder to dissolve once the data arrives. */ export const SKELETON_FADE = 220; export interface UseSkeletonHandoffResult { /** True while the placeholder should render — through the fade, not just the wait. */ mounted: boolean; /** The placeholder's opacity, `1` while loading and tweened to `0` after. */ opacity: SharedValue; } export function useSkeletonHandoff( loading: boolean, duration: number = SKELETON_FADE ): UseSkeletonHandoffResult { const opacity = useSharedValue(loading ? 1 : 0); // Mounted a beat longer than `loading`, so there is something to fade. const [mounted, setMounted] = useState(loading); const reducedMotion = useReducedMotion(); useEffect(() => { if (loading) { opacity.value = 1; setMounted(true); return; } // Reduced motion still wants the placeholder gone; it is the dissolve that // is the motion, not the disappearance. if (reducedMotion) { opacity.value = 0; setMounted(false); return; } opacity.value = withTiming(0, { duration }, (finished) => { if (finished) runOnJS(setMounted)(false); }); }, [loading, reducedMotion, duration, opacity]); return { mounted, opacity }; }