├── example ├── .watchmanconfig ├── .babelrc ├── app.json ├── App.test.js ├── .gitignore ├── README.md ├── package.json ├── App.js └── .flowconfig ├── .gitignore ├── .babelrc ├── LICENSE ├── package.json ├── README.md └── src └── index.js /example/.watchmanconfig: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | yarn.lock 4 | package-lock.json -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-expo"], 3 | "env": { 4 | "development": { 5 | "plugins": ["transform-react-jsx-source"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"], 3 | "env": { 4 | "development": { 5 | "plugins": ["transform-react-jsx-source"] 6 | } 7 | }, 8 | "retainLines": true 9 | } 10 | -------------------------------------------------------------------------------- /example/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "react-native-offscreen-toolbar example", 4 | "sdkVersion": "25.0.0", 5 | "androidStatusBar": { 6 | "backgroundColor": "#0288D1" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App'; 3 | 4 | import renderer from 'react-test-renderer'; 5 | 6 | it('renders without crashing', () => { 7 | const rendered = renderer.create().toJSON(); 8 | expect(rendered).toBeTruthy(); 9 | }); 10 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # expo 2 | .expo/ 3 | 4 | # dependencies 5 | /node_modules 6 | 7 | # misc 8 | .env.local 9 | .env.development.local 10 | .env.test.local 11 | .env.production.local 12 | 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | yarn.lock 18 | package-lock.json 19 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # react-native-offscreen-toolbar: example application 2 | 3 | Example usage of the library, using a simple `FlatList` for the scrollable content and `ToolbarAndroid` for the toolbar. 4 | 5 | Created via [create-react-native-app](https://github.com/react-community/create-react-native-app) 6 | 7 | ![simple list demo](https://lopespm.github.io/files/rn-offscreen-toolbar/simplelist_demo.gif) 8 | 9 | 10 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-native-scripts": "1.10.0", 7 | "jest-expo": "25.0.0", 8 | "react-test-renderer": "16.2.0" 9 | }, 10 | "main": "./node_modules/react-native-scripts/build/bin/crna-entry.js", 11 | "scripts": { 12 | "start": "react-native-scripts start", 13 | "eject": "react-native-scripts eject", 14 | "android": "react-native-scripts android", 15 | "ios": "react-native-scripts ios", 16 | "test": "node node_modules/jest/bin/jest.js" 17 | }, 18 | "jest": { 19 | "preset": "jest-expo" 20 | }, 21 | "dependencies": { 22 | "expo": "^25.0.0", 23 | "react": "16.2.0", 24 | "react-native": "0.52.0", 25 | "react-native-offscreen-toolbar": "~1.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Pedro Lopes 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. -------------------------------------------------------------------------------- /example/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StyleSheet, View, Text, ToolbarAndroid, FlatList } from 'react-native'; 3 | import OffscreenToolbar from 'react-native-offscreen-toolbar'; 4 | 5 | const TOOLBAR_HEIGHT = 56; 6 | const DUMMY_DATA = new Array(50).fill({}).map((elem, index) => ({key: `List Item ${index}`})); 7 | 8 | export default class App extends React.Component { 9 | render() { 10 | const toolbar = () => (); 11 | const listItem = ({item}) => {item.key}; 12 | const scrollable = () => (); 13 | return ( 14 | 15 | 20 | 21 | ); 22 | } 23 | } 24 | 25 | const styles = StyleSheet.create({ 26 | container: { 27 | flex: 1, 28 | }, 29 | toolbar: { 30 | backgroundColor: '#03A9F4', 31 | height: TOOLBAR_HEIGHT, 32 | alignSelf: 'stretch', 33 | }, 34 | listItem: { 35 | height: 48, 36 | paddingLeft: 16, 37 | paddingRight: 16, 38 | textAlignVertical: 'center', 39 | fontSize: 16, 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-offscreen-toolbar", 3 | "version": "1.0.10", 4 | "description": "Component which animates the toolbar off-screen while scrolling, a material design pattern", 5 | "main": "src/index.js", 6 | "files": [ 7 | "./src/index.js" 8 | ], 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/lopespm/react-native-offscreen-toolbar.git" 15 | }, 16 | "keywords": [ 17 | "react-native", 18 | "react-component", 19 | "react-native-component", 20 | "react", 21 | "mobile", 22 | "ui", 23 | "component", 24 | "toolbar", 25 | "navbar", 26 | "header", 27 | "material", 28 | "material-design" 29 | ], 30 | "author": "Pedro Lopes", 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/lopespm/react-native-offscreen-toolbar/issues" 34 | }, 35 | "homepage": "https://github.com/lopespm/react-native-offscreen-toolbar", 36 | "dependencies": { 37 | "prop-types": "^15.6.0" 38 | }, 39 | "peerDependencies": { 40 | "react": ">=16.0.0", 41 | "react-native": ">=0.50.0" 42 | }, 43 | "devDependencies": { 44 | "react": "^16.0.0", 45 | "react-native": "^0.50.0", 46 | "babel-eslint": "^8.1.2", 47 | "babel-preset-react-native-stage-0": "^1.0.1", 48 | "eslint-config-airbnb": "^16.1.0", 49 | "eslint-plugin-import": "^2.8.0", 50 | "eslint-plugin-jsx-a11y": "^6.0.3", 51 | "eslint-plugin-react": "^7.5.1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-offscreen-toolbar [![npm version](https://badge.fury.io/js/react-native-offscreen-toolbar.svg)](https://badge.fury.io/js/react-native-offscreen-toolbar) 2 | 3 | Component generalization of [Janic Duplessis' solution](https://medium.com/appandflow/react-native-collapsible-navbar-e51a049b560a) to animate the toolbar off-screen while scrolling, a common [material design pattern](https://material.io/guidelines/patterns/scrolling-techniques.html#scrolling-techniques-behavior) 4 | 5 | ![simple list demo](https://lopespm.github.io/files/rn-offscreen-toolbar/simplelist_demo.gif) ![spacer](https://user-images.githubusercontent.com/3640622/35420943-3795b3c4-0238-11e8-98c3-8c176c75e1d7.png) ![search demo](https://lopespm.github.io/files/rn-offscreen-toolbar/search_demo.gif) 6 | 7 | Library usage in both the example bundled with the library and in the search screen of a to be released application 8 | 9 | 10 | ## Installation 11 | 12 | `$ npm install react-native-offscreen-toolbar --save` 13 | 14 | ## Usage 15 | 16 | ```js 17 | import OffscreenToolbar from 'react-native-offscreen-toolbar'; 18 | 19 | export default class YourComponent extends React.Component { 20 | render() { 21 | const toolbar = () => (); 22 | const listItem = ({item}) => {item.key}; 23 | const scrollable = () => (); 24 | return ( 25 | 26 | 29 | 30 | ); 31 | } 32 | } 33 | ``` 34 | 35 | ### Properties 36 | 37 | | Prop | Description | Default | 38 | |---|---|---| 39 | |**`toolbar`**|Component for the toolbar/navbar. |*None*| 40 | |**`scrollable`**|Component for the scrollable, a `FlatList` or `ScrollView` for example. |*None*| 41 | |**`scrollableOverlay`**|Optional component for the scrollable overlay. |*None*| 42 | |**`toolbarHeight`**|Toolbar height used when calculating the animations. |`56`| 43 | |**`scrollablePaddingTop`**|Since the scrollable is placed behind the toolbar, this padding is typically used to make the scrollable content appear below the toolbar. |`64`| 44 | 45 | 46 | ## Example 47 | 48 | * [`Example` project bundled with this module, using a simple FlatList](https://github.com/lopespm/react-native-offscreen-toolbar/tree/master/example) -------------------------------------------------------------------------------- /example/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | ; We fork some components by platform 3 | .*/*[.]android.js 4 | 5 | ; Ignore templates for 'react-native init' 6 | /node_modules/react-native/local-cli/templates/.* 7 | 8 | ; Ignore RN jest 9 | /node_modules/react-native/jest/.* 10 | 11 | ; Ignore RNTester 12 | /node_modules/react-native/RNTester/.* 13 | 14 | ; Ignore the website subdir 15 | /node_modules/react-native/website/.* 16 | 17 | ; Ignore the Dangerfile 18 | /node_modules/react-native/danger/dangerfile.js 19 | 20 | ; Ignore Fbemitter 21 | /node_modules/fbemitter/.* 22 | 23 | ; Ignore "BUCK" generated dirs 24 | /node_modules/react-native/\.buckd/ 25 | 26 | ; Ignore unexpected extra "@providesModule" 27 | .*/node_modules/.*/node_modules/fbjs/.* 28 | 29 | ; Ignore polyfills 30 | /node_modules/react-native/Libraries/polyfills/.* 31 | 32 | ; Ignore various node_modules 33 | /node_modules/react-native-gesture-handler/.* 34 | /node_modules/expo/.* 35 | /node_modules/react-navigation/.* 36 | /node_modules/xdl/.* 37 | /node_modules/reqwest/.* 38 | /node_modules/metro-bundler/.* 39 | 40 | [include] 41 | 42 | [libs] 43 | node_modules/react-native/Libraries/react-native/react-native-interface.js 44 | node_modules/react-native/flow/ 45 | node_modules/expo/flow/ 46 | 47 | [options] 48 | emoji=true 49 | 50 | module.system=haste 51 | 52 | module.file_ext=.js 53 | module.file_ext=.jsx 54 | module.file_ext=.json 55 | module.file_ext=.ios.js 56 | 57 | munge_underscores=true 58 | 59 | module.name_mapper='^[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> 'RelativeImageStub' 60 | 61 | suppress_type=$FlowIssue 62 | suppress_type=$FlowFixMe 63 | suppress_type=$FlowFixMeProps 64 | suppress_type=$FlowFixMeState 65 | suppress_type=$FixMe 66 | 67 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(5[0-6]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native_oss[a-z,_]*\\)?)\\) 68 | suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(5[0-6]\\|[1-4][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native_oss[a-z,_]*\\)?)\\)?:? #[0-9]+ 69 | suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy 70 | suppress_comment=\\(.\\|\n\\)*\\$FlowExpectedError 71 | 72 | unsafe.enable_getters_and_setters=true 73 | 74 | [version] 75 | ^0.56.0 76 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { StyleSheet, View, Animated, StatusBar } from 'react-native'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const STATUS_BAR_HEIGHT = StatusBar.currentHeight; 6 | 7 | class OffscreenToolbar extends Component { 8 | static propTypes = { 9 | toolbar: PropTypes.func.isRequired, 10 | scrollableOverlay: PropTypes.func, 11 | scrollable: PropTypes.func.isRequired, 12 | toolbarHeight: PropTypes.number.isRequired, 13 | scrollablePaddingTop: PropTypes.number.isRequired, 14 | }; 15 | 16 | static defaultProps = { 17 | toolbarHeight: 56, 18 | scrollablePaddingTop: 56 + 8, 19 | }; 20 | 21 | constructor(props) { 22 | super(props); 23 | 24 | const scrollAnim = new Animated.Value(0); 25 | const offsetAnim = new Animated.Value(0); 26 | 27 | this.state = { 28 | scrollAnim, 29 | offsetAnim, 30 | clampedScroll: Animated.diffClamp( 31 | Animated.add( 32 | scrollAnim.interpolate({ 33 | inputRange: [0, 1], 34 | outputRange: [0, 1], 35 | extrapolateLeft: 'clamp', 36 | }), 37 | offsetAnim, 38 | ), 39 | 0, 40 | props.toolbarHeight, 41 | ), 42 | }; 43 | } 44 | 45 | clampedScrollValue = 0; 46 | offsetValue = 0; 47 | scrollValue = 0; 48 | 49 | componentDidMount() { 50 | this.state.scrollAnim.addListener(({ value }) => { 51 | const diff = value - this.scrollValue; 52 | this.scrollValue = value; 53 | this.clampedScrollValue = Math.min( 54 | Math.max(this.clampedScrollValue + diff, 0), 55 | this.props.toolbarHeight 56 | ); 57 | }); 58 | this.state.offsetAnim.addListener(({ value }) => { 59 | this.offsetValue = value; 60 | }); 61 | } 62 | 63 | componentWillUnmount() { 64 | this.state.scrollAnim.removeAllListeners(); 65 | this.state.offsetAnim.removeAllListeners(); 66 | } 67 | 68 | onScrollEndDrag = () => { 69 | this.scrollEndTimer = setTimeout(this.onMomentumScrollEnd, 250); 70 | }; 71 | 72 | onMomentumScrollBegin = () => { 73 | clearTimeout(this.scrollEndTimer); 74 | }; 75 | 76 | onMomentumScrollEnd = () => { 77 | const statusAndToolbarHeight = this.props.toolbarHeight + STATUS_BAR_HEIGHT; 78 | const targetOffset = this.isToolbarNearHidingPosition() 79 | ? this.offsetValue + statusAndToolbarHeight 80 | : this.offsetValue - statusAndToolbarHeight ; 81 | 82 | Animated.timing(this.state.offsetAnim, { 83 | toValue: targetOffset, 84 | duration: 350, 85 | }).start(); 86 | }; 87 | 88 | isToolbarNearHidingPosition() { 89 | const toolbarHeight = this.props.toolbarHeight; 90 | const statusAndToolbarHeight = this.props.toolbarHeight + STATUS_BAR_HEIGHT; 91 | return this.scrollValue > statusAndToolbarHeight && 92 | this.clampedScrollValue > (toolbarHeight) / 2; 93 | } 94 | 95 | scrollableWithScrollHooks = () => { 96 | const scrollable = this.props.scrollable(); 97 | return React.cloneElement( 98 | scrollable, 99 | { 100 | scrollEventThrottle: 1, 101 | onMomentumScrollBegin: this.onMomentumScrollBegin, 102 | onMomentumScrollEnd: this.onMomentumScrollEnd, 103 | onScrollEndDrag: this.onScrollEndDrag, 104 | onScroll: Animated.event([{ nativeEvent: { contentOffset: { y: this.state.scrollAnim } } }]), 105 | contentContainerStyle: { paddingTop: this.props.scrollablePaddingTop }, 106 | style: { ...scrollable.props.style }, 107 | }, 108 | ); 109 | } 110 | 111 | render() { 112 | const toolbarHeight = this.props.toolbarHeight; 113 | const toolbarTranslationY = this.state.clampedScroll.interpolate({ 114 | inputRange: [0, toolbarHeight], 115 | outputRange: [0, -toolbarHeight], 116 | extrapolate: 'clamp', 117 | }); 118 | 119 | return ( 120 | 121 | {this.scrollableWithScrollHooks()} 122 | {this.props.scrollableOverlay ? this.props.scrollableOverlay() : null} 123 | 124 | {this.props.toolbar()} 125 | 126 | 127 | ); 128 | } 129 | } 130 | 131 | const styles = StyleSheet.create({ 132 | container: { 133 | flex: 1, 134 | }, 135 | toolbarContainer: { 136 | position: 'absolute', 137 | top: 0, 138 | left: 0, 139 | right: 0, 140 | }, 141 | }); 142 | 143 | export default OffscreenToolbar; 144 | --------------------------------------------------------------------------------