Compare commits
No commits in common. "master" and "v1.0.0" have entirely different histories.
|
@ -62,7 +62,7 @@ export function virtualScrollDriver(props, oldState, getRenderedItemHeight)
|
||||||
if (!lastItemSize)
|
if (!lastItemSize)
|
||||||
{
|
{
|
||||||
// Some required items in the end are missing
|
// Some required items in the end are missing
|
||||||
lastItemSize = 0;
|
return newState;
|
||||||
}
|
}
|
||||||
lastItemsHeight += lastItemSize < props.minRowHeight ? props.minRowHeight : lastItemSize;
|
lastItemsHeight += lastItemSize < props.minRowHeight ? props.minRowHeight : lastItemSize;
|
||||||
lastVisibleItems++;
|
lastVisibleItems++;
|
||||||
|
@ -80,7 +80,7 @@ export function virtualScrollDriver(props, oldState, getRenderedItemHeight)
|
||||||
? newState.avgRowHeight
|
? newState.avgRowHeight
|
||||||
: oldState.avgRowHeight;
|
: oldState.avgRowHeight;
|
||||||
}
|
}
|
||||||
newState.targetHeight = newState.avgRowHeight * newState.scrollHeightInItems + newState.viewportHeight;
|
newState.targetHeight = newState.avgRowHeight * newState.scrollHeightInItems;
|
||||||
const scrollTop = props.scrollTop;
|
const scrollTop = props.scrollTop;
|
||||||
let scrollPos = scrollTop / (newState.targetHeight - newState.viewportHeight);
|
let scrollPos = scrollTop / (newState.targetHeight - newState.viewportHeight);
|
||||||
if (scrollPos > 1)
|
if (scrollPos > 1)
|
||||||
|
|
65
README.md
65
README.md
|
@ -5,12 +5,12 @@ of visible items and skip items that are offscreen. You may also have heard abou
|
||||||
"buffered render" or "windowed render" - it's the same.
|
"buffered render" or "windowed render" - it's the same.
|
||||||
|
|
||||||
There are plenty of virtual scroll implementations for JS.
|
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
|
Some of them are part of a larger UI library (ag-grid, ExtJS and so on),
|
||||||
standalone (react-virtualized, react-window, ngx-virtual-scroller, ngx-ui-scroll, react-dynamic-virtual-list).
|
some of them are more standalone (react-virtualized, react-window, ngx-virtual-scroller, react-dynamic-virtual-list).
|
||||||
|
|
||||||
However, there is a thing that they all miss: dynamic (and unknown apriori) row heights.
|
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
|
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;
|
all heights before rendering; some allow dynamic row heights, but have bugs and do not really work;
|
||||||
others just force you to use fixed row height. Most implementations are also tied to some specific
|
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.
|
UI component or framework and are unusable with other ones.
|
||||||
|
|
||||||
|
@ -67,64 +67,7 @@ What to do with it:
|
||||||
* also note that you MUST set `overflow-anchor: none` on your scroll container. You'll end up with
|
* 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.
|
`virtualScrollDriver()` not able to finish updating in Chrome if you don't.
|
||||||
|
|
||||||
# Usage with React
|
# Usage example 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`.
|
See `DynamicVirtualScrollExample.js`.
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dynamic-virtual-scroll",
|
"name": "dynamic-virtual-scroll",
|
||||||
"version": "1.0.15",
|
"version": "1.0.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Vitaliy Filippov",
|
"name": "Vitaliy Filippov",
|
||||||
"email": "vitalif@yourcmc.ru",
|
"email": "vitalif@yourcmc.ru",
|
||||||
|
@ -16,8 +16,7 @@
|
||||||
"dynamic",
|
"dynamic",
|
||||||
"virtual-list",
|
"virtual-list",
|
||||||
"virtual-scroll",
|
"virtual-scroll",
|
||||||
"lazy-rendering",
|
"lazy-rendering"
|
||||||
"react"
|
|
||||||
],
|
],
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -37,8 +36,6 @@
|
||||||
"files": [
|
"files": [
|
||||||
"DynamicVirtualScroll.es5.js",
|
"DynamicVirtualScroll.es5.js",
|
||||||
"DynamicVirtualScroll.js",
|
"DynamicVirtualScroll.js",
|
||||||
"VirtualScrollList.es5.js",
|
|
||||||
"VirtualScrollList.js",
|
|
||||||
"DynamicVirtualScrollExample.js",
|
"DynamicVirtualScrollExample.js",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
|
@ -62,7 +59,7 @@
|
||||||
"webpack-cli": "^3.1.2"
|
"webpack-cli": "^3.1.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "eslint DynamicVirtualScroll.js && webpack --mode=production --optimize-minimize && (babel DynamicVirtualScroll.js > DynamicVirtualScroll.es5.js) && (babel VirtualScrollList.js > VirtualScrollList.es5.js)",
|
"build": "eslint Dynamic*.js && webpack --mode=production --optimize-minimize && babel DynamicVirtualScroll.js > DynamicVirtualScroll.es5.js",
|
||||||
"watch-dev": "NODE_ENV=development webpack --mode=development -w",
|
"watch-dev": "NODE_ENV=development webpack --mode=development -w",
|
||||||
"watch": "webpack --mode=production -w --optimize-minimize"
|
"watch": "webpack --mode=production -w --optimize-minimize"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue