react-native-web
Version:
React Native for Web
210 lines (203 loc) • 8.15 kB
JavaScript
/**
* 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.
*
*
* @format
*/
'use strict';
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default;
exports.__esModule = true;
exports.default = void 0;
var _objectSpread2 = _interopRequireDefault(require("@babel/runtime/helpers/objectSpread2"));
var _createForOfIteratorHelperLoose2 = _interopRequireDefault(require("@babel/runtime/helpers/createForOfIteratorHelperLoose"));
var _invariant = _interopRequireDefault(require("fbjs/lib/invariant"));
/**
* A Utility class for calculating viewable items based on current metrics like scroll position and
* layout.
*
* An item is said to be in a "viewable" state when any of the following
* is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction`
* is true):
*
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
* visible in the view area >= `itemVisiblePercentThreshold`.
* - Entirely visible on screen
*/
class ViewabilityHelper {
constructor(config) {
if (config === void 0) {
config = {
viewAreaCoveragePercentThreshold: 0
};
}
this._hasInteracted = false;
this._timers = new Set();
this._viewableIndices = [];
this._viewableItems = new Map();
this._config = config;
}
/**
* Cleanup, e.g. on unmount. Clears any pending timers.
*/
dispose() {
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
* comment suppresses an error found when Flow v0.63 was deployed. To see
* the error delete this comment and run Flow. */
this._timers.forEach(clearTimeout);
}
/**
* Determines which items are viewable based on the current metrics and config.
*/
computeViewableItems(props, scrollOffset, viewportHeight, getFrameMetrics,
// Optional optimization to reduce the scan size
renderRange) {
var itemCount = props.getItemCount(props.data);
var _this$_config = this._config,
itemVisiblePercentThreshold = _this$_config.itemVisiblePercentThreshold,
viewAreaCoveragePercentThreshold = _this$_config.viewAreaCoveragePercentThreshold;
var viewAreaMode = viewAreaCoveragePercentThreshold != null;
var viewablePercentThreshold = viewAreaMode ? viewAreaCoveragePercentThreshold : itemVisiblePercentThreshold;
(0, _invariant.default)(viewablePercentThreshold != null && itemVisiblePercentThreshold != null !== (viewAreaCoveragePercentThreshold != null), 'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold');
var viewableIndices = [];
if (itemCount === 0) {
return viewableIndices;
}
var firstVisible = -1;
var _ref = renderRange || {
first: 0,
last: itemCount - 1
},
first = _ref.first,
last = _ref.last;
if (last >= itemCount) {
console.warn('Invalid render range computing viewability ' + JSON.stringify({
renderRange,
itemCount
}));
return [];
}
for (var idx = first; idx <= last; idx++) {
var metrics = getFrameMetrics(idx, props);
if (!metrics) {
continue;
}
var top = metrics.offset - scrollOffset;
var bottom = top + metrics.length;
if (top < viewportHeight && bottom > 0) {
firstVisible = idx;
if (_isViewable(viewAreaMode, viewablePercentThreshold, top, bottom, viewportHeight, metrics.length)) {
viewableIndices.push(idx);
}
} else if (firstVisible >= 0) {
break;
}
}
return viewableIndices;
}
/**
* Figures out which items are viewable and how that has changed from before and calls
* `onViewableItemsChanged` as appropriate.
*/
onUpdate(props, scrollOffset, viewportHeight, getFrameMetrics, createViewToken, onViewableItemsChanged,
// Optional optimization to reduce the scan size
renderRange) {
var itemCount = props.getItemCount(props.data);
if (this._config.waitForInteraction && !this._hasInteracted || itemCount === 0 || !getFrameMetrics(0, props)) {
return;
}
var viewableIndices = [];
if (itemCount) {
viewableIndices = this.computeViewableItems(props, scrollOffset, viewportHeight, getFrameMetrics, renderRange);
}
if (this._viewableIndices.length === viewableIndices.length && this._viewableIndices.every((v, ii) => v === viewableIndices[ii])) {
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
// extra work in those cases.
return;
}
this._viewableIndices = viewableIndices;
if (this._config.minimumViewTime) {
var handle = setTimeout(() => {
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
* comment suppresses an error found when Flow v0.63 was deployed. To
* see the error delete this comment and run Flow. */
this._timers.delete(handle);
this._onUpdateSync(props, viewableIndices, onViewableItemsChanged, createViewToken);
}, this._config.minimumViewTime);
/* $FlowFixMe[incompatible-call] (>=0.63.0 site=react_native_fb) This
* comment suppresses an error found when Flow v0.63 was deployed. To see
* the error delete this comment and run Flow. */
this._timers.add(handle);
} else {
this._onUpdateSync(props, viewableIndices, onViewableItemsChanged, createViewToken);
}
}
/**
* clean-up cached _viewableIndices to evaluate changed items on next update
*/
resetViewableIndices() {
this._viewableIndices = [];
}
/**
* Records that an interaction has happened even if there has been no scroll.
*/
recordInteraction() {
this._hasInteracted = true;
}
_onUpdateSync(props, viewableIndicesToCheck, onViewableItemsChanged, createViewToken) {
// Filter out indices that have gone out of view since this call was scheduled.
viewableIndicesToCheck = viewableIndicesToCheck.filter(ii => this._viewableIndices.includes(ii));
var prevItems = this._viewableItems;
var nextItems = new Map(viewableIndicesToCheck.map(ii => {
var viewable = createViewToken(ii, true, props);
return [viewable.key, viewable];
}));
var changed = [];
for (var _iterator = (0, _createForOfIteratorHelperLoose2.default)(nextItems), _step; !(_step = _iterator()).done;) {
var _step$value = _step.value,
key = _step$value[0],
viewable = _step$value[1];
if (!prevItems.has(key)) {
changed.push(viewable);
}
}
for (var _iterator2 = (0, _createForOfIteratorHelperLoose2.default)(prevItems), _step2; !(_step2 = _iterator2()).done;) {
var _step2$value = _step2.value,
_key = _step2$value[0],
_viewable = _step2$value[1];
if (!nextItems.has(_key)) {
changed.push((0, _objectSpread2.default)((0, _objectSpread2.default)({}, _viewable), {}, {
isViewable: false
}));
}
}
if (changed.length > 0) {
this._viewableItems = nextItems;
onViewableItemsChanged({
viewableItems: Array.from(nextItems.values()),
changed,
viewabilityConfig: this._config
});
}
}
}
function _isViewable(viewAreaMode, viewablePercentThreshold, top, bottom, viewportHeight, itemLength) {
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
return true;
} else {
var pixels = _getPixelsVisible(top, bottom, viewportHeight);
var percent = 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
return percent >= viewablePercentThreshold;
}
}
function _getPixelsVisible(top, bottom, viewportHeight) {
var visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
return Math.max(0, visibleHeight);
}
function _isEntirelyVisible(top, bottom, viewportHeight) {
return top >= 0 && bottom <= viewportHeight && bottom > top;
}
var _default = exports.default = ViewabilityHelper;
module.exports = exports.default;