From c66f35e25d2a7f134613d6ff54ff933aa628e2d9 Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Tue, 9 Oct 2018 14:08:18 +0300 Subject: [PATCH] Dynamic Virtual Scroll Driver, abstracted away from render implementations --- .babelrc | 4 + .eslintrc.js | 42 ++++++++++ DynamicVirtualScroll.js | 143 +++++++++++++++++++++++++++++++++ DynamicVirtualScrollExample.js | 107 ++++++++++++++++++++++++ index.html | 12 +++ main.js | 8 ++ package.json | 31 +++++++ webpack.config.js | 49 +++++++++++ 8 files changed, 396 insertions(+) create mode 100644 .babelrc create mode 100644 .eslintrc.js create mode 100644 DynamicVirtualScroll.js create mode 100644 DynamicVirtualScrollExample.js create mode 100644 index.html create mode 100644 main.js create mode 100644 package.json create mode 100644 webpack.config.js 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 + } +};