/** * Collapse — a body that opens and closes by animating its own height. * * Height cannot be animated from `auto`, so the content is measured first and * the measurement is what gets animated to. While that is happening the content * is taken out of the flow, because a child of a view whose height is * mid-animation reports that animated height back and the panel then settles on * whatever it happened to measure. * * The moment the animation finishes, both go away and the content lays out * normally again. That is what lets a Collapse hold another Collapse — or hold * content that is still streaming in — without either of them being clipped to * a number that was true a frame ago. * * ```tsx * * Anything, of any height. * * ``` * * The alternative — unmounting the body and letting a layout transition on the * parent carry the change — is what `Accordion` does, and it is right when the * closed state should cost nothing. This is for a body that opens and closes * repeatedly and often while its content is still arriving, where remounting * would throw away scroll position and restart every animation inside it. */ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { View, type LayoutChangeEvent, type ViewProps } from 'react-native'; import Animated, { runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated'; import { cn } from '../utils/cn'; /** Long enough to read as the panel opening, short enough not to be waited on. */ export const COLLAPSE_DURATION = 200; export interface CollapseProps extends Omit { open: boolean; /** Classes on the measured content, not on the clipping frame. */ className?: string; duration?: number; children?: ReactNode; } export function Collapse({ open, className, duration = COLLAPSE_DURATION, children, ...props }: CollapseProps) { const reducedMotion = useReducedMotion(); const [height, setHeight] = useState(0); const [animating, setAnimating] = useState(false); const progress = useSharedValue(open ? 1 : 0); const mounted = useRef(false); useEffect(() => { // The first pass is not a transition — a body that starts open should be // open, not play its own entrance at nobody. if (!mounted.current) { mounted.current = true; progress.value = open ? 1 : 0; return; } if (reducedMotion) { progress.value = open ? 1 : 0; setAnimating(false); return; } setAnimating(true); progress.value = withTiming(open ? 1 : 0, { duration }, (finished) => { if (finished) runOnJS(setAnimating)(false); }); }, [open, reducedMotion, duration, progress]); const style = useAnimatedStyle(() => ({ height: progress.value * height, opacity: progress.value, })); // Re-measured rather than measured once: a body whose content is still // streaming in grows, and a height captured on the first frame would crop it. const onLayout = (event: LayoutChangeEvent) => { const next = event.nativeEvent.layout.height; setHeight((current) => (Math.abs(current - next) < 1 ? current : next)); }; /* * Once it has finished opening, the measured height and the absolute * positioning are both dropped and the content lays out normally. * * They are only needed *while* the height is being animated to a number, and * keeping them costs real bugs: a Collapse whose content is itself a Collapse * measures a child that has not settled yet, and settles on that wrong * number, so the inner body ends up clipped to nothing. Content that is still * streaming in has the same problem against itself. In natural flow both are * simply laid out, and the measurement carries on in the background against * the next time it closes. */ const natural = open && !animating; return ( {/* The same two elements in both modes, so switching between them is a style change rather than a remount — a remount would throw away the state of everything inside. */} {children} ); } Collapse.displayName = 'Collapse';