react-infinite-cursor
Version:
A browser-ready efficient scrolling container based on UITableView. Takes a cursor for rendering.
462 lines (381 loc) • 17.1 kB
JavaScript
'use strict';
function _objectWithoutProperties(obj, keys) { var target = {}; for (var i in obj) { if (keys.indexOf(i) >= 0) continue; if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; target[i] = obj[i]; } return target; }
var React = global.React || require('react');
var ReactDOM = global.ReactDOM || require('react-dom');
require('./utils/establish-polyfills');
var scaleEnum = require('./utils/scaleEnum');
var infiniteHelpers = require('./utils/infiniteHelpers');
var _isFinite = require('lodash.isfinite');
var preloadType = require('./utils/types').preloadType;
var checkProps = checkProps = require('./utils/checkProps');
var Infinite = React.createClass({
displayName: 'Infinite',
propTypes: {
children: React.PropTypes.any,
handleScroll: React.PropTypes.func,
// preloadBatchSize causes updates only to
// happen each preloadBatchSize pixels of scrolling.
// Set a larger number to cause fewer updates to the
// element list.
preloadBatchSize: preloadType,
// preloadAdditionalHeight determines how much of the
// list above and below the container is preloaded even
// when it is not currently visible to the user. In the
// regular scroll implementation, preloadAdditionalHeight
// is equal to the entire height of the list.
preloadAdditionalHeight: preloadType, // page to screen ratio
// The provided elementHeight can be either
// 1. a constant: all elements are the same height
// 2. an array containing the height of each element
elementHeight: React.PropTypes.oneOfType([React.PropTypes.number, React.PropTypes.arrayOf(React.PropTypes.number)]).isRequired,
// This is the total height of the visible window. One
// of
containerHeight: React.PropTypes.number,
useWindowAsScrollContainer: React.PropTypes.bool,
displayBottomUpwards: React.PropTypes.bool.isRequired,
infiniteLoadBeginEdgeOffset: React.PropTypes.number,
onInfiniteLoad: React.PropTypes.func,
loadingSpinnerDelegate: React.PropTypes.node,
isInfiniteLoading: React.PropTypes.bool,
timeScrollStateLastsForAfterUserScrolls: React.PropTypes.number,
className: React.PropTypes.string,
styles: React.PropTypes.shape({
scrollableStyle: React.PropTypes.object
}).isRequired,
list: React.PropTypes.array.isRequired,
childRender: React.PropTypes.func.isRequired,
renderLoadMore: React.PropTypes.func
},
statics: {
containerHeightScaleFactor: function containerHeightScaleFactor(factor) {
if (!_isFinite(factor)) {
throw new Error('The scale factor must be a number.');
}
return {
type: scaleEnum.CONTAINER_HEIGHT_SCALE_FACTOR,
amount: factor
};
}
},
// Properties currently used but which may be
// refactored away in the future.
computedProps: {},
utils: {},
shouldAttachToBottom: false,
preservedScrollState: 0,
loadingSpinnerHeight: 0,
deprecationWarned: false,
getDefaultProps: function getDefaultProps() {
return {
handleScroll: function handleScroll() {},
useWindowAsScrollContainer: false,
onInfiniteLoad: function onInfiniteLoad() {},
loadingSpinnerDelegate: React.createElement('div', null),
displayBottomUpwards: false,
isInfiniteLoading: false,
timeScrollStateLastsForAfterUserScrolls: 150,
className: '',
styles: {},
list: []
};
},
// automatic adjust to scroll direction
// give spinner a ReactCSSTransitionGroup
getInitialState: function getInitialState() {
var nextInternalState = this.recomputeInternalStateFromProps(this.props);
this.computedProps = nextInternalState.computedProps;
this.utils = nextInternalState.utils;
this.shouldAttachToBottom = this.props.displayBottomUpwards;
var state = nextInternalState.newState;
state.scrollTimeout = undefined;
state.isScrolling = false;
return state;
},
generateComputedProps: function generateComputedProps(props) {
// These are extracted so their type definitions do not conflict.
var containerHeight = props.containerHeight;
var preloadBatchSize = props.preloadBatchSize;
var preloadAdditionalHeight = props.preloadAdditionalHeight;
var oldProps = _objectWithoutProperties(props, ['containerHeight', 'preloadBatchSize', 'preloadAdditionalHeight']);
var newProps = {};
containerHeight = typeof containerHeight === 'number' ? containerHeight : 0;
newProps.containerHeight = props.useWindowAsScrollContainer ? window.innerHeight : containerHeight;
if (oldProps.infiniteLoadBeginBottomOffset !== undefined) {
newProps.infiniteLoadBeginEdgeOffset = oldProps.infiniteLoadBeginBottomOffset;
if (!this.deprecationWarned) {
console.error('Warning: React Infinite\'s infiniteLoadBeginBottomOffset prop\n has been deprecated as of 0.6.0. Please use infiniteLoadBeginEdgeOffset.\n Because this is a rather descriptive name, a simple find and replace\n should suffice.');
this.deprecationWarned = true;
}
}
var defaultPreloadBatchSizeScaling = {
type: scaleEnum.CONTAINER_HEIGHT_SCALE_FACTOR,
amount: 0.5
};
var batchSize = preloadBatchSize && preloadBatchSize.type ? preloadBatchSize : defaultPreloadBatchSizeScaling;
if (typeof preloadBatchSize === 'number') {
newProps.preloadBatchSize = preloadBatchSize;
} else if (typeof batchSize === 'object' && batchSize.type === scaleEnum.CONTAINER_HEIGHT_SCALE_FACTOR) {
newProps.preloadBatchSize = newProps.containerHeight * batchSize.amount;
} else {
newProps.preloadBatchSize = 0;
}
var defaultPreloadAdditionalHeightScaling = {
type: scaleEnum.CONTAINER_HEIGHT_SCALE_FACTOR,
amount: 1
};
var additionalHeight = preloadAdditionalHeight && preloadAdditionalHeight.type ? preloadAdditionalHeight : defaultPreloadAdditionalHeightScaling;
if (typeof preloadAdditionalHeight === 'number') {
newProps.preloadAdditionalHeight = preloadAdditionalHeight;
} else if (typeof additionalHeight === 'object' && additionalHeight.type === scaleEnum.CONTAINER_HEIGHT_SCALE_FACTOR) {
newProps.preloadAdditionalHeight = newProps.containerHeight * additionalHeight.amount;
} else {
newProps.preloadAdditionalHeight = 0;
}
return Object.assign(oldProps, newProps);
},
generateComputedUtilityFunctions: function generateComputedUtilityFunctions(props) {
var _this = this;
var utilities = {};
utilities.getLoadingSpinnerHeight = function () {
var loadingSpinnerHeight = 0;
if (_this.refs && _this.refs.loadingSpinner) {
var loadingSpinnerNode = ReactDOM.findDOMNode(_this.refs.loadingSpinner);
loadingSpinnerHeight = loadingSpinnerNode.offsetHeight || 0;
}
return loadingSpinnerHeight;
};
if (props.useWindowAsScrollContainer) {
utilities.subscribeToScrollListener = function () {
window.addEventListener('scroll', _this.infiniteHandleScroll);
};
utilities.unsubscribeFromScrollListener = function () {
window.removeEventListener('scroll', _this.infiniteHandleScroll);
};
utilities.nodeScrollListener = function () {};
utilities.getScrollTop = function () {
return window.pageYOffset;
};
utilities.setScrollTop = function (top) {
window.scroll(window.pageXOffset, top);
};
utilities.scrollShouldBeIgnored = function () {
return false;
};
utilities.buildScrollableStyle = function () {
return {};
};
} else {
utilities.subscribeToScrollListener = function () {};
utilities.unsubscribeFromScrollListener = function () {};
utilities.nodeScrollListener = this.infiniteHandleScroll;
utilities.getScrollTop = function () {
var scrollable;
if (_this.refs && _this.refs.scrollable) {
scrollable = ReactDOM.findDOMNode(_this.refs.scrollable);
}
return scrollable ? scrollable.scrollTop : 0;
};
utilities.setScrollTop = function (top) {
var scrollable;
if (_this.refs && _this.refs.scrollable) {
scrollable = ReactDOM.findDOMNode(_this.refs.scrollable);
}
if (scrollable) {
scrollable.scrollTop = top;
}
};
utilities.scrollShouldBeIgnored = function (event) {
return event.target !== ReactDOM.findDOMNode(_this.refs.scrollable);
};
utilities.buildScrollableStyle = function () {
return Object.assign({}, {
height: _this.computedProps.containerHeight,
overflowX: 'hidden',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
overflowAnchor: 'none'
}, _this.computedProps.styles.scrollableStyle || {});
};
}
return utilities;
},
recomputeInternalStateFromProps: function recomputeInternalStateFromProps(props) {
checkProps(props);
var computedProps = this.generateComputedProps(props);
var utils = this.generateComputedUtilityFunctions(props);
var newState = {};
newState.numberOfChildren = computedProps.list.length;
newState.infiniteComputer = infiniteHelpers.createInfiniteComputer(computedProps.elementHeight, computedProps.list, computedProps.displayBottomUpwards);
if (computedProps.isInfiniteLoading !== undefined) {
newState.isInfiniteLoading = computedProps.isInfiniteLoading;
}
newState.preloadBatchSize = computedProps.preloadBatchSize;
newState.preloadAdditionalHeight = computedProps.preloadAdditionalHeight;
newState = Object.assign(newState, infiniteHelpers.recomputeApertureStateFromOptionsAndScrollTop(newState, utils.getScrollTop()));
return {
computedProps: computedProps,
utils: utils,
newState: newState
};
},
componentWillReceiveProps: function componentWillReceiveProps(nextProps) {
var nextInternalState = this.recomputeInternalStateFromProps(nextProps);
this.computedProps = nextInternalState.computedProps;
this.utils = nextInternalState.utils;
this.setState(nextInternalState.newState);
},
componentWillUpdate: function componentWillUpdate() {
if (this.props.displayBottomUpwards) {
this.preservedScrollState = this.utils.getScrollTop() - this.loadingSpinnerHeight;
}
},
componentDidUpdate: function componentDidUpdate(prevProps, prevState) {
if (this.props.displayBottomUpwards) {
this.loadingSpinnerHeight = this.utils.getLoadingSpinnerHeight();
var lowestScrollTop = this.getLowestPossibleScrollTop();
if (this.shouldAttachToBottom && this.utils.getScrollTop() < lowestScrollTop) {
this.utils.setScrollTop(lowestScrollTop);
} else if (prevProps.isInfiniteLoading && !this.props.isInfiniteLoading) {
this.utils.setScrollTop(this.state.infiniteComputer.getTotalScrollableHeight() - prevState.infiniteComputer.getTotalScrollableHeight() + this.preservedScrollState);
}
}
var hasLoadedMoreChildren = this.props.list.length !== prevProps.list.length;
if (hasLoadedMoreChildren) {
var newApertureState = infiniteHelpers.recomputeApertureStateFromOptionsAndScrollTop(this.state, this.utils.getScrollTop());
this.setState(newApertureState);
}
var isMissingVisibleRows = hasLoadedMoreChildren && !this.hasAllVisibleItems() && !this.state.isInfiniteLoading;
if (isMissingVisibleRows) {
this.onInfiniteLoad();
}
},
componentDidMount: function componentDidMount() {
this.utils.subscribeToScrollListener();
if (!this.hasAllVisibleItems()) {
this.onInfiniteLoad();
}
if (this.props.displayBottomUpwards) {
var lowestScrollTop = this.getLowestPossibleScrollTop();
if (this.shouldAttachToBottom && this.utils.getScrollTop() < lowestScrollTop) {
this.utils.setScrollTop(lowestScrollTop);
}
}
},
componentWillUnmount: function componentWillUnmount() {
this.utils.unsubscribeFromScrollListener();
},
infiniteHandleScroll: function infiniteHandleScroll(e) {
if (this.utils.scrollShouldBeIgnored(e)) {
return;
}
this.computedProps.handleScroll(ReactDOM.findDOMNode(this.refs.scrollable));
this.handleScroll(this.utils.getScrollTop());
},
manageScrollTimeouts: function manageScrollTimeouts() {
// Maintains a series of timeouts to set this.state.isScrolling
// to be true when the element is scrolling.
if (this.state.scrollTimeout) {
clearTimeout(this.state.scrollTimeout);
}
var that = this,
scrollTimeout = setTimeout(function () {
that.setState({
isScrolling: false,
scrollTimeout: undefined
});
}, this.computedProps.timeScrollStateLastsForAfterUserScrolls);
this.setState({
isScrolling: true,
scrollTimeout: scrollTimeout
});
},
getLowestPossibleScrollTop: function getLowestPossibleScrollTop() {
return this.state.infiniteComputer.getTotalScrollableHeight() - this.computedProps.containerHeight;
},
hasAllVisibleItems: function hasAllVisibleItems() {
return !(_isFinite(this.computedProps.infiniteLoadBeginEdgeOffset) && this.state.infiniteComputer.getTotalScrollableHeight() < this.computedProps.containerHeight);
},
passedEdgeForInfiniteScroll: function passedEdgeForInfiniteScroll(scrollTop) {
if (this.computedProps.displayBottomUpwards) {
return !this.shouldAttachToBottom && scrollTop < this.computedProps.infiniteLoadBeginEdgeOffset;
} else {
return scrollTop > this.state.infiniteComputer.getTotalScrollableHeight() - this.computedProps.containerHeight - this.computedProps.infiniteLoadBeginEdgeOffset;
}
},
onInfiniteLoad: function onInfiniteLoad() {
this.setState({ isInfiniteLoading: true });
this.computedProps.onInfiniteLoad();
},
handleScroll: function handleScroll(scrollTop) {
this.shouldAttachToBottom = this.computedProps.displayBottomUpwards && scrollTop >= this.getLowestPossibleScrollTop();
this.manageScrollTimeouts();
var newApertureState = infiniteHelpers.recomputeApertureStateFromOptionsAndScrollTop(this.state, scrollTop);
if (this.passedEdgeForInfiniteScroll(scrollTop) && !this.state.isInfiniteLoading) {
this.setState(Object.assign({}, newApertureState));
this.onInfiniteLoad();
} else {
this.setState(newApertureState);
}
},
buildHeightStyle: function buildHeightStyle(height) {
return {
width: '100%',
height: Math.ceil(height)
};
},
render: function render() {
var _this2 = this;
var displayables;
if (this.computedProps.list.length > 1) {
displayables = this.computedProps.list.slice(this.state.displayIndexStart, this.state.displayIndexEnd + 1);
} else {
displayables = this.computedProps.list;
}
var displayablesRender = displayables.map(function (displayable, i) {
return _this2.props.childRender(displayable, i);
});
var infiniteScrollStyles = {};
if (this.state.isScrolling) {
infiniteScrollStyles.pointerEvents = 'none';
}
var topSpacerHeight = this.state.infiniteComputer.getTopSpacerHeight(this.state.displayIndexStart),
bottomSpacerHeight = this.state.infiniteComputer.getBottomSpacerHeight(this.state.displayIndexEnd);
// This asymmetry is due to a reluctance to use CSS to control
// the bottom alignment
if (this.computedProps.displayBottomUpwards) {
var heightDifference = this.computedProps.containerHeight - this.state.infiniteComputer.getTotalScrollableHeight();
if (heightDifference > 0) {
topSpacerHeight = heightDifference - this.loadingSpinnerHeight;
}
}
var loadingSpinner = this.computedProps.infiniteLoadBeginEdgeOffset === undefined ? null : React.createElement(
'div',
{ ref: 'loadingSpinner' },
this.state.isInfiniteLoading ? this.computedProps.loadingSpinnerDelegate : null
);
var loadMore = this.props.renderLoadMore ? this.props.renderLoadMore() : null;
// topSpacer and bottomSpacer take up the amount of space that the
// rendered elements would have taken up otherwise
return React.createElement(
'div',
{ className: this.computedProps.className,
ref: 'scrollable',
style: this.utils.buildScrollableStyle(),
onScroll: this.utils.nodeScrollListener },
React.createElement(
'div',
{ ref: 'smoothScrollingWrapper', style: infiniteScrollStyles },
React.createElement('div', { ref: 'topSpacer',
style: this.buildHeightStyle(topSpacerHeight) }),
this.computedProps.displayBottomUpwards && loadingSpinner,
displayablesRender,
loadMore,
!this.computedProps.displayBottomUpwards && loadingSpinner,
React.createElement('div', { ref: 'bottomSpacer',
style: this.buildHeightStyle(bottomSpacerHeight) })
)
);
}
});
module.exports = Infinite;
global.Infinite = Infinite;