/** * 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(); } } 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 (
{this.props.header} {this.state.targetHeight > 0 ?
: null} {this.state.topPlaceholderHeight ?
: null} {this.renderItems(this.state.firstMiddleItem, this.state.middleItemCount)} {this.state.middlePlaceholderHeight ?
: null} {this.renderItems(this.props.totalItems-this.state.lastItemCount, this.state.lastItemCount, true)}
); } 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; } }