diff --git a/package.json b/package.json index 45602682..e11df4d6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "clean": "sh scripts/clean.sh", "release": "sh scripts/publish-latest.sh", "release-beta": "sh scripts/publish-beta.sh", - "release-local": "sh scripts/publish-local.sh" + "release-local": "sh scripts/publish-local.sh", + "prepare": "npm run build" }, "keywords": [ "react-native", @@ -34,6 +35,7 @@ }, "homepage": "https://github.com/Flipkart/recyclerlistview", "dependencies": { + "@types/lodash": "4.14.182", "lodash.debounce": "4.0.8", "prop-types": "15.8.1", "ts-object-utils": "0.0.5" @@ -47,9 +49,12 @@ "@types/prop-types": "15.5.2", "@types/react-native": "0.49.5", "@types/react": "16.4.7", - "@types/resize-observer-browser": "^0.1.7", + "@types/resize-observer-browser": "0.1.7", "file-directives": "1.4.6", "tslint": "5.11.0", "typescript": "3.3.1" + }, + "resolutions": { + "@types/lodash": "4.14.182" } } diff --git a/src/core/RecyclerListView.tsx b/src/core/RecyclerListView.tsx index 95dd87be..e9c23802 100644 --- a/src/core/RecyclerListView.tsx +++ b/src/core/RecyclerListView.tsx @@ -76,6 +76,11 @@ export interface OnRecreateParams { lastOffset?: number; } +export interface ViewabilityConfig { + minimumItemViewPercentage: number; + minimumViewTime: number; +} + export interface RecyclerListViewProps { layoutProvider: BaseLayoutProvider; dataProvider: BaseDataProvider; @@ -110,6 +115,7 @@ export interface RecyclerListViewProps { //For all props that need to be proxied to inner/external scrollview. Put them in an object and they'll be spread //and passed down. For better typescript support. scrollViewProps?: object; + viewabilityConfig?: ViewabilityConfig; applyWindowCorrection?: (offsetX: number, offsetY: number, windowCorrection: WindowCorrection) => void; onItemLayout?: (index: number) => void; windowCorrectionConfig?: { value?: WindowCorrection, applyToInitialOffset?: boolean, applyToItemScroll?: boolean }; @@ -141,6 +147,7 @@ export default class RecyclerListView
{ return this.props.dataProvider.getStableId(index); - }, !props.disableRecycling); + }, !props.disableRecycling, props.viewabilityConfig); if (this.props.windowCorrectionConfig) { let windowCorrection; @@ -228,6 +235,9 @@ export default class RecyclerListView
= new Set();
- constructor(renderAheadOffset: number, initialOffset: number) {
+ constructor(renderAheadOffset: number, initialOffset: number, viewabilityConfig: ViewabilityConfig | undefined) {
this._currentOffset = Math.max(0, initialOffset);
this._maxOffset = 0;
this._actualOffset = 0;
@@ -52,12 +57,14 @@ export default class ViewabilityTracker {
this._visibleIndexes = []; //needs to be sorted
this._engagedIndexes = []; //needs to be sorted
+ this._lastReportedVisibleIndexes = [];
this.onVisibleRowsChanged = null;
this.onEngagedRowsChanged = null;
this._relevantDim = { start: 0, end: 0 };
this._defaultCorrection = { startCorrection: 0, endCorrection: 0, windowShift: 0 };
+ this._viewabilityConfig = viewabilityConfig;
}
public init(windowCorrection: WindowCorrection): void {
@@ -85,6 +92,10 @@ export default class ViewabilityTracker {
this.updateOffset(offset, false, this._defaultCorrection);
}
+ public updateViewabilityConfig(newViewabilityConfig: ViewabilityConfig): void {
+ this._viewabilityConfig = newViewabilityConfig;
+ }
+
public updateOffset(offset: number, isActual: boolean, windowCorrection: WindowCorrection): void {
let correctedOffset = offset;
if (isActual) {
@@ -142,6 +153,11 @@ export default class ViewabilityTracker {
this.forceRefreshWithOffset(this._currentOffset);
}
+ public resetViewabilityTracking(): void {
+ this._visibleIndexes = [];
+ this._lastReportedVisibleIndexes = [];
+ }
+
public getCurrentRenderAheadOffset(): number {
return this._renderAheadOffset;
}
@@ -149,6 +165,11 @@ export default class ViewabilityTracker {
this._actualOffset = actualOffset;
}
+ public timerCleanup(): void {
+ this.timers.forEach(clearTimeout);
+ this.timers.clear();
+ }
+
private _findFirstVisibleIndexOptimally(): number {
let firstVisibleIndex = 0;
@@ -185,7 +206,8 @@ export default class ViewabilityTracker {
for (let i = 0; i < count; i++) {
itemRect = this._layouts[i];
this._setRelevantBounds(itemRect, relevantDim);
- if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) {
+ const minimumItemViewPercentage = this._viewabilityConfig && this._viewabilityConfig.minimumItemViewPercentage || undefined;
+ if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end, minimumItemViewPercentage)) {
return i;
}
}
@@ -242,7 +264,8 @@ export default class ViewabilityTracker {
const itemRect = this._layouts[index];
let isFound = false;
this._setRelevantBounds(itemRect, relevantDim);
- if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end)) {
+ const mininumViewPercentage = this._viewabilityConfig && this._viewabilityConfig.minimumItemViewPercentage || undefined;
+ if (this._itemIntersectsVisibleWindow(relevantDim.start, relevantDim.end, mininumViewPercentage)) {
if (insertOnTop) {
newVisibleIndexes.splice(0, 0, index);
newEngagedIndexes.splice(0, 0, index);
@@ -297,8 +320,37 @@ export default class ViewabilityTracker {
return this._itemIntersectsWindow(this._engagedWindow, startBound, endBound);
}
- private _itemIntersectsVisibleWindow(startBound: number, endBound: number): boolean {
- return this._itemIntersectsWindow(this._visibleWindow, startBound, endBound);
+ // Checks if list item is visible in viewport as per minimum view poercentage
+ private _isItemInVisibleBounds(window: Range, itemStartBound: number, itemEndBound: number, mininumViewPercentage: number | undefined): boolean {
+ let visibleItemContent = 0;
+ const itemSize = itemEndBound - itemStartBound;
+
+ if (window.start >= itemStartBound && window.end >= itemEndBound) {
+ // List item is visible in viewport from screen top
+ visibleItemContent = itemEndBound - window.start;
+ } else if (window.start <= itemStartBound && window.end <= itemEndBound) {
+ // List item is visible in viewport from screen bottom
+ visibleItemContent = window.end - itemStartBound;
+ } else if (window.start <= itemStartBound && window.end >= itemEndBound) {
+ // Entire list item is visible in viewport
+ visibleItemContent = itemEndBound - itemStartBound;
+ } else if (window.start >= itemStartBound && window.end <= itemEndBound) {
+ // List item is covering the entire screen
+ return true;
+ } else {
+ // List item is not visible in viewport
+ return false;
+ }
+
+ const isVisible = mininumViewPercentage
+ ? visibleItemContent / itemSize * 100 >= mininumViewPercentage
+ : visibleItemContent > 0;
+ return isVisible;
+ }
+
+ private _itemIntersectsVisibleWindow(startBound: number, endBound: number, mininumViewPercentage?: number): boolean {
+ return this._isItemInVisibleBounds(this._visibleWindow, startBound, endBound, mininumViewPercentage) ||
+ this._isZeroHeightEdgeElement(this._visibleWindow, startBound, endBound);
}
private _updateTrackingWindows(offset: number, correction: WindowCorrection): void {
@@ -317,18 +369,46 @@ export default class ViewabilityTracker {
//TODO:Talha optimize this
private _diffUpdateOriginalIndexesAndRaiseEvents(newVisibleItems: number[], newEngagedItems: number[]): void {
- this._diffArraysAndCallFunc(newVisibleItems, this._visibleIndexes, this.onVisibleRowsChanged);
+ const minimumViewTime = this._viewabilityConfig && this._viewabilityConfig.minimumViewTime
+ ? this._viewabilityConfig.minimumViewTime
+ : 0;
+ this._diffArraysAndCallFunc(newVisibleItems, this._visibleIndexes, this.onVisibleRowsChanged, minimumViewTime);
this._diffArraysAndCallFunc(newEngagedItems, this._engagedIndexes, this.onEngagedRowsChanged);
this._visibleIndexes = newVisibleItems;
this._engagedIndexes = newEngagedItems;
}
- private _diffArraysAndCallFunc(newItems: number[], oldItems: number[], func: TOnItemStatusChanged | null): void {
+ private checkMinimumViewTime = (all: number[], now: number[], notNow: number[], minimumViewTime: number, callbackFunc: TOnItemStatusChanged): void => {
+ const that = this;
+ const timeoutId = setTimeout(() => {
+ that.timers.delete(timeoutId);
+
+ const currAll = all.filter((index) => that._visibleIndexes.indexOf(index) >= 0);
+ const currNow = currAll.filter((index) => that._lastReportedVisibleIndexes.indexOf(index) === -1);
+ const currNotNow = that._lastReportedVisibleIndexes.filter((index) => currAll.indexOf(index) === -1);
+
+ if (currAll.length > 0 && (currNow.length > 0 || currNotNow.length > 0)) {
+ that._lastReportedVisibleIndexes = currAll;
+ callbackFunc(currAll, currNow, currNotNow);
+ }
+ }, minimumViewTime);
+ this.timers.add(timeoutId);
+ }
+
+ private _diffArraysAndCallFunc(newItems: number[], oldItems: number[], func: TOnItemStatusChanged | null, minimumViewTime?: number): 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);
+ if (minimumViewTime) {
+ // Adding default minimum view time for performance optimization
+ const finalMinViewTime = minimumViewTime <= Constants.DEFAULT_MIN_VIEW_TIME
+ ? Constants.DEFAULT_MIN_VIEW_TIME
+ : minimumViewTime;
+ this.checkMinimumViewTime([...newItems], now, notNow, finalMinViewTime, func);
+ } else {
+ func([...newItems], now, notNow);
+ }
}
}
}
diff --git a/src/core/VirtualRenderer.ts b/src/core/VirtualRenderer.ts
index d5ae0af4..c749f893 100644
--- a/src/core/VirtualRenderer.ts
+++ b/src/core/VirtualRenderer.ts
@@ -7,6 +7,7 @@ import ViewabilityTracker, { TOnItemStatusChanged, WindowCorrection } from "./Vi
import { ObjectUtil, Default } from "ts-object-utils";
import TSCast from "../utils/TSCast";
import { BaseDataProvider } from "./dependencies/DataProvider";
+import { ViewabilityConfig } from "./RecyclerListView";
/***
* Renderer which keeps track of recyclable items and the currently rendered items. Notifies list view to re render if something changes, like scroll offset
@@ -52,11 +53,13 @@ export default class VirtualRenderer {
private _viewabilityTracker: ViewabilityTracker | null = null;
private _dimensions: Dimension | null;
private _optimizeForAnimations: boolean = false;
+ private _viewabilityConfig: ViewabilityConfig | undefined = undefined;
constructor(renderStackChanged: (renderStack: RenderStack) => void,
scrollOnNextUpdate: (point: Point) => void,
fetchStableId: StableIdProvider,
- isRecyclingEnabled: boolean) {
+ isRecyclingEnabled: boolean,
+ viewabilityConfig?: ViewabilityConfig) {
//Keeps track of items that need to be rendered in the next render cycle
this._renderStack = {};
@@ -78,6 +81,8 @@ export default class VirtualRenderer {
this._startKey = 0;
this.onVisibleItemsChanged = null;
+
+ this._viewabilityConfig = viewabilityConfig;
}
public getLayoutDimension(): Dimension {
@@ -112,11 +117,26 @@ export default class VirtualRenderer {
this.onVisibleItemsChanged = callback;
}
+ public updateViewabilityConfig(newViewabilityConfig: ViewabilityConfig): void {
+ this._viewabilityConfig = newViewabilityConfig;
+ if (this._viewabilityTracker) {
+ this._viewabilityTracker.updateViewabilityConfig(newViewabilityConfig);
+ }
+ }
+
+ public timerCleanup(): void {
+ if (!this._viewabilityTracker) {
+ return;
+ }
+ this._viewabilityTracker.timerCleanup();
+ }
+
public removeVisibleItemsListener(): void {
this.onVisibleItemsChanged = null;
if (this._viewabilityTracker) {
this._viewabilityTracker.onVisibleRowsChanged = null;
+ this._viewabilityTracker.timerCleanup();
}
}
@@ -192,9 +212,10 @@ export default class VirtualRenderer {
if (this._params) {
this._viewabilityTracker = new ViewabilityTracker(
Default.value