commit c66f35e25d2a7f134613d6ff54ff933aa628e2d9 Author: Vitaliy Filippov Date: Tue Oct 9 14:08:18 2018 +0300 Dynamic Virtual Scroll Driver, abstracted away from render implementations diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..b26d6e6 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [ "env", "stage-1", "react" ], + "retainLines": true +} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..da76387 --- /dev/null +++ b/.eslintrc.js @@ -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" + ] + } +}; diff --git a/DynamicVirtualScroll.js b/DynamicVirtualScroll.js new file mode 100644 index 0000000..362097f --- /dev/null +++ b/DynamicVirtualScroll.js @@ -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; +} diff --git a/DynamicVirtualScrollExample.js b/DynamicVirtualScrollExample.js new file mode 100644 index 0000000..aad1d1e --- /dev/null +++ b/DynamicVirtualScrollExample.js @@ -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) => (
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 +
)); + } + + render() + { + this.itemElements = []; + return (
this.viewport = e} + onScroll={this.componentDidUpdate}> +
+ {this.state.topPlaceholderHeight + ?
+ : null} + {this.state.middleItemCount + ? this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount) + : null} + {this.state.middlePlaceholderHeight + ?
+ : null} + {this.state.lastItemCount + ? this.renderItems(this.state.items.length-this.state.lastItemCount, this.state.lastItemCount) + : null} +
+
); + } + + // 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; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..4cb4dae --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + +Dynamic Virtual Scroll Driver Demo + + +
+
+ + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..f72ca75 --- /dev/null +++ b/main.js @@ -0,0 +1,8 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +import { DynamicVirtualScrollExample } from './DynamicVirtualScrollExample.js'; + +ReactDOM.render( + , document.getElementById('app') +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..51084f8 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..8dd00e4 --- /dev/null +++ b/webpack.config.js @@ -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 + } +};