Cross platform PullToRefreshView component

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 https://github.com/facebook/react-native/pull/4915

Reviewed By: svcscm

Differential Revision: D2799246

Pulled By: nicklockwood

fb-gh-sync-id: 75872c12143ddbc05cc91900ab4612e477ca5765
This commit is contained in:
Janic Duplessis 2016-01-04 07:59:10 -08:00 коммит произвёл facebook-github-bot-5
Родитель 86af597c2e
Коммит 44f7a00e95
13 изменённых файлов: 414 добавлений и 22 удалений

Просмотреть файл

@ -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;

Просмотреть файл

@ -28,6 +28,7 @@ var COMPONENTS = [
require('./ProgressBarAndroidExample'),
require('./ScrollViewSimpleExample'),
require('./SwitchAndroidExample'),
require('./RefreshControlExample'),
require('./PullToRefreshViewAndroidExample.android'),
require('./TextExample.android'),
require('./TextInputExample.android'),

Просмотреть файл

@ -42,6 +42,7 @@ var COMPONENTS = [
require('./NavigatorIOSExample'),
require('./PickerIOSExample'),
require('./ProgressViewIOSExample'),
require('./RefreshControlExample'),
require('./ScrollViewExample'),
require('./SegmentedControlIOSExample'),
require('./SliderIOSExample'),

Просмотреть файл

@ -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;

Просмотреть файл

@ -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;
@ -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],
@ -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') {
@ -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}
@ -519,6 +546,7 @@ if (Platform.OS === 'android') {
'AndroidHorizontalScrollView',
ScrollView
);
var AndroidSwipeRefreshLayout = requireNativeComponent('AndroidSwipeRefreshLayout');
} else if (Platform.OS === 'ios') {
var RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView);
}

1
Libraries/react-native/react-native.js поставляемый
Просмотреть файл

@ -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'); },

Просмотреть файл

@ -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
*/
@ -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'),

Просмотреть файл

@ -71,6 +71,8 @@
14F7A0EC1BDA3B3C003C6C10 /* RCTPerfMonitor.m in Sources */ = {isa = PBXBuildFile; fileRef = 14F7A0EB1BDA3B3C003C6C10 /* RCTPerfMonitor.m */; };
14F7A0F01BDA714B003C6C10 /* RCTFPSGraph.m in Sources */ = {isa = PBXBuildFile; fileRef = 14F7A0EF1BDA714B003C6C10 /* RCTFPSGraph.m */; };
1BCBD4A71C32FA0B006FC476 /* RCTBundleURLProcessor.m in Sources */ = {isa = PBXBuildFile; fileRef = 1BCBD4A61C32FA0B006FC476 /* RCTBundleURLProcessor.m */; };
191E3EBE1C29D9AF00C180A6 /* RCTRefreshControlManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 191E3EBD1C29D9AF00C180A6 /* RCTRefreshControlManager.m */; };
191E3EC11C29DC3800C180A6 /* RCTRefreshControl.m in Sources */ = {isa = PBXBuildFile; fileRef = 191E3EC01C29DC3800C180A6 /* RCTRefreshControl.m */; };
58114A161AAE854800E7D092 /* RCTPicker.m in Sources */ = {isa = PBXBuildFile; fileRef = 58114A131AAE854800E7D092 /* RCTPicker.m */; };
58114A171AAE854800E7D092 /* RCTPickerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 58114A151AAE854800E7D092 /* RCTPickerManager.m */; };
58114A501AAE93D500E7D092 /* RCTAsyncLocalStorage.m in Sources */ = {isa = PBXBuildFile; fileRef = 58114A4E1AAE93D500E7D092 /* RCTAsyncLocalStorage.m */; };
@ -245,6 +247,10 @@
14F7A0EF1BDA714B003C6C10 /* RCTFPSGraph.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTFPSGraph.m; sourceTree = "<group>"; };
1BCBD4A51C32FA0B006FC476 /* RCTBundleURLProcessor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTBundleURLProcessor.h; sourceTree = "<group>"; };
1BCBD4A61C32FA0B006FC476 /* RCTBundleURLProcessor.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBundleURLProcessor.m; sourceTree = "<group>"; };
191E3EBC1C29D9AF00C180A6 /* RCTRefreshControlManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRefreshControlManager.h; sourceTree = "<group>"; };
191E3EBD1C29D9AF00C180A6 /* RCTRefreshControlManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRefreshControlManager.m; sourceTree = "<group>"; };
191E3EBF1C29DC3800C180A6 /* RCTRefreshControl.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRefreshControl.h; sourceTree = "<group>"; };
191E3EC01C29DC3800C180A6 /* RCTRefreshControl.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRefreshControl.m; sourceTree = "<group>"; };
58114A121AAE854800E7D092 /* RCTPicker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTPicker.h; sourceTree = "<group>"; };
58114A131AAE854800E7D092 /* RCTPicker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTPicker.m; sourceTree = "<group>"; };
58114A141AAE854800E7D092 /* RCTPickerManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTPickerManager.h; sourceTree = "<group>"; };
@ -401,6 +407,10 @@
13442BF31AA90E0B0037E5B0 /* RCTPointerEvents.h */,
13513F3A1B1F43F400FCE529 /* RCTProgressViewManager.h */,
13513F3B1B1F43F400FCE529 /* RCTProgressViewManager.m */,
191E3EBF1C29DC3800C180A6 /* RCTRefreshControl.h */,
191E3EC01C29DC3800C180A6 /* RCTRefreshControl.m */,
191E3EBC1C29D9AF00C180A6 /* RCTRefreshControlManager.h */,
191E3EBD1C29D9AF00C180A6 /* RCTRefreshControlManager.m */,
13C325271AA63B6A0048765F /* RCTScrollableProtocol.h */,
13B07FF61A6947C200A75B9A /* RCTScrollView.h */,
13B07FF71A6947C200A75B9A /* RCTScrollView.m */,
@ -703,6 +713,7 @@
13A1F71E1A75392D00D3D453 /* RCTKeyCommands.m in Sources */,
83CBBA531A601E3B00E9B192 /* RCTUtils.m in Sources */,
14435CE61AAC4AE100FC20F4 /* RCTMapManager.m in Sources */,
191E3EC11C29DC3800C180A6 /* RCTRefreshControl.m in Sources */,
13C156051AB1A2840079392D /* RCTWebView.m in Sources */,
83CBBA601A601EAA00E9B192 /* RCTBridge.m in Sources */,
13C156061AB1A2840079392D /* RCTWebViewManager.m in Sources */,
@ -713,6 +724,7 @@
1450FF871BCFF28A00208362 /* RCTProfileTrampoline-arm.S in Sources */,
131B6AF51AF1093D00FFC3E0 /* RCTSegmentedControlManager.m in Sources */,
58114A171AAE854800E7D092 /* RCTPickerManager.m in Sources */,
191E3EBE1C29D9AF00C180A6 /* RCTRefreshControlManager.m in Sources */,
13B0801A1A69489C00A75B9A /* RCTNavigator.m in Sources */,
137327E71AA5CF210034F82E /* RCTTabBar.m in Sources */,
13F17A851B8493E5007D4C75 /* RCTRedBox.m in Sources */,

Просмотреть файл

@ -0,0 +1,19 @@
/**
* 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.
*/
#import <UIKit/UIKit.h>
#import "RCTComponent.h"
@interface RCTRefreshControl : UIRefreshControl
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@end

Просмотреть файл

@ -0,0 +1,54 @@
/**
* 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.
*/
#import "RCTRefreshControl.h"
#import "RCTUtils.h"
@implementation RCTRefreshControl
- (instancetype)init
{
if ((self = [super init])) {
[self addTarget:self action:@selector(refreshControlValueChanged) forControlEvents:UIControlEventValueChanged];
}
return self;
}
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
- (NSString *)title
{
return self.attributedTitle.string;
}
- (void)setTitle:(NSString *)title
{
self.attributedTitle = [[NSAttributedString alloc] initWithString:title];
}
- (void)setRefreshing:(BOOL)refreshing
{
if (super.refreshing != refreshing) {
if (refreshing) {
[self beginRefreshing];
} else {
[self endRefreshing];
}
}
}
- (void)refreshControlValueChanged
{
if (_onRefresh) {
_onRefresh(nil);
}
}
@end

Просмотреть файл

@ -0,0 +1,14 @@
/**
* 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.
*/
#import "RCTViewManager.h"
@interface RCTRefreshControlManager : RCTViewManager
@end

Просмотреть файл

@ -0,0 +1,28 @@
/**
* 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.
*/
#import "RCTRefreshControlManager.h"
#import "RCTRefreshControl.h"
@implementation RCTRefreshControlManager
RCT_EXPORT_MODULE()
- (UIView *)view
{
return [RCTRefreshControl new];
}
RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock)
RCT_EXPORT_VIEW_PROPERTY(refreshing, BOOL)
RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor)
RCT_EXPORT_VIEW_PROPERTY(title, NSString)
@end

Просмотреть файл

@ -14,6 +14,7 @@
#import "RCTConvert.h"
#import "RCTEventDispatcher.h"
#import "RCTLog.h"
#import "RCTRefreshControl.h"
#import "RCTUIManager.h"
#import "RCTUtils.h"
#import "UIView+Private.h"
@ -410,20 +411,31 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
- (void)insertReactSubview:(UIView *)view atIndex:(__unused NSInteger)atIndex
{
RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
_contentView = view;
[_scrollView addSubview:view];
if ([view isKindOfClass:[RCTRefreshControl class]]) {
_scrollView.refreshControl = (RCTRefreshControl*)view;
} else {
RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
_contentView = view;
[_scrollView addSubview:view];
}
}
- (void)removeReactSubview:(UIView *)subview
{
RCTAssert(_contentView == subview, @"Attempted to remove non-existent subview");
_contentView = nil;
[subview removeFromSuperview];
if ([subview isKindOfClass:[RCTRefreshControl class]]) {
_scrollView.refreshControl = nil;
} else {
RCTAssert(_contentView == subview, @"Attempted to remove non-existent subview");
_contentView = nil;
[subview removeFromSuperview];
}
}
- (NSArray<UIView *> *)reactSubviews
{
if (_contentView && _scrollView.refreshControl) {
return @[_contentView, _scrollView.refreshControl];
}
return _contentView ? @[_contentView] : @[];
}