UNPKG

react-native-speedy-list

Version:

A performance focused list component for React Native.

450 lines 19 kB
import React from "react"; import { Dimensions, ScrollView, StyleSheet, View, } from "react-native"; import { ObjectUtil } from "../../util/ObjectUtil"; import { ThrottlingUtil } from "../../util/ThrottlingUtil"; import { RecyclableItem } from "../RecyclableItem"; import { INITIAL_BATCH_SIZE, RECYCLABLE_ITEMS_COUNT, RECYCLING_DELAY, } from "./types"; export class RecyclableList extends React.Component { constructor(props) { super(props); /** * True after the first render. * */ this.rendered = false; /** * Used to dynamic calculate the recycled height. * */ this.windowHeight = 0; /** * Auto updated using header view onLayout. * */ this.headerHeight = 0; /** * Sum of all items heights. * */ this.itemsHeightSum = 0; /** * Mean item height. * */ this.meanItemHeight = 0; /** * Current scroll position. * */ this.scrollY = 0; /** * Current scrolling speed. * */ this.scrollSpeed = 0.0; /** * Scroll layout info. * */ this.scrollHeight = 0; this.scrollContentHeight = 0; this.lastOnEndReachedContentHeight = 0; /** * Amount of recyclable items. It should be enough to cover * at least two times the screen size, in order to avoid blank * spaces when scrolling. * */ this.recyclableViewCount = 0; /** * Dictionary of indexes and refs for each recycled view. * */ this.recyclableRefsDict = {}; /** * Current items as a dictionary and as a list for ease access. * */ this.itemsDict = {}; this.itemsList = []; this.itemCount = 0; /** * Helper to show debug logs. * */ this._debug = (...args) => { if (this.props.debug) { console.log(args.length ? "SpeedyList -" : "SpeedyList", ...args); } }; /** * Returns the recycled views. * */ this._getContent = () => { const { itemEquals, itemRenderer, recyclingDelay, initialBatchSize } = this.props; const content = []; for (let index = 0; index < this.recyclableViewCount; index++) { const { item, layout } = this.itemsList[index]; content.push(React.createElement(RecyclableItem, { ref: (ref) => (this.recyclableRefsDict[index] = ref), key: index, layout: layout, itemProps: { item, index, height: layout.height, }, itemRenderer: index < initialBatchSize ? itemRenderer : undefined, itemEquals: itemEquals })); } if (!this.rendered) { let limit = initialBatchSize; const _renderContent = () => { if (this.recyclableRefsDict[this.recyclableViewCount - 1]) { limit += initialBatchSize; this._updateContentThrottled(limit); } if (limit < this.recyclableViewCount) { setTimeout(_renderContent, recyclingDelay); } }; setTimeout(() => { _renderContent(); }, recyclingDelay); } return content; }; /** * Returns which items should be * currently recycled. * */ this._getRecycledItems = (limit = Infinity) => { const recycledItems = []; // Scroll direction info. const goingUp = this.scrollSpeed < -1; const goingDown = this.scrollSpeed > 1; const stationary = !goingUp && !goingDown; const recycledHeight = this.meanItemHeight * this.recyclableViewCount; const heightDiff = recycledHeight - this.windowHeight; const heightDiffHalf = heightDiff / 2; const topEdgeY = stationary ? -heightDiffHalf : goingDown ? -heightDiffHalf / 2.0 : (-heightDiff * 3.0) / 4.0; const bottomEdgeY = topEdgeY + recycledHeight; for (let i = 0; i < Math.min(this.recyclableViewCount, limit); i++) { let itemIndex = i; let itemMeta = this.itemsList[itemIndex]; let middle = itemMeta.layout.top + itemMeta.layout.height / 2.0 + this.headerHeight - this.scrollY; while (itemIndex + this.recyclableViewCount < this.itemCount && middle < bottomEdgeY) { if (topEdgeY - itemMeta.layout.height / 2.0 < middle && middle < bottomEdgeY) { break; } itemIndex += this.recyclableViewCount; itemMeta = this.itemsList[itemIndex]; middle = itemMeta.layout.top + itemMeta.layout.height / 2.0 + this.headerHeight - this.scrollY; } recycledItems.push(itemMeta); } return recycledItems; }; this._validateItemRef = (items, item, refIndex) => { let refIndexAvailable = true; const previousItems = items.filter((i) => i.index < item.index); for (const { index, recyclableViewIndex } of previousItems) { if ( // Whether the current items have the same ref... String(recyclableViewIndex) === String(refIndex) && // And the indexes are too near. item.index - index < this.recyclableViewCount) { // Then, the current refIndex isn't available. refIndexAvailable = false; break; } } return refIndexAvailable; }; /** * Handle scroll changes. * */ this._onScroll = (event) => { var _a, _b, _c; this._debug("_onScroll"); const { nativeEvent } = event; this.scrollY = nativeEvent.contentOffset.y; this.scrollSpeed = ((_a = nativeEvent.velocity) === null || _a === void 0 ? void 0 : _a.y) || 0; this._updateContentThrottled(); this._checkOnEndReached(); if ((_b = this.props.scrollViewProps) === null || _b === void 0 ? void 0 : _b.onScroll) { (_c = this.props.scrollViewProps) === null || _c === void 0 ? void 0 : _c.onScroll(event); } }; /** * Watches scroll layout changes. * */ this._onScrollLayout = (event) => { var _a, _b; this._debug("_onScrollLayout"); const { nativeEvent } = event; this.scrollHeight = nativeEvent.layout.height; this._checkOnEndReached(); if ((_a = this.props.scrollViewProps) === null || _a === void 0 ? void 0 : _a.onLayout) { (_b = this.props.scrollViewProps) === null || _b === void 0 ? void 0 : _b.onLayout(event); } }; /** * Watches scroll content layout changes. * */ this._onScrollContentChange = (width, height) => { this._debug("_onScrollContentChange"); this.scrollContentHeight = height; this._checkOnEndReached(); }; this._checkOnEndReached = () => { const { scrollY, scrollHeight, scrollContentHeight } = this; const { onEndReached, onEndReachedOffset } = this.props; const endOffset = scrollContentHeight - scrollHeight - scrollY; if (typeof onEndReached === "function" && this.lastOnEndReachedContentHeight !== scrollContentHeight && endOffset <= (onEndReachedOffset !== null && onEndReachedOffset !== void 0 ? onEndReachedOffset : 0.0)) { this.lastOnEndReachedContentHeight = scrollContentHeight; onEndReached(); } }; /** * Handles header layout changes. * */ this._onHeaderLayout = (event) => { this._debug("_onHeaderLayout"); this.headerHeight = event.nativeEvent.layout.height; }; /** * Updates which ref should handle each list item. * */ this._updateRefs = (items) => { var _a; this._debug("_updateRefs"); // Temporary ref dict, maps each ref to the respective items. const refItemMap = {}; items.forEach((item) => { if (item.recyclableViewIndex !== null) { if (!refItemMap[item.recyclableViewIndex]) { refItemMap[item.recyclableViewIndex] = {}; } refItemMap[item.recyclableViewIndex][item.key] = item; } }); for (const itemMeta of items) { // Checking if the existing ref is valid. if (itemMeta.recyclableViewIndex !== null && this._validateItemRef(Object.values(refItemMap[itemMeta.recyclableViewIndex]), itemMeta, itemMeta.recyclableViewIndex)) { // If so, just skipping... } else { // Otherwise, looking for an available ref. for (let refIndex = 0; refIndex < this.recyclableViewCount; refIndex++) { if (this._validateItemRef(Object.values((_a = refItemMap[refIndex]) !== null && _a !== void 0 ? _a : {}), itemMeta, refIndex)) { // Updating temporary ref dict. if (itemMeta.recyclableViewIndex !== null) { delete refItemMap[itemMeta.recyclableViewIndex][itemMeta.key]; } if (!refItemMap[refIndex]) { refItemMap[refIndex] = {}; } refItemMap[refIndex][itemMeta.key] = itemMeta; itemMeta.recyclableViewIndex = Number(refIndex); break; } } } } }; /** * Updates the list metadata. * */ this._updateMeta = (props) => { this._debug("_updateMeta"); const { items, itemRenderer, itemHeight, itemKey, recyclableItemsCount, recyclingDelay } = props || this.props; if (!items || typeof items.forEach !== "function") { throw new Error("RecyclableList: the prop 'items' requires a valid array."); } if (typeof itemRenderer !== "function") { throw new Error("RecyclableList: the prop 'itemRenderer' requires a valid function."); } if (typeof itemHeight !== "number" && typeof itemHeight !== "function") { throw new Error("RecyclableList: the prop 'itemHeight' requires a valid number or function."); } if (typeof itemKey !== "string" && typeof itemKey !== "function") { throw new Error("RecyclableList: the prop 'itemKey' requires a valid string or function."); } // Updating throttle delay this._updateContentThrottled = ThrottlingUtil.throttle(this._updateContent, recyclingDelay); let top = 0; const seenKeys = []; const previousItemsDict = { ...this.itemsDict, }; this.itemCount = items.length; this.itemsDict = {}; this.itemsList = []; items.forEach((item, index) => { const key = typeof itemKey === "string" ? String(item[itemKey]) : String(itemKey({ item, index })); if (seenKeys.includes(key)) { console.warn(`SpeedyList: found two items with the same key '${key}'.`); } seenKeys.push(key); let height; if (typeof itemHeight === "function") { height = itemHeight({ item: items[index], index }); } else { height = itemHeight; } const itemMeta = { key, item, index, layout: { top, height, }, recyclableViewIndex: null, }; this.itemsDict[key] = itemMeta; this.itemsList.push(itemMeta); top += height; }); this.windowHeight = Dimensions.get("window").height; this.itemsHeightSum = top; this.meanItemHeight = top / items.length; this.recyclableViewCount = Math.min(recyclableItemsCount, items.length); Object.values(this.itemsDict).forEach((item) => { const previousMeta = previousItemsDict[item.key]; if (previousMeta && previousMeta.recyclableViewIndex !== null && previousMeta.recyclableViewIndex < this.recyclableViewCount) { item.recyclableViewIndex = previousMeta.recyclableViewIndex; } }); }; /** * Updating recycled views for the next * predicted scroll position. * */ this._updateContent = (limit = Infinity) => { var _a; this._debug("_updateContent"); const { itemEquals, itemRenderer } = this.props; const recycledItems = this._getRecycledItems(limit); this._updateRefs(recycledItems); for (const itemMeta of recycledItems) { const ref = this.recyclableRefsDict[(_a = itemMeta.recyclableViewIndex) !== null && _a !== void 0 ? _a : -1]; if (!ref) { this._debug(`Can't find ref at index: ${itemMeta.recyclableViewIndex}. ${Object.values(this.recyclableRefsDict).length} refs available.`); } if (ref) { ref.recycle({ itemEquals, itemRenderer, itemProps: { index: itemMeta.index, item: itemMeta.item, height: itemMeta.layout.height, }, layout: itemMeta.layout, }); } } }; // Throttling content updates to save performance. this._updateContentThrottled = ThrottlingUtil.throttle(this._updateContent); this._debug("constructor"); // Updating metadata based on the initial props. this._updateMeta(); // Already executed by the first render. // this._updateContentThrottled(); } shouldComponentUpdate(nextProps) { let shouldUpdate = false; if (this.props.itemHeight !== nextProps.itemHeight) { this._debug("shouldComponentUpdate: itemHeight diff"); shouldUpdate = true; } if (this.props.itemRenderer !== nextProps.itemRenderer) { this._debug("shouldComponentUpdate: itemRenderer diff"); shouldUpdate = true; } if (this.props.debug !== nextProps.debug) { this._debug("shouldComponentUpdate: debug diff"); shouldUpdate = true; } // Updating if the items changed. if (this.props.items.length !== nextProps.items.length) { this._debug(`shouldComponentUpdate: Length diff ${this.props.items.length} vs ${nextProps.items.length}`); shouldUpdate = true; } else if (typeof nextProps.itemEquals === "function") { let index = 0; for (const item of this.props.items) { if (!nextProps.itemEquals(item, nextProps.items[index])) { this._debug("shouldComponentUpdate: Items diff at:", index); shouldUpdate = true; break; } index++; } } // Updating metadata before the next render. if (shouldUpdate) { this._updateMeta(nextProps); } // Updating if height value changed. return shouldUpdate; } /** * Updating recycled views each list update. * */ componentDidUpdate() { this._updateContentThrottled(); } render() { this._debug("render"); const { scrollViewProps, header, headerStyle, footer, footerStyle, contentStyle } = this.props; const contentHeightStyle = { height: this.itemsHeightSum, }; const content = this._getContent(); this.rendered = true; return (React.createElement(View, { style: styles.scrollWrapper }, React.createElement(ScrollView, { ...scrollViewProps, style: [styles.scroll, scrollViewProps === null || scrollViewProps === void 0 ? void 0 : scrollViewProps.style], contentContainerStyle: [styles.scrollInner, scrollViewProps === null || scrollViewProps === void 0 ? void 0 : scrollViewProps.contentContainerStyle], scrollEventThrottle: RECYCLING_DELAY, onScroll: this._onScroll, onLayout: this._onScrollLayout, onContentSizeChange: this._onScrollContentChange }, React.createElement(View, { style: [styles.header, headerStyle], onLayout: this._onHeaderLayout }, header), React.createElement(View, { style: [styles.content, contentStyle, contentHeightStyle] }, content), React.createElement(View, { style: [styles.footer, footerStyle] }, footer)))); } } RecyclableList.defaultProps = { itemEquals: ObjectUtil.equals, recyclingDelay: RECYCLING_DELAY, initialBatchSize: INITIAL_BATCH_SIZE, recyclableItemsCount: RECYCLABLE_ITEMS_COUNT, onEndReachedOffset: 0, }; const styles = StyleSheet.create({ scrollWrapper: { flex: 1, }, scroll: { flex: 1, }, scrollInner: { flexGrow: 1, }, header: { flexDirection: "column", alignItems: "stretch", width: "100%", }, footer: { flexDirection: "column", alignItems: "stretch", width: "100%", }, content: { flexDirection: "column", alignItems: "stretch", width: "100%", }, recyclableItem: { position: "absolute", left: 0, right: 0, }, topLine: { position: "absolute", top: 0, }, bottomLine: { position: "absolute", bottom: 0, }, }); //# sourceMappingURL=index.js.map