import {
isValidElement,
type ReactElement,
type ReactNode,
useEffect,
useReducer,
useRef,
} from "react";
import { StyleSheet, View, type ViewStyle } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
export interface LiveChartTransitionProps {
/** Key of the active child to show. Must match a child's `key`. */
active: string;
/** Chart elements, each with a unique `key`. */
children: ReactNode;
/** Cross-fade duration in ms. Default `300`. */
duration?: number;
/**
* Keep every child mounted for the lifetime of the transition (only the
* opacity animates). Each chart's engine then settles its y-range once, up
* front, so switching is a pure cross-fade with no reveal/range re-animation.
* Costs more (all engines run continuously). Default `false` (mount the
* active child + unmount the outgoing one after the fade).
*/
keepMounted?: boolean;
/** Container style. */
style?: ViewStyle;
}
function FadeLayer({
visible,
duration,
children,
}: {
visible: boolean;
duration: number;
children: ReactNode;
}) {
const opacity = useSharedValue(visible ? 1 : 0);
useEffect(() => {
opacity.set(withTiming(visible ? 1 : 0, { duration }));
}, [visible, duration, opacity]);
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));
return (
{children}
);
}
type MountedAction =
| { type: "mount"; key: string }
| { type: "unmount"; key: string };
/** Tracks which child keys are currently mounted during a cross-fade. */
function mountedReducer(
mounted: Set,
action: MountedAction,
): Set {
switch (action.type) {
case "mount":
if (mounted.has(action.key)) return mounted;
return new Set(mounted).add(action.key);
case "unmount": {
if (!mounted.has(action.key)) return mounted;
const next = new Set(mounted);
next.delete(action.key);
return next;
}
}
}
/**
* Cross-fades between chart instances by `key` (e.g. line ↔ candle). Mounts the
* active child plus any outgoing child still fading out; unmounts the outgoing
* child once the fade completes. RN/Reanimated analogue of liveline's
* `LivelineTransition`.
*/
export function LiveChartTransition({
active,
children,
duration = 300,
keepMounted = false,
style,
}: LiveChartTransitionProps) {
// Use the raw children (not Children.toArray, which rewrites keys to ".$key").
const childArray = (
Array.isArray(children) ? children : [children]
).filter(isValidElement) as ReactElement[];
const [mounted, dispatch] = useReducer(
mountedReducer,
active,
(initial) => new Set([initial]),
);
const prevRef = useRef(active);
useEffect(() => {
if (keepMounted) return;
if (active === prevRef.current) return;
const outgoing = prevRef.current;
prevRef.current = active;
dispatch({ type: "mount", key: active });
const timer = setTimeout(() => {
dispatch({ type: "unmount", key: outgoing });
}, duration + 50);
return () => clearTimeout(timer);
}, [active, duration, keepMounted]);
return (
{childArray.map((child) => {
const key = String(child.key ?? "");
if (!keepMounted && !mounted.has(key)) return null;
return (
{child}
);
})}
);
}
const styles = StyleSheet.create({
root: { flex: 1, position: "relative" },
});