Dynamic Virtual Scroll Driver, abstracted away from render implementations

master
Vitaliy Filippov 2018-10-09 14:08:18 +03:00
commit c66f35e25d
8 changed files with 396 additions and 0 deletions

4
.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": [ "env", "stage-1", "react" ],
"retainLines": true
}

42
.eslintrc.js Normal file
View File

@ -0,0 +1,42 @@
module.exports = {
"parser": "babel-eslint",
"env": {
"es6": true,
"browser": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended"
],
"parserOptions": {
"ecmaVersion": 6,
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
}
},
"plugins": [
"react"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"semi": [
"error",
"always"
],
"no-control-regex": [
"off"
],
"no-empty": [
"off"
]
}
};

143
DynamicVirtualScroll.js Normal file
View File

@ -0,0 +1,143 @@
/**
* 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;
}

View File

@ -0,0 +1,107 @@
import React from 'react';
import { virtualScrollDriver } from './DynamicVirtualScroll.js';
export class DynamicVirtualScrollExample extends React.PureComponent
{
constructor()
{
super();
const items = [];
for (let i = 0; i < 1000; i++)
{
items[i] = 30 + Math.round(Math.random()*50);
}
this.state = { items };
}
getRenderedItemHeight_MemoryExample = (index) =>
{
// Just for example: imitating renderer not knowing about off-screen items
if (index >= this.state.firstMiddleItem && index < this.state.firstMiddleItem+this.state.middleItemCount ||
index >= this.state.items.length - this.state.lastItemCount)
{
return this.state.items[index];
}
return 0;
}
getRenderedItemHeight_DOMExample = (index) =>
{
// DOM example. As smooth as the previous one (memory example), even without caching
if (this.itemElements[index])
{
return this.itemElements[index].offsetHeight;
}
return 0;
}
getRenderedItemHeight = this.getRenderedItemHeight_DOMExample
renderItems(start, count)
{
return this.state.items.slice(start, start+count).map((item, index) => (<div
key={'i'+(index+start)}
ref={e => this.itemElements[index+start] = e}
style={{height: item+'px', color: 'white', textAlign: 'center', lineHeight: item+'px', background: 'rgb('+Math.round(item*255/80)+',0,0)'}}>
{index+start}: {item}px
</div>));
}
render()
{
this.itemElements = [];
return (<div style={{overflowY: 'scroll', height: '400px', width: '400px'}}
ref={e => this.viewport = e}
onScroll={this.componentDidUpdate}>
<div style={{height: this.state.targetHeight+'px'}}>
{this.state.topPlaceholderHeight
? <div style={{height: this.state.topPlaceholderHeight+'px'}}></div>
: null}
{this.state.middleItemCount
? this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount)
: null}
{this.state.middlePlaceholderHeight
? <div style={{height: this.state.middlePlaceholderHeight+'px'}}></div>
: null}
{this.state.lastItemCount
? this.renderItems(this.state.items.length-this.state.lastItemCount, this.state.lastItemCount)
: null}
</div>
</div>);
}
// We should re-render only when we know we need some items that are not currently rendered
componentDidUpdate = () =>
{
const newState = virtualScrollDriver(
{
totalItems: this.state.items.length,
minRowHeight: 30,
viewportHeight: this.viewport.clientHeight,
scrollTop: this.viewport.scrollTop,
},
this.state,
this.getRenderedItemHeight
);
this.setStateIfDiffers(newState);
}
componentDidMount()
{
this.componentDidUpdate();
}
setStateIfDiffers(state, cb)
{
for (const k in state)
{
if (this.state[k] != state[k])
{
this.setState(state, cb);
return true;
}
}
return false;
}
}

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Dynamic Virtual Scroll Driver Demo</title>
</head>
<body>
<div id="app">
</div>
<script type="text/javascript" src="dist/main.js"></script>
</body>
</html>

8
main.js Normal file
View File

@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { DynamicVirtualScrollExample } from './DynamicVirtualScrollExample.js';
ReactDOM.render(
<DynamicVirtualScrollExample />, document.getElementById('app')
);

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "struct-edit",
"author": {
"name": "Vitaliy Filippov",
"email": "vitalif@yourcmc.ru",
"url": "http://yourcmc.ru/wiki/"
},
"description": "Dynamic Virtual Scroll Driver",
"dependencies": {},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-eslint": "^10.0.1",
"babel-loader": "^7.1.5",
"babel-polyfill": "^6.26.0",
"babel-preset-env": "^1.7.0",
"babel-preset-react": "^6.24.1",
"babel-preset-stage-1": "^6.24.1",
"eslint": "^5.1.0",
"eslint-plugin-react": "^7.10.0",
"react": "^16.4.1",
"react-dom": "^16.4.1",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2"
},
"index": "DynamicVirtualScroll.js",
"scripts": {
"build": "eslint Dynamic*.js && webpack --mode=production --optimize-minimize",
"watch-dev": "NODE_ENV=development webpack --mode=development -w",
"watch": "webpack --mode=production -w --optimize-minimize"
}
}

49
webpack.config.js Normal file
View File

@ -0,0 +1,49 @@
const webpack = require('webpack');
const path = require('path');
module.exports = {
entry: {
main: [ "babel-polyfill", './main.js' ]
},
context: __dirname,
output: {
path: __dirname,
filename: 'dist/[name].js'
},
module: {
rules: [
{
test: /.jsx?$/,
loader: 'babel-loader',
exclude: /node_modules(?!\/react-toolbox\/components)/
},
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
modules: true, // default is false
sourceMap: true,
importLoaders: 1,
localIdentName: "[name]--[local]--[hash:base64:8]"
}
},
"postcss-loader"
]
}
]
},
plugins: [
new webpack.DefinePlugin({
"process.env": {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || "production")
}
})
],
performance: {
maxEntrypointSize: 3000000,
maxAssetSize: 3000000
}
};