import BinarySearch from "../utils/BinarySearch"; import { Dimension } from "./dependencies/LayoutProvider"; import { Layout } from "./layoutmanager/LayoutManager"; /*** * Given an offset this utility can compute visible items. Also tracks previously visible items to compute items which get hidden or visible * Virtual renderer uses callbacks from this utility to main recycle pool and the render stack. * The utility optimizes finding visible indexes by using the last visible items. However, that can be slow if scrollToOffset is explicitly called. * We use binary search to optimize in most cases like while finding first visible item or initial offset. In future we'll also be using BS to speed up * scroll to offset. */ export interface Range { start: number; end: number; } export interface WindowCorrection { windowShift: number; startCorrection: number; endCorrection: number; } export type TOnItemStatusChanged = ((all: number[], now: number[], notNow: number[]) => void); export default class ViewabilityTracker { public onVisibleRowsChanged: TOnItemStatusChanged | null; public onEngagedRowsChanged: TOnItemStatusChanged | null; private _currentOffset: number; private _maxOffset: number; private _renderAheadOffset: number; private _visibleWindow: Range; private _engagedWindow: Range; private _relevantDim: Range; private _isHorizontal: boolean; private _windowBound: number; private _visibleIndexes: number[]; private _engagedIndexes: number[]; private _layouts: Layout[] = []; private _actualOffset: number; private _defaultCorrection: WindowCorrection; constructor(renderAheadOffset: number, initialOffset: number) { this._currentOffset = Math.max(0, initialOffset); this._maxOffset = 0; this._actualOffset = 0; this._renderAheadOffset = renderAheadOffset; this._visibleWindow = { start: 0, end: 0 }; this._engagedWindow = { start: 0, end: 0 }; this._isHorizontal = false; this._windowBound = 0; this._visibleIndexes = []; //needs to be sorted this._engagedIndexes = []; //needs to be sorted this.onVisibleRowsChanged = null; this.onEngagedRowsChanged = null; this._relevantDim = { start: 0, end: 0 }; this._defaultCorrection = { startCorrection: 0, endCorrection: 0, windowShift: 0 }; } public init(windowCorrection: WindowCorrection): void { this._doInitialFit(this._currentOffset, windowCorrection); } public setLayouts(layouts: Layout[], maxOffset: number): void { this._layouts = layouts; this._maxOffset = maxOffset; } public setDimensions(dimension: Dimension, isHorizontal: boolean): void { this._isHorizontal = isHorizontal; this._windowBound = isHorizontal ? dimension.width : dimension.height; } public forceRefresh(): boolean { const shouldForceScroll = this._currentOffset >= (this._maxOffset - this._windowBound); this.forceRefreshWithOffset(this._currentOffset); return shouldForceScroll; } public forceRefreshWithOffset(offset: number): void { this._currentOffset = -1; this.updateOffset(offset, false, this._defaultCorrection); } public updateOffset(offset: number, isActual: boolean, windowCorrection: WindowCorrection): void { let correctedOffset = offset; if (isActual) { this._actualOffset = offset; correctedOffset = Math.min(this._maxOffset, Math.max(0, offset + (windowCorrection.windowShift + windowCorrection.startCorrection))); } if (this._currentOffset !== correctedOffset) { this._currentOffset = correctedOffset; this._updateTrackingWindows(offset, windowCorrection); let startIndex = 0; if (this._visibleIndexes.length > 0) { startIndex = this._visibleIndexes[0]; } this._fitAndUpdate(startIndex); } } public getLastOffset(): number { return this._currentOffset; } public getLastActualOffset(): number { return this._actualOffset; } public getEngagedIndexes(): number[] { return this._engagedIndexes; } public findFirstLogicallyVisibleIndex(): number { const relevantIndex = this._findFirstVisibleIndexUsingBS(0.001); let result = relevantIndex; for (let i = relevantIndex - 1; i >= 0; i--) { if (this._isHorizontal) { if (this._layouts[relevantIndex].x !== this._layouts[i].x) { break; } else { result = i; } } else { if (this._layouts[relevantIndex].y !== this._layouts[i].y) { break; } else { result = i; } } } return result; } public updateRenderAheadOffset(renderAheadOffset: number): void { this._renderAheadOffset = Math.max(0, renderAheadOffset); this.forceRefreshWithOffset(this._currentOffset); } public getCurrentRenderAheadOffset(): number { return this._renderAheadOffset; } public setActualOffset(actualOffset: number): void { this._actualOffset = actualOffset; } private _findFirstVisibleIndexOptimally(): number { let firstVisibleIndex = 0; //TODO: Talha calculate this value smartly if (this._currentOffset > 5000) { firstVisibleIndex = this._findFirstVisibleIndexUsingBS(); } else if (this._currentOffset > 0) { firstVisibleIndex = this._findFirstVisibleIndexLinearly(); } return firstVisibleIndex; } private _fitAndUpdate(startIndex: number): void { const newVisibleItems: number[] = []; const newEngagedItems: number[] = []; this._fitIndexes(newVisibleItems, newEngagedItems, startIndex, true); this._fitIndexes(newVisibleItems, newEngagedItems, startIndex + 1, false); this._diffUpdateOriginalIndexesAndRaiseEvents(newVisibleItems, newEngagedItems); } private _doInitialFit(offset: number, windowCorrection: WindowCorrection): void { offset = Math.min(this._maxOffset, Math.max(0, offset)); this._updateTrackingWindows(offset, windowCorrection); const firstVisibleIndex = this._findFirstVisibleIndexOptimally(); this._fitAndUpdate(firstVisibleIndex); } //TODO:Talha switch to binary search and remove atleast once logic in _fitIndexes private _findFirstVisibleIndexLinearly(): number { const count = this._layouts.length; let itemRect = null; const relevantDim = { start: 0, end: 0 }; for (let i = 0; i < count; i++) { itemRect = this._layouts[i]; this._setRelevantBounds(itemRect, relevantDim); if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) { return i; } } return 0; } private _findFirstVisibleIndexUsingBS(bias = 0): number { const count = this._layouts.length; return BinarySearch.findClosestHigherValueIndex(count, this._visibleWindow.start + bias, this._valueExtractorForBinarySearch); } private _valueExtractorForBinarySearch = (index: number): number => { const itemRect = this._layouts[index]; this._setRelevantBounds(itemRect, this._relevantDim); return this._relevantDim.end; } //TODO:Talha Optimize further in later revisions, alteast once logic can be replace with a BS lookup private _fitIndexes(newVisibleIndexes: number[], newEngagedIndexes: number[], startIndex: number, isReverse: boolean): void { const count = this._layouts.length; const relevantDim: Range = { start: 0, end: 0 }; let i = 0; let atLeastOneLocated = false; if (startIndex < count) { if (!isReverse) { for (i = startIndex; i < count; i++) { if (this._checkIntersectionAndReport(i, false, relevantDim, newVisibleIndexes, newEngagedIndexes)) { atLeastOneLocated = true; } else { if (atLeastOneLocated) { break; } } } } else { for (i = startIndex; i >= 0; i--) { if (this._checkIntersectionAndReport(i, true, relevantDim, newVisibleIndexes, newEngagedIndexes)) { atLeastOneLocated = true; } else { if (atLeastOneLocated) { break; } } } } } } private _checkIntersectionAndReport(index: number, insertOnTop: boolean, relevantDim: Range, newVisibleIndexes: number[], newEngagedIndexes: number[]): boolean { const itemRect = this._layouts[index]; let isFound = false; this._setRelevantBounds(itemRect, relevantDim); if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) { if (insertOnTop) { newVisibleIndexes.splice(0, 0, index); newEngagedIndexes.splice(0, 0, index); } else { newVisibleIndexes.push(index); newEngagedIndexes.push(index); } isFound = true; } else if (this._itemIntersectsEngagedWindow(relevantDim.start, relevantDim.end)) { //TODO: This needs to be optimized if (insertOnTop) { newEngagedIndexes.splice(0, 0, index); } else { newEngagedIndexes.push(index); } isFound = true; } return isFound; } private _setRelevantBounds(itemRect: Layout, relevantDim: Range): void { if (this._isHorizontal) { relevantDim.end = itemRect.x + itemRect.width; relevantDim.start = itemRect.x; } else { relevantDim.end = itemRect.y + itemRect.height; relevantDim.start = itemRect.y; } } private _isItemInBounds(window: Range, itemBound: number): boolean { return (window.start < itemBound && window.end > itemBound); } private _isItemBoundsBeyondWindow(window: Range, startBound: number, endBound: number): boolean { return (window.start >= startBound && window.end <= endBound); } private _isZeroHeightEdgeElement(window: Range, startBound: number, endBound: number): boolean { return startBound - endBound === 0 && (window.start === startBound || window.end === endBound); } private _itemIntersectsWindow(window: Range, startBound: number, endBound: number): boolean { return this._isItemInBounds(window, startBound) || this._isItemInBounds(window, endBound) || this._isItemBoundsBeyondWindow(window, startBound, endBound) || this._isZeroHeightEdgeElement(window, startBound, endBound); } private _itemIntersectsEngagedWindow(startBound: number, endBound: number): boolean { return this._itemIntersectsWindow(this._engagedWindow, startBound, endBound); } private _itemIntersectsVisibleWindow(startBound: number, endBound: number): boolean { return this._itemIntersectsWindow(this._visibleWindow, startBound, endBound); } private _updateTrackingWindows(offset: number, correction: WindowCorrection): void { const startCorrection = correction.windowShift + correction.startCorrection; const bottomCorrection = correction.windowShift + correction.endCorrection; const startOffset = offset + startCorrection; const endOffset = (offset + this._windowBound) + bottomCorrection; this._engagedWindow.start = Math.max(0, startOffset - this._renderAheadOffset); this._engagedWindow.end = endOffset + this._renderAheadOffset; this._visibleWindow.start = startOffset; this._visibleWindow.end = endOffset; } //TODO:Talha optimize this private _diffUpdateOriginalIndexesAndRaiseEvents(newVisibleItems: number[], newEngagedItems: number[]): void { this._diffArraysAndCallFunc(newVisibleItems, this._visibleIndexes, this.onVisibleRowsChanged); this._diffArraysAndCallFunc(newEngagedItems, this._engagedIndexes, this.onEngagedRowsChanged); this._visibleIndexes = newVisibleItems; this._engagedIndexes = newEngagedItems; } private _diffArraysAndCallFunc(newItems: number[], oldItems: number[], func: TOnItemStatusChanged | null): void { if (func) { const now = this._calculateArrayDiff(newItems, oldItems); const notNow = this._calculateArrayDiff(oldItems, newItems); if (now.length > 0 || notNow.length > 0) { func([...newItems], now, notNow); } } } //TODO:Talha since arrays are sorted this can be much faster private _calculateArrayDiff(arr1: number[], arr2: number[]): number[] { const len = arr1.length; const diffArr = []; for (let i = 0; i < len; i++) { if (BinarySearch.findIndexOf(arr2, arr1[i]) === -1) { diffArr.push(arr1[i]); } } return diffArr; } }