diff --git a/GiftedListView.js b/GiftedListView.js index 149a3bd..b59080e 100644 --- a/GiftedListView.js +++ b/GiftedListView.js @@ -9,8 +9,12 @@ var { View, Text, RefreshControl, + ScrollView, } = React; +import shallowCompare from 'react-addons-shallow-compare'; + + // small helper function which merged two objects into one function MergeRecursive(obj1, obj2) { @@ -36,6 +40,8 @@ var GiftedListView = React.createClass({ return { customStyles: {}, initialListSize: 10, + onEndReachedThreshold: 100, + onEndReachedEventThrottle: 1000, firstLoader: true, pagination: true, refreshable: true, @@ -49,6 +55,7 @@ var GiftedListView = React.createClass({ sectionHeaderView: null, scrollEnabled: true, withSections: false, + autoPaginate: false, onFetch(page, callback, options) { callback([]); }, paginationFetchingView: null, @@ -56,12 +63,17 @@ var GiftedListView = React.createClass({ paginationWaitingView: null, emptyView: null, renderSeparator: null, + + rows: null, + fetchOptions: null, }; }, propTypes: { customStyles: React.PropTypes.object, initialListSize: React.PropTypes.number, + onEndReachedThreshold: React.PropTypes.number, + onEndReachedEventThrottle: React.PropTypes.number, firstLoader: React.PropTypes.bool, pagination: React.PropTypes.bool, refreshable: React.PropTypes.bool, @@ -75,6 +87,7 @@ var GiftedListView = React.createClass({ sectionHeaderView: React.PropTypes.func, scrollEnabled: React.PropTypes.bool, withSections: React.PropTypes.bool, + autoPaginate: React.PropTypes.bool, onFetch: React.PropTypes.func, paginationFetchingView: React.PropTypes.func, @@ -82,6 +95,9 @@ var GiftedListView = React.createClass({ paginationWaitingView: React.PropTypes.func, emptyView: React.PropTypes.func, renderSeparator: React.PropTypes.func, + + rows: React.PropTypes.array, + fetchOptions: React.PropTypes.object, }, _setPage(page) { this._page = page; }, @@ -172,6 +188,7 @@ var GiftedListView = React.createClass({ getInitialState() { this._setPage(1); this._setRows([]); + this.refreshedAt = new Date; var ds = null; if (this.props.withSections === true) { @@ -197,85 +214,217 @@ var GiftedListView = React.createClass({ }, componentDidMount() { - this.props.onFetch(this._getPage(), this._postRefresh, {firstLoad: true}); + //if(this.props.rows) { + // this._postRefresh(this.props.rows, this.beforeOptions); + //} + + this._fetch(this._getPage(), {firstLoad: true, ...this.props.fetchOptions}); + + //imperative OOP state utilized since onEndReached is imperatively called. So why waste cycles on rendering, which + //can cause loss of frames in animation. + this.lastGrantAt = this.lastReleaseAt = this.lastEndReachedAt = this.lastManualRefreshAt = this.lastPaginateUpdateAt = new Date; }, setNativeProps(props) { this.refs.listview.setNativeProps(props); }, - _refresh() { - this._onRefresh({external: true}); + scrollTo(config) { + this.refs.listview.scrollTo(config); + }, + + //open up `refresh` as a public API + refresh(options) { + return this._refresh(options); + }, + + _refresh(options) { + this.lastManualRefreshAt = new Date; //can trigger scrollview to push past endReached threshold if you are already scrolled down when you call this + + this._onRefresh(Object.assign({ + external: true, + mustSetLastManualRefreshAt: true, //we pass it along, so when the rows are updated we know to store the date as well + }, options)); + + this.scrollTo({y: 0}); //refreshing may be triggered by new fetchOptions while scrolled down, which should trigger scrolling to the top }, _onRefresh(options = {}) { + options = {...this.props.fetchOptions, ...options}; + if (this.isMounted()) { - this.setState({ - isRefreshing: true, - }); + this.refs.refreshControl.setIsRefreshing(true); this._setPage(1); - this.props.onFetch(this._getPage(), this._postRefresh, options); + this.refreshedAt = new Date; + let page = this._getPage(); + this._fetch(page, options); + } + }, + + //The refactoring was done solely so we can pass `beforeOptions` along + //and insure such things as `lastManualRefreshAt` are passed to client code and back to our `_updateRows` method. + //But I think this could be useful for any data we want to pass to developers and guarantee comes back to us. + _fetch(page, beforeOptions, postCallback) { + postCallback = postCallback || this._postRefresh; + + this.beforeOptions = beforeOptions; //will be used by componentWillReceive props; parent components only need to provide rows + + this.props.onFetch(page, (rows, options) => { + postCallback(rows, Object.assign(beforeOptions, options)); + }, beforeOptions); + }, + + //Configure props for `onFetch`, `fetchOptions` and 'rows' to declaratively change the rows displayed. + //`onFetch` should now be used to dispatch a redux action, which reduces the `rows` prop :) + shouldComponentUpdate(nextProps, nextState) { + let rows = nextProps.rows; + let shouldUpdate = true; + + if(rows !== this.props.rows) { + if(this.beforeOptions && this.beforeOptions.paginatedFetch) { + setTimeout(() => { + this._postPaginate(rows, {...this.beforeOptions, allLoaded: nextProps.allLoaded}); + }, 1000); + } + else { + let timeSinceRefresh = new Date - this.refreshedAt; //make sure at least 1 second goes by before hiding refresh control, + let delay = timeSinceRefresh > 1000 ? 0 : 1000 - timeSinceRefresh; //or otherwise it will stick to its open position + + setTimeout(() => { + this._postRefresh(rows, this.beforeOptions); + }, delay); + } + + shouldUpdate = false; } + + //allow for declaratively refreshing simply by changing fetchOptions, + //which will then call `onFetch` and if done right will result in new `rows` props, i.e. the above code. + else if(nextProps.fetchOptions !== this.props.fetchOptions) { + this.refresh(nextProps.fetchOptions); + return false; + } + + this.beforeOptions = {}; + return shouldUpdate ? shallowCompare(this, nextProps, nextState) : false; }, + _postRefresh(rows = [], options = {}) { if (this.isMounted()) { - this._updateRows(rows, options); + this._updateRows(rows, options, true); + } + }, + + _updateRows(rows = [], options = {}, isRefresh=false) { + let state = { + paginationStatus: (options.allLoaded === true || rows.length === 0 || rows.length % this.props.limit !== 0 || (this._prevRowsLength === rows.length && !isRefresh || (this.props.limit && rows.length < this.props.limit)) ? 'allLoaded' : 'waiting'), + }; + + this._prevRowsLength = rows.length; + + if(options.mustSetLastManualRefreshAt) this.lastManualRefreshAt = new Date; + + if (rows !== null) { + this._setRows(rows); + + if (this.props.withSections === true) { + state.dataSource = this.state.dataSource.cloneWithRowsAndSections(rows); + } else { + state.dataSource = this.state.dataSource.cloneWithRows(rows); + } + } + + this.setState(state, this.props.onRefresh); + this.refs.refreshControl.setIsRefreshing(false); + + //this must be fired separately or iOS will call onEndReached 2-3 additional times as + //the ListView is filled. So instead we rely on React's rendering to cue this task + //until after the previous state is filled and the ListView rendered. After that, + //onEndReached callbacks will fire. See onEndReached() above. + if(!this.firstLoadCompleteAt) this.firstLoadCompleteAt = new Date; + }, + + onEndReached() { + //firstLoadCompleteAte prevents any onEndReached firings in initial rendering. There is usuallyl 2 such firings you don't want. + if(!this.firstLoadCompleteAt || new Date - this.firstLoadCompleteAt < 1000) return; + + //lastPaginateUpdateAt solves the issue where paginationView's disappearing trigger onEndReached. + //This happens when you're near the end of the page and the dissapperance of the pagination view + //triggers onEndReached. The timing is so small so as not to disrupt other regular scrolling behavior. + if(new Date - this.lastPaginateUpdateAt < 300) return; + + //lastManualRefreshAt handles the case where you call _refresh(), which if you do while the page is near the end + //will trigger onEndReached even though you just moments ago manually refreshed. + if(new Date - this.lastManualRefreshAt < 300) return; + + //Here's the bread and butter of strong event firing management in regards to when the user in fact does want lots of pagination refreshes: + + //The base case is simply lastEndReachedAt, which very easily can fire, so we want to block that while still allowing for + //fast scrolling. If you scroll to the end of the page again within one second (fast scrolling), it will know you want more based + //on lastReleasedAt (you will have to have released multiple times to scroll fast). lastGrantAt is for if you have short rows + //and/or a low # of rows per page and you're able to move to the end without even releasing your finger. + if(new Date - this.lastEndReachedAt < (this.props.onEndReachedEventThrottle || 1000)) { + if(new Date - this.lastGrantAt < 3000) return; //we can likely lower this number, + if(new Date - this.lastReleaseAt < 3000) return; //or make it configurable via props, but I think making it configurable will be unwanted added complexity for client developers + } + + this.lastEndReachedAt = new Date; + + + if (this.props.autoPaginate) { + this._onPaginate(); + } + if (this.props.onEndReached) { + this.props.onEndReached(); } }, + + onResponderGrant() { + this.lastGrantAt = new Date; + }, + onResponderRelease() { + this.lastReleaseAt = new Date; + }, _onPaginate() { - if(this.state.paginationStatus==='allLoaded'){ - return null - }else { - this.setState({ - paginationStatus: 'fetching', - }); - this.props.onFetch(this._getPage() + 1, this._postPaginate, {}); + if(this.state.paginationStatus === 'allLoaded') return; + + if (this.state.paginationStatus === 'firstLoad' || this.state.paginationStatus === 'waiting') { + this.setState({paginationStatus: 'fetching'}); + this._fetch(this._getPage() + 1, {paginatedFetch: true, ...this.props.fetchOptions}, this._postPaginate); } }, _postPaginate(rows = [], options = {}) { this._setPage(this._getPage() + 1); + var mergedRows = null; + if (this.props.withSections === true) { mergedRows = MergeRecursive(this._getRows(), rows); } else { - mergedRows = this._getRows().concat(rows); - } - this._updateRows(mergedRows, options); - }, - - _updateRows(rows = [], options = {}) { - if (rows !== null) { - this._setRows(rows); - if (this.props.withSections === true) { - this.setState({ - dataSource: this.state.dataSource.cloneWithRowsAndSections(rows), - isRefreshing: false, - paginationStatus: (options.allLoaded === true ? 'allLoaded' : 'waiting'), - }); - } else { - this.setState({ - dataSource: this.state.dataSource.cloneWithRows(rows), - isRefreshing: false, - paginationStatus: (options.allLoaded === true ? 'allLoaded' : 'waiting'), - }); + if(this.props.dontConcat) { + mergedRows = rows; //because rows are already concatenated for use in a Redux store that needs access to all rows + } + else { + mergedRows = this._getRows().concat(rows); } - } else { - this.setState({ - isRefreshing: false, - paginationStatus: (options.allLoaded === true ? 'allLoaded' : 'waiting'), - }); } + + this.lastPaginateUpdateAt = new Date; + + this._updateRows(mergedRows, options); }, _renderPaginationView() { - if ((this.state.paginationStatus === 'fetching' && this.props.pagination === true) || (this.state.paginationStatus === 'firstLoad' && this.props.firstLoader === true)) { + let paginationEnabled = this.props.pagination === true || this.props.autoPaginate === true; + + if ((this.state.paginationStatus === 'fetching' && paginationEnabled) || (this.state.paginationStatus === 'firstLoad' && this.props.firstLoader === true)) { return this.paginationFetchingView(); - } else if (this.state.paginationStatus === 'waiting' && this.props.pagination === true && (this.props.withSections === true || this._getRows().length > 0)) { + } else if (this.state.paginationStatus === 'waiting' && this.props.pagination === true && (this.props.withSections === true || this._getRows().length > 0)) { //never show waiting for autoPaginate return this.paginationWaitingView(this._onPaginate); - } else if (this.state.paginationStatus === 'allLoaded' && this.props.pagination === true) { + } else if (this.state.paginationStatus === 'allLoaded' && paginationEnabled) { return this.paginationAllLoadedView(); } else if (this._getRows().length === 0) { return this.emptyView(this._onRefresh); @@ -289,9 +438,9 @@ var GiftedListView = React.createClass({ return this.props.renderRefreshControl({ onRefresh: this._onRefresh }); } return ( - + ); + } +}