import Animated, { interpolate, interpolateColor, runOnJS, SharedValue, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming, } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { StatusBar } from "react-native"; import React from "react"; import { BottomSheetInstance, SheetPayload, SheetProviderProps, Sheets, StackBehavior, } from "./types"; import { eventManager } from "./events"; export const providerRegistryStack: string[] = []; /** * An object that holds all the sheet components against their ids. */ export const sheetsRegistry: { [context: string]: { [id: string]: React.ElementType }; } = { global: {}, }; export interface SheetProps { sheetId: SheetId; payload?: Sheets[SheetId]["payload"]; } // Registers your Sheet with the SheetProvider. export function registerSheet( id: SheetId | (string & {}), Sheet: React.ElementType, ...contexts: string[] ) { if (!id || !Sheet) return; if (!contexts || contexts.length === 0) contexts = ["global"]; for (let context of contexts) { const registry = !sheetsRegistry[context] ? (sheetsRegistry[context] = {}) : sheetsRegistry[context]; registry[id as string] = Sheet; eventManager.publishAsync(`${context}-on-register`); } } /** * The SheetProvider makes available the sheets in a given context. The default context is * `global`. However if you want to render a Sheet within another sheet or if you want to render * Sheets in a modal. You can use a separate Provider with a custom context value. * * > **Note:** Context names must be unique across all `SheetProvider` instances. * * @example * ```ts * // Define your SheetProvider in the component/modal where * // you want to show some Sheets. * * * // Then register your sheet at module level (outside JSX): * registerSheet('local-sheet', LocalSheet, 'local-context'); * ``` */ export function SheetProvider({ context = "global", statusBar, scaleConfig, children, }: SheetProviderProps) { const { top } = useSafeAreaInsets(); const [, forceUpdate] = React.useReducer((x) => x + 1, 0); const sheetIds = Object.keys(sheetsRegistry[context] || sheetsRegistry["global"] || {}); // IOS modal sheet type of animation const initialFullScreenValues: Record = {}; const fullScreenValues = useSharedValue(initialFullScreenValues); const isFullScreen = useDerivedValue(() => { const values = Object.values(fullScreenValues.value); let max = -1; for (let i = 0; i < values.length; i++) { if (values[i] > max) max = values[i]; } return max; }); const colorStyle = useAnimatedStyle(() => ({ flex: 1, backgroundColor: interpolateColor( isFullScreen.value, [0, 1], ["transparent", "#000"], ), })); const animatedStyle = useAnimatedStyle(() => { const radius = interpolate( isFullScreen.value, [0, 0.3], [0, scaleConfig?.borderRadius ?? 24], "clamp", ); const scale = interpolate( isFullScreen.value, [0.5, 0.8], [1, scaleConfig?.scale ?? 0.92], "clamp", ); const translateY = interpolate( isFullScreen.value, [0.5, 0.7], [0, top + (scaleConfig?.translateY ?? 5)], "clamp", ); return { flex: 1, overflow: "hidden", borderRadius: scaleConfig?.animation?.type === "spring" ? withSpring(radius, scaleConfig.animation.config) : withTiming(radius, scaleConfig?.animation?.config ?? { duration: 200 }), transform: [ { scaleX: scaleConfig?.animation?.type === "spring" ? withSpring(scale, scaleConfig.animation.config) : withTiming(scale, scaleConfig?.animation?.config ?? { duration: 200 }), }, { translateY: scaleConfig?.animation?.type === "spring" ? withSpring(translateY, scaleConfig.animation.config) : withTiming( translateY, scaleConfig?.animation?.config ?? { duration: 200 }, ), }, ], }; }, [top, scaleConfig]); // Since background color is white, we need to set status bar to light const setStatusBar = StatusBar.setBarStyle; useAnimatedReaction( () => isFullScreen.value, (currentValue) => { "worklet"; if (currentValue >= 0) { runOnJS(setStatusBar)( currentValue >= 0.5 ? "light-content" : (statusBar ?? "default"), true, ); } }, [], ); React.useEffect(() => { providerRegistryStack.indexOf(context) > -1 ? providerRegistryStack.indexOf(context) : providerRegistryStack.push(context) - 1; const unsub = eventManager.subscribe(`${context}-on-register`, forceUpdate); return () => { providerRegistryStack.splice(providerRegistryStack.indexOf(context), 1); unsub?.unsubscribe(); }; }, [context, forceUpdate]); return ( {children} {sheetIds.map((id) => ( ))} ); } const ProviderContext = React.createContext("global"); const SheetIDContext = React.createContext(undefined); const SheetSharedContext = React.createContext<{ isFullScreen: SharedValue; fullScreenValues: SharedValue>; topInset: number; }>({ isFullScreen: { value: 0 } as SharedValue, fullScreenValues: { value: {} } as SharedValue>, topInset: 0, }); export const SheetRefContext = React.createContext< React.RefObject >({} as React.RefObject); const SheetPayloadContext = React.createContext(undefined); // Stack behavior context for managing sheet transitions interface StackBehaviorContextValue { behavior: StackBehavior; isTransitioning: boolean; previousSheetId: string | null; } const StackBehaviorContext = React.createContext({ behavior: "switch", isTransitioning: false, previousSheetId: null, }); /** * Get id of the current context. */ export const useProviderContext = () => React.useContext(ProviderContext); /** * Get id of the current sheet */ export const useSheetIDContext = () => React.useContext(SheetIDContext); /** * Get the current sheet animation context. */ export const useSheetSharedContext = () => React.useContext(SheetSharedContext); /** * Get stack behavior context for the current sheet. */ export const useStackBehaviorContext = () => React.useContext(StackBehaviorContext); /** * Get the current Sheet's internal ref. * Note: `current` may be null before the sheet is fully mounted. */ export const useSheetRef = < SheetId extends keyof Sheets = never, >(): React.MutableRefObject> => React.useContext(SheetRefContext) as React.MutableRefObject< BottomSheetInstance >; /** * Get the payload this sheet was opened with. */ export function useSheetPayload() { return React.useContext(SheetPayloadContext) as Sheets[SheetId]["payload"]; } /** * Listen to sheet events. */ export function useOnSheet( id: SheetId | (string & {}), type: "show" | "hide" | "onclose", listener: (payload: SheetPayload, context: string, ...args: unknown[]) => void, ) { React.useEffect(() => { const subscription = eventManager.subscribe(`${type}_${id}`, listener); return () => subscription.unsubscribe(); }, [id, listener, type]); } interface RenderSheetProps { id: string; context: string; } const RenderSheet = ({ id, context }: RenderSheetProps) => { const [payload, setPayload] = React.useState(); const [visible, setVisible] = React.useState(false); const [stackBehavior, setStackBehavior] = React.useState("switch"); const [isPending, startTransition] = React.useTransition(); const [previousSheetId, setPreviousSheetId] = React.useState(null); const ref = React.useRef(null); const Sheet = context.startsWith("$$-auto-") ? sheetsRegistry?.global?.[id] : sheetsRegistry[context] ? sheetsRegistry[context]?.[id] : undefined; const onShow = React.useCallback( (data: unknown, ctx = "global", reopened?: boolean, behavior?: StackBehavior) => { if (ctx !== context) return; if (behavior) { setStackBehavior(behavior); } if (!reopened) { setPayload(data); } // Smooth transition handling using React's useTransition startTransition(() => { setVisible(true); }); }, [context], ); const onClose = React.useCallback( (_data: unknown, ctx = "global", reopened?: boolean, nextSheetId?: string) => { if (context !== ctx) return; if (nextSheetId) { setPreviousSheetId(nextSheetId); } if (!reopened) { setPayload(undefined); setVisible(false); } else { setVisible(false); setPreviousSheetId(null); } }, [context], ); const onHide = React.useCallback( (data: unknown, ctx = "global") => { eventManager.publish(`hide_${id}`, data, ctx); }, [id], ); React.useEffect(() => { if (visible) { eventManager.publish(`show_${id}`, payload, context); } }, [context, id, payload, visible]); React.useEffect(() => { const subs = [ eventManager.subscribe(`show_wrap_${id}`, onShow), eventManager.subscribe(`onclose_${id}`, onClose), eventManager.subscribe(`hide_wrap_${id}`, onHide), ]; return () => { subs.forEach((s) => s.unsubscribe()); }; }, [id, context, onShow, onHide, onClose]); if (!Sheet) return null; const stackContextValue: StackBehaviorContextValue = { behavior: stackBehavior, isTransitioning: isPending, previousSheetId, }; if (!visible) return null; return ( ); };