├── .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 [![NPM Version](https://img.shields.io/npm/v/react-native-web-refresh-control.svg)](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 | --------------------------------------------------------------------------------