import React, { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle, } from 'react'; import { useWindowDimensions, View } from 'react-native'; import type { VisibilitySensorRef, VisibilitySensorProps, RectDimensionsState, } from './visibilitySensor.types'; enum MeasurementState { IDLE = 'IDLE', // Not yet measured MEASURING = 'MEASURING', // Measurement in progress MEASURED = 'MEASURED', // Has valid measurements } function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback); useEffect(() => { savedCallback.current = callback; }, [callback]); useEffect(() => { if (delay === null || delay === undefined) { return; } const id = setInterval(() => savedCallback.current(), delay); return () => clearInterval(id); }, [delay]); } const VisibilitySensor = forwardRef( (props, ref) => { const { onChange, onPercentChange, disabled = false, triggerOnce = false, delay, threshold = {}, children, ...rest } = props; useImperativeHandle(ref, () => ({ getInnerRef: () => localRef.current, })); const window = useWindowDimensions(); const localRef = useRef(null); const isMountedRef = useRef(true); const measurementStateRef = useRef(MeasurementState.IDLE); const lastPercentRef = useRef(undefined); const [rectDimensions, setRectDimensions] = useState({ rectTop: 0, rectBottom: 0, rectLeft: 0, rectRight: 0, rectWidth: 0, rectHeight: 0, }); const [lastValue, setLastValue] = useState(undefined); const [active, setActive] = useState(false); const measureInnerView = useCallback(() => { /* Check if the sensor is active to prevent unnecessary measurements This avoids running measurements when the sensor is disabled or stopped */ if ( !active || !isMountedRef.current || measurementStateRef.current === MeasurementState.MEASURING ) { return; } measurementStateRef.current = MeasurementState.MEASURING; localRef.current?.measure( ( _x: number, _y: number, width: number, height: number, pageX: number, pageY: number ) => { // Check if component is still mounted before setting state because measurement can be asynchronous if (!isMountedRef.current) { return; } const dimensions = { rectTop: pageY, rectBottom: pageY + height, rectLeft: pageX, rectRight: pageX + width, rectWidth: width, rectHeight: height, }; if ( rectDimensions.rectTop !== dimensions.rectTop || rectDimensions.rectBottom !== dimensions.rectBottom || rectDimensions.rectLeft !== dimensions.rectLeft || rectDimensions.rectRight !== dimensions.rectRight || rectDimensions.rectWidth !== dimensions.rectWidth || rectDimensions.rectHeight !== dimensions.rectHeight ) { setRectDimensions(dimensions); } /* Set measurementStateRef to MEASURED to indicate that a valid measurement has been taken. This ensures visibility checks only proceed after initial measurement */ measurementStateRef.current = MeasurementState.MEASURED; } ); }, [active, rectDimensions]); useInterval(measureInnerView, delay || 100); const startWatching = useCallback(() => { if (!active) setActive(true); }, [active]); const stopWatching = useCallback(() => { if (active) { setActive(false); /* Reset measurement state when stopping to ensure fresh measurements when the sensor is reactivated */ measurementStateRef.current = MeasurementState.IDLE; // Reset state } }, [active]); // Effect to trigger initial measurement when component becomes active: useEffect(() => { let timer: ReturnType; if (active && measurementStateRef.current === MeasurementState.IDLE) { // Use setTimeout with 0 delay to ensure layout is complete timer = setTimeout(() => { measureInnerView(); }, 0); } return () => { if (timer) clearTimeout(timer); }; }, [active, measureInnerView]); // Reset measurement state when dimensions change: useEffect(() => { if ( isMountedRef.current && measurementStateRef.current === MeasurementState.MEASURED ) { // Reset measurement state to force remeasurement with new dimensions measurementStateRef.current = MeasurementState.IDLE; } }, [window]); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); useEffect(() => { if (!disabled) { startWatching(); } return () => { stopWatching(); }; }, [disabled, startWatching, stopWatching]); useEffect(() => { /* Ensure visibility checks only run when the sensor is active and at least one measurement has been completed. This prevents premature visibility calculations with invalid or stale dimensions */ if ( !active || measurementStateRef.current !== MeasurementState.MEASURED || !isMountedRef.current ) { return; } const isVisible: boolean = rectDimensions.rectTop + (threshold.top || 0) <= window.height && // Top edge is within the bottom of the window rectDimensions.rectBottom - (threshold.bottom || 0) >= 0 && // Bottom edge is within the top of the window rectDimensions.rectLeft + (threshold.left || 0) <= window.width && // Left edge is within the right of the window rectDimensions.rectRight - (threshold.right || 0) >= 0; // Right edge is within the left of the window // Calculate percent visible if callback is requested / provided if ( onPercentChange && rectDimensions.rectWidth > 0 && rectDimensions.rectHeight > 0 ) { let percentVisible = 0; // Don't perform % calculation if not visible for efficiency if (isVisible) { // Thresholds reduce the effective viewport const viewportTop = 0 + (threshold.top || 0); const viewportBottom = window.height - (threshold.bottom || 0); const viewportLeft = 0 + (threshold.left || 0); const viewportRight = window.width - (threshold.right || 0); // Calculate the visible portion of the element within the reduced viewport const visibleTop = Math.max(viewportTop, rectDimensions.rectTop); const visibleBottom = Math.min( viewportBottom, rectDimensions.rectBottom ); const visibleLeft = Math.max(viewportLeft, rectDimensions.rectLeft); const visibleRight = Math.min( viewportRight, rectDimensions.rectRight ); // Calculate visible dimensions const visibleHeight = Math.max(0, visibleBottom - visibleTop); const visibleWidth = Math.max(0, visibleRight - visibleLeft); // Calculate percent visible based on actual element area const visibleArea = visibleHeight * visibleWidth; const totalArea = rectDimensions.rectHeight * rectDimensions.rectWidth; percentVisible = totalArea > 0 ? Math.round((visibleArea / totalArea) * 100) : 0; } else { // when !isVisible percentVisible = 0; // No need to calculate, it's fully out of view, so 0% } // Only fire callback if percent has changed if (lastPercentRef.current !== percentVisible) { lastPercentRef.current = percentVisible; // Update last reported percent onPercentChange(percentVisible); } } if (lastValue !== isVisible) { setLastValue(isVisible); onChange(isVisible); if (isVisible && triggerOnce) { stopWatching(); } } }, [ rectDimensions, window, lastValue, active, onPercentChange, threshold, onChange, triggerOnce, stopWatching, ]); return ( {children} ); } ); export default VisibilitySensor;