Dynamic Virtual Scroll Driver, abstracted away from render implementations
commit
c66f35e25d
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": [ "env", "stage-1", "react" ],
|
||||
"retainLines": true
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { DynamicVirtualScrollExample } from './DynamicVirtualScrollExample.js';
|
||||
|
||||
ReactDOM.render(
|
||||
<DynamicVirtualScrollExample />, document.getElementById('app')
|
||||
);
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue