diff --git a/Examples/UIExplorer/RefreshControlExample.js b/Examples/UIExplorer/RefreshControlExample.js new file mode 100644 index 00000000000000..026862e356be61 --- /dev/null +++ b/Examples/UIExplorer/RefreshControlExample.js @@ -0,0 +1,122 @@ +/** +* 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 ( + + + + {this.props.data.text + ' (' + this.props.data.clicks + ' clicks)'} + + + + ); + }, +}); + +const RefreshControlExample = React.createClass({ + statics: { + title: '', + 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 ; + }); + return ( + + + {rows} + + ); + }, + + _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; diff --git a/Examples/UIExplorer/UIExplorerList.android.js b/Examples/UIExplorer/UIExplorerList.android.js index 7aa70c51633962..81442e0b982e24 100644 --- a/Examples/UIExplorer/UIExplorerList.android.js +++ b/Examples/UIExplorer/UIExplorerList.android.js @@ -28,6 +28,7 @@ var COMPONENTS = [ require('./ProgressBarAndroidExample'), require('./ScrollViewSimpleExample'), require('./SwitchAndroidExample'), + require('./RefreshControlExample'), require('./PullToRefreshViewAndroidExample.android'), require('./TextExample.android'), require('./TextInputExample.android'), diff --git a/Examples/UIExplorer/UIExplorerList.ios.js b/Examples/UIExplorer/UIExplorerList.ios.js index c2ba81752099f4..1d96b2d49ce848 100644 --- a/Examples/UIExplorer/UIExplorerList.ios.js +++ b/Examples/UIExplorer/UIExplorerList.ios.js @@ -42,6 +42,7 @@ var COMPONENTS = [ require('./NavigatorIOSExample'), require('./PickerIOSExample'), require('./ProgressViewIOSExample'), + require('./RefreshControlExample'), require('./ScrollViewExample'), require('./SegmentedControlIOSExample'), require('./SliderIOSExample'), diff --git a/Libraries/Components/RefreshControl/RefreshControl.js b/Libraries/Components/RefreshControl/RefreshControl.js new file mode 100644 index 00000000000000..9f07c44266f3c8 --- /dev/null +++ b/Libraries/Components/RefreshControl/RefreshControl.js @@ -0,0 +1,109 @@ +/** + * 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 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: React.PropTypes.string, + /** + * 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(React.PropTypes.string), + /** + * The background color of the refresh indicator. + * @platform android + */ + progressBackgroundColor: React.PropTypes.string, + /** + * Size of the refresh indicator, see PullToRefreshView.SIZE. + * @platform android + */ + size: React.PropTypes.oneOf(RefreshLayoutConsts.SIZE.DEFAULT, RefreshLayoutConsts.SIZE.LARGE), + }, + + render() { + if (Platform.OS === 'ios') { + return this._renderIOS(); + } else if (Platform.OS === 'android') { + return this._renderAndroid(); + } + }, + + _renderIOS() { + return ( + + ); + }, + + _renderAndroid() { + // 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; diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 870044ecaa13ff..ec0035862c0616 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -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; @@ -400,6 +401,21 @@ var ScrollView = React.createClass({ }; } + // Extract the RefreshControl from the children if there is one. + var refreshControl = null; + var children; + if (Array.isArray(this.props.children)) { + children = this.props.children.filter(c => { + if (c && c.type && c.type.displayName === 'RefreshControl') { + refreshControl = c; + return false; + } + return true; + }); + } else { + children = this.props.children; + } + var contentContainer = - {this.props.children} + {children} ; var alwaysBounceHorizontal = @@ -466,6 +482,32 @@ var ScrollView = React.createClass({ 'ScrollViewClass must not be undefined' ); + if (refreshControl) { + if (Platform.OS === 'ios') { + // On iOS the RefreshControl is a child of the ScrollView. + return ( + + {refreshControl} + {contentContainer} + + ); + } 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 ( + + + {contentContainer} + + + ); + } + } return ( {contentContainer} @@ -519,6 +561,7 @@ if (Platform.OS === 'android') { 'AndroidHorizontalScrollView', ScrollView ); + var AndroidSwipeRefreshLayout = requireNativeComponent('AndroidSwipeRefreshLayout'); } else if (Platform.OS === 'ios') { var RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView); } diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js index 5b33bd36341fcb..b28f255d9c35ff 100644 --- a/Libraries/react-native/react-native.js +++ b/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'); }, diff --git a/Libraries/react-native/react-native.js.flow b/Libraries/react-native/react-native.js.flow index a250e57b0e4b54..7773b27c0281c7 100644 --- a/Libraries/react-native/react-native.js.flow +++ b/Libraries/react-native/react-native.js.flow @@ -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'), diff --git a/React/React.xcodeproj/project.pbxproj b/React/React.xcodeproj/project.pbxproj index 141bed35aa22dc..a1802c9444e028 100644 --- a/React/React.xcodeproj/project.pbxproj +++ b/React/React.xcodeproj/project.pbxproj @@ -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 = ""; }; 1BCBD4A51C32FA0B006FC476 /* RCTBundleURLProcessor.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTBundleURLProcessor.h; sourceTree = ""; }; 1BCBD4A61C32FA0B006FC476 /* RCTBundleURLProcessor.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBundleURLProcessor.m; sourceTree = ""; }; + 191E3EBC1C29D9AF00C180A6 /* RCTRefreshControlManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRefreshControlManager.h; sourceTree = ""; }; + 191E3EBD1C29D9AF00C180A6 /* RCTRefreshControlManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRefreshControlManager.m; sourceTree = ""; }; + 191E3EBF1C29DC3800C180A6 /* RCTRefreshControl.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRefreshControl.h; sourceTree = ""; }; + 191E3EC01C29DC3800C180A6 /* RCTRefreshControl.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRefreshControl.m; sourceTree = ""; }; 58114A121AAE854800E7D092 /* RCTPicker.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTPicker.h; sourceTree = ""; }; 58114A131AAE854800E7D092 /* RCTPicker.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTPicker.m; sourceTree = ""; }; 58114A141AAE854800E7D092 /* RCTPickerManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTPickerManager.h; sourceTree = ""; }; @@ -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 */, diff --git a/React/Views/RCTRefreshControl.h b/React/Views/RCTRefreshControl.h new file mode 100644 index 00000000000000..7347f546d0f691 --- /dev/null +++ b/React/Views/RCTRefreshControl.h @@ -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 + +#import "RCTComponent.h" + +@interface RCTRefreshControl : UIRefreshControl + +@property (nonatomic, assign) NSString *title; +@property (nonatomic, copy) RCTDirectEventBlock onRefresh; + +@end diff --git a/React/Views/RCTRefreshControl.m b/React/Views/RCTRefreshControl.m new file mode 100644 index 00000000000000..e0f31da3210477 --- /dev/null +++ b/React/Views/RCTRefreshControl.m @@ -0,0 +1,43 @@ +/** + * 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)refreshControlValueChanged +{ + if (_onRefresh) { + _onRefresh(nil); + } +} + +@end diff --git a/React/Views/RCTRefreshControlManager.h b/React/Views/RCTRefreshControlManager.h new file mode 100644 index 00000000000000..8d1c3f961668e0 --- /dev/null +++ b/React/Views/RCTRefreshControlManager.h @@ -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 diff --git a/React/Views/RCTRefreshControlManager.m b/React/Views/RCTRefreshControlManager.m new file mode 100644 index 00000000000000..b034a7ba4a64b5 --- /dev/null +++ b/React/Views/RCTRefreshControlManager.m @@ -0,0 +1,35 @@ +/** + * 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(tintColor, UIColor) +RCT_EXPORT_VIEW_PROPERTY(title, NSString) +RCT_EXPORT_VIEW_PROPERTY(onRefresh, RCTDirectEventBlock) +RCT_CUSTOM_VIEW_PROPERTY(refreshing, BOOL, RCTRefreshControl) +{ + if (json ? [RCTConvert BOOL:json] : defaultView.refreshing) { + [view beginRefreshing]; + } else { + [view endRefreshing]; + } +} + +@end diff --git a/React/Views/RCTScrollView.m b/React/Views/RCTScrollView.m index 5ae7fb05a6b5f1..e5e2ffceddc70c 100644 --- a/React/Views/RCTScrollView.m +++ b/React/Views/RCTScrollView.m @@ -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,16 +411,24 @@ - (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews - (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 *)reactSubviews