Skip to content

Commit

Permalink
Cross platform PullToRefreshView component
Browse files Browse the repository at this point in the history
Summary:
Both iOS and Android currently support some sort of native pull to refresh control but the API was very different. I tried implementing a component based on PullToRefreshViewAndroid but that works on both platforms.

I liked the idea of wrapping the ListView or ScrollView with the PullToRefreshView component and allow styling the refresh view with platform specific props if needed. I also like the fact that 'refreshing' is a controlled prop so there is no need to keep a ref to the component or to the stopRefreshing function.

It is a pretty rough start so I'm looking for feedback and ideas to improve on the API before cleaning up everything.

On iOS we could probably deprecate the onRefreshStart property of the ScrollView and implement the native stuff in a PullToRefreshViewManager. We could then add props to customize the look of the UIRefreshControl (tintColor). We could also deprecate the Android only component and remove it later.
Closes #4915

Reviewed By: svcscm

Differential Revision: D2799246

Pulled By: nicklockwood

fb-gh-sync-id: 75872c12143ddbc05cc91900ab4612e477ca5765
  • Loading branch information
janicduplessis authored and facebook-github-bot-5 committed Jan 4, 2016
1 parent 86af597 commit 44f7a00
Show file tree
Hide file tree
Showing 13 changed files with 414 additions and 22 deletions.
125 changes: 125 additions & 0 deletions Examples/UIExplorer/RefreshControlExample.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* The examples provided by Facebook are for non-commercial testing and
* evaluation purposes only.
*
* Facebook reserves all rights not expressly granted.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL
* FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
*/
'use strict';

const React = require('react-native');
const {
ScrollView,
StyleSheet,
RefreshControl,
Text,
TouchableWithoutFeedback,
View,
} = React;

const styles = StyleSheet.create({
row: {
borderColor: 'grey',
borderWidth: 1,
padding: 20,
backgroundColor: '#3a5795',
margin: 5,
},
text: {
alignSelf: 'center',
color: '#fff',
},
scrollview: {
flex: 1,
},
});

const Row = React.createClass({
_onClick: function() {
this.props.onClick(this.props.data);
},
render: function() {
return (
<TouchableWithoutFeedback onPress={this._onClick} >
<View style={styles.row}>
<Text style={styles.text}>
{this.props.data.text + ' (' + this.props.data.clicks + ' clicks)'}
</Text>
</View>
</TouchableWithoutFeedback>
);
},
});

const RefreshControlExample = React.createClass({
statics: {
title: '<RefreshControl>',
description: 'Adds pull-to-refresh support to a scrollview.'
},

getInitialState() {
return {
isRefreshing: false,
loaded: 0,
rowData: Array.from(new Array(20)).map(
(val, i) => ({text: 'Initial row' + i, clicks: 0})),
};
},

_onClick(row) {
row.clicks++;
this.setState({
rowData: this.state.rowData,
});
},

render() {
const rows = this.state.rowData.map((row, ii) => {
return <Row key={ii} data={row} onClick={this._onClick}/>;
});
return (
<ScrollView
style={styles.scrollview}
refreshControl={
<RefreshControl
refreshing={this.state.isRefreshing}
onRefresh={this._onRefresh}
tintColor="#ff0000"
title="Loading..."
colors={['#ff0000', '#00ff00', '#0000ff']}
progressBackgroundColor="#ffff00"
/>
}>
{rows}
</ScrollView>
);
},

_onRefresh() {
this.setState({isRefreshing: true});
setTimeout(() => {
// prepend 10 items
const rowData = Array.from(new Array(10))
.map((val, i) => ({
text: 'Loaded row' + (+this.state.loaded + i),
clicks: 0,
}))
.concat(this.state.rowData);

this.setState({
loaded: this.state.loaded + 10,
isRefreshing: false,
rowData: rowData,
});
}, 5000);
},
});

module.exports = RefreshControlExample;
1 change: 1 addition & 0 deletions Examples/UIExplorer/UIExplorerList.android.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var COMPONENTS = [
require('./ProgressBarAndroidExample'),
require('./ScrollViewSimpleExample'),
require('./SwitchAndroidExample'),
require('./RefreshControlExample'),
require('./PullToRefreshViewAndroidExample.android'),
require('./TextExample.android'),
require('./TextInputExample.android'),
Expand Down
1 change: 1 addition & 0 deletions Examples/UIExplorer/UIExplorerList.ios.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var COMPONENTS = [
require('./NavigatorIOSExample'),
require('./PickerIOSExample'),
require('./ProgressViewIOSExample'),
require('./RefreshControlExample'),
require('./ScrollViewExample'),
require('./SegmentedControlIOSExample'),
require('./SliderIOSExample'),
Expand Down
96 changes: 96 additions & 0 deletions Libraries/Components/RefreshControl/RefreshControl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule RefreshControl
*/
'use strict';

const React = require('React');
const Platform = require('Platform');
const ColorPropType = require('ColorPropType');

const requireNativeComponent = require('requireNativeComponent');

if (Platform.OS === 'ios') {
var RefreshLayoutConsts = {SIZE: {}};
} else if (Platform.OS === 'android') {
var RefreshLayoutConsts = require('NativeModules').UIManager.AndroidSwipeRefreshLayout.Constants;
}

/**
* This component is used inside a ScrollView to add pull to refresh
* functionality. When the ScrollView is at `scrollY: 0`, swiping down
* triggers an `onRefresh` event.
*/
const RefreshControl = React.createClass({
statics: {
SIZE: RefreshLayoutConsts.SIZE,
},

propTypes: {
/**
* Called when the view starts refreshing.
*/
onRefresh: React.PropTypes.func,
/**
* Whether the view should be indicating an active refresh.
*/
refreshing: React.PropTypes.bool,
/**
* The color of the refresh indicator.
* @platform ios
*/
tintColor: ColorPropType,
/**
* The title displayed under the refresh indicator.
* @platform ios
*/
title: React.PropTypes.string,
/**
* Whether the pull to refresh functionality is enabled.
* @platform android
*/
enabled: React.PropTypes.bool,
/**
* The colors (at least one) that will be used to draw the refresh indicator.
* @platform android
*/
colors: React.PropTypes.arrayOf(ColorPropType),
/**
* The background color of the refresh indicator.
* @platform android
*/
progressBackgroundColor: ColorPropType,
/**
* Size of the refresh indicator, see RefreshControl.SIZE.
* @platform android
*/
size: React.PropTypes.oneOf(RefreshLayoutConsts.SIZE.DEFAULT, RefreshLayoutConsts.SIZE.LARGE),
},

render() {
if (Platform.OS === 'ios') {
return <NativeRefreshControl {...this.props}/>;
} else {
// On Android the ScrollView is wrapped so this component doesn't render
// anything and only acts as a way to configure the wrapper view.
// ScrollView will wrap itself in a AndroidSwipeRefreshLayout using props
// from this.
return null;
}
},
});

if (Platform.OS === 'ios') {
var NativeRefreshControl = requireNativeComponent(
'RCTRefreshControl',
RefreshControl
);
}

module.exports = RefreshControl;
58 changes: 43 additions & 15 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var insetsDiffer = require('insetsDiffer');
var invariant = require('invariant');
var pointsDiffer = require('pointsDiffer');
var requireNativeComponent = require('requireNativeComponent');
var processColor = require('processColor');

var PropTypes = React.PropTypes;

Expand Down Expand Up @@ -294,19 +295,16 @@ var ScrollView = React.createClass({
zoomScale: PropTypes.number,

/**
* When defined, displays a UIRefreshControl.
* Invoked with a function to stop refreshing when the UIRefreshControl is animating.
*
* ```
* (endRefreshing) => {
* endRefreshing();
* }
* ```
*
* A RefreshControl component, used to provide pull-to-refresh
* functionality for the ScrollView.
*/
refreshControl: PropTypes.element,

/**
* Deprecated - use `refreshControl` property instead.
* @platform ios
*/
onRefreshStart: PropTypes.func,

},

mixins: [ScrollResponder.Mixin],
Expand Down Expand Up @@ -445,11 +443,13 @@ var ScrollView = React.createClass({
};

var onRefreshStart = this.props.onRefreshStart;
// this is necessary because if we set it on props, even when empty,
// it'll trigger the default pull-to-refresh behavior on native.
props.onRefreshStart = onRefreshStart
? function() { onRefreshStart && onRefreshStart(this.endRefreshing); }.bind(this)
: null;
if (onRefreshStart) {
console.warn('onRefreshStart is deprecated. Use the refreshControl prop instead.');
// this is necessary because if we set it on props, even when empty,
// it'll trigger the default pull-to-refresh behavior on native.
props.onRefreshStart =
function() { onRefreshStart && onRefreshStart(this.endRefreshing); }.bind(this);
}

var ScrollViewClass;
if (Platform.OS === 'ios') {
Expand All @@ -466,6 +466,33 @@ var ScrollView = React.createClass({
'ScrollViewClass must not be undefined'
);

var refreshControl = this.props.refreshControl;
if (refreshControl) {
if (Platform.OS === 'ios') {
// On iOS the RefreshControl is a child of the ScrollView.
return (
<ScrollViewClass {...props} ref={SCROLLVIEW}>
{refreshControl}
{contentContainer}
</ScrollViewClass>
);
} else if (Platform.OS === 'android') {
// On Android wrap the ScrollView with a AndroidSwipeRefreshLayout.
// Since the ScrollView is wrapped add the style props to the
// AndroidSwipeRefreshLayout and use flex: 1 for the ScrollView.
var refreshProps = refreshControl.props;
return (
<AndroidSwipeRefreshLayout
{...refreshProps}
colors={refreshProps.colors && refreshProps.colors.map(processColor)}
style={props.style}>
<ScrollViewClass {...props} style={styles.base} ref={SCROLLVIEW}>
{contentContainer}
</ScrollViewClass>
</AndroidSwipeRefreshLayout>
);
}
}
return (
<ScrollViewClass {...props} ref={SCROLLVIEW}>
{contentContainer}
Expand Down Expand Up @@ -519,6 +546,7 @@ if (Platform.OS === 'android') {
'AndroidHorizontalScrollView',
ScrollView
);
var AndroidSwipeRefreshLayout = requireNativeComponent('AndroidSwipeRefreshLayout');
} else if (Platform.OS === 'ios') {
var RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView);
}
Expand Down
1 change: 1 addition & 0 deletions Libraries/react-native/react-native.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var ReactNative = {
get Switch() { return require('Switch'); },
get PullToRefreshViewAndroid() { return require('PullToRefreshViewAndroid'); },
get RecyclerViewBackedScrollView() { return require('RecyclerViewBackedScrollView'); },
get RefreshControl() { return require('RefreshControl'); },
get SwitchAndroid() { return require('SwitchAndroid'); },
get SwitchIOS() { return require('SwitchIOS'); },
get TabBarIOS() { return require('TabBarIOS'); },
Expand Down
3 changes: 2 additions & 1 deletion Libraries/react-native/react-native.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* and Flow doesn't have a good way to enable getters and setters for
* react-native without forcing all react-native users to also enable getters
* and setters. Until we solve that issue, we can use this .flow file to
* pretend like react-native doesn't use getters and setters
* pretend like react-native doesn't use getters and setters
*
* @flow
*/
Expand Down Expand Up @@ -47,6 +47,7 @@ var ReactNative = Object.assign(Object.create(require('React')), {
Switch: require('Switch'),
PullToRefreshViewAndroid: require('PullToRefreshViewAndroid'),
RecyclerViewBackedScrollView: require('RecyclerViewBackedScrollView'),
RefreshControl: require('RefreshControl'),
SwitchAndroid: require('SwitchAndroid'),
SwitchIOS: require('SwitchIOS'),
TabBarIOS: require('TabBarIOS'),
Expand Down
Loading

5 comments on commit 44f7a00

@mauron85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summary is referring to PullToRefreshView, but in actual commit there is no such a component. So I'm confused. Are we talking about new PullToRefreshView, or ScrollView has been just extended of refreshControl component?

@janicduplessis
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mauron85 The commit summary is misleading, the component is called RefreshControl and is used with the ScrollView refreshControl property.

@httnn
Copy link

@httnn httnn commented on 44f7a00 Jan 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's exactly why I'm here as well

@mauron85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@janicduplessis thanks for explanation. Now it's clear. I've spent some time looking for PullToRefreshView, before getting here. :-). My journey stated here:

https://github.com/facebook/react-native/releases/tag/v0.18.0

screenshot 2016-01-21 18 55 21

Edit: I've forgot to say thank you. It's great to have it as official component.

@transitive-bullshit
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change seems half implemented. The refreshing prop is only available on Android even though the RefreshControl is present on iOS. For anyone else who ends up here googling around, I would advise avoiding this feature on iOS for now.

Please sign in to comment.