import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native'; import { useEffect, useState } from 'react'; import { isDesktop } from './environment'; type NativeSizeClassPayload = { horizontal?: number; vertical?: number; sizeClass?: number; orientation?: string; isLargeScreen?: boolean; }; const sizeClassNativeModule = NativeModules.SizeClassEmitter as | { getCurrentSizeClass?: () => Promise; addListener: (eventType: string) => any; removeListeners: (count: number) => void; } | undefined; const sizeClassNativeEmitter = sizeClassNativeModule ? new NativeEventEmitter(sizeClassNativeModule) : null; const NATIVE_EVENT_NAME = 'sizeClassDidChange'; // Size class definitions following iOS conventions export enum SizeClass { Compact, // Small size (iPhone width or height in landscape) Regular, // Standard size (iPad, or iPhone height in portrait) Large, // Additional size for larger screens (not in iOS, but useful for our app) } // Interface for the result of getSizeClass export interface SizeClassInfo { // Size classes horizontalSizeClass: SizeClass; verticalSizeClass: SizeClass; // Overall size class (derived from horizontal and vertical) sizeClass: SizeClass; // Orientation orientation: 'portrait' | 'landscape'; // Helper properties isCompact: boolean; isLarge: boolean; // Legacy support isLargeScreen: boolean; } const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait'); const coerceSizeClassValue = (value?: number): SizeClass => { if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) { return value; } return SizeClass.Regular; }; const calculateFromDimensions = (): SizeClassInfo => { const { width, height } = Dimensions.get('window'); const isLandscape = width > height; const orientation = isLandscape ? 'landscape' : 'portrait'; const horizontalSizeClass = Platform.OS === 'ios' && Platform.isPad ? SizeClass.Regular : isDesktop ? SizeClass.Large : isLandscape && width >= 667 ? SizeClass.Regular : SizeClass.Compact; const verticalSizeClass = Platform.OS === 'ios' && Platform.isPad ? SizeClass.Regular : isDesktop ? SizeClass.Large : isLandscape ? SizeClass.Compact : SizeClass.Regular; const sizeClass = coerceSizeClassValue(horizontalSizeClass); const isLargeScreen = sizeClass === SizeClass.Large; return { horizontalSizeClass, verticalSizeClass, sizeClass, orientation, isCompact: sizeClass === SizeClass.Compact, isLarge: sizeClass === SizeClass.Large, isLargeScreen, }; }; const normalizeNativePayload = (payload?: NativeSizeClassPayload | null): SizeClassInfo | null => { if (!payload) { return null; } const horizontalSizeClass = coerceSizeClassValue(payload.horizontal); const verticalSizeClass = coerceSizeClassValue(payload.vertical); const sizeClass = coerceSizeClassValue(payload.sizeClass); const isLargeScreen = payload.isLargeScreen ?? sizeClass === SizeClass.Large; const orientation = normalizeOrientation(payload.orientation); return { horizontalSizeClass, verticalSizeClass, sizeClass, orientation, isCompact: sizeClass === SizeClass.Compact, isLarge: sizeClass === SizeClass.Large, isLargeScreen, }; }; let cachedSizeClassInfo: SizeClassInfo = calculateFromDimensions(); let nativeInitRequested = false; const fetchNativeSizeClass = async (): Promise => { if (!sizeClassNativeModule?.getCurrentSizeClass) { return null; } try { const result = await sizeClassNativeModule.getCurrentSizeClass(); return normalizeNativePayload(result); } catch (error) { console.debug('[SizeClass] Failed to read native size class', error); return null; } }; /** * Get current size class information. */ export function getSizeClass(): SizeClassInfo { if (!sizeClassNativeModule) { cachedSizeClassInfo = calculateFromDimensions(); } else if (!nativeInitRequested) { nativeInitRequested = true; fetchNativeSizeClass().then(nativeInfo => { if (nativeInfo) { cachedSizeClassInfo = nativeInfo; } }); } return cachedSizeClassInfo; } /** * React hook to use size classes in components */ export function useSizeClass(): SizeClassInfo { const [sizeClassInfo, setSizeClassInfo] = useState(cachedSizeClassInfo); useEffect(() => { let isMounted = true; const applySizeClass = (info: SizeClassInfo) => { if (!isMounted) return; cachedSizeClassInfo = info; setSizeClassInfo(info); console.debug( `[SizeClass] Updated:`, `horizontal=${SizeClass[info.horizontalSizeClass]}`, `vertical=${SizeClass[info.verticalSizeClass]}`, `orientation=${info.orientation}`, `isLargeScreen=${info.isLargeScreen}`, ); }; const updateFromDimensions = () => { const calculated = calculateFromDimensions(); applySizeClass(calculated); }; const requestNativeUpdate = async () => { const nativeInfo = await fetchNativeSizeClass(); if (nativeInfo) { applySizeClass(nativeInfo); } }; const dimensionSubscription = Dimensions.addEventListener('change', () => { updateFromDimensions(); requestNativeUpdate(); }); const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { if (nextAppState === 'active') { requestNativeUpdate(); } }); const nativeSubscription = sizeClassNativeEmitter?.addListener(NATIVE_EVENT_NAME, (payload: NativeSizeClassPayload) => { const normalized = normalizeNativePayload(payload); if (normalized) { applySizeClass(normalized); } }); // Kick off an initial native fetch to override the heuristic when available. requestNativeUpdate(); return () => { isMounted = false; dimensionSubscription.remove(); appStateSubscription.remove(); nativeSubscription?.remove(); }; }, []); return sizeClassInfo; }