├── README.md
├── .gitignore
├── package.json
├── LICENSE
├── RefreshIndicator.js
└── RefreshableScrollView.js
/README.md:
--------------------------------------------------------------------------------
1 | # RefreshableScrollView [](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 |
--------------------------------------------------------------------------------