import { useLayoutEffect, useMemo, useState } from "react"; import { View } from "react-native"; import Animated, { makeMutable, type SharedValue, useAnimatedStyle, useReducedMotion, useSharedValue, } from "react-native-reanimated"; import { scheduleOnUI } from "react-native-worklets"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SYNCED_LOADER_DOT_COUNT, getSyncedLoaderDotOpacity, getSyncedLoaderStep, } from "@/components/synced-loader-state"; const GRID_COLUMNS = 2; const DOT_KEYS = Array.from({ length: SYNCED_LOADER_DOT_COUNT }, (_, i) => `dot-${i}`); const sharedStep = makeMutable(0); const activeLoaderCount = makeMutable(0); const clockRunning = makeMutable(false); let nextStepListenerId = 1; function advanceSharedStep(): void { "worklet"; if (activeLoaderCount.value === 0) { clockRunning.value = false; return; } const nextStep = getSyncedLoaderStep(Date.now()); if (sharedStep.value !== nextStep) { sharedStep.value = nextStep; } requestAnimationFrame(advanceSharedStep); } function registerStepListener( step: SharedValue, registered: SharedValue, listenerId: number, ): void { "worklet"; if (registered.value) { return; } registered.value = true; step.value = getSyncedLoaderStep(Date.now()); sharedStep.addListener(listenerId, (nextStep) => { step.value = nextStep; }); activeLoaderCount.value += 1; if (!clockRunning.value) { clockRunning.value = true; sharedStep.value = step.value; requestAnimationFrame(advanceSharedStep); } } function unregisterStepListener(registered: SharedValue, listenerId: number): void { "worklet"; if (!registered.value) { return; } registered.value = false; sharedStep.removeListener(listenerId); activeLoaderCount.value -= 1; } function useSyncedLoaderStep(active: boolean, reduceMotion: boolean): SharedValue { // The local value lets retained loaders detach from the app-wide clock without // unmounting their animated views or leaving hidden style worklets subscribed. const step = useSharedValue(reduceMotion ? 0 : getSyncedLoaderStep(Date.now())); const registered = useSharedValue(false); const [listenerId] = useState(() => nextStepListenerId++); useLayoutEffect(() => { if (!active || reduceMotion) { return; } scheduleOnUI(registerStepListener, step, registered, listenerId); return () => { scheduleOnUI(unregisterStepListener, registered, listenerId); }; }, [active, listenerId, reduceMotion, registered, step]); return step; } export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) { const active = useRetainedPanelActive(); const reduceMotion = useReducedMotion(); const step = useSyncedLoaderStep(active, reduceMotion); // The 2x3 grid fills `size` exactly on its long axis: the dot is whatever is left after // the two gaps, not a floored integer. Flooring cost the grid up to a third of a dot per // row — at size 10 it drew 8pt of ink in a 10pt box and read as a small mark in a large // slot. Fractional dots are fine here; these are sub-pixel radii on a moving glyph. const gap = Math.max(1, Math.round(size * 0.12)); const dotSize = Math.max(2, (size - gap * 2) / 3); const gridWidth = dotSize * 2 + gap; const gridHeight = dotSize * 3 + gap * 2; const gridStyle = useMemo( () => ({ width: gridWidth, height: gridHeight }), [gridHeight, gridWidth], ); const containerStyle = useMemo( () => ({ width: size, height: size, alignItems: "center", justifyContent: "center", }) as const, [size], ); return ( {Array.from({ length: SYNCED_LOADER_DOT_COUNT }).map((_, dotIndex) => { const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); const columnIndex = dotIndex % GRID_COLUMNS; return ( ); })} ); } function SpinnerDot({ color, dotSize, dotIndex, step, left, top, }: { color: string; dotSize: number; dotIndex: number; step: SharedValue; left: number; top: number; }) { const animatedStyle = useAnimatedStyle(() => ({ opacity: getSyncedLoaderDotOpacity(step.value, dotIndex), })); const dotStyle = useMemo( () => [ animatedStyle, { width: dotSize, height: dotSize, borderRadius: dotSize / 2, backgroundColor: color, position: "absolute" as const, left, top, }, ], [animatedStyle, dotSize, color, left, top], ); return ; }