dynamic-virtual-scroll/DynamicVirtualScroll.js

152 lines
6.5 KiB
JavaScript

/**
* Virtual scroll driver for dynamic row heights
*
* License: GNU LGPLv3.0+
* (c) Vitaliy Filippov 2018+
*
* @param props { totalItems, minRowHeight, viewportHeight, scrollTop }
* @param oldState - previous state object
* @param getRenderedItemHeight = (itemIndex) => height
* this function MUST return the height of currently rendered item or 0 if it's not currently rendered
* the returned height MUST be >= props.minRowHeight
* the function MAY cache heights of rendered items if you want your list to be more responsive
* @returns new state object
* you MUST re-render your list when any state values change
* you MUST preserve all keys in the state object and pass it back via `oldState` on the next run
* you MUST use the following keys for rendering:
* newState.targetHeight - height of the 1px wide invisible div you should render in the scroll container
* newState.topPlaceholderHeight - height of the first (top) placeholder. omit placeholder if it is 0
* newState.firstMiddleItem - first item to be rendered after top placeholder
* newState.middleItemCount - item count to be renderer after top placeholder. omit items if it is 0
* newState.middlePlaceholderHeight - height of the second (middle) placeholder. omit placeholder if it is 0
* newState.lastItemCount - item count to be rendered in the end of the list
*/
export function virtualScrollDriver(props, oldState, getRenderedItemHeight)
{
const viewportHeight = props.viewportHeight;
const viewportItemCount = Math.ceil(viewportHeight/props.minRowHeight); // +border?
const newState = {
viewportHeight,
viewportItemCount,
totalItems: props.totalItems,
scrollHeightInItems: oldState.scrollHeightInItems,
avgRowHeight: oldState.avgRowHeight,
targetHeight: 0,
topPlaceholderHeight: 0,
firstMiddleItem: 0,
middleItemCount: 0,
middlePlaceholderHeight: 0,
lastItemCount: props.totalItems,
lastItemsTotalHeight: oldState.lastItemsTotalHeight,
};
if (!oldState.viewportHeight)
{
oldState = { ...oldState };
for (let k in newState)
{
oldState[k] = oldState[k] || 0;
}
}
if (2*newState.viewportItemCount >= props.totalItems)
{
// We need at least 2*viewportItemCount to perform virtual scrolling
return newState;
}
newState.lastItemCount = newState.viewportItemCount;
{
let lastItemsHeight = 0, lastVisibleItems = 0;
let lastItemSize;
while (lastItemsHeight < viewportHeight)
{
lastItemSize = getRenderedItemHeight(props.totalItems - 1 - lastVisibleItems);
if (!lastItemSize)
{
// Some required items in the end are missing
lastItemSize = 0;
}
lastItemsHeight += lastItemSize < props.minRowHeight ? props.minRowHeight : lastItemSize;
lastVisibleItems++;
}
newState.scrollHeightInItems = props.totalItems - lastVisibleItems + (lastItemsHeight-viewportHeight) / lastItemSize;
// Calculate heights of the rest of items
while (lastVisibleItems < newState.viewportItemCount)
{
lastItemsHeight += getRenderedItemHeight(props.totalItems - 1 - lastVisibleItems);
lastVisibleItems++;
}
newState.lastItemsTotalHeight = lastItemsHeight;
newState.avgRowHeight = lastItemsHeight / lastVisibleItems;
newState.avgRowHeight = !oldState.avgRowHeight || newState.avgRowHeight > oldState.avgRowHeight
? newState.avgRowHeight
: oldState.avgRowHeight;
}
newState.targetHeight = newState.avgRowHeight * newState.scrollHeightInItems + newState.viewportHeight;
const scrollTop = props.scrollTop;
let scrollPos = scrollTop / (newState.targetHeight - newState.viewportHeight);
if (scrollPos > 1)
{
// Rare case - avgRowHeight isn't enough and we need more
// avgRowHeight will be corrected after rendering all items
scrollPos = 1;
}
let firstVisibleItem = scrollPos * newState.scrollHeightInItems;
const firstVisibleItemOffset = firstVisibleItem - Math.floor(firstVisibleItem);
// FIXME: Render some items before current for smoothness
firstVisibleItem = Math.floor(firstVisibleItem);
let firstVisibleItemHeight = getRenderedItemHeight(firstVisibleItem) || newState.avgRowHeight;
newState.topPlaceholderHeight = scrollTop - firstVisibleItemHeight*firstVisibleItemOffset;
if (newState.topPlaceholderHeight < 0)
{
newState.topPlaceholderHeight = 0;
}
if (firstVisibleItem + newState.viewportItemCount >= props.totalItems - newState.viewportItemCount)
{
// Only one placeholder is required
newState.lastItemCount = props.totalItems - firstVisibleItem;
let sum = 0, count = props.totalItems - newState.viewportItemCount - firstVisibleItem;
count = count > 0 ? count : 0;
for (let i = 0; i < count; i++)
{
const itemSize = getRenderedItemHeight(i+newState.firstMiddleItem);
if (!itemSize)
{
// Some required items in the middle are missing
return newState;
}
sum += itemSize;
}
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (count + newState.viewportItemCount);
if (correctedAvg > newState.avgRowHeight)
{
newState.avgRowHeight = correctedAvg;
}
}
else
{
newState.firstMiddleItem = firstVisibleItem;
newState.middleItemCount = newState.viewportItemCount;
let sum = 0;
for (let i = 0; i < newState.middleItemCount; i++)
{
const itemSize = getRenderedItemHeight(i+newState.firstMiddleItem);
if (!itemSize)
{
// Some required items in the middle are missing
return newState;
}
sum += itemSize;
}
newState.middlePlaceholderHeight = newState.targetHeight - sum - newState.lastItemsTotalHeight - newState.topPlaceholderHeight;
if (newState.middlePlaceholderHeight < 0)
{
newState.middlePlaceholderHeight = 0;
}
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (newState.middleItemCount + newState.viewportItemCount);
if (correctedAvg > newState.avgRowHeight)
{
newState.avgRowHeight = correctedAvg;
}
}
return newState;
}