Compare commits

..

No commits in common. "very-old" and "master" have entirely different histories.

11 changed files with 866 additions and 230 deletions

3
.babelrc Normal file
View File

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

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"
]
}
};

151
DynamicVirtualScroll.js Normal file
View File

@ -0,0 +1,151 @@
/**
* Virtual scroll driver for dynamic row heights
*
* License: GNU 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 want 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
lastItemSize = 0;
}
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 + newState.viewportHeight;
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 (newState.topPlaceholderHeight < 0)
{
newState.topPlaceholderHeight = 0;
}
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;
}
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (count + newState.viewportItemCount);
if (correctedAvg > newState.avgRowHeight)
{
newState.avgRowHeight = correctedAvg;
}
}
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)
{
newState.middlePlaceholderHeight = 0;
}
const correctedAvg = (sum + newState.lastItemsTotalHeight) / (newState.middleItemCount + newState.viewportItemCount);
if (correctedAvg > newState.avgRowHeight)
{
newState.avgRowHeight = correctedAvg;
}
}
return newState;
}

View File

@ -0,0 +1,132 @@
import React from 'react';
import { virtualScrollDriver } from './DynamicVirtualScroll.js';
export class DynamicVirtualScrollExample extends React.PureComponent
{
useFixedHeader = true
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].getBoundingClientRect().height;
}
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={{position: 'relative', width: '400px'}}>
<div style={{overflowY: 'scroll', height: '400px', width: '400px', overflowAnchor: 'none', outline: 'none'}}
tabIndex="1"
ref={e => this.viewport = e}
onScroll={this.driver}>
<div style={{height: this.state.targetHeight+'px'}}>
{this.useFixedHeader
? <div style={{height: '30px'}}></div>
: null}
{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>
{this.useFixedHeader ? <div style={{
position: 'absolute',
top: 0,
left: 0,
right: this.state.scrollbarWidth+'px',
height: '30px',
background: '#0080c0',
color: 'white',
textAlign: 'center',
lineHeight: '30px'}}>
fixed header
</div> : null}
</div>);
}
driver = () =>
{
const newState = virtualScrollDriver(
{
totalItems: this.state.items.length,
minRowHeight: 30,
viewportHeight: this.viewport.clientHeight - (this.useFixedHeader ? 30 : 0),
scrollTop: this.viewport.scrollTop,
},
this.state,
this.getRenderedItemHeight
);
newState.scrollbarWidth = this.viewport ? this.viewport.offsetWidth-this.viewport.clientWidth : 12;
this.setStateIfDiffers(newState);
}
componentDidUpdate = () =>
{
this.driver();
}
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;
}
}

161
README.md Normal file
View File

@ -0,0 +1,161 @@
# Dynamic Virtual Scroll Driver
Virtual scrolling is a technique for displaying long lists or tables when you render only a small number
of visible items and skip items that are offscreen. You may also have heard about it like
"buffered render" or "windowed render" - it's the same.
There are plenty of virtual scroll implementations for JS.
Some of them are part of a larger UI library (ag-grid, ExtJS and so on), some of them are more
standalone (react-virtualized, react-window, ngx-virtual-scroller, ngx-ui-scroll, react-dynamic-virtual-list).
However, there is a thing that they all miss: dynamic (and unknown apriori) row heights.
Some implementations allow to set different row heights for items, but you must calculate
all heights before rendering; some allow dynamic row heights, but have bugs and act weird or don't really work;
others just force you to use fixed row height. Most implementations are also tied to some specific
UI component or framework and are unusable with other ones.
Good news, everyone: we have a solution!
It is render-agnostic and implemented in this library. Basically, this library only does the maths for you
while letting you render your component yourself. You can use it with React, Angular, pure JS or any other
framework you want to. You can implement lists and grids (tables) with it. It works smoothly, does not break
built-in browser scrolling and even works on mobile devices.
# Usage
The library exports a single function:
```
import { virtualScrollDriver } from 'dynamic-virtual-scroll';
const newState = virtualScrollDriver(
{ totalItems, minRowHeight, viewportHeight, scrollTop },
oldState,
function getRenderedItemHeight(itemIndex) { ... }
);
```
You must call it after each render and also when the viewport, scroll position or items change.
Description of parameters:
* `totalItems` - total number of items in your list
* `minRowHeight` - minimum item height
* `viewportHeight` - current viewport height (take from DOM)
* `scrollTop` - current scroll position (take from DOM)
* `oldState` - previous state object as returned from previous `virtualScrollDriver()` call
* `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 want your list to be more responsive
* WARNING: you SHOULD NOT use `element.offsetHeight` for measuring. Either use `element.getBoundingClientRect().height`
or use some pre-computed heights, because `offsetHeight` may truncate the height to -1px when
browser scale is not 100%. Also it gives incorrect results with CSS transforms.
Returned object is `newState`. It contains the render parameters for you and also some internal state variables.
What to do with it:
* 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 base your rendering on the following keys:
* `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
* also note that you MUST set `overflow-anchor: none` on your scroll container. You'll end up with
`virtualScrollDriver()` not able to finish updating in Chrome if you don't.
# Usage with React
There is a reference virtual list implementation for React.
It is usually sufficient for almost everything, including grids. Sad thing about grids (virtualized tables)
in HTML is that automatic table-like layout is slow in browsers, so in fact the best way to implement
them is via simple virtualized lists of \<div>'s with absolutely positioned cells inside.
```
import React from 'react';
import { VirtualScrollList } from 'dynamic-virtual-scroll/VirtualScrollList.es5.js';
class MyList extends React.Component
{
renderItem = (i) =>
{
if (!this.items[i])
return null;
return <div style={{minHeight: '20px'}}>{this.items[i].title}</div>;
}
render()
{
return <VirtualScrollList
totalItems={this.items.length}
renderItem={this.renderItem}
minRowHeight={20}
/>;
}
}
```
Description of VirtualScrollList parameters:
- totalItems: required, total number of items in the list.
- minRowHeight: required, minimal possible item height in the list.
- renderItem: required, function that renders item by index as React element(s).
- viewportHeight: optional, viewport height to use for virtual scrolling.
May be used in case when it can't be determined automatically by VirtualScroll,
for example inside an animated element with css maxHeight.
- header: optional, React element(s) to unconditionally render in the beginning of
the list. The intended usage is to render fixed header row with CSS position: sticky
over the scrolled content.
- headerHeight: optional. In case there is a fixed header, this must be its height
in pixels.
- All other parameters (className, style, onClick, etc) are passed as-is to the
underlying root \<div> of the list.
VirtualScrollList contains some extra shenanigans to make sure the scroll position
preserves when the total number of items changes. Also it has two extra methods:
- `list.scrollToItem(itemIndex)` - positions the list at `itemIndex`. The index may
contain fractional part, in that case the list will be positioned at the corresponding
% of height of the item.
- `list.getItemScrollPos()` - returns current scroll position in items. The returned
index may contain fractional part and may be used as input to `list.scrollToItem()`.
# Simpler React example
See `DynamicVirtualScrollExample.js`.
How to test it:
* Clone this repository
* `npm install`
* `npm run build`
* Open `index.html` in your browser
# Demo
http://yourcmc.ru/dynamic-virtual-scroll/
# Algorithm
* Use reasonable fixed minimum row height
* Always render `screen/minHeight` last rows
* Find maximum possible viewport start in units of (item number + % of item)
* Measure average height of last rows
* `avgHeight = max(minHeight, lastRowAvgHeight)`
* `targetHeight = avgHeight*rowCount`
* Total scroll view height will be `targetHeight`
* `scrollPos = targetHeight > offsetHeight ? min(1, scrollTop / (targetHeight - offsetHeight)) : 0`
* First visible item will be `Math.floor(scrollPos*maxPossibleViewportStart)`
* Additional scroll offset will be `itemOffset = (scrollPos*maxPossibleViewportStart - Math.floor(scrollPos*maxPossibleViewportStart))*firstVisibleItemHeight`
* First (top) placeholder height will be `scrollTop-itemOffset`
* Second (middle) placeholder height will be `avgHeight*nodeCount - sum(heights of all rendered rows) - (first placeholder height)`
# License and author
Author: Vitaliy Filippov, 2018+
License: GNU LGPLv3.0 or newer

239
VirtualScrollList.js Normal file
View File

@ -0,0 +1,239 @@
/**
* A simple React list with virtual scrolling based on dynamic-virtual-scroll driver
* USUALLY sufficient for everything including grids (using absolute sizing of cells).
* Just because browsers can't do virtualized grid or table layouts efficiently.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { virtualScrollDriver } from 'dynamic-virtual-scroll';
export class VirtualScrollList extends React.Component
{
static propTypes = {
className: PropTypes.string,
style: PropTypes.object,
totalItems: PropTypes.number.isRequired,
minRowHeight: PropTypes.number.isRequired,
viewportHeight: PropTypes.number,
header: PropTypes.any,
headerHeight: PropTypes.number,
renderItem: PropTypes.func.isRequired,
}
state = {
targetHeight: 0,
topPlaceholderHeight: 0,
firstMiddleItem: 0,
middleItemCount: 0,
middlePlaceholderHeight: 0,
lastItemCount: 0,
scrollTo: 0,
scrollTimes: 0,
}
setItemRef = []
itemRefs = []
itemRefCount = []
makeRef(i)
{
this.setItemRef[i] = (e) =>
{
// If the new row instance is mounted before unmouting the old one,
// we get called 2 times in wrong order: first with the new instance,
// then with null telling us that the old one is unmounted.
// We track reference count to workaround it.
this.itemRefCount[i] = (this.itemRefCount[i]||0) + (e ? 1 : -1);
if (e || !this.itemRefCount[i])
{
this.itemRefs[i] = e;
}
};
}
renderItems(start, count, is_end)
{
let r = [];
for (let i = 0; i < count; i++)
{
let item = this.props.renderItem(i+start);
if (item)
{
if (!this.setItemRef[i+start])
{
this.makeRef(i+start);
}
r.push(<item.type {...item.props} key={i+start} ref={this.setItemRef[i+start]} />);
}
}
return r;
}
render()
{
if (this.state.totalItems && this.props.totalItems != this.state.totalItems &&
this.state.scrollTimes <= 0 && this.viewport && this.viewport.offsetParent)
{
// Automatically preserve scroll position when item count changes...
// But only when the list is on-screen! We'll end up with an infinite update loop if it's off-screen.
this.state.scrollTo = this.getItemScrollPos();
this.state.scrollTimes = 2;
}
const props = { ...this.props };
for (const k in VirtualScrollList.propTypes)
{
delete props[k];
}
return (<div
{...props}
className={this.props.className}
style={{
position: 'relative',
...(this.props.style||{}),
overflowAnchor: 'none',
}}
ref={this.setViewport}
onScroll={this.onScroll}>
{this.props.header}
{this.state.targetHeight > 0
? <div key="target" style={{position: 'absolute', top: 0, left: '-5px', width: '1px', height: this.state.targetHeight+'px'}}></div>
: null}
{this.state.topPlaceholderHeight
? <div style={{height: this.state.topPlaceholderHeight+'px'}} key="top"></div>
: null}
{this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount)}
{this.state.middlePlaceholderHeight
? <div style={{height: this.state.middlePlaceholderHeight+'px'}} key="mid"></div>
: null}
{this.renderItems(this.props.totalItems-this.state.lastItemCount, this.state.lastItemCount, true)}
</div>);
}
setViewport = (e) =>
{
this.viewport = e;
}
getRenderedItemHeight = (index) =>
{
if (this.itemRefs[index])
{
const e = ReactDOM.findDOMNode(this.itemRefs[index]);
if (e)
{
// MSIE sometimes manages to report non-integer element heights for elements of an integer height...
// Non-integer element sizes are allowed in getBoundingClientRect, one notable example of them
// are collapsed table borders. But we still ignore less than 1/100 of a pixel difference.
return Math.round(e.getBoundingClientRect().height*100)/100;
}
}
return 0;
}
onScroll = () =>
{
this.driver();
if (this.props.onScroll)
{
this.props.onScroll(this.viewport);
}
}
componentDidUpdate = () =>
{
let changed = this.driver();
if (!changed && this.state.scrollTimes > 0 && this.props.totalItems > 0 &&
this.viewport && this.viewport.offsetParent)
{
// FIXME: It would be better to find a way to put this logic back into virtual-scroll-driver
let pos = this.state.scrollTo;
if (pos > this.state.scrollHeightInItems)
{
pos = this.state.scrollHeightInItems;
}
if (this.state.targetHeight)
{
this.viewport.scrollTop = Math.round((this.state.targetHeight - this.state.viewportHeight)*pos/this.state.scrollHeightInItems);
this.setState({ scrollTimes: this.state.scrollTimes - 1 });
}
else
{
const el = ReactDOM.findDOMNode(this.itemRefs[Math.floor(pos)]);
if (el)
{
this.viewport.scrollTop = el.offsetTop - (this.props.headerHeight||0) + el.offsetHeight*(pos-Math.floor(pos));
}
this.setState({ scrollTimes: 0 });
}
}
}
componentDidMount()
{
this.driver();
}
scrollToItem = (pos) =>
{
// Scroll position must be recalculated twice, because first render
// may change the average row height. In fact, it must be repeated
// until average row height stops changing, but twice is usually sufficient
this.setState({ scrollTo: pos, scrollTimes: 2 });
}
getItemScrollPos = () =>
{
if (this.state.targetHeight)
{
// Virtual scroll is active
let pos = this.viewport.scrollTop / (this.state.targetHeight - this.state.viewportHeight);
return pos * this.state.scrollHeightInItems;
}
else
{
// Virtual scroll is inactive
let avgr = this.viewport.scrollHeight / this.state.totalItems;
return this.viewport.scrollTop / avgr;
}
}
driver = () =>
{
if (!this.viewport || !this.viewport.offsetParent)
{
// Fool tolerance - do nothing if we are hidden
return false;
}
const newState = virtualScrollDriver(
{
totalItems: this.props.totalItems,
minRowHeight: this.props.minRowHeight,
viewportHeight: this.props.viewportHeight || (this.viewport.clientHeight-(this.props.headerHeight||0)),
scrollTop: this.viewport.scrollTop,
},
this.state,
this.getRenderedItemHeight
);
if (newState.viewportHeight || this.state.viewportHeight)
{
return this.setStateIfDiffers(newState);
}
return false;
}
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')
);

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "dynamic-virtual-scroll",
"version": "1.0.15",
"author": {
"name": "Vitaliy Filippov",
"email": "vitalif@yourcmc.ru",
"url": "http://yourcmc.ru/wiki/"
},
"description": "Render-agnostic Dynamic Virtual Scroll Driver",
"keywords": [
"virtual",
"scrolling",
"list",
"grid",
"windowed",
"dynamic",
"virtual-list",
"virtual-scroll",
"lazy-rendering",
"react"
],
"repository": {
"type": "git",
"url": "https://github.com/vitalif/dynamic-virtual-scroll"
},
"homepage": "https://github.com/vitalif/dynamic-virtual-scroll",
"bugs": {
"url": "https://github.com/vitalif/dynamic-virtual-scroll/issues"
},
"license": "LGPL",
"maintainers": [
{
"name": "vitalif",
"email": "vitalif@yourcmc.ru"
}
],
"files": [
"DynamicVirtualScroll.es5.js",
"DynamicVirtualScroll.js",
"VirtualScrollList.es5.js",
"VirtualScrollList.js",
"DynamicVirtualScrollExample.js",
"README.md"
],
"main": "./DynamicVirtualScroll.es5.js",
"module": "./DynamicVirtualScroll.js",
"dependencies": {},
"devDependencies": {
"babel-cli": "^6.26.0",
"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"
},
"scripts": {
"build": "eslint DynamicVirtualScroll.js && webpack --mode=production --optimize-minimize && (babel DynamicVirtualScroll.js > DynamicVirtualScroll.es5.js) && (babel VirtualScrollList.js > VirtualScrollList.es5.js)",
"watch-dev": "NODE_ENV=development webpack --mode=development -w",
"watch": "webpack --mode=production -w --optimize-minimize"
}
}

230
test.htm
View File

@ -1,230 +0,0 @@
<div tabindex="1" id="scroll" style="overflow-y: scroll; width: 300px; position: relative; height: 300px; border: 1px solid gray;" onscroll="enqueue(scrollother)">
<div style="height: 1500px" id="content"></div>
<div id="realcontent" style="z-index: 1; width: 100%; position: absolute; overflow: hidden"></div>
</div>
<style>
.item { border: 1px solid gray; box-sizing: border-box; text-align: center; }
</style>
<script>
/*<!--*/
//http://stackoverflow.com/questions/11730596/non-linear-scrolling
var s = document.getElementById('scroll');
var c = document.getElementById('content');
var rc = document.getElementById('realcontent');
var minheight = 100;
var api = (function() {
var items = [];
for (var i = 0; i < 100; i++)
items.push(30+Math.round(Math.random()*120));
items = [31,109,107,97,140,133,49,69,123,43,137,128,31,101,120,148,119,30,31,37,43,119,105,106,91,148,36,85,76,97,148,31,53,126,138,51,85,65,53,78,39,49,133,49,103,146,143,148,115,49,55,98,44,84,57,127,98,98,104,116,150,82,123,62,134,109,89,71,36,50,146,146,53,35,86,143,86,138,39,81,72,106,88,117,127,97,127,147,102,42,124,99,32,51,84,45,106,139,140,69];
// Аналог API получения элементов
return [ items.length, function(start, count, callback)
{
setTimeout(function() { callback(items.slice(start, start+count)); }, 200);
} ]
})();
var totalItems = api[0];
var getItems = api[1];
c.style.height = (totalItems*minheight)+'px';
var lastFirst = 0;
var curFirst = 0, curOffset = 0, curCount = 0, curPos = null;
var pagesize = Math.floor(s.offsetHeight/30);
rc.parentNode.addEventListener('keydown', handlekey);
c.addEventListener('keydown', handlekey, true);
rc.addEventListener('keydown', handlekey, true);
//rc.parentNode.addEventListener('DOMMouseScroll', mouse_wheel);
//rc.parentNode.onmousewheel = mouse_wheel;
rc.parentNode.focus();
function mouse_wheel(event)
{
event = event||window.event;
var direction = ((event.wheelDelta) ? event.wheelDelta/120 : event.detail/-3) || false;
if (direction)
{
if (event.preventDefault)
event.preventDefault();
event.returnValue = false;
enqueue(scrollbypx, -direction * 30);
}
}
function handlekey(ev)
{
if (ev.keyCode == 38 || ev.keyCode == 40 || ev.keyCode == 33 || ev.keyCode == 34) // up, down, pgup, pgdown
{
var px;
if (ev.keyCode == 38) px = -30;
else if (ev.keyCode == 40) px = 30;
else if (ev.keyCode == 33) px = -270;
else if (ev.keyCode == 34) px = 270;
enqueue(scrollbypx, px);
}
else if (ev.keyCode == 36)
s.scrollTop = 0;
else if (ev.keyCode == 35)
s.scrollTop = s.scrollHeight-s.offsetHeight+1;
else
return;
ev.preventDefault();
}
var rq = [];
var loadedItems = [];
function enqueue(fn, arg)
{
if (!rq.length)
fn(arg);
else if (arg)
rq.push(function() { fn(arg) });
else if (rq[rq.length-1] != fn)
rq.push(fn);
}
function loadfrom(start, count)
{
console.log('load from '+start+': '+count+' items');
getItems(start, count, function(partItems)
{
var h, i;
for (i = 0; i < partItems.length; i++)
loadedItems[start+i] = partItems[i];
rq.length && (rq.shift())();
});
}
// скроллинг в пикселях (в пределах видимости - можно использовать для up/down, pgup/pgdown, колеса мыши)
function scrollbypx(px)
{
if (px < 0)
{
var cpx = px;
cpx += curOffset*loadedItems[curFirst];
var nf = curFirst;
while (nf > 0 && cpx < 0)
{
nf--;
if (!loadedItems[nf])
{
rq.unshift(function() { scrollbypx(px) });
loadfrom(nf+1 > pagesize ? nf+1-pagesize : 0, pagesize);
return;
}
cpx += loadedItems[nf];
}
newFirst = nf+(cpx/loadedItems[nf]);
}
else
{
px -= loadedItems[curFirst]*(1-curOffset);
var nf = curFirst;
while (nf < totalItems-1 && px > 0)
{
nf++;
px -= loadedItems[nf];
}
nfo = (px >= 0 ? loadedItems[nf] : px+loadedItems[nf]);
newFirst = nf+(nfo/loadedItems[nf]);
}
s.scrollTop = newFirst/lastFirst*(s.scrollHeight-s.offsetHeight+1);
rq.length && (rq.shift())();
}
function loadnext()
{
var h, i;
for (h = 0, i = curFirst; i < totalItems && h < s.offsetHeight; i++)
{
if (!loadedItems[i])
{
var start = i;
while (start < totalItems && !loadedItems[start] && start < i+pagesize)
start++;
start = start-pagesize;
start = start < 0 ? 0 : start;
rq.unshift(loadnext);
loadfrom(start, pagesize);
return;
}
h += (i == curFirst ? loadedItems[i]*(1-curOffset) : loadedItems[i]);
}
curCount = i-curFirst;
rq.length && (rq.shift())();
}
function renderitems()
{
var ih = '', h = 0;
for (var i = 0; i < curCount; i++)
{
ih += '<div class="item" style="height: '+loadedItems[curFirst+i]+'px">'+(curFirst+i)+'</div>';
h += loadedItems[curFirst+i];
}
// curPos, чтобы при плавном скролле вниз загруженное в середину не было видно
var t = Math.round(-loadedItems[curFirst]*curOffset+curPos);
rc.style.height = '1px'; // на случай, если браузеру вздумается где-то посередине перерисовать
rc.style.top = t+'px';
rc.style.height = (t+h < s.scrollHeight-1 ? h : s.scrollHeight-1-t)+'px';
rc.innerHTML = ih;
// Тут можно убить лишние элементы
var q0 = curFirst-pagesize;
q0 = q0 - q0%pagesize;
var q1 = curFirst+curCount+pagesize;
q1 = q1 + (pagesize-q1%pagesize);
var destroyed = 0;
for (var i in loadedItems)
{
if (i < q0 || i >= q1)
{
destroyed++;
delete loadedItems[i];
}
}
if (destroyed > 0)
console.log('destroy '+destroyed+' items');
rq.length && (rq.shift())();
}
// нелинейный скроллинг (в % от элементов)
function scrollother()
{
var i, h;
if (s.scrollTop == curPos)
return;
var newFirst = s.scrollTop / (s.scrollHeight-s.offsetHeight+1) * lastFirst;
if (newFirst > lastFirst)
newFirst = lastFirst;
curFirst = Math.floor(newFirst);
curOffset = newFirst-curFirst;
curPos = s.scrollTop;
rq.unshift(renderitems);
loadnext();
}
function initscroll()
{
var h = 0, i;
for (i = totalItems-1; i >= 0 && h < s.offsetHeight; i--)
{
if (!loadedItems[i])
{
rq.unshift(initscroll);
loadfrom(i+1-pagesize, pagesize);
return;
}
h += loadedItems[i];
}
lastFirst = i+1+(h-s.offsetHeight)/loadedItems[i+1];
rq.length && (rq.shift())();
}
rq.push(scrollother);
initscroll();
//-->
</script>

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
}
};