UNPKG

react-native

Version:

A framework for building native apps using React

346 lines (306 loc) 11.5 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict-local * @format */ // flowlint unsafe-getters-setters:off import type {IntersectionObserverId} from './internals/IntersectionObserverManager'; import type IntersectionObserverEntry from './IntersectionObserverEntry'; import ReactNativeElement from '../dom/nodes/ReactNativeElement'; import * as IntersectionObserverManager from './internals/IntersectionObserverManager'; export type IntersectionObserverCallback = ( entries: Array<IntersectionObserverEntry>, observer: IntersectionObserver, ) => mixed; export interface IntersectionObserverInit { // root?: ReactNativeElement, // This option exists on the Web but it's not currently supported in React Native. // rootMargin?: string, // This option exists on the Web but it's not currently supported in React Native. threshold?: number | $ReadOnlyArray<number>; /** * This is a React Native specific option (not spec compliant) that specifies * ratio threshold(s) of the intersection area to the total `root` area. * * If set, it will either be a singular ratio value between 0-1 (inclusive) * or an array of such ratios. * * Note: If `rnRootThreshold` is set, and `threshold` is not set, * `threshold` will not default to [0] (as per spec) */ rnRootThreshold?: number | $ReadOnlyArray<number>; } /** * The [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) * provides a way to asynchronously observe changes in the intersection of a * target element with an ancestor element or with a top-level document's * viewport. * * The ancestor element or viewport is referred to as the root. * * When an `IntersectionObserver` is created, it's configured to watch for given * ratios of visibility within the root. * * The configuration cannot be changed once the `IntersectionObserver` is * created, so a given observer object is only useful for watching for specific * changes in degree of visibility; however, you can watch multiple target * elements with the same observer. * * This implementation only supports the `threshold` option at the moment * (`root` and `rootMargin` are not supported) and provides a React Native specific * option `rnRootThreshold`. * */ export default class IntersectionObserver { _callback: IntersectionObserverCallback; _thresholds: $ReadOnlyArray<number>; _observationTargets: Set<ReactNativeElement> = new Set(); _intersectionObserverId: ?IntersectionObserverId; _rootThresholds: $ReadOnlyArray<number> | null; constructor( callback: IntersectionObserverCallback, options?: IntersectionObserverInit, ): void { if (callback == null) { throw new TypeError( "Failed to construct 'IntersectionObserver': 1 argument required, but only 0 present.", ); } if (typeof callback !== 'function') { throw new TypeError( "Failed to construct 'IntersectionObserver': parameter 1 is not of type 'Function'.", ); } // $FlowExpectedError[prop-missing] it's not typed in React Native but exists on Web. if (options?.root != null) { throw new TypeError( "Failed to construct 'IntersectionObserver': root is not supported", ); } // $FlowExpectedError[prop-missing] it's not typed in React Native but exists on Web. if (options?.rootMargin != null) { throw new TypeError( "Failed to construct 'IntersectionObserver': rootMargin is not supported", ); } this._callback = callback; this._rootThresholds = normalizeRootThreshold(options?.rnRootThreshold); this._thresholds = normalizeThreshold( options?.threshold, this._rootThresholds != null, // only provide default if no rootThreshold ); } /** * The `ReactNativeElement` whose bounds are used as the bounding box when * testing for intersection. * If no `root` value was passed to the constructor or its value is `null`, * the root view is used. * * NOTE: This cannot currently be configured and `root` is always `null`. */ get root(): ReactNativeElement | null { return null; } /** * String with syntax similar to that of the CSS `margin` property. * Each side of the rectangle represented by `rootMargin` is added to the * corresponding side in the root element's bounding box before the * intersection test is performed. * * NOTE: This cannot currently be configured and `rootMargin` is always * `null`. */ get rootMargin(): string { return '0px 0px 0px 0px'; } /** * A list of thresholds, sorted in increasing numeric order, where each * threshold is a ratio of intersection area to bounding box area of an * observed target. * Notifications for a target are generated when any of the thresholds specified * in `rnRootThreshold` or `threshold` are crossed for that target. * * If no value was passed to the constructor, and no `rnRootThreshold` * is set, `0` is used. */ get thresholds(): $ReadOnlyArray<number> { return this._thresholds; } /** * A list of root thresholds, sorted in increasing numeric order, where each * threshold is a ratio of intersection area to bounding box area of the specified * root view, which defaults to the viewport. * Notifications for a target are generated when any of the thresholds specified * in `rnRootThreshold` or `threshold` are crossed for that target. */ get rnRootThresholds(): $ReadOnlyArray<number> | null { return this._rootThresholds; } /** * Adds an element to the set of target elements being watched by the * `IntersectionObserver`. * One observer has one set of thresholds and one root, but can watch multiple * target elements for visibility changes. * To stop observing the element, call `IntersectionObserver.unobserve()`. */ observe(target: ReactNativeElement): void { if (target == null) { throw new TypeError( "Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is null or undefined.", ); } if (!(target instanceof ReactNativeElement)) { throw new TypeError( "Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", ); } if (this._observationTargets.has(target)) { return; } const didStartObserving = IntersectionObserverManager.observe({ intersectionObserverId: this._getOrCreateIntersectionObserverId(), target, }); if (didStartObserving) { this._observationTargets.add(target); } } /** * Instructs the `IntersectionObserver` to stop observing the specified target * element. */ unobserve(target: ReactNativeElement): void { if (!(target instanceof ReactNativeElement)) { throw new TypeError( "Failed to execute 'unobserve' on 'IntersectionObserver': parameter 1 is not of type 'ReactNativeElement'.", ); } if (!this._observationTargets.has(target)) { return; } const intersectionObserverId = this._intersectionObserverId; if (intersectionObserverId == null) { // This is unexpected if the target is in `_observationTargets`. console.error( "Unexpected state in 'IntersectionObserver': could not find observer ID to unobserve target.", ); return; } IntersectionObserverManager.unobserve(intersectionObserverId, target); this._observationTargets.delete(target); if (this._observationTargets.size === 0) { IntersectionObserverManager.unregisterObserver(intersectionObserverId); this._intersectionObserverId = null; } } /** * Stops watching all of its target elements for visibility changes. */ disconnect(): void { for (const target of this._observationTargets.keys()) { this.unobserve(target); } } _getOrCreateIntersectionObserverId(): IntersectionObserverId { let intersectionObserverId = this._intersectionObserverId; if (intersectionObserverId == null) { intersectionObserverId = IntersectionObserverManager.registerObserver( this, this._callback, ); this._intersectionObserverId = intersectionObserverId; } return intersectionObserverId; } // Only for tests __getObserverID(): ?IntersectionObserverId { return this._intersectionObserverId; } } /** * Converts the user defined `threshold` value into an array of sorted valid * threshold options for `IntersectionObserver` (double ∈ [0, 1]). * * If `defaultEmpty` is true, then defaults to empty array, otherwise [0]. * * @example * normalizeThresholds(0.5); // → [0.5] * normalizeThresholds([1, 0.5, 0]); // → [0, 0.5, 1] * normalizeThresholds(['1', '0.5', '0']); // → [0, 0.5, 1] * normalizeThresholds(null); // → [0] * normalizeThresholds([null, null]); // → [0, 0] * * normalizeThresholds([null], true); // → [0] * normalizeThresholds(null, true); // → [] * normalizeThresholds([], true); // → [] */ function normalizeThreshold( threshold: mixed, defaultEmpty: boolean = false, ): $ReadOnlyArray<number> { if (Array.isArray(threshold)) { if (threshold.length > 0) { return threshold .map(t => normalizeThresholdValue(t, 'threshold')) .map(t => t ?? 0) .sort(); } else if (defaultEmpty) { return []; } else { return [0]; } } const normalized = normalizeThresholdValue(threshold, 'threshold'); if (normalized == null) { return defaultEmpty ? [] : [0]; } return [normalized]; } /** * Converts the user defined `rnRootThreshold` value into an array of sorted valid * threshold options for `IntersectionObserver` (double ∈ [0, 1]). * * If invalid array or null, returns null. * * @example * normalizeRootThreshold(0.5); // → [0.5] * normalizeRootThresholds([1, 0.5, 0]); // → [0, 0.5, 1] * normalizeRootThresholds([null, '0.5', '0']); // → [0, 0.5] * normalizeRootThresholds(null); // → null * normalizeRootThresholds([null, null]); // → null */ function normalizeRootThreshold( rootThreshold: mixed, ): null | $ReadOnlyArray<number> { if (Array.isArray(rootThreshold)) { const normalizedArr = rootThreshold .map(rt => normalizeThresholdValue(rt, 'rnRootThreshold')) .filter((rt): rt is number => rt != null) .sort(); return normalizedArr.length === 0 ? null : normalizedArr; } const normalized = normalizeThresholdValue(rootThreshold, 'rnRootThreshold'); return normalized == null ? null : [normalized]; } function normalizeThresholdValue( threshold: mixed, property: string, ): null | number { if (threshold == null) { return null; } const thresholdAsNumber = Number(threshold); if (!Number.isFinite(thresholdAsNumber)) { throw new TypeError( `Failed to read the '${property}' property from 'IntersectionObserverInit': The provided double value is non-finite.`, ); } if (thresholdAsNumber < 0 || thresholdAsNumber > 1) { throw new RangeError( "Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1", ); } return thresholdAsNumber; }