144 lines
6.3 KiB
JavaScript
144 lines
6.3 KiB
JavaScript
/**
|
|
* Virtual scroll driver for dynamic row heights
|
|
*
|
|
* License: 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 feel 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
|
|
return newState;
|
|
}
|
|
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;
|
|
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 (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;
|
|
}
|
|
if (sum + newState.lastItemsTotalHeight + newState.topPlaceholderHeight > newState.targetHeight)
|
|
{
|
|
// avgRowHeight should be corrected
|
|
newState.avgRowHeight = (sum + newState.lastItemsTotalHeight) / (count + newState.viewportItemCount);
|
|
}
|
|
}
|
|
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)
|
|
{
|
|
// avgRowHeight should be corrected
|
|
newState.avgRowHeight = (sum + newState.lastItemsTotalHeight) / (newState.middleItemCount + newState.viewportItemCount);
|
|
}
|
|
}
|
|
return newState;
|
|
}
|