Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

solutions for infiniteScrolling onEndReached firing when it shouldn't #38

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
279 changes: 234 additions & 45 deletions GiftedListView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -36,6 +40,8 @@ var GiftedListView = React.createClass({
return {
customStyles: {},
initialListSize: 10,
onEndReachedThreshold: 100,
onEndReachedEventThrottle: 1000,
firstLoader: true,
pagination: true,
refreshable: true,
Expand All @@ -49,19 +55,25 @@ var GiftedListView = React.createClass({
sectionHeaderView: null,
scrollEnabled: true,
withSections: false,
autoPaginate: false,
onFetch(page, callback, options) { callback([]); },

paginationFetchingView: null,
paginationAllLoadedView: null,
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,
Expand All @@ -75,13 +87,17 @@ 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,
paginationAllLoadedView: React.PropTypes.func,
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; },
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -289,9 +438,9 @@ var GiftedListView = React.createClass({
return this.props.renderRefreshControl({ onRefresh: this._onRefresh });
}
return (
<RefreshControl
<RefreshControlWithState
ref='refreshControl'
onRefresh={this._onRefresh}
refreshing={this.state.isRefreshing}
colors={this.props.refreshableColors}
progressBackgroundColor={this.props.refreshableProgressBackgroundColor}
size={this.props.refreshableSize}
Expand All @@ -312,6 +461,24 @@ var GiftedListView = React.createClass({
renderFooter={this._renderPaginationView}
renderSeparator={this.renderSeparator}

onResponderGrant={this.onResponderGrant}
//onResponderMove={this.onResponderMove}
onResponderRelease={this.onResponderRelease}
//onMomentumScrollEnd={this.onMomentumScrollEnd}

//check out this thread: https://github.com/facebook/react-native/issues/1410
//and this stackoverflow post: http://stackoverflow.com/questions/33350556/how-to-get-onpress-event-from-scrollview-component-in-react-native
//basically onScrollAnimationEnd is incorrect (onMomentumScrollEnd is the right one) and all the native event callbacks
//are available, but no documented. Often times library developers do not want to build
//on top of such things. But my opinion in this case obviously is we should. The responderRelease code in call edonEndReached() is extremely stable and clear.
//I am willing to maintain this for a while, so in the rare case these become available,
//I will find something out. In all likelihood, only better APIs that are closer
//to our precise needs and do not require all this still will become available. When they do, I will implement them. But at the same timeout
//I find it unlikely that PanResponder methods that ScrollViews are based on will disappear, even if they remain undocumented for a long time.

onEndReached={this.onEndReached}
onEndReachedThreshold={this.props.onEndReachedThreshold || 100} //new useful prop, yay!

automaticallyAdjustContentInsets={false}
scrollEnabled={this.props.scrollEnabled}
canCancelContentTouches={true}
Expand Down Expand Up @@ -353,3 +520,25 @@ var GiftedListView = React.createClass({


module.exports = GiftedListView;



class RefreshControlWithState extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {isRefreshing: false};
}

setIsRefreshing(isRefreshing) {
this.setState({isRefreshing});
}

render() {
return (
<RefreshControl
{...this.props}
refreshing={this.state.isRefreshing}
/>
);
}
}