├── README.md ├── .gitignore ├── package.json ├── LICENSE ├── RefreshIndicator.js └── RefreshableScrollView.js /README.md: -------------------------------------------------------------------------------- 1 | # RefreshableScrollView [![Slack](http://slack.exponentjs.com/badge.svg)](http://slack.exponentjs.com) 2 | A ScrollView that supports pull-to-refresh. You can customize it with the RefreshIndicator and type of ScrollView (ex: ListView) of your choice. 3 | 4 | **This component is under development.** 5 | 6 | # Installation and Usage 7 | 8 | Use this with react-native 0.8.0-rc. 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-refreshable-scroll-view", 3 | "version": "0.1.1", 4 | "description": "A ScrollView that supports pull-to-refresh. You can customize it with the RefreshIndicator and type of ScrollView (ex: ListView) of your choice.", 5 | "main": "RefreshableScrollView.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/exponentjs/react-native-refreshable-scroll-view.git" 9 | }, 10 | "keywords": [ 11 | "react-native", 12 | "refresh", 13 | "scroll-view" 14 | ], 15 | "author": "James Ide", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/exponentjs/react-native-refreshable-scroll-view/issues" 19 | }, 20 | "homepage": "https://github.com/exponentjs/react-native-refreshable-scroll-view#readme", 21 | "dependencies": { 22 | "react-clone-referenced-element": "^1.0.1", 23 | "react-native-scrollable-mixin": "^1.0.1", 24 | "react-timer-mixin": "^0.13.3" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-present 650 Industries 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /RefreshIndicator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow weak 3 | */ 4 | 'use strict'; 5 | 6 | import React, { 7 | PropTypes, 8 | } from 'react'; 9 | import { 10 | ActivityIndicatorIOS, 11 | StyleSheet, 12 | } from 'react-native'; 13 | 14 | /** 15 | * A default refresh indicator. This component will likely change so copy and 16 | * paste this code if you rely on it. 17 | */ 18 | class RefreshIndicator extends React.Component { 19 | 20 | static propTypes = { 21 | progress: PropTypes.number.isRequired, 22 | active: PropTypes.bool.isRequired, 23 | }; 24 | 25 | shouldComponentUpdate(nextProps) { 26 | let { progress, active } = this.props; 27 | return (progress !== nextProps.progress) || (active !== nextProps.active); 28 | } 29 | 30 | render() { 31 | let { progress, active } = this.props; 32 | let animatedStyle = { 33 | transform: [ 34 | { rotate: `${progress * 2 * Math.PI}rad` }, 35 | ], 36 | }; 37 | return ( 38 | 43 | ); 44 | } 45 | } 46 | 47 | let styles = StyleSheet.create({ 48 | container: { 49 | marginVertical: 16, 50 | }, 51 | }); 52 | 53 | module.exports = RefreshIndicator; 54 | -------------------------------------------------------------------------------- /RefreshableScrollView.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @flow weak 3 | */ 4 | 'use strict'; 5 | 6 | import React, { 7 | PropTypes, 8 | } from 'react'; 9 | import { 10 | ScrollView, 11 | StyleSheet, 12 | View, 13 | } from 'react-native'; 14 | import ScrollableMixin from 'react-native-scrollable-mixin'; 15 | import TimerMixin from 'react-timer-mixin'; 16 | 17 | import cloneReferencedElement from 'react-clone-referenced-element'; 18 | 19 | import RefreshIndicator from './RefreshIndicator'; 20 | 21 | const SCROLL_ANIMATION_DURATION_MS = 300; 22 | 23 | let RefreshableScrollView = React.createClass({ 24 | mixins: [ScrollableMixin, TimerMixin], 25 | 26 | propTypes: { 27 | ...ScrollView.propTypes, 28 | pullToRefreshDistance: PropTypes.number, 29 | onRefreshStart: PropTypes.func.isRequired, 30 | renderRefreshIndicator: PropTypes.func.isRequired, 31 | }, 32 | 33 | getDefaultProps() { 34 | return { 35 | scrollEventThrottle: 33, 36 | renderRefreshIndicator: props => , 37 | renderScrollComponent: props => , 38 | }; 39 | }, 40 | 41 | getInitialState() { 42 | return { 43 | tracking: false, 44 | pullToRefreshProgress: 0, 45 | refreshing: false, 46 | waitingToRest: false, 47 | returningToTop: false, 48 | shouldIncreaseContentInset: false, 49 | refreshIndicatorEnd: null, 50 | }; 51 | }, 52 | 53 | shouldComponentUpdate(nextProps, nextState) { 54 | if (this.props !== nextProps) { 55 | return true; 56 | } 57 | let stateChanged = 58 | this.state.tracking !== nextState.tracking || 59 | this.state.pullToRefreshProgress !== nextState.pullToRefreshProgress || 60 | this.state.refreshing !== nextState.refreshing || 61 | this.state.waitingToRest !== nextState.waitingToRest || 62 | this.state.returningToTop !== nextState.returningToTop || 63 | this.state.shouldIncreaseContentInset !== nextState.shouldIncreaseContentInset || 64 | this.state.refreshIndicatorEnd !== nextState.refreshIndicatorEnd 65 | return stateChanged; 66 | }, 67 | 68 | getScrollResponder(): ReactComponent { 69 | return this._scrollComponent.getScrollResponder(); 70 | }, 71 | 72 | setNativeProps(props) { 73 | this._scrollComponent.setNativeProps(props); 74 | }, 75 | 76 | render() { 77 | let { 78 | contentInset, 79 | renderScrollComponent, 80 | style, 81 | ...scrollViewProps, 82 | } = this.props; 83 | 84 | let refreshIndicatorStyle = {}; 85 | if (this.props.horizontal) { 86 | if (contentInset && contentInset.left != null) { 87 | refreshIndicatorStyle.left = contentInset.left; 88 | } else { 89 | refreshIndicatorStyle.left = 0; 90 | } 91 | } else { 92 | if (contentInset && contentInset.top != null) { 93 | refreshIndicatorStyle.top = contentInset.top; 94 | } else { 95 | refreshIndicatorStyle.top = 0; 96 | } 97 | } 98 | 99 | let isRefreshIndicatorActive = 100 | this.state.refreshing || this.state.waitingToRest; 101 | if (!isRefreshIndicatorActive && this.state.pullToRefreshProgress <= 0) { 102 | refreshIndicatorStyle.opacity = 0; 103 | } 104 | 105 | let refreshIndicator = this.props.renderRefreshIndicator({ 106 | progress: this.state.pullToRefreshProgress, 107 | active: isRefreshIndicatorActive, 108 | }); 109 | 110 | let scrollComponent = renderScrollComponent({ 111 | pointerEvents: this.state.returningToTop ? 'none' : 'auto', 112 | ...scrollViewProps, 113 | contentInset: this._getContentInsetAdjustedForIndicator(), 114 | onResponderGrant: this._handleResponderGrant, 115 | onResponderRelease: this._handleResponderRelease, 116 | onScroll: this._handleScroll, 117 | onMomentumScrollEnd: this._handleMomentumScrollEnd, 118 | style: styles.scrollComponent, 119 | }); 120 | scrollComponent = cloneReferencedElement(scrollComponent, { 121 | ref: component => { this._scrollComponent = component; }, 122 | }); 123 | 124 | return ( 125 | 126 | 130 | {refreshIndicator} 131 | 132 | {scrollComponent} 133 | 134 | ); 135 | }, 136 | 137 | _getContentInsetAdjustedForIndicator() { 138 | let { contentInset, horizontal } = this.props; 139 | let { shouldIncreaseContentInset } = this.state; 140 | 141 | if (!shouldIncreaseContentInset) { 142 | return contentInset; 143 | } 144 | 145 | contentInset = { ...contentInset }; 146 | if (horizontal) { 147 | contentInset.left = Math.max( 148 | this.state.refreshIndicatorEnd - this._nativeContentInsetAdjustment.left, 149 | contentInset.left != null ? contentInset.left : 0 150 | ); 151 | } else { 152 | contentInset.top = Math.max( 153 | this.state.refreshIndicatorEnd - this._nativeContentInsetAdjustment.top, 154 | contentInset.top != null ? contentInset.top : 0 155 | ); 156 | } 157 | return contentInset; 158 | }, 159 | 160 | _isOverscrolled() { 161 | let { x, y } = this._nativeContentOffset; 162 | let distanceFromTop = this.props.horizontal ? 163 | x + this._nativeContentInset.left : 164 | y + this._nativeContentInset.top; 165 | return distanceFromTop < 0; 166 | }, 167 | 168 | _handleResponderGrant(event) { 169 | if (this.props.onResponderGrant) { 170 | this.props.onResponderGrant(event); 171 | } 172 | this.setState({ tracking: true }); 173 | }, 174 | 175 | _handleResponderRelease(event) { 176 | if (this.props.onResponderRelease) { 177 | this.props.onResponderRelease(event); 178 | } 179 | this.setState(state => ({ 180 | tracking: false, 181 | shouldIncreaseContentInset: state.refreshing || state.waitingToRest, 182 | })); 183 | }, 184 | 185 | _handleScroll(event) { 186 | if (this.props.onScroll) { 187 | this.props.onScroll(event); 188 | } 189 | 190 | let { contentInset, contentOffset } = event.nativeEvent; 191 | this._nativeContentInset = contentInset; 192 | this._nativeContentOffset = contentOffset; 193 | this._nativeContentInsetAdjustment = 194 | this._calculateNativeContentInsetAdjustment(contentInset); 195 | 196 | let pullToRefreshProgress = 0; 197 | if (this.props.pullToRefreshDistance != null || 198 | this.state.refreshIndicatorEnd != null) { 199 | let scrollAxisInset = 200 | this.props.horizontal ? contentInset.left : contentInset.top; 201 | let scrollAxisOffset = 202 | this.props.horizontal ? contentOffset.x : contentOffset.y; 203 | let pullDistance = -(scrollAxisInset + scrollAxisOffset); 204 | let pullToRefreshDistance = this.props.pullToRefreshDistance ? 205 | this.props.pullToRefreshDistance : 206 | (this.state.refreshIndicatorEnd - scrollAxisInset) * 2; 207 | 208 | if (pullToRefreshDistance > 0) { 209 | pullToRefreshProgress = pullDistance / pullToRefreshDistance; 210 | pullToRefreshProgress = Math.max(Math.min(pullToRefreshProgress, 1), 0); 211 | } else { 212 | pullToRefreshProgress = 1; 213 | } 214 | } 215 | 216 | if (pullToRefreshProgress <= 0 && this.state.pullToRefreshProgress <= 0) { 217 | return; 218 | } 219 | 220 | let wasRefreshing; 221 | this.setState(state => { 222 | let { tracking, refreshing, waitingToRest, returningToTop } = state; 223 | wasRefreshing = refreshing; 224 | let shouldBeginRefreshing = (pullToRefreshProgress === 1) && 225 | tracking && !refreshing && !waitingToRest && !returningToTop; 226 | return { 227 | pullToRefreshProgress, 228 | refreshing: state.refreshing || shouldBeginRefreshing, 229 | }; 230 | }, () => { 231 | if (!wasRefreshing && this.state.refreshing) { 232 | this.props.onRefreshStart(this._handleRefreshEnd); 233 | } 234 | }); 235 | }, 236 | 237 | _calculateNativeContentInsetAdjustment(nativeContentInset) { 238 | let { contentInset } = this._scrollComponent.props; 239 | let adjustment = { top: 0, left: 0, bottom: 0, right: 0}; 240 | if (!contentInset) { 241 | return adjustment; 242 | } 243 | 244 | for (let side in adjustment) { 245 | if (contentInset[side] != null) { 246 | adjustment[side] = nativeContentInset[side] - contentInset[side]; 247 | } 248 | } 249 | return adjustment; 250 | }, 251 | 252 | _handleMomentumScrollEnd(event) { 253 | if (this.props.onMomentumScrollEnd) { 254 | this.props.onMomentumScrollEnd(event); 255 | } 256 | 257 | // Wait for the onResponderGrant handler to run in case the scroll ended 258 | // because the user touched a moving scroll view. requestAnimationFrame is 259 | // a crude but concise way to do this. 260 | this.requestAnimationFrame(() => { 261 | if (this.state.waitingToRest && !this.state.tracking) { 262 | this._restoreScrollView(); 263 | } 264 | }); 265 | }, 266 | 267 | _handleRefreshEnd() { 268 | if (!this.state.refreshing) { 269 | return; 270 | } 271 | 272 | // Let the scroll view naturally bounce back to its resting position before 273 | // hiding the loading indicator if it is still pulled down or the user is 274 | // touching it 275 | let waitingToRest = this.state.tracking || this._isOverscrolled(); 276 | this.setState({ 277 | refreshing: false, 278 | waitingToRest, 279 | }); 280 | 281 | if (!waitingToRest) { 282 | this._restoreScrollView(); 283 | } 284 | }, 285 | 286 | _restoreScrollView() { 287 | // Scroll up to the top to restore the scrollable content's position 288 | let scrollDestination = null; 289 | let { x, y } = this._nativeContentOffset; 290 | let { horizontal, contentInset } = this.props; 291 | let contentInsetLeft = contentInset && contentInset.left ? contentInset.left : 0; 292 | let contentInsetTop = contentInset && contentInset.top ? contentInset.top : 0; 293 | let contentInsetWithIndicator = this._scrollComponent.props.contentInset; 294 | if (horizontal) { 295 | let indicatorWidth = contentInsetWithIndicator.left - contentInsetLeft; 296 | let scrolledDistance = this._nativeContentInset.left + x; 297 | if (indicatorWidth > 0 && indicatorWidth > scrolledDistance) { 298 | let destinationX = Math.min(x, -this._nativeContentInset.left) + indicatorWidth; 299 | scrollDestination = [y, destinationX]; 300 | } 301 | } else { 302 | let indicatorHeight = contentInsetWithIndicator.top - contentInsetTop; 303 | let scrolledDistance = this._nativeContentInset.top + y; 304 | if (indicatorHeight > 0 && indicatorHeight > scrolledDistance) { 305 | let destinationY = Math.min(y, -this._nativeContentInset.top) + indicatorHeight; 306 | scrollDestination = [destinationY, x]; 307 | } 308 | } 309 | 310 | 311 | this.setState({ 312 | refreshing: false, 313 | waitingToRest: false, 314 | returningToTop: !!scrollDestination, 315 | shouldIncreaseContentInset: false, 316 | }, () => { 317 | if (scrollDestination) { 318 | this.scrollTo(...scrollDestination); 319 | // We (plan to) detect whether the scrolling has finished based on the scroll 320 | // position, but we must eventually set returningToTop to false since 321 | // we block user interactions while it is true 322 | this.clearTimeout(this._returningToTopSafetyTimeout); 323 | this._returningToTopSafetyTimeout = this.setTimeout(() => { 324 | this._returningToTopSafetyTimeout = null; 325 | this.setState({ returningToTop: false }); 326 | }, SCROLL_ANIMATION_DURATION_MS); 327 | } 328 | }); 329 | }, 330 | 331 | _handleRefreshIndicatorContainerLayout(event) { 332 | let { x, y, width, height } = event.nativeEvent.layout; 333 | let { horizontal} = this.props; 334 | let end = horizontal ? (x + width) : (y + height); 335 | this.setState({ refreshIndicatorEnd: end }); 336 | }, 337 | }); 338 | 339 | let styles = StyleSheet.create({ 340 | container: { 341 | flex: 1, 342 | }, 343 | refreshIndicatorContainer: { 344 | backgroundColor: 'transparent', 345 | position: 'absolute', 346 | top: 0, 347 | left: 0, 348 | right: 0, 349 | alignItems: 'center', 350 | }, 351 | scrollComponent: { 352 | backgroundColor: 'transparent', 353 | }, 354 | }); 355 | 356 | module.exports = RefreshableScrollView; 357 | --------------------------------------------------------------------------------