Compare commits

...

11 Commits

4 changed files with 121 additions and 19 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++;

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`.

View File

@ -5,6 +5,7 @@
*/ */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { virtualScrollDriver } from 'dynamic-virtual-scroll'; import { virtualScrollDriver } from 'dynamic-virtual-scroll';
@ -33,16 +34,40 @@ export class VirtualScrollList extends React.Component
scrollTimes: 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) renderItems(start, count, is_end)
{ {
let r = []; let r = [];
for (let i = 0; i < count; i++) for (let i = 0; i < count; i++)
{ {
r.push( let item = this.props.renderItem(i+start);
<div data-item={i+start} key={i+start}> if (item)
{this.props.renderItem(i+start)} {
</div> 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; return r;
} }
@ -50,13 +75,20 @@ export class VirtualScrollList extends React.Component
render() render()
{ {
if (this.state.totalItems && this.props.totalItems != this.state.totalItems && if (this.state.totalItems && this.props.totalItems != this.state.totalItems &&
this.state.scrollTimes <= 0) this.state.scrollTimes <= 0 && this.viewport && this.viewport.offsetParent)
{ {
// Automatically preserve scroll position when item count changes // 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.scrollTo = this.getItemScrollPos();
this.state.scrollTimes = 2; this.state.scrollTimes = 2;
} }
const props = { ...this.props };
for (const k in VirtualScrollList.propTypes)
{
delete props[k];
}
return (<div return (<div
{...props}
className={this.props.className} className={this.props.className}
style={{ style={{
position: 'relative', position: 'relative',
@ -67,7 +99,7 @@ export class VirtualScrollList extends React.Component
onScroll={this.onScroll}> onScroll={this.onScroll}>
{this.props.header} {this.props.header}
{this.state.targetHeight > 0 {this.state.targetHeight > 0
? <div key="target" style={{position: 'absolute', left: '-5px', width: '1px', height: this.state.targetHeight+'px'}}></div> ? <div key="target" style={{position: 'absolute', top: 0, left: '-5px', width: '1px', height: this.state.targetHeight+'px'}}></div>
: null} : null}
{this.state.topPlaceholderHeight {this.state.topPlaceholderHeight
? <div style={{height: this.state.topPlaceholderHeight+'px'}} key="top"></div> ? <div style={{height: this.state.topPlaceholderHeight+'px'}} key="top"></div>
@ -87,12 +119,15 @@ export class VirtualScrollList extends React.Component
getRenderedItemHeight = (index) => getRenderedItemHeight = (index) =>
{ {
if (this.viewport) if (this.itemRefs[index])
{ {
const e = this.viewport.querySelector('div[data-item="'+index+'"]'); const e = ReactDOM.findDOMNode(this.itemRefs[index]);
if (e) if (e)
{ {
return e.getBoundingClientRect().height; // 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; return 0;
@ -101,12 +136,17 @@ export class VirtualScrollList extends React.Component
onScroll = () => onScroll = () =>
{ {
this.driver(); this.driver();
if (this.props.onScroll)
{
this.props.onScroll(this.viewport);
}
} }
componentDidUpdate = () => componentDidUpdate = () =>
{ {
let changed = this.driver(); let changed = this.driver();
if (!changed && this.state.scrollTimes > 0 && this.props.totalItems > 0) 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 // FIXME: It would be better to find a way to put this logic back into virtual-scroll-driver
let pos = this.state.scrollTo; let pos = this.state.scrollTo;
@ -121,7 +161,7 @@ export class VirtualScrollList extends React.Component
} }
else else
{ {
const el = this.viewport.querySelector('div[data-item="'+Math.floor(pos)+'"]'); const el = ReactDOM.findDOMNode(this.itemRefs[Math.floor(pos)]);
if (el) if (el)
{ {
this.viewport.scrollTop = el.offsetTop - (this.props.headerHeight||0) + el.offsetHeight*(pos-Math.floor(pos)); this.viewport.scrollTop = el.offsetTop - (this.props.headerHeight||0) + el.offsetHeight*(pos-Math.floor(pos));
@ -162,6 +202,11 @@ export class VirtualScrollList extends React.Component
driver = () => driver = () =>
{ {
if (!this.viewport || !this.viewport.offsetParent)
{
// Fool tolerance - do nothing if we are hidden
return false;
}
const newState = virtualScrollDriver( const newState = virtualScrollDriver(
{ {
totalItems: this.props.totalItems, totalItems: this.props.totalItems,

View File

@ -1,6 +1,6 @@
{ {
"name": "dynamic-virtual-scroll", "name": "dynamic-virtual-scroll",
"version": "1.0.5", "version": "1.0.15",
"author": { "author": {
"name": "Vitaliy Filippov", "name": "Vitaliy Filippov",
"email": "vitalif@yourcmc.ru", "email": "vitalif@yourcmc.ru",