UNPKG

react-virtualized

Version:

React components for efficiently rendering large, scrollable lists and tabular data

775 lines (668 loc) 27.4 kB
import { computeCellMetadataAndUpdateScrollOffsetHelper, createCallbackMemoizer, getOverscanIndices, getUpdatedOffsetForIndex, getVisibleCellIndices, initCellMetadata, updateScrollIndexHelper } from './GridUtils'; import cn from 'classnames'; import raf from 'raf'; import getScrollbarSize from 'dom-helpers/util/scrollbarSize'; import React, { Component, PropTypes } from 'react'; import shallowCompare from 'react-addons-shallow-compare'; /** * Specifies the number of miliseconds during which to disable pointer events while a scroll is in progress. * This improves performance and makes scrolling smoother. */ var IS_SCROLLING_TIMEOUT = 150; /** * Controls whether the Grid updates the DOM element's scrollLeft/scrollTop based on the current state or just observes it. * This prevents Grid from interrupting mouse-wheel animations (see issue #2). */ var SCROLL_POSITION_CHANGE_REASONS = { OBSERVED: 'observed', REQUESTED: 'requested' }; /** * Renders tabular data with virtualization along the vertical and horizontal axes. * Row heights and column widths must be known ahead of time and specified as properties. */ var Grid = function (_Component) { babelHelpers.inherits(Grid, _Component); function Grid(props, context) { babelHelpers.classCallCheck(this, Grid); var _this = babelHelpers.possibleConstructorReturn(this, Object.getPrototypeOf(Grid).call(this, props, context)); _this.state = { computeGridMetadataOnNextUpdate: false, isScrolling: false, scrollLeft: 0, scrollTop: 0 }; // Invokes onSectionRendered callback only when start/stop row or column indices change _this._onGridRenderedMemoizer = createCallbackMemoizer(); _this._onScrollMemoizer = createCallbackMemoizer(false); // Bind functions to instance so they don't lose context when passed around _this._computeGridMetadata = _this._computeGridMetadata.bind(_this); _this._invokeOnGridRenderedHelper = _this._invokeOnGridRenderedHelper.bind(_this); _this._onScroll = _this._onScroll.bind(_this); _this._updateScrollLeftForScrollToColumn = _this._updateScrollLeftForScrollToColumn.bind(_this); _this._updateScrollTopForScrollToRow = _this._updateScrollTopForScrollToRow.bind(_this); return _this; } /** * Forced recompute of row heights and column widths. * This function should be called if dynamic column or row sizes have changed but nothing else has. * Since Grid only receives :columnsCount and :rowsCount it has no way of detecting when the underlying data changes. */ babelHelpers.createClass(Grid, [{ key: 'recomputeGridSize', value: function recomputeGridSize() { this.setState({ computeGridMetadataOnNextUpdate: true }); } }, { key: 'componentDidMount', value: function componentDidMount() { var _props = this.props; var scrollLeft = _props.scrollLeft; var scrollToColumn = _props.scrollToColumn; var scrollTop = _props.scrollTop; var scrollToRow = _props.scrollToRow; this._scrollbarSize = getScrollbarSize(); if (scrollLeft >= 0 || scrollTop >= 0) { this._setScrollPosition({ scrollLeft: scrollLeft, scrollTop: scrollTop }); } if (scrollToColumn >= 0 || scrollToRow >= 0) { this._updateScrollLeftForScrollToColumn(); this._updateScrollTopForScrollToRow(); } // Update onRowsRendered callback this._invokeOnGridRenderedHelper(); // Initialize onScroll callback this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft || 0, scrollTop: scrollTop || 0, totalColumnsWidth: this._getTotalColumnsWidth(), totalRowsHeight: this._getTotalRowsHeight() }); } /** * @private * This method updates scrollLeft/scrollTop in state for the following conditions: * 1) New scroll-to-cell props have been set */ }, { key: 'componentDidUpdate', value: function componentDidUpdate(prevProps, prevState) { var _props2 = this.props; var columnsCount = _props2.columnsCount; var columnWidth = _props2.columnWidth; var height = _props2.height; var rowHeight = _props2.rowHeight; var rowsCount = _props2.rowsCount; var scrollToColumn = _props2.scrollToColumn; var scrollToRow = _props2.scrollToRow; var width = _props2.width; var _state = this.state; var scrollLeft = _state.scrollLeft; var scrollPositionChangeReason = _state.scrollPositionChangeReason; var scrollTop = _state.scrollTop; // Make sure requested changes to :scrollLeft or :scrollTop get applied. // Assigning to scrollLeft/scrollTop tells the browser to interrupt any running scroll animations, // And to discard any pending async changes to the scroll position that may have happened in the meantime (e.g. on a separate scrolling thread). // So we only set these when we require an adjustment of the scroll position. // See issue #2 for more information. if (scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.REQUESTED) { if (scrollLeft >= 0 && scrollLeft !== prevState.scrollLeft && scrollLeft !== this.refs.scrollingContainer.scrollLeft) { this.refs.scrollingContainer.scrollLeft = scrollLeft; } if (scrollTop >= 0 && scrollTop !== prevState.scrollTop && scrollTop !== this.refs.scrollingContainer.scrollTop) { this.refs.scrollingContainer.scrollTop = scrollTop; } } // Update scroll offsets if the current :scrollToColumn or :scrollToRow values requires it updateScrollIndexHelper({ cellsCount: columnsCount, cellMetadata: this._columnMetadata, cellSize: columnWidth, previousCellsCount: prevProps.columnsCount, previousCellSize: prevProps.columnWidth, previousScrollToIndex: prevProps.scrollToColumn, previousSize: prevProps.width, scrollOffset: scrollLeft, scrollToIndex: scrollToColumn, size: width, updateScrollIndexCallback: this._updateScrollLeftForScrollToColumn }); updateScrollIndexHelper({ cellsCount: rowsCount, cellMetadata: this._rowMetadata, cellSize: rowHeight, previousCellsCount: prevProps.rowsCount, previousCellSize: prevProps.rowHeight, previousScrollToIndex: prevProps.scrollToRow, previousSize: prevProps.height, scrollOffset: scrollTop, scrollToIndex: scrollToRow, size: height, updateScrollIndexCallback: this._updateScrollTopForScrollToRow }); // Update onRowsRendered callback if start/stop indices have changed this._invokeOnGridRenderedHelper(); } }, { key: 'componentWillMount', value: function componentWillMount() { this._computeGridMetadata(this.props); } }, { key: 'componentWillUnmount', value: function componentWillUnmount() { if (this._disablePointerEventsTimeoutId) { clearTimeout(this._disablePointerEventsTimeoutId); } if (this._setNextStateAnimationFrameId) { raf.cancel(this._setNextStateAnimationFrameId); } } /** * @private * This method updates scrollLeft/scrollTop in state for the following conditions: * 1) Empty content (0 rows or columns) * 2) New scroll props overriding the current state * 3) Cells-count or cells-size has changed, making previous scroll offsets invalid */ }, { key: 'componentWillUpdate', value: function componentWillUpdate(nextProps, nextState) { if (nextProps.columnsCount === 0 && nextState.scrollLeft !== 0 || nextProps.rowsCount === 0 && nextState.scrollTop !== 0) { this._setScrollPosition({ scrollLeft: 0, scrollTop: 0 }); } else if (nextProps.scrollLeft !== this.props.scrollLeft || nextProps.scrollTop !== this.props.scrollTop) { this._setScrollPosition({ scrollLeft: nextProps.scrollLeft, scrollTop: nextProps.scrollTop }); } // Update scroll offsets if the size or number of cells have changed, invalidating the previous value computeCellMetadataAndUpdateScrollOffsetHelper({ cellsCount: this.props.columnsCount, cellSize: this.props.columnWidth, computeMetadataCallback: this._computeGridMetadata, computeMetadataCallbackProps: nextProps, computeMetadataOnNextUpdate: nextState.computeGridMetadataOnNextUpdate, nextCellsCount: nextProps.columnsCount, nextCellSize: nextProps.columnWidth, nextScrollToIndex: nextProps.scrollToColumn, scrollToIndex: this.props.scrollToColumn, updateScrollOffsetForScrollToIndex: this._updateScrollLeftForScrollToColumn }); computeCellMetadataAndUpdateScrollOffsetHelper({ cellsCount: this.props.rowsCount, cellSize: this.props.rowHeight, computeMetadataCallback: this._computeGridMetadata, computeMetadataCallbackProps: nextProps, computeMetadataOnNextUpdate: nextState.computeGridMetadataOnNextUpdate, nextCellsCount: nextProps.rowsCount, nextCellSize: nextProps.rowHeight, nextScrollToIndex: nextProps.scrollToRow, scrollToIndex: this.props.scrollToRow, updateScrollOffsetForScrollToIndex: this._updateScrollTopForScrollToRow }); this.setState({ computeGridMetadataOnNextUpdate: false }); } }, { key: 'render', value: function render() { var _props3 = this.props; var className = _props3.className; var columnsCount = _props3.columnsCount; var height = _props3.height; var noContentRenderer = _props3.noContentRenderer; var overscanColumnsCount = _props3.overscanColumnsCount; var overscanRowsCount = _props3.overscanRowsCount; var renderCell = _props3.renderCell; var rowsCount = _props3.rowsCount; var width = _props3.width; var _state2 = this.state; var isScrolling = _state2.isScrolling; var scrollLeft = _state2.scrollLeft; var scrollTop = _state2.scrollTop; var childrenToDisplay = []; // Render only enough columns and rows to cover the visible area of the grid. if (height > 0 && width > 0) { var visibleColumnIndices = getVisibleCellIndices({ cellsCount: columnsCount, cellMetadata: this._columnMetadata, containerSize: width, currentOffset: scrollLeft }); var visibleRowIndices = getVisibleCellIndices({ cellsCount: rowsCount, cellMetadata: this._rowMetadata, containerSize: height, currentOffset: scrollTop }); // Store for _invokeOnGridRenderedHelper() this._renderedColumnStartIndex = visibleColumnIndices.start; this._renderedColumnStopIndex = visibleColumnIndices.stop; this._renderedRowStartIndex = visibleRowIndices.start; this._renderedRowStopIndex = visibleRowIndices.stop; var overscanColumnIndices = getOverscanIndices({ cellsCount: columnsCount, overscanCellsCount: overscanColumnsCount, startIndex: this._renderedColumnStartIndex, stopIndex: this._renderedColumnStopIndex }); var overscanRowIndices = getOverscanIndices({ cellsCount: rowsCount, overscanCellsCount: overscanRowsCount, startIndex: this._renderedRowStartIndex, stopIndex: this._renderedRowStopIndex }); // Store for _invokeOnGridRenderedHelper() this._columnStartIndex = overscanColumnIndices.overscanStartIndex; this._columnStopIndex = overscanColumnIndices.overscanStopIndex; this._rowStartIndex = overscanRowIndices.overscanStartIndex; this._rowStopIndex = overscanRowIndices.overscanStopIndex; for (var rowIndex = this._rowStartIndex; rowIndex <= this._rowStopIndex; rowIndex++) { var rowDatum = this._rowMetadata[rowIndex]; for (var columnIndex = this._columnStartIndex; columnIndex <= this._columnStopIndex; columnIndex++) { var columnDatum = this._columnMetadata[columnIndex]; var renderedCell = renderCell({ columnIndex: columnIndex, rowIndex: rowIndex }); var key = rowIndex + '-' + columnIndex; // any other falsey value will be rendered // as a text node by React if (renderedCell == null || renderedCell === false) { continue; } var child = React.createElement( 'div', { key: key, className: 'Grid__cell', style: { height: this._getRowHeight(rowIndex), left: columnDatum.offset + 'px', top: rowDatum.offset + 'px', width: this._getColumnWidth(columnIndex) } }, renderedCell ); childrenToDisplay.push(child); } } } var gridStyle = { height: height, width: width }; var totalColumnsWidth = this._getTotalColumnsWidth(); var totalRowsHeight = this._getTotalRowsHeight(); // Force browser to hide scrollbars when we know they aren't necessary. // Otherwise once scrollbars appear they may not disappear again. // For more info see issue #116 if (totalColumnsWidth <= width) { gridStyle.overflowX = 'hidden'; } if (totalRowsHeight <= height) { gridStyle.overflowY = 'hidden'; } return React.createElement( 'div', { ref: 'scrollingContainer', className: cn('Grid', className), onScroll: this._onScroll, tabIndex: 0, style: gridStyle }, childrenToDisplay.length > 0 && React.createElement( 'div', { className: 'Grid__innerScrollContainer', style: { width: totalColumnsWidth, height: totalRowsHeight, maxWidth: totalColumnsWidth, maxHeight: totalRowsHeight, pointerEvents: isScrolling ? 'none' : 'auto' } }, childrenToDisplay ), childrenToDisplay.length === 0 && noContentRenderer() ); } }, { key: 'shouldComponentUpdate', value: function shouldComponentUpdate(nextProps, nextState) { return shallowCompare(this, nextProps, nextState); } /* ---------------------------- Helper methods ---------------------------- */ }, { key: '_computeGridMetadata', value: function _computeGridMetadata(props) { var columnsCount = props.columnsCount; var columnWidth = props.columnWidth; var rowHeight = props.rowHeight; var rowsCount = props.rowsCount; this._columnMetadata = initCellMetadata({ cellsCount: columnsCount, size: columnWidth }); this._rowMetadata = initCellMetadata({ cellsCount: rowsCount, size: rowHeight }); } /** * Sets an :isScrolling flag for a small window of time. * This flag is used to disable pointer events on the scrollable portion of the Grid. * This prevents jerky/stuttery mouse-wheel scrolling. */ }, { key: '_enablePointerEventsAfterDelay', value: function _enablePointerEventsAfterDelay() { var _this2 = this; if (this._disablePointerEventsTimeoutId) { clearTimeout(this._disablePointerEventsTimeoutId); } this._disablePointerEventsTimeoutId = setTimeout(function () { _this2._disablePointerEventsTimeoutId = null; _this2.setState({ isScrolling: false }); }, IS_SCROLLING_TIMEOUT); } }, { key: '_getColumnWidth', value: function _getColumnWidth(index) { var columnWidth = this.props.columnWidth; return columnWidth instanceof Function ? columnWidth(index) : columnWidth; } }, { key: '_getRowHeight', value: function _getRowHeight(index) { var rowHeight = this.props.rowHeight; return rowHeight instanceof Function ? rowHeight(index) : rowHeight; } }, { key: '_getTotalColumnsWidth', value: function _getTotalColumnsWidth() { if (this._columnMetadata.length === 0) { return 0; } var datum = this._columnMetadata[this._columnMetadata.length - 1]; return datum.offset + datum.size; } }, { key: '_getTotalRowsHeight', value: function _getTotalRowsHeight() { if (this._rowMetadata.length === 0) { return 0; } var datum = this._rowMetadata[this._rowMetadata.length - 1]; return datum.offset + datum.size; } }, { key: '_invokeOnGridRenderedHelper', value: function _invokeOnGridRenderedHelper() { var onSectionRendered = this.props.onSectionRendered; this._onGridRenderedMemoizer({ callback: onSectionRendered, indices: { columnOverscanStartIndex: this._columnStartIndex, columnOverscanStopIndex: this._columnStopIndex, columnStartIndex: this._renderedColumnStartIndex, columnStopIndex: this._renderedColumnStopIndex, rowOverscanStartIndex: this._rowStartIndex, rowOverscanStopIndex: this._rowStopIndex, rowStartIndex: this._renderedRowStartIndex, rowStopIndex: this._renderedRowStopIndex } }); } }, { key: '_invokeOnScrollMemoizer', value: function _invokeOnScrollMemoizer(_ref) { var scrollLeft = _ref.scrollLeft; var scrollTop = _ref.scrollTop; var totalColumnsWidth = _ref.totalColumnsWidth; var totalRowsHeight = _ref.totalRowsHeight; var _props4 = this.props; var height = _props4.height; var onScroll = _props4.onScroll; var width = _props4.width; this._onScrollMemoizer({ callback: function callback(_ref2) { var scrollLeft = _ref2.scrollLeft; var scrollTop = _ref2.scrollTop; onScroll({ clientHeight: height, clientWidth: width, scrollHeight: totalRowsHeight, scrollLeft: scrollLeft, scrollTop: scrollTop, scrollWidth: totalColumnsWidth }); }, indices: { scrollLeft: scrollLeft, scrollTop: scrollTop } }); } /** * Updates the state during the next animation frame. * Use this method to avoid multiple renders in a small span of time. * This helps performance for bursty events (like onScroll). */ }, { key: '_setNextState', value: function _setNextState(state) { var _this3 = this; if (this._setNextStateAnimationFrameId) { raf.cancel(this._setNextStateAnimationFrameId); } this._setNextStateAnimationFrameId = raf(function () { _this3._setNextStateAnimationFrameId = null; _this3.setState(state); }); } }, { key: '_setScrollPosition', value: function _setScrollPosition(_ref3) { var scrollLeft = _ref3.scrollLeft; var scrollTop = _ref3.scrollTop; var newState = { scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED }; if (scrollLeft >= 0) { newState.scrollLeft = scrollLeft; } if (scrollTop >= 0) { newState.scrollTop = scrollTop; } if (scrollLeft >= 0 && scrollLeft !== this.state.scrollLeft || scrollTop >= 0 && scrollTop !== this.state.scrollTop) { this.setState(newState); } } }, { key: '_updateScrollLeftForScrollToColumn', value: function _updateScrollLeftForScrollToColumn(scrollToColumnOverride) { var scrollToColumn = scrollToColumnOverride != null ? scrollToColumnOverride : this.props.scrollToColumn; var width = this.props.width; var scrollLeft = this.state.scrollLeft; if (scrollToColumn >= 0) { var calculatedScrollLeft = getUpdatedOffsetForIndex({ cellMetadata: this._columnMetadata, containerSize: width, currentOffset: scrollLeft, targetIndex: scrollToColumn }); if (scrollLeft !== calculatedScrollLeft) { this._setScrollPosition({ scrollLeft: calculatedScrollLeft }); } } } }, { key: '_updateScrollTopForScrollToRow', value: function _updateScrollTopForScrollToRow(scrollToRowOverride) { var scrollToRow = scrollToRowOverride != null ? scrollToRowOverride : this.props.scrollToRow; var height = this.props.height; var scrollTop = this.state.scrollTop; if (scrollToRow >= 0) { var calculatedScrollTop = getUpdatedOffsetForIndex({ cellMetadata: this._rowMetadata, containerSize: height, currentOffset: scrollTop, targetIndex: scrollToRow }); if (scrollTop !== calculatedScrollTop) { this._setScrollPosition({ scrollTop: calculatedScrollTop }); } } } }, { key: '_onScroll', value: function _onScroll(event) { // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. // See issue #404 for more information. if (event.target !== this.refs.scrollingContainer) { return; } // Prevent pointer events from interrupting a smooth scroll this._enablePointerEventsAfterDelay(); // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. // This causes a series of rapid renders that is slow for long lists. // We can avoid that by doing some simple bounds checking to ensure that scrollTop never exceeds the total height. var _props5 = this.props; var height = _props5.height; var width = _props5.width; var scrollbarSize = this._scrollbarSize; var totalRowsHeight = this._getTotalRowsHeight(); var totalColumnsWidth = this._getTotalColumnsWidth(); var scrollLeft = Math.min(totalColumnsWidth - width + scrollbarSize, event.target.scrollLeft); var scrollTop = Math.min(totalRowsHeight - height + scrollbarSize, event.target.scrollTop); // Certain devices (like Apple touchpad) rapid-fire duplicate events. // Don't force a re-render if this is the case. // The mouse may move faster then the animation frame does. // Use requestAnimationFrame to avoid over-updating. if (this.state.scrollLeft !== scrollLeft || this.state.scrollTop !== scrollTop) { // Browsers with cancelable scroll events (eg. Firefox) interrupt scrolling animations if scrollTop/scrollLeft is set. // Other browsers (eg. Safari) don't scroll as well without the help under certain conditions (DOM or style changes during scrolling). // All things considered, this seems to be the best current work around that I'm aware of. // For more information see https://github.com/bvaughn/react-virtualized/pull/124 var scrollPositionChangeReason = event.cancelable ? SCROLL_POSITION_CHANGE_REASONS.OBSERVED : SCROLL_POSITION_CHANGE_REASONS.REQUESTED; if (!this.state.isScrolling) { this.setState({ isScrolling: true }); } this._setNextState({ isScrolling: true, scrollLeft: scrollLeft, scrollPositionChangeReason: scrollPositionChangeReason, scrollTop: scrollTop }); } this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft, scrollTop: scrollTop, totalColumnsWidth: totalColumnsWidth, totalRowsHeight: totalRowsHeight }); } }]); return Grid; }(Component); Grid.propTypes = { /** * Optional custom CSS class name to attach to root Grid element. */ className: PropTypes.string, /** * Number of columns in grid. */ columnsCount: PropTypes.number.isRequired, /** * Either a fixed column width (number) or a function that returns the width of a column given its index. * Should implement the following interface: (index: number): number */ columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.func]).isRequired, /** * Height of Grid; this property determines the number of visible (vs virtualized) rows. */ height: PropTypes.number.isRequired, /** * Optional renderer to be used in place of rows when either :rowsCount or :columnsCount is 0. */ noContentRenderer: PropTypes.func.isRequired, /** * Callback invoked whenever the scroll offset changes within the inner scrollable region. * This callback can be used to sync scrolling between lists, tables, or grids. * ({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }): void */ onScroll: PropTypes.func.isRequired, /** * Callback invoked with information about the section of the Grid that was just rendered. * ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }): void */ onSectionRendered: PropTypes.func.isRequired, /** * Number of columns to render before/after the visible section of the grid. * These columns can help for smoother scrolling on touch devices or browsers that send scroll events infrequently. */ overscanColumnsCount: PropTypes.number.isRequired, /** * Number of rows to render above/below the visible section of the grid. * These rows can help for smoother scrolling on touch devices or browsers that send scroll events infrequently. */ overscanRowsCount: PropTypes.number.isRequired, /** * Responsible for rendering a cell given an row and column index. * Should implement the following interface: ({ columnIndex: number, rowIndex: number }): PropTypes.node */ renderCell: PropTypes.func.isRequired, /** * Either a fixed row height (number) or a function that returns the height of a row given its index. * Should implement the following interface: (index: number): number */ rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]).isRequired, /** * Number of rows in grid. */ rowsCount: PropTypes.number.isRequired, /** Horizontal offset. */ scrollLeft: PropTypes.number, /** * Column index to ensure visible (by forcefully scrolling if necessary) */ scrollToColumn: PropTypes.number, /** Vertical offset. */ scrollTop: PropTypes.number, /** * Row index to ensure visible (by forcefully scrolling if necessary) */ scrollToRow: PropTypes.number, /** * Width of Grid; this property determines the number of visible (vs virtualized) columns. */ width: PropTypes.number.isRequired }; Grid.defaultProps = { noContentRenderer: function noContentRenderer() { return null; }, onScroll: function onScroll() { return null; }, onSectionRendered: function onSectionRendered() { return null; }, overscanColumnsCount: 0, overscanRowsCount: 10 }; export default Grid;