Problem Explanation
I have grid with a bunch of square tiles in it(about 120). If only 8 tiles are visible at a time, then I only want to render about 16 tiles.
- The 8 visible tiles, plus a buffer
When scrolling, when the top 4 or so tiles scroll off the screen, these tiles would be recycled, have the information in them replaced with the information for next 4 tiles, and then be placed in the buffer so that they are displayed when the user scrolls a bit more.
This really optimizes the component if you have a lot of tiles, because you only need to render the ones that are visible, plus a buffer.
Further Reference
This kind of concept is implemented in React Native's Flatlist, as well as Recycler View. I have seen some node modules
which look like a react.js version of a Flatlist, but I do not think most of these will work, because I am trying to implement this concept using a CSS Grid type layout.
I have also heard this problem described as dequeuing
Best online solution
After looking at a lot of node modules, I have found an example node module of this concept which uses a grid type layout called InfiniteScrollGrid, which has a live demo. The main problem with this node module is that it is old, unmaintained, uses old React styles, has security vulnerabilities, and I couldn't even get it to compile.
One other issue is that I would like the individual tiles to maintain their original width:height ratio as the user changes the width of their web browser
Attempted Solution
Below I have tried to implement the functionality of the node module myself using hooks. I've tried my best to replicate it, but feel like I haven't fully wrapped my head around all the code in that node module.
There is a codesandbox and github repo for the example below.
- Note: For some reason the codesandbox has some errors that do not appear locally even though I forked the sandbox from the Github
InfiniteScrollGrid.js
import React, {useState, useEffect, useRef, memo} from 'react';
import PropTypes from 'prop-types';
import Item from './Item';
//Inifinately scrolling grid, that only renders as many items as visible on the screen
//Based on React Native FlatList, and this Github Repo https://github.com/ggordan/react-infinite-grid
const InfiniteScrollGrid = memo(({
buffer = 10,
padding = 10,
entries = [],
height = 250,
width = 250,
wrapHeight,
lazyCallback,
renderRangeCallback
}) => {
if (!entries.length) return null;
const itemHeightConst = height + (2 * padding);
const itemWidthConst = width + (2 * padding);
const wrapper = useRef();
const grid = useRef();
const [initiatedLazyload, setInitiatedLazyload] = useState(false);
const [numEntries, setNumEntries] = useState(entries.length);
const [minHeight, setMinHeight] = useState(2);
const [minItemIndex, setMinItemIndex] = useState(0);
const [maxItemIndex, setMaxItemIndex] = useState(100);
const [wrapperHeight, setWrapperHeight] = useState(wrapHeight);
const [itemHeight, setItemHeight] = useState(itemHeightConst);
const [itemWidth, setItemWidth] = useState(itemHeightConst);
const [itemGridWidth, setItemGridWidth] = useState(0);
const [itemsPerRow, setItemsPerRow] = useState(2);
const [initiatedLazyLoad, setInitiatedLazyLoad] = useState(false);
const gridHeight = Math.floor(entries.length / itemsPerRow) * itemHeight;
const getItemsPerRow = Math.floor(grid?.current?.clientWidth / itemWidthConst);
const numVisibleRows = Math.ceil(wrapper?.current?.height / itemHeight);
const scrolledPastRows = Math.floor((grid?.current?.height - grid?.current?.bottom) / itemHeight);
const [scrollOffset, setScrollOffset] = useState('');
const styles = {
wrapper: {
maxHeight: grid?.current?.clientHeight,
overflowY: 'scroll',
width: '100%',
height: wrapperHeight,
WebkitOverflowScrolling: true
},
grid: {
position: 'relative',
marginTop: padding,
marginLeft: padding,
minHeight: grid?.current?.clientHeight
}
};
const totalRows = () => {
const scrolledPastHeight = (entries.length / itemsPerRow) * itemHeightConst;
return scrolledPastHeight < 0 ? 0 : scrolledPastHeight;
};
const visibleIndexes = () => {
// The number of rows that the user has scrolled past
let scrolledPast = Math.max((scrolledPastRows * itemsPerRow), 0);
let min = Math.max((scrolledPast - itemsPerRow), 0);
let bufferRows = numVisibleRows + buffer;
let max = min(scrolledPast + (itemsPerRow * bufferRows), entries.length);
setMinItemIndex(min);
setMaxItemIndex(max);
};
const updateItemDimensions = () => {
// setItemHeight(itemHeightConst);
// setItemWidth(itemWidthConst);
setItemGridWidth(grid?.current?.width);
setItemsPerRow(getItemsPerRow());
setMinHeight(totalRows());
};
const scrollListener = (event) => {
clearTimeout(scrollOffset);
setScrollOffset(setTimeout(() => visibleIndexes(), 10));
};
useEffect(() => {
const resizeListener = () => {
if (!wrapperHeight) setWrapperHeight(window?.innerHeight);
updateItemDimensions();
visibleIndexes();
};
window.addEventListener('resize', resizeListener);
updateItemDimensions();
visibleIndexes();
return window.removeEventListener('resize', resizeListener);
}, []);
useEffect(() => { //TODO: May need to disable on the first render
if (!initiatedLazyload && (maxItemIndex === entries.length) && lazyCallback) {
setInitiatedLazyLoad(true);
lazyCallback(maxItemIndex);
};
}, [minItemIndex, maxItemIndex]);
useEffect(() => {
if (entries.length > numEntries) {
setInitiatedLazyLoad(false);
setNumEntries(entries.length);
};
visibleIndexes();
}, [entries]);
useEffect(() => {
if (typeof renderRangeCallback === 'function') {
renderRangeCallback(minItemIndex, maxItemIndex); //TODO: What is this about
};
}, [buffer, padding, entries, height, width, wrapHeight, lazyCallback, renderRangeCallback, initiatedLazyload, numEntries, minHeight, minItemIndex, maxItemIndex, wrapperHeight, itemHeight, itemWidth, itemGridWidth, itemsPerRow, initiatedLazyLoad]); //What are prev props and prev state
return (
<div
ref={wrapper}
className='infinite-grid-wrapper'
onScroll={scrollListener}
style={styles.wrapper}
>
<div ref={grid} className='infinite-grid' style={styles.grid}>
{entries.slice(minItemIndex, maxItemIndex).map((entry, i) => (
<Item
key={`item-${i}`}
index={minItemIndex + i}
padding={padding}
width={itemWidth}
height={itemHeight}
itemsPerRow={itemsPerRow}
data={entry}
/>
))}
</div>
</div>
);
});
InfiniteScrollGrid.propTypes = {
buffer: PropTypes.number,
padding: PropTypes.number,
entries: PropTypes.number,
height: PropTypes.number,
width: PropTypes.number,
wrapHeight: PropTypes.number,
lazyCallback: PropTypes.func,
renderRangeCallback: PropTypes.func
};
export default InfiniteScrollGrid;
Item.js
import React, {memo} from 'react';
import PropTypes from 'prop-types';
const Item = memo(({width, itemsPerRow, index, height, padding, data}) => {
const style = {
width: (width / itemsPerRow) - padding,
height: height - padding,
left: (index % itemsPerRow) * (width / itemsPerRow),
top: Math.floor((index / itemsPerRow) * height),
position: 'absolute'
};
return (
<div style={style} className="item">
<div>{data}</div>
</div>
);
});
Item.propTypes = {
width: PropTypes.number,
itemsPerRow: PropTypes.number,
index: PropTypes.number,
height: PropTypes.number,
padding: PropTypes.number,
data: PropTypes.any
};
export default Item;
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
Example Usage
import React from 'react';
import InfiniteScrollGrid from './index';
const ExampleItem = ({index}) => (
<div className='example'>
This is {index}
</div>
);
export default () => {
let items = [];
for (let i = 0; i <= 1000; i++) {
items.push(<ExampleItem index={i} />);
};
const lazyCallback = (index) => {
console.log(index);
};
return (
<InfiniteScrollGrid entries={items} wrapperHeight={400} lazyCallback={lazyCallback} />
);
};
I would like a working solution of this infinately scrolling grid component