├── .gitignore
├── .eslintignore
├── example.gif
├── src
├── RefreshControl.js
├── index.js
└── RefreshControl.web.js
├── .prettierrc
├── .eslintrc.js
├── package.json
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 |
--------------------------------------------------------------------------------
/example.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NiciusB/react-native-web-refresh-control/HEAD/example.gif
--------------------------------------------------------------------------------
/src/RefreshControl.js:
--------------------------------------------------------------------------------
1 | import { RefreshControl } from 'react-native'
2 | export default RefreshControl
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "semi": false,
4 | "tabWidth": 2,
5 | "singleQuote": true,
6 | "jsxBracketSameLine": true,
7 | "trailingComma": "es5"
8 | }
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "es6": true,
4 | "node": false
5 | },
6 | "extends": [
7 | "universe/native",
8 | "plugin:react/recommended",
9 | "prettier"
10 | ],
11 | "plugins": [
12 | "react-hooks",
13 | "prettier"
14 | ],
15 | "rules": {
16 | "react-hooks/rules-of-hooks": "error",
17 | "react-hooks/exhaustive-deps": "warn",
18 | "prettier/prettier": "error"
19 | },
20 | "settings": {
21 | "react": {
22 | "version": "detect"
23 | }
24 | }
25 | };
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { FlatList, Platform, ScrollView } from 'react-native'
3 | import CustomRefreshControl from './RefreshControl'
4 |
5 | export { CustomRefreshControl as RefreshControl }
6 | export function patchFlatListProps(options = {}) {
7 | try {
8 | if (Platform.OS === 'web') {
9 | setCustomFlatListWeb(options)
10 | }
11 | } catch (e) {
12 | console.error(e)
13 | }
14 | }
15 |
16 | function setCustomFlatListWeb(options) {
17 | FlatList.defaultProps = {
18 | ...FlatList.defaultProps,
19 | //eslint-disable-next-line react/display-name
20 | renderScrollComponent: props => (
21 | }
25 | />
26 | ),
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-native-web-refresh-control",
3 | "version": "1.1.2",
4 | "description": "An implementation of React Native's RefreshControl for web, since react-native-web currently does not provide one",
5 | "main": "src/index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "git+https://github.com/NiciusB/react-native-web-refresh-control.git"
12 | },
13 | "keywords": [
14 | "react-native",
15 | "react-native-web",
16 | "RefreshControl"
17 | ],
18 | "author": "Nuno Balbona",
19 | "license": "MIT",
20 | "bugs": {
21 | "url": "https://github.com/NiciusB/react-native-web-refresh-control/issues"
22 | },
23 | "homepage": "https://github.com/NiciusB/react-native-web-refresh-control#readme",
24 | "peerDependencies": {
25 | "react": "*",
26 | "react-native": "*"
27 | },
28 | "devDependencies": {
29 | "eslint": "^5.16.0",
30 | "eslint-config-universe": "^1.0.7",
31 | "eslint-plugin-react-hooks": "^2.1.1",
32 | "prettier": "^1.17.0"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2015-present, Nicolas Gallagher.
4 | Copyright (c) 2015-present, Facebook, Inc.
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy
7 | of this software and associated documentation files (the "Software"), to deal
8 | in the Software without restriction, including without limitation the rights
9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 | copies of the Software, and to permit persons to whom the Software is
11 | furnished to do so, subject to the following conditions:
12 |
13 | The above copyright notice and this permission notice shall be included in all
14 | copies or substantial portions of the Software.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-native-web-refresh-control [](https://npmjs.com/package/react-native-web-refresh-control)
2 |
3 | Drop-in RefreshControl component for web
4 |
5 |
6 |
7 | ## Installation and Configuration
8 |
9 | ```bash
10 | npm i react-native-web-refresh-control
11 | ```
12 |
13 | #### If you're using Expo
14 | You can go ahead and use the package!
15 |
16 | #### If you're NOT using Expo
17 | You will need to configure webpack to parse JSX in `node_modules/react-native-web-refresh-control`.
18 |
19 | 1. Eject from `react-scripts` with `npm run eject`. Make sure to know what ejecting is before doing it.
20 | 2. Modify the main `babel-loader` module in `config/webpack.config.js`.
21 | * Replace `include: paths.appSrc,` with `include: [paths.appSrc, /node_modules\/react-native-web-refresh-control/],`
22 |
23 | ## Usage
24 |
25 | `react-native-web-refresh-control` exports two properties:
26 |
27 | * `patchFlatListProps` is a function that you can call at some point, while your app is loading. It replaces the default value of the refreshControl prop of `FlatList`
28 |
29 | * `RefreshControl` can be used to easily give `ScrollView` a pull-to-refresh functionality, just like the `RefreshControl` exported from react-native. However, if you used the `RefreshControl` from react-native, it would not work on the web. To see how to do this, check out this snack: https://snack.expo.io/@niciusb/refreshcontrol-example
30 |
31 | ## Example of RefreshControl
32 |
33 | https://snack.expo.io/@niciusb/refreshcontrol-example
34 |
35 | ```
36 | import { RefreshControl } from 'react-native-web-refresh-control'
37 |
38 |
39 |
42 | }
43 | >
44 | This scrollview will have pull-to-refresh functionality on the web
45 |
46 | ```
47 |
48 | ## Example of patchFlatListProps
49 |
50 | ```
51 | // index.js
52 | import { patchFlatListProps } from 'react-native-web-refresh-control'
53 |
54 | import App from './App'
55 |
56 | patchFlatListProps()
57 | registerRootComponent(App)
58 | ```
59 |
60 | ## Customize flat list refresh control for web
61 |
62 | * `patchFlatListProps` takes optional `options` to customize the refresh control:
63 | * To customize refresh control for iOS and Android, please see [RefreshControl API](https://reactnative.dev/docs/refreshcontrol)
64 |
65 | | option | Type | Description | default |
66 | |------------|---------------------|------------------------------------------------------------------------------------------|----------------------------------|
67 | | colors | array | If tintColor is not defined, it uses the first color in the array for refresh indicator. | |
68 | | enabled | boolean | Whether the pull to refresh functionality is enabled. | true |
69 | | size | RefreshControl.SIZE | Size of the refresh indicator. | RefreshLayoutConsts.SIZE.DEFAULT |
70 | | tintColor | color | The color of the refresh indicator. | |
71 | | title | string | The title displayed under the refresh indicator. | |
72 | | titleColor | color | The color of the refresh indicator title. | | | |
73 |
74 | * Example
75 | ```
76 | // index.js
77 | import { patchFlatListProps } from 'react-native-web-refresh-control'
78 |
79 | import App from './App'
80 |
81 | // make refresh control red
82 | patchFlatListProps({tintColor: 'red'})
83 | registerRootComponent(App)
84 | ```
85 |
--------------------------------------------------------------------------------
/src/RefreshControl.web.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect, useCallback, useMemo } from 'react'
2 | import { View, Text, PanResponder, Animated, ActivityIndicator, findNodeHandle } from 'react-native'
3 | import PropTypes from 'prop-types'
4 |
5 | const arrowIcon =
6 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAQAAABKfvVzAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAADdcAAA3XAUIom3gAAAAHdElNRQfgCQYHLCTylhV1AAAAjklEQVQ4y2P8z0AaYCJRPX4NsyNWM5Ok4R/n+/noWhjx+2F20n8HwcTQv0T7IXUe4wFUWwh6Gl0LEaGEqoWoYEXWQmQ8ILQwEh/TkBBjme3HIESkjn+Mv9/vJjlpkOwkom2AxTmRGhBJhCgNyCmKCA2oCZCgBvT0ykSacgIaZiaiKydoA7pykiKOSE+jAwADZUnJjMWwUQAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNi0wOS0wNlQwNzo0NDozNiswMjowMAZN3oQAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTYtMDktMDZUMDc6NDQ6MzYrMDI6MDB3EGY4AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAABJRU5ErkJggg=='
7 |
8 | RefreshControl.propTypes = {
9 | colors: PropTypes.array,
10 | enabled: PropTypes.bool,
11 | onRefresh: PropTypes.func,
12 | progressBackgroundColor: PropTypes.any,
13 | progressViewOffset: PropTypes.number,
14 | refreshing: PropTypes.bool.isRequired,
15 | size: PropTypes.oneOf(['small', 'large']),
16 | tintColor: PropTypes.any,
17 | title: PropTypes.string,
18 | titleColor: PropTypes.any,
19 | style: PropTypes.any,
20 | children: PropTypes.any,
21 | }
22 | export default function RefreshControl({
23 | refreshing,
24 | tintColor,
25 | colors,
26 | style,
27 | progressViewOffset,
28 | children,
29 | size,
30 | title,
31 | titleColor,
32 | onRefresh,
33 | enabled,
34 | }) {
35 | const onRefreshRef = useRef(onRefresh)
36 | useEffect(() => {
37 | onRefreshRef.current = onRefresh
38 | }, [onRefresh])
39 | const enabledRef = useRef(enabled)
40 | useEffect(() => {
41 | enabledRef.current = enabled
42 | }, [enabled])
43 |
44 | const containerRef = useRef()
45 | const pullPosReachedState = useRef(0)
46 | const pullPosReachedAnimated = useRef(new Animated.Value(0))
47 | const pullDownSwipeMargin = useRef(new Animated.Value(0))
48 |
49 | useEffect(() => {
50 | Animated.timing(pullDownSwipeMargin.current, {
51 | toValue: refreshing ? 50 : 0,
52 | duration: 350,
53 | useNativeDriver: false,
54 | }).start()
55 | if (refreshing) {
56 | pullPosReachedState.current = 0
57 | pullPosReachedAnimated.current.setValue(0)
58 | }
59 | }, [refreshing])
60 |
61 | const onPanResponderFinish = useCallback(() => {
62 | if (pullPosReachedState.current && onRefreshRef.current) {
63 | onRefreshRef.current()
64 | }
65 | if (!pullPosReachedState.current) {
66 | Animated.timing(pullDownSwipeMargin.current, {
67 | toValue: 0,
68 | duration: 350,
69 | useNativeDriver: false,
70 | }).start()
71 | }
72 | }, [])
73 |
74 | const panResponder = useRef(
75 | PanResponder.create({
76 | onStartShouldSetPanResponder: () => false,
77 | onStartShouldSetPanResponderCapture: () => false,
78 | onMoveShouldSetPanResponder: (_,gestureState) => {
79 | if (!containerRef.current) return false
80 | const containerDOM = findNodeHandle(containerRef.current)
81 | if (!containerDOM) return false
82 | return containerDOM.children[0].scrollTop === 0
83 | && (Math.abs(gestureState.dy) > Math.abs(gestureState.dx) * 2 && Math.abs(gestureState.vy) > Math.abs(gestureState.vx) * 2.5)
84 | },
85 | onMoveShouldSetPanResponderCapture: () => false,
86 | onPanResponderMove: (_, gestureState) => {
87 | if (enabledRef.current !== undefined && !enabledRef.current) return
88 |
89 | const adjustedDy = gestureState.dy <= 0 ? 0 : (gestureState.dy * 150) / (gestureState.dy + 120) // Diminishing returns function
90 | pullDownSwipeMargin.current.setValue(adjustedDy)
91 | const newValue = adjustedDy > 45 ? 1 : 0
92 | if (newValue !== pullPosReachedState.current) {
93 | pullPosReachedState.current = newValue
94 | Animated.timing(pullPosReachedAnimated.current, {
95 | toValue: newValue,
96 | duration: 150,
97 | useNativeDriver: false,
98 | }).start()
99 | }
100 | },
101 | onPanResponderTerminationRequest: () => true,
102 | onPanResponderRelease: onPanResponderFinish,
103 | onPanResponderTerminate: onPanResponderFinish,
104 | })
105 | )
106 |
107 | const refreshIndicatorColor = useMemo(() => (tintColor ? tintColor : colors && colors.length ? colors[0] : null), [
108 | colors,
109 | tintColor,
110 | ])
111 | const pullDownIconStyle = useMemo(
112 | () => ({
113 | width: 22,
114 | height: 22,
115 | marginBottom: 18,
116 | transform: [
117 | {
118 | rotate: pullPosReachedAnimated.current.interpolate({
119 | inputRange: [0, 1],
120 | outputRange: ['90deg', '270deg'],
121 | }),
122 | },
123 | ],
124 | }),
125 | []
126 | )
127 |
128 | const containerStyle = useMemo(
129 | () => [style, { overflowY: 'hidden', overflow: 'hidden', paddingTop: progressViewOffset }],
130 | [progressViewOffset, style]
131 | )
132 | const indicatorTransformStyle = useMemo(
133 | () => ({
134 | alignSelf: 'center',
135 | marginTop: -40,
136 | height: 40,
137 | transform: [{ translateY: pullDownSwipeMargin.current }],
138 | }),
139 | []
140 | )
141 |
142 | // This is messing with react-native-web's internal implementation
143 | // Will probably break if anything changes on their end
144 | const AnimatedContentContainer = useMemo(
145 | () => withAnimated(childProps => ),
146 | []
147 | )
148 | const newContentContainerStyle = useMemo(
149 | () => [children.props.children.props.style, { transform: [{ translateY: pullDownSwipeMargin.current }] }],
150 | [children.props.children.props.style]
151 | )
152 | const newChildren = React.cloneElement(
153 | children,
154 | null,
155 | <>
156 |
157 | {refreshing ? (
158 | <>
159 |
164 | {title && {title}}
165 | >
166 | ) : (
167 |
168 | )}
169 |
170 |
171 | >
172 | )
173 |
174 | return (
175 |
176 | {newChildren}
177 |
178 | )
179 | }
180 |
181 | function withAnimated(WrappedComponent) {
182 | const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component'
183 |
184 | class WithAnimated extends React.Component {
185 | static displayName = `WithAnimated(${displayName})`
186 |
187 | render() {
188 | return
189 | }
190 | }
191 |
192 | return Animated.createAnimatedComponent(WithAnimated)
193 | }
194 |
--------------------------------------------------------------------------------