Compare commits
No commits in common. "master" and "very-old" have entirely different histories.
42
.eslintrc.js
42
.eslintrc.js
|
@ -1,42 +0,0 @@
|
|||
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"
|
||||
]
|
||||
}
|
||||
};
|
|
@ -1,151 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
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
161
README.md
|
@ -1,161 +0,0 @@
|
|||
# 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
|
|
@ -1,239 +0,0 @@
|
|||
/**
|
||||
* 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
12
index.html
|
@ -1,12 +0,0 @@
|
|||
<!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
8
main.js
|
@ -1,8 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { DynamicVirtualScrollExample } from './DynamicVirtualScrollExample.js';
|
||||
|
||||
ReactDOM.render(
|
||||
<DynamicVirtualScrollExample />, document.getElementById('app')
|
||||
);
|
69
package.json
69
package.json
|
@ -1,69 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,230 @@
|
|||
<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>
|
|
@ -1,49 +0,0 @@
|
|||
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