|
| 1 | +import { |
| 2 | + View, |
| 3 | + Dimensions, |
| 4 | + AppState, |
| 5 | + type LayoutChangeEvent, |
| 6 | +} from 'react-native'; |
| 7 | +import { useRef, useState, useCallback, useEffect } from 'react'; |
| 8 | + |
| 9 | +interface UseVisibilityOptions { |
| 10 | + threshold?: number; // Percentage of component that must be visible (0-1) |
| 11 | + checkOnAppState?: boolean; // Whether to check app state (active/background) |
| 12 | + checkInterval?: number; // How often to check visibility in ms (0 = only on layout changes) |
| 13 | + enablePeriodicCheck?: boolean; // Whether to enable periodic checking for navigation changes |
| 14 | +} |
| 15 | + |
| 16 | +interface LayoutInfo { |
| 17 | + x: number; |
| 18 | + y: number; |
| 19 | + width: number; |
| 20 | + height: number; |
| 21 | +} |
| 22 | + |
| 23 | +export const useComponentVisibility = (options: UseVisibilityOptions = {}) => { |
| 24 | + const { |
| 25 | + threshold = 0.1, |
| 26 | + checkOnAppState = true, |
| 27 | + checkInterval = 0, // Default to only check on layout changes |
| 28 | + enablePeriodicCheck = true, // Enable periodic checking by default for navigation |
| 29 | + } = options; |
| 30 | + |
| 31 | + const [isVisible, setIsVisible] = useState(false); |
| 32 | + const [appState, setAppState] = useState(AppState.currentState); |
| 33 | + const componentRef = useRef<View>(null); |
| 34 | + const [layout, setLayout] = useState<LayoutInfo>({ |
| 35 | + x: 0, |
| 36 | + y: 0, |
| 37 | + width: 0, |
| 38 | + height: 0, |
| 39 | + }); |
| 40 | + const intervalRef = useRef<NodeJS.Timeout | null>(null); |
| 41 | + |
| 42 | + // Handle layout changes |
| 43 | + const handleLayout = useCallback((event: LayoutChangeEvent) => { |
| 44 | + const { x, y, width, height } = event.nativeEvent.layout; |
| 45 | + setLayout({ x, y, width, height }); |
| 46 | + }, []); |
| 47 | + |
| 48 | + // Check if component is visible on screen using measure |
| 49 | + const checkVisibility = useCallback((): Promise<boolean> => { |
| 50 | + if (!componentRef.current || layout.width === 0 || layout.height === 0) { |
| 51 | + return Promise.resolve(false); |
| 52 | + } |
| 53 | + |
| 54 | + return new Promise<boolean>((resolve) => { |
| 55 | + componentRef.current?.measure((_x, _y, width, height, pageX, pageY) => { |
| 56 | + const screenHeight = Dimensions.get('window').height; |
| 57 | + const screenWidth = Dimensions.get('window').width; |
| 58 | + |
| 59 | + // Calculate visible area using page coordinates |
| 60 | + const visibleTop = Math.max(0, pageY); |
| 61 | + const visibleBottom = Math.min(screenHeight, pageY + height); |
| 62 | + const visibleLeft = Math.max(0, pageX); |
| 63 | + const visibleRight = Math.min(screenWidth, pageX + width); |
| 64 | + |
| 65 | + const visibleHeight = Math.max(0, visibleBottom - visibleTop); |
| 66 | + const visibleWidth = Math.max(0, visibleRight - visibleLeft); |
| 67 | + |
| 68 | + const visibleArea = visibleHeight * visibleWidth; |
| 69 | + const totalArea = height * width; |
| 70 | + const visibilityRatio = totalArea > 0 ? visibleArea / totalArea : 0; |
| 71 | + |
| 72 | + resolve(visibilityRatio >= threshold); |
| 73 | + }); |
| 74 | + }).catch(() => { |
| 75 | + // Fallback to layout-based calculation if measure fails |
| 76 | + const screenHeight = Dimensions.get('window').height; |
| 77 | + const screenWidth = Dimensions.get('window').width; |
| 78 | + |
| 79 | + const visibleTop = Math.max(0, layout.y); |
| 80 | + const visibleBottom = Math.min(screenHeight, layout.y + layout.height); |
| 81 | + const visibleLeft = Math.max(0, layout.x); |
| 82 | + const visibleRight = Math.min(screenWidth, layout.x + layout.width); |
| 83 | + |
| 84 | + const visibleHeight = Math.max(0, visibleBottom - visibleTop); |
| 85 | + const visibleWidth = Math.max(0, visibleRight - visibleLeft); |
| 86 | + |
| 87 | + const visibleArea = visibleHeight * visibleWidth; |
| 88 | + const totalArea = layout.height * layout.width; |
| 89 | + const visibilityRatio = totalArea > 0 ? visibleArea / totalArea : 0; |
| 90 | + |
| 91 | + return visibilityRatio >= threshold; |
| 92 | + }); |
| 93 | + }, [layout, threshold]); |
| 94 | + |
| 95 | + // Update visibility state |
| 96 | + const updateVisibility = useCallback(async () => { |
| 97 | + const isComponentVisible = await checkVisibility(); |
| 98 | + const isAppActive = !checkOnAppState || appState === 'active'; |
| 99 | + const newVisibility = isComponentVisible && isAppActive; |
| 100 | + |
| 101 | + setIsVisible(newVisibility); |
| 102 | + }, [checkVisibility, appState, checkOnAppState]); |
| 103 | + |
| 104 | + // Update visibility when layout or app state changes |
| 105 | + useEffect(() => { |
| 106 | + updateVisibility(); |
| 107 | + }, [updateVisibility]); |
| 108 | + |
| 109 | + // Set up periodic checking for navigation changes |
| 110 | + useEffect(() => { |
| 111 | + const interval = |
| 112 | + checkInterval > 0 ? checkInterval : enablePeriodicCheck ? 500 : 0; |
| 113 | + |
| 114 | + if (interval > 0) { |
| 115 | + intervalRef.current = setInterval(updateVisibility, interval); |
| 116 | + return () => { |
| 117 | + if (intervalRef.current) { |
| 118 | + clearInterval(intervalRef.current); |
| 119 | + } |
| 120 | + }; |
| 121 | + } |
| 122 | + return undefined; |
| 123 | + }, [checkInterval, enablePeriodicCheck, updateVisibility]); |
| 124 | + |
| 125 | + // Listen to app state changes |
| 126 | + useEffect(() => { |
| 127 | + if (!checkOnAppState) return; |
| 128 | + |
| 129 | + const handleAppStateChange = (nextAppState: string) => { |
| 130 | + setAppState(nextAppState as typeof AppState.currentState); |
| 131 | + }; |
| 132 | + |
| 133 | + const subscription = AppState.addEventListener( |
| 134 | + 'change', |
| 135 | + handleAppStateChange |
| 136 | + ); |
| 137 | + return () => subscription?.remove(); |
| 138 | + }, [checkOnAppState]); |
| 139 | + |
| 140 | + // Clean up interval on unmount |
| 141 | + useEffect(() => { |
| 142 | + return () => { |
| 143 | + if (intervalRef.current) { |
| 144 | + clearInterval(intervalRef.current); |
| 145 | + } |
| 146 | + }; |
| 147 | + }, []); |
| 148 | + |
| 149 | + return { |
| 150 | + isVisible, |
| 151 | + componentRef, |
| 152 | + handleLayout, |
| 153 | + appState, |
| 154 | + layout, |
| 155 | + }; |
| 156 | +}; |
0 commit comments