Compare commits

...

16 Commits

Author SHA1 Message Date
Vitaliy Filippov 8567a14027 Mention ngx-ui-scroll in README 2019-12-18 19:00:14 +03:00
Vitaliy Filippov f530c8301c Oops, sorry. Error message is bad here 2019-11-15 18:09:48 +03:00
Vitaliy Filippov ea707b2c08 Fix for wrong ref call order 2019-11-15 18:04:30 +03:00
Vitaliy Filippov ba46a65559 Fix "infinite update loop" when item count changes off-screen 2019-10-10 16:56:10 +03:00
Vitaliy Filippov 43f9864c13 Position sizer at top: 0 to prevent header-related bugs 2019-08-05 20:05:31 +03:00
Vitaliy Filippov 24b12584d0 Fix for possible infinite looping in MSIE 2019-07-19 21:05:42 +03:00
Vitaliy Filippov d6dc215d06 Add onScroll hook for VirtualScrollList 2019-07-05 00:06:42 +03:00
Vitaliy Filippov b7df500935 Add fool tolerance 2019-07-04 15:49:14 +03:00
Vitaliy Filippov 7520acd02d refs instead of {data-item=} 2019-06-16 12:38:44 +03:00
Vitaliy Filippov f0e25628ae Add notes about VirtualScrollList 2019-04-23 13:12:38 +03:00
Vitaliy Filippov 1aca4ba455 Preserve other props 2019-04-22 20:05:09 +03:00
Vitaliy Filippov 68693f3312 Fix scroll position preservation with header 2019-04-22 19:59:39 +03:00
Vitaliy Filippov dc00c10f96 Re-export breaks it for those who do not want React 2019-04-22 19:36:14 +03:00
Vitaliy Filippov 630ea5664a Fix repositioning from disabled virtual scroll 2019-04-22 19:22:17 +03:00
Vitaliy Filippov f8575e59ed Re-export via index.js 2019-04-22 19:06:35 +03:00
Vitaliy Filippov 9f11a1c7ca Fix a bug, add a virtualized list implementation 2019-04-22 18:58:18 +03:00
4 changed files with 308 additions and 9 deletions

View File

@ -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
return newState; lastItemSize = 0;
} }
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.targetHeight = newState.avgRowHeight * newState.scrollHeightInItems + newState.viewportHeight;
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)

View File

@ -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 part of a larger UI library (ag-grid, ExtJS and so on), some of them are more
some of them are more standalone (react-virtualized, react-window, ngx-virtual-scroller, react-dynamic-virtual-list). 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. 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 do not really work; 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 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,7 +67,64 @@ 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 example with React # 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`. See `DynamicVirtualScrollExample.js`.

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

View File

@ -1,6 +1,6 @@
{ {
"name": "dynamic-virtual-scroll", "name": "dynamic-virtual-scroll",
"version": "1.0.0", "version": "1.0.15",
"author": { "author": {
"name": "Vitaliy Filippov", "name": "Vitaliy Filippov",
"email": "vitalif@yourcmc.ru", "email": "vitalif@yourcmc.ru",
@ -16,7 +16,8 @@
"dynamic", "dynamic",
"virtual-list", "virtual-list",
"virtual-scroll", "virtual-scroll",
"lazy-rendering" "lazy-rendering",
"react"
], ],
"repository": { "repository": {
"type": "git", "type": "git",
@ -36,6 +37,8 @@
"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"
], ],
@ -59,7 +62,7 @@
"webpack-cli": "^3.1.2" "webpack-cli": "^3.1.2"
}, },
"scripts": { "scripts": {
"build": "eslint Dynamic*.js && webpack --mode=production --optimize-minimize && babel DynamicVirtualScroll.js > DynamicVirtualScroll.es5.js", "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-dev": "NODE_ENV=development webpack --mode=development -w",
"watch": "webpack --mode=production -w --optimize-minimize" "watch": "webpack --mode=production -w --optimize-minimize"
} }