@futurejj/react-native-visibility-sensor
Version:
A React Native wrapper to check whether a component is in the view port to track impressions and clicks
205 lines (195 loc) • 8.77 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var _react = _interopRequireWildcard(require("react"));
var _reactNative = require("react-native");
var _jsxRuntime = require("react/jsx-runtime");
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
var MeasurementState = /*#__PURE__*/function (MeasurementState) {
MeasurementState["IDLE"] = "IDLE";
// Not yet measured
MeasurementState["MEASURING"] = "MEASURING";
// Measurement in progress
MeasurementState["MEASURED"] = "MEASURED"; // Has valid measurements
return MeasurementState;
}(MeasurementState || {});
function useInterval(callback, delay) {
const savedCallback = (0, _react.useRef)(callback);
(0, _react.useEffect)(() => {
savedCallback.current = callback;
}, [callback]);
(0, _react.useEffect)(() => {
if (delay === null || delay === undefined) {
return;
}
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
const VisibilitySensor = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => {
const {
onChange,
onPercentChange,
disabled = false,
triggerOnce = false,
delay,
threshold = {},
children,
...rest
} = props;
(0, _react.useImperativeHandle)(ref, () => ({
getInnerRef: () => localRef.current
}));
const window = (0, _reactNative.useWindowDimensions)();
const localRef = (0, _react.useRef)(null);
const isMountedRef = (0, _react.useRef)(true);
const measurementStateRef = (0, _react.useRef)(MeasurementState.IDLE);
const lastPercentRef = (0, _react.useRef)(undefined);
const [rectDimensions, setRectDimensions] = (0, _react.useState)({
rectTop: 0,
rectBottom: 0,
rectLeft: 0,
rectRight: 0,
rectWidth: 0,
rectHeight: 0
});
const [lastValue, setLastValue] = (0, _react.useState)(undefined);
const [active, setActive] = (0, _react.useState)(false);
const measureInnerView = (0, _react.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, _y, width, height, pageX, pageY) => {
// 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 = (0, _react.useCallback)(() => {
if (!active) setActive(true);
}, [active]);
const stopWatching = (0, _react.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:
(0, _react.useEffect)(() => {
let timer;
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:
(0, _react.useEffect)(() => {
if (isMountedRef.current && measurementStateRef.current === MeasurementState.MEASURED) {
// Reset measurement state to force remeasurement with new dimensions
measurementStateRef.current = MeasurementState.IDLE;
}
}, [window]);
(0, _react.useEffect)(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
(0, _react.useEffect)(() => {
if (!disabled) {
startWatching();
}
return () => {
stopWatching();
};
}, [disabled, startWatching, stopWatching]);
(0, _react.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 = 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 /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
ref: localRef,
...rest,
children: children
});
});
var _default = exports.default = VisibilitySensor;
//# sourceMappingURL=VisibilitySensor.js.map