├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── ScrollableTabStringDemo.js └── ScrollableTabStringParentDemo.js ├── index.d.ts ├── package.json └── src ├── ScrollableTabString.js └── index.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaVersion: 7, 4 | ecmaFeatures: { 5 | impliedStrict: true, 6 | jsx: true, 7 | experimentalObjectRestSpread: true 8 | }, 9 | sourceType: 'module' 10 | }, 11 | parser: 'babel-eslint', 12 | env: { 13 | node: true, 14 | es6: true 15 | }, 16 | extends: [ 17 | // "@react-native-community", 18 | 'airbnb', 19 | 'plugin:react/recommended', 20 | ], 21 | plugins: [ 22 | 'react', 23 | 'react-native', 24 | // "@react-native-community" 25 | ], 26 | rules: { 27 | 'max-len': ["error", { "code": 200 }], 28 | 'comma-dangle': 0, 29 | 'radix': 0, 30 | 'global-require': 1, 31 | 'no-nested-ternary': 0, 32 | 'no-return-assign': 0, 33 | 'react/require-default-props': 0, 34 | 'react/prefer-stateless-function': 0, 35 | 'function-paren-newline': 0, 36 | 'import/prefer-default-export': 0, 37 | 'react/forbid-prop-types': 0, 38 | 'react/jsx-filename-extension': 0, 39 | 'arrow-body-style': 'warn', 40 | 'no-console': 0, 41 | 'react/prop-types': 0, 42 | 'react/no-string-refs': 0, 43 | 'no-undef': 'error', 44 | 'linebreak-style': 0, 45 | 'class-methods-use-this': 0, 46 | 'no-underscore-dangle': 0, 47 | 'import/no-extraneous-dependencies': ["error", { devDependencies: true }], 48 | 'react/no-did-mount-set-state': 0, 49 | 'react/sort-comp': 0, 50 | 'camelcase': 0, 51 | // Indent with 4 spaces∂ 52 | "indent": ["error", 4], 53 | // Indent JSX with 4 spaces 54 | "react/jsx-indent": ["error", 4], 55 | // Indent props with 4 spaces 56 | "react/jsx-indent-props": ["error", 4], 57 | // "import/no-unresolved": ["error", {commonjs: true, caseSensitive: true}], 58 | "no-use-before-define": [2, { "functions": false, "classes": false, "variables": false }], 59 | "react/jsx-props-no-spreading": [0, {}], 60 | "consistent-return": [0], 61 | "import/no-unresolved": [0], 62 | "no-mixed-operators": [0], 63 | "no-unused-expressions": [0], 64 | "no-useless-constructor": [0], 65 | "no-plusplus": [0] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: false, 3 | jsxBracketSameLine: true, 4 | singleQuote: true, 5 | trailingComma: 'all', 6 | "requirePragma": true, 7 | }; 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2020 Thong Bui 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 | 7 | [![Build Status](https://travis-ci.org/joemccann/dillinger.svg?branch=master)](https://travis-ci.org/joemccann/dillinger) 8 | 9 | 10 | 11 | A ScrollView-like component with animated horizontal tab when scrolling 12 | 13 | # Get started 14 | 15 | ## Installation 16 | Install the dependency. 17 | ```sh 18 | $ npm install react-native-scrollable-tabstring 19 | ``` 20 | ```sh 21 | $ yarn add react-native-scrollable-tabstring 22 | ``` 23 | 24 | ## Usage 25 | 26 | Start using the components or try it on Snack 27 | [here](https://snack.expo.io/@thongbui/rn-scrollable-tabstring). 28 | 29 | ```js 30 | import ScrollableTabString from 'react-native-scrollable-tabstring'; 31 | //Standard scrollable tab 32 | yourCustomOnPressIfNeeded} 34 | dataTabs={yourTabNamesList} 35 | dataSections={yourDataSectionList} 36 | renderSection={(item) => yourCustomSectionItemRender} 37 | renderTabName={(item) => yourCustomSectionTabName} 38 | selectedTabStyle={{ 39 | ...your custom styles when a Tab is scrolled to or tapped 40 | }} 41 | unselectedTabStyle={{ 42 | ...your custom styles when a Tab is normal 43 | }} 44 | /> 45 | ``` 46 | 47 | ## Component Detail 48 | This component currently support tab list for **horizontal** side and vertical section list. Both of which are __**[Flatlist](https://facebook.github.io/react-native/docs/flatlist)**__ 49 | 50 | | Property | Type | Required | Default | Description | 51 | | -------- | ---- | -------- | ------- | ----------- | 52 | | dataTabs | Array | Yes | [] | A tab list to represent | 53 | | dataSections | Array | Yes | [] | A Section list to represent | 54 | | isParent | Boolean | No | false | Switch to `true` if you want to support more sections following by a parent tab, see detail [here](https://github.com/hoangthongbui/react-native-scrollable-tabstring#scrollable-tab-with-parent-tab) | 55 | | headerTransitionWhenScroll | Boolean | No | true | Animation at tab header when section scrolling | 56 | | tabPosition | String | No | top | Tab list position arrangement, `top` and `bottom` | 57 | | renderSectionItem | Func | Yes | | Function to render Section Item | 58 | | renderTabNameItem | Func | Yes | | Function to render Tab Item, equal to [renderItem](https://reactnative.dev/docs/flatlist#renderitem) in `Flatlist` | 59 | | customTabNamesProps | Object | No | | [Flatlist](https://reactnative.dev/docs/flatlist) Props, avoid props like `renderItem`, `data`, `ref`, `onScroll` as may result some issues | 60 | | customSectionProps | Object | No | | [ScrollView](https://reactnative.dev/docs/scrollview) Props | 61 | | onPressTab | Func | No | | Custom function when pressing on a tab | 62 | | onScrollSection | Func | No | | Custom function when section scrolling | 63 | | selectedTabStyle | Object | No | `{ borderBottomColor: 'black', borderBottomWidth: 1, }` | Custom style when a tab is selected | 64 | | unselectedTabStyle | Object | No | `{ backgroundColor: 'white', alignItems: 'center', justifyContent: 'center', }` | Custom style when a tab is unselected | 65 | 66 | ## Example 67 | ### Scrollable tab 68 | 69 | Display a basic scrollable tab 70 | 71 | ```text 72 | Note: Length of `dataTabs` and `dataSections` must equal, otherwise may result in incorrect scrolling order 73 | ``` 74 | 75 | 76 | 77 | ```js 78 | const tabNames = [{ 79 | title: 'Tab 1', 80 | }, 81 | ................... 82 | { 83 | title: 'Tab 6', 84 | }]; 85 | 86 | const dataSections = [ 87 | { 88 | name: 'Section 1', 89 | data: [..........] 90 | }, 91 | ............... 92 | { 93 | name: 'Section 6', 94 | data: [..........] 95 | 96 | }, 97 | ]; 98 | 99 | render () { 100 | return ( 101 | ( 105 | 106 | {item.name} 107 | { 108 | item.data.map((i) => ( 109 | {i.name} 110 | )) 111 | } 112 | 113 | )} 114 | renderTabName={(item) => ( 115 | 116 | 117 | {item.title} 118 | 119 | 120 | )} 121 | selectedTabStyle={{ 122 | borderColor: Colors.brown_grey, 123 | borderRadius: 10, 124 | borderWidth: 1, 125 | margin: 10 126 | }} 127 | unselectedTabStyle={{ 128 | backgroundColor: Colors.white, 129 | alignItems: 'center', 130 | justifyContent: 'center', 131 | }} 132 | /> 133 | ) 134 | }; 135 | ``` 136 | 137 | ### Scrollable tab with parent tab 138 | 139 | Scrollable tab with parent tab and children section follow 140 | 141 | Use this if you want to support more sections following on a tab. 142 | 143 | Add `index` key to parent tab and sections (start from 0). For example Tab 1 has 2 children section follow. They are Section 1 and Section 2 -> index of Tab 1, Section 1 and 2 are 0 144 | 145 | ```text 146 | Note: Index of both parent and children section must equivalent and those sections must be adjacent. 147 | ``` 148 | 149 | 150 | 151 | ```js 152 | const tabNames = [{ 153 | title: 'Tab 1', 154 | index: 0 155 | } 156 | ..... 157 | , { 158 | title: 'Tab 6', 159 | index: 5 160 | }]; 161 | 162 | const dataSections = [ 163 | { 164 | name: 'Section 1', 165 | index: 0, 166 | data: [..........] 167 | }, 168 | { 169 | name: 'Section 2', 170 | index: 0, 171 | data: [..........] 172 | }, 173 | { 174 | name: 'Section 3', 175 | index: 1, 176 | data: [..........] 177 | }, 178 | { 179 | name: 'Section 4', 180 | index: 1, 181 | data: [..........] 182 | }, 183 | { 184 | name: 'Section 5', 185 | index: 2, 186 | data: [..........] 187 | }, 188 | { 189 | name: 'Section 6', 190 | index: 2, 191 | data: [..........] 192 | }, 193 | { 194 | name: 'Section 7', 195 | index: 3, 196 | data: [..........] 197 | }, 198 | { 199 | name: 'Section 8', 200 | index: 4, 201 | data: [..........] 202 | }, 203 | ]; 204 | 205 | const ScrollableTabStringDemo = () => ( 206 | ( 211 | 212 | {item.name} 213 | { 214 | item.data.map((i) => ( 215 | {i.name} 216 | )) 217 | } 218 | 219 | )} 220 | renderTabName={(item) => ( 221 | 222 | 223 | {item.title} 224 | 225 | 226 | )} 227 | selectedTabStyle={{ 228 | borderColor: Colors.brown_grey, 229 | borderRadius: 10, 230 | borderWidth: 1, 231 | margin: 10 232 | }} 233 | unselectedTabStyle={{ 234 | backgroundColor: Colors.white, 235 | alignItems: 'center', 236 | justifyContent: 'center', 237 | }} 238 | /> 239 | ); 240 | ``` 241 | 242 | ## Limitation 243 | This component allows you to customize some Flatlist props on Tab Name and ScrollView props as well. However, you should avoid some of properties like `onScroll`, `renderItem`, `CellRendererComponent`, `horizontal` as may result some issues. 244 | 245 | Furthermore, this component doesn't support on load more yet due to heavily calculated, still working on this :p 246 | 247 | ## Contributing 248 | All contributions are welcome! Please open an issue if you get stuck and bugs, or a PR if you have any feature idea, improvements and bug fixing. I'm very appreciate ! 249 | 250 | ## License 251 | MIT 252 | 253 | 254 | 255 | 256 | -------------------------------------------------------------------------------- /examples/ScrollableTabStringDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TouchableOpacity, View, Text } from 'react-native'; 3 | import ScrollableTabString from '../src'; 4 | 5 | const tabNames = [{ 6 | title: 'Tab 1', 7 | }, { 8 | title: 'Tab 2', 9 | }, { 10 | title: 'Tab 3', 11 | }, { 12 | title: 'Tab 4', 13 | }, { 14 | title: 'Tab 5', 15 | }, { 16 | title: 'Tab 6', 17 | }]; 18 | 19 | const dataMain = [ 20 | { 21 | name: 'Section 1', 22 | data: [ 23 | { 24 | id: '0', 25 | name: 'Section 1 - 1', 26 | }, 27 | { 28 | id: '1', 29 | name: 'Section 1 - 2', 30 | }, 31 | { 32 | id: '2', 33 | name: 'Section 1 - 3', 34 | }, 35 | { 36 | id: '3', 37 | name: 'Section 1 - 4', 38 | }, 39 | { 40 | id: '4', 41 | name: 'Section 1 - 5', 42 | }, 43 | ] 44 | }, 45 | { 46 | name: 'Section 2', 47 | data: [ 48 | { 49 | id: '5', 50 | name: 'Section 2 - 1', 51 | }, 52 | { 53 | id: '6', 54 | name: 'Section 2 - 2', 55 | }, 56 | { 57 | id: '7', 58 | name: 'Section 2 - 3', 59 | }, 60 | { 61 | id: '8', 62 | name: 'Section 2 - 4', 63 | }, 64 | { 65 | id: '9', 66 | name: 'Section 2 - 5', 67 | }, 68 | ] 69 | }, 70 | { 71 | name: 'Section 3', 72 | data: [ 73 | { 74 | id: '10', 75 | name: 'Section 3 - 1', 76 | }, 77 | { 78 | id: '11', 79 | name: 'Section 3 - 2', 80 | }, 81 | ] 82 | }, 83 | { 84 | name: 'Section 4', 85 | data: [ 86 | { 87 | id: '15', 88 | name: 'Section 4 - 1', 89 | }, 90 | { 91 | id: '16', 92 | name: 'Section 4 - 2', 93 | }, 94 | { 95 | id: '17', 96 | name: 'Section 4 - 3', 97 | }, 98 | ] 99 | }, 100 | { 101 | name: 'Section 5', 102 | data: [ 103 | { 104 | id: '18', 105 | name: 'Section 5 - 1', 106 | }, 107 | { 108 | id: '19', 109 | name: 'Section 5 - 2', 110 | }, 111 | { 112 | id: '20', 113 | name: 'Section 5 - 3', 114 | }, 115 | { 116 | id: '21', 117 | name: 'Section 5 - 2', 118 | }, 119 | { 120 | id: '22', 121 | name: 'Section 5 - 3', 122 | }, 123 | ] 124 | }, 125 | { 126 | name: 'Section 6', 127 | data: [ 128 | { 129 | id: '18', 130 | name: 'Section 5 - 1', 131 | }, 132 | { 133 | id: '19', 134 | name: 'Section 5 - 2', 135 | }, 136 | { 137 | id: '20', 138 | name: 'Section 5 - 3', 139 | }, 140 | { 141 | id: '21', 142 | name: 'Section 5 - 2', 143 | }, 144 | { 145 | id: '22', 146 | name: 'Section 5 - 3', 147 | }, 148 | ] 149 | }, 150 | ]; 151 | 152 | const ScrollableTabStringDemo = () => ( 153 | ( 157 | 158 | {item.name} 159 | { 160 | item.data.map((i) => ( 161 | {i.name} 162 | )) 163 | } 164 | 165 | )} 166 | renderTabName={(item) => ( 167 | 168 | 169 | {item.title} 170 | 171 | 172 | )} 173 | selectedTabStyle={{ 174 | borderColor: 'black', 175 | borderRadius: 10, 176 | borderWidth: 1, 177 | margin: 10 178 | }} 179 | unselectedTabStyle={{ 180 | backgroundColor: 'black', 181 | alignItems: 'center', 182 | justifyContent: 'center', 183 | }} 184 | /> 185 | ); 186 | 187 | export default ScrollableTabStringDemo; 188 | -------------------------------------------------------------------------------- /examples/ScrollableTabStringParentDemo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text, TouchableOpacity, View } from 'react-native'; 3 | import { Colors } from '../..'; 4 | import ScrollableTabString from './ScrollableTabString'; 5 | 6 | const tabNames = [{ 7 | title: 'Tab 1', 8 | index: 0 9 | }, { 10 | title: 'Tab 2', 11 | index: 1 12 | }, { 13 | title: 'Tab 3', 14 | index: 2 15 | }, { 16 | title: 'Tab 4', 17 | index: 3 18 | }, { 19 | title: 'Tab 5', 20 | index: 4 21 | }, { 22 | title: 'Tab 6', 23 | index: 5 24 | }]; 25 | 26 | const dataMain = [ 27 | { 28 | name: 'Section 1 - Tab 1', 29 | index: 0, 30 | data: [ 31 | { 32 | id: '0', 33 | name: 'Section 1 - 1', 34 | }, 35 | { 36 | id: '1', 37 | name: 'Section 1 - 2', 38 | }, 39 | { 40 | id: '2', 41 | name: 'Section 1 - 3', 42 | }, 43 | { 44 | id: '3', 45 | name: 'Section 1 - 4', 46 | }, 47 | { 48 | id: '4', 49 | name: 'Section 1 - 5', 50 | }, 51 | ] 52 | }, 53 | { 54 | name: 'Section 2 - Tab 1', 55 | index: 0, 56 | data: [ 57 | { 58 | id: '5', 59 | name: 'Section 2 - 1', 60 | }, 61 | { 62 | id: '6', 63 | name: 'Section 2 - 2', 64 | }, 65 | { 66 | id: '7', 67 | name: 'Section 2 - 3', 68 | }, 69 | { 70 | id: '8', 71 | name: 'Section 2 - 4', 72 | }, 73 | { 74 | id: '9', 75 | name: 'Section 2 - 5', 76 | }, 77 | ] 78 | }, 79 | { 80 | name: 'Section 3 - Tab 2', 81 | index: 1, 82 | data: [ 83 | { 84 | id: '10', 85 | name: 'Section 3 - 1', 86 | }, 87 | { 88 | id: '11', 89 | name: 'Section 3 - 2', 90 | }, 91 | ] 92 | }, 93 | { 94 | name: 'Section 4 - Tab 2', 95 | index: 1, 96 | data: [ 97 | { 98 | id: '15', 99 | name: 'Section 4 - 1', 100 | }, 101 | { 102 | id: '16', 103 | name: 'Section 4 - 2', 104 | }, 105 | { 106 | id: '17', 107 | name: 'Section 4 - 3', 108 | }, 109 | ] 110 | }, 111 | { 112 | name: 'Section 5 - Tab 3', 113 | index: 2, 114 | data: [ 115 | { 116 | id: '18', 117 | name: 'Section 5 - 1', 118 | }, 119 | { 120 | id: '19', 121 | name: 'Section 5 - 2', 122 | }, 123 | { 124 | id: '20', 125 | name: 'Section 5 - 3', 126 | }, 127 | { 128 | id: '21', 129 | name: 'Section 5 - 2', 130 | }, 131 | { 132 | id: '22', 133 | name: 'Section 5 - 3', 134 | }, 135 | ] 136 | }, 137 | { 138 | name: 'Section 6 - Tab 3', 139 | index: 2, 140 | data: [ 141 | { 142 | id: '18', 143 | name: 'Section 5 - 1', 144 | }, 145 | { 146 | id: '19', 147 | name: 'Section 5 - 2', 148 | }, 149 | { 150 | id: '20', 151 | name: 'Section 5 - 3', 152 | }, 153 | { 154 | id: '21', 155 | name: 'Section 5 - 2', 156 | }, 157 | { 158 | id: '22', 159 | name: 'Section 5 - 3', 160 | }, 161 | ] 162 | }, 163 | { 164 | name: 'Section 7 - Tab 4', 165 | index: 3, 166 | data: [ 167 | { 168 | id: '18', 169 | name: 'Section 5 - 1', 170 | }, 171 | { 172 | id: '19', 173 | name: 'Section 5 - 2', 174 | }, 175 | ] 176 | }, 177 | { 178 | name: 'Section 8 - Tab 4', 179 | index: 3, 180 | data: [ 181 | { 182 | id: '18', 183 | name: 'Section 5 - 1', 184 | }, 185 | { 186 | id: '19', 187 | name: 'Section 5 - 2', 188 | }, 189 | { 190 | id: '20', 191 | name: 'Section 5 - 3', 192 | }, 193 | { 194 | id: '21', 195 | name: 'Section 5 - 2', 196 | }, 197 | { 198 | id: '22', 199 | name: 'Section 5 - 3', 200 | }, 201 | ] 202 | }, 203 | { 204 | name: 'Section 9 - Tab 5', 205 | index: 4, 206 | data: [ 207 | { 208 | id: '18', 209 | name: 'Text', 210 | }, 211 | { 212 | id: '19', 213 | name: 'Text', 214 | }, 215 | { 216 | id: '20', 217 | name: 'Text', 218 | }, 219 | { 220 | id: '21', 221 | name: 'Text', 222 | }, 223 | { 224 | id: '22', 225 | name: 'Text', 226 | }, 227 | ] 228 | }, 229 | { 230 | name: 'Section 10 - Tab 5', 231 | index: 4, 232 | data: [ 233 | { 234 | id: '18', 235 | name: 'Text', 236 | }, 237 | { 238 | id: '19', 239 | name: 'Text', 240 | }, 241 | ] 242 | }, 243 | { 244 | name: 'Section 11 - Tab 6', 245 | index: 5, 246 | data: [ 247 | { 248 | id: '18', 249 | name: 'Text', 250 | }, 251 | { 252 | id: '19', 253 | name: 'Text', 254 | }, 255 | { 256 | id: '20', 257 | name: 'Text', 258 | }, 259 | { 260 | id: '21', 261 | name: 'Text', 262 | }, 263 | { 264 | id: '22', 265 | name: 'Text', 266 | }, 267 | ] 268 | }, 269 | { 270 | name: 'Section 12 - Tab 6', 271 | index: 5, 272 | data: [ 273 | { 274 | id: '18', 275 | name: 'Text', 276 | }, 277 | { 278 | id: '19', 279 | name: 'Text', 280 | }, 281 | ] 282 | }, 283 | ]; 284 | 285 | const ScrollableTabStringDemo = () => ( 286 | ( 291 | 292 | {item.name} 293 | { 294 | item.data.map((i) => ( 295 | {i.name} 296 | )) 297 | } 298 | 299 | )} 300 | renderTabName={(item) => ( 301 | 302 | 303 | {item.title} 304 | 305 | 306 | )} 307 | selectedTabStyle={{ 308 | borderColor: Colors.brown_grey, 309 | borderRadius: 10, 310 | borderWidth: 1, 311 | margin: 10 312 | }} 313 | unselectedTabStyle={{ 314 | contentContainerStyle: { 315 | backgroundColor: Colors.white, 316 | alignItems: 'center', 317 | justifyContent: 'center', 318 | } 319 | }} 320 | /> 321 | ); 322 | 323 | export default ScrollableTabStringDemo; 324 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { StyleProp, ViewStyle, FlatListProps, StyleProp, FlatListProps } from 'react-native'; 4 | 5 | interface ScrollableTabStringProps { 6 | dataTabs: Array, 7 | dataSections: Array, 8 | isParent: boolean, 9 | headerTransitionWhenScroll: boolean, 10 | tabPosition?: 'top' | 'bottom', 11 | customTabNamesProps: FlatListProps, 12 | customSectionProps: FlatListProps, 13 | selectedTabStyle: StyleProp, 14 | unselectedTabStyle: StyleProp, 15 | onPressTab: Function, 16 | onScrollSection: Function, 17 | renderSectionItem: Function, 18 | renderTabNameItem: Function, 19 | } 20 | 21 | export class ScrollableTabString extends React.Component { } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-scrollable-tabstring", 3 | "version": "0.0.8", 4 | "private": false, 5 | "description": "a scrollable list with animated horizontal tab when scrolling", 6 | "main": "src/index.js", 7 | "directories": { 8 | "example": "examples" 9 | }, 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/hoangthongbui/react-native-scrollable-tabstring.git" 16 | }, 17 | "keywords": [ 18 | "react", 19 | "native", 20 | "tab", 21 | "scroll", 22 | "string", 23 | "navigator", 24 | "tabstring", 25 | "stringtab", 26 | "scrollable", 27 | "horizontal", 28 | "animated", 29 | "component", 30 | "scrollview", 31 | "ios", 32 | "android" 33 | ], 34 | "author": "Thong Bui", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/hoangthongbui/react-native-scrollable-tabstring/issues" 38 | }, 39 | "homepage": "https://github.com/hoangthongbui/react-native-scrollable-tabstring/blob/master/README.md", 40 | "dependencies": { 41 | "prop-types": "^15.7.2", 42 | "eslint": "7.10.0", 43 | "prettier": "2.1.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ScrollableTabString.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | Animated, 4 | Platform, 5 | View 6 | } from 'react-native'; 7 | 8 | import PropTypes from 'prop-types'; 9 | 10 | const binarySearch = (arr, element) => { 11 | let right = arr.length - 1; 12 | let left = 0; 13 | let mid; 14 | while (left <= right) { 15 | mid = Math.floor((left + right) / 2); 16 | if (arr[mid].y <= element) { 17 | left = mid + 1; 18 | } else { 19 | right = mid - 1; 20 | } 21 | } 22 | return [left, right]; 23 | }; 24 | 25 | const isCloseToBottom = ({ layoutMeasurement, contentOffset, contentSize }) => { 26 | const paddingToBottom = 20; 27 | return layoutMeasurement.height + contentOffset.y 28 | >= contentSize.height - paddingToBottom; 29 | }; 30 | 31 | const listViews = []; 32 | 33 | class ScrollableTabString extends Component { 34 | 35 | static TAB_POSITION_TOP = 'top' 36 | static TAB_POSITION_BOTTOM = 'bottom' 37 | 38 | constructor(props) { 39 | super(props); 40 | this.state = { 41 | selectedScrollIndex: 0, 42 | isPressToScroll: false, 43 | }; 44 | this.heightTabNames = 0; 45 | 46 | this.goToIndex = this.goToIndex.bind(this); 47 | this.dataTabNameChildren = this.dataTabNameChildren.bind(this); 48 | this.dataSectionsChildren = this.dataSectionsChildren.bind(this); 49 | this.onScroll = this.onScroll.bind(this); 50 | } 51 | 52 | componentDidMount() { 53 | const { dataSections, dataTabs, isParent, tabPosition } = this.props; 54 | 55 | if (dataSections.length !== dataTabs.length && !isParent) { 56 | console.warn('The \'dataSections\' and \'dataTabs\'' 57 | + ' length are not equal. This will cause some issues, especially when the section list is scrolling.' 58 | + ' Consider number of items of those lists to be equal, or add \'isParent\'' 59 | + ' param if you are supporting parent tab - children sections'); 60 | } 61 | 62 | if (tabPosition && (tabPosition !== ScrollableTabString.TAB_POSITION_BOTTOM) && (tabPosition !== ScrollableTabString.TAB_POSITION_TOP)) { 63 | console.warn('The tabPosition only accept \'top\' or \'bottom\' only !') 64 | } 65 | } 66 | 67 | componentDidUpdate(prevProps) { 68 | const { dataSections } = this.props; 69 | 70 | if (dataSections.length > prevProps.dataSections.length) { 71 | console.warn('Are you loading more items on the dataSections ? This component does not support on load more yet !'); 72 | } 73 | } 74 | 75 | goToIndex(item) { 76 | const { onPressTab } = this.props; 77 | 78 | this.setState({ isPressToScroll: true }); 79 | 80 | const findMinYAxis = Math.min(...listViews.filter((i) => i.item.index === item.index).map((ii) => ii.y)); 81 | const res = findMinYAxis && listViews.find((i) => i.y === findMinYAxis); 82 | 83 | this.tabScrollMainRef?.scrollTo({ animated: true, y: res?.y || 0 }); 84 | this.setState({ 85 | selectedScrollIndex: res?.item?.index || 0 86 | }); 87 | 88 | onPressTab && onPressTab(item); 89 | } 90 | 91 | // map tab item 92 | dataTabNameChildren({ item, index }) { 93 | const { renderTabName, selectedTabStyle, unselectedTabStyle } = this.props; 94 | const { heightTabNames } = this; 95 | const { selectedScrollIndex } = this.state; 96 | 97 | return React.Children.map( 98 | React.Children.toArray(renderTabName(item, index)), 99 | (children) => React.cloneElement(children, { 100 | style: { ...(index === selectedScrollIndex) ? selectedTabStyle : unselectedTabStyle }, 101 | onPress: () => this.goToIndex(item), 102 | onLayout: (e) => { 103 | if (heightTabNames === 0) { 104 | this.heightTabNames = e.nativeEvent.layout.height; 105 | } 106 | } 107 | }) 108 | ); 109 | } 110 | 111 | // map section item 112 | dataSectionsChildren(item, index) { 113 | const { renderSection, dataSections } = this.props; 114 | 115 | return React.Children.map( 116 | React.Children.toArray(renderSection(item, index)), 117 | (children) => React.cloneElement(children, { 118 | onLayout: (e) => { 119 | listViews.push({ 120 | item: { ...item }, 121 | y: e.nativeEvent.layout.y, 122 | }); 123 | if (listViews.length >= dataSections.length) { 124 | listViews.sort((a, b) => a.y - b.y); 125 | } 126 | } 127 | }) 128 | ); 129 | } 130 | 131 | onScroll(e) { 132 | const { onScrollSection, dataTabs, headerTransitionWhenScroll } = this.props; 133 | const { selectedScrollIndex, isPressToScroll } = this.state; 134 | 135 | onScrollSection && onScrollSection(e); 136 | 137 | if (!isPressToScroll && headerTransitionWhenScroll) { 138 | try { 139 | if (e.nativeEvent.contentOffset.y === 0) { 140 | this.tabNamesRef?.scrollToOffset({ 141 | offset: 0, 142 | animated: Platform.OS === 'ios', 143 | viewPosition: 0.5, 144 | }); 145 | 146 | this.setState({ 147 | selectedScrollIndex: 0, 148 | }); 149 | } else if (isCloseToBottom(e.nativeEvent)) { 150 | const lastIndex = dataTabs.length - 1; 151 | 152 | this.tabNamesRef?.scrollToIndex({ 153 | animated: Platform.OS === 'ios', 154 | index: lastIndex, 155 | viewPosition: 0.5, 156 | }); 157 | 158 | this.setState({ 159 | selectedScrollIndex: lastIndex 160 | }); 161 | } else { 162 | const res = binarySearch(listViews, e.nativeEvent.contentOffset.y); 163 | 164 | const indexToScrollTo = res.includes(-1) 165 | ? listViews[Math.max(...res)]?.item?.index 166 | : Math.max( 167 | listViews[res[0]]?.item?.index, 168 | listViews[res[1]]?.item?.index 169 | ); 170 | 171 | if ( 172 | indexToScrollTo 173 | && indexToScrollTo !== -1 174 | && indexToScrollTo !== selectedScrollIndex) { 175 | this.tabNamesRef?.scrollToIndex({ 176 | animated: Platform.OS === 'ios', 177 | index: indexToScrollTo, 178 | viewPosition: 0.5, 179 | }); 180 | 181 | this.setState({ 182 | selectedScrollIndex: indexToScrollTo 183 | }); 184 | } 185 | } 186 | } catch (err) { 187 | console.warn('err: ', err); 188 | } 189 | } 190 | } 191 | 192 | render() { 193 | const { 194 | dataTabs, 195 | dataSections, 196 | isParent, 197 | tabPosition, 198 | customSectionProps, 199 | customTabNamesProps, 200 | } = this.props; 201 | return ( 202 | <> 203 | { this.tabScrollMainRef = ref; }} 207 | bounces={false} 208 | onScrollBeginDrag={() => this.setState({ isPressToScroll: false })} 209 | nestedScrollEnabled 210 | showsVerticalScrollIndicator={false} 211 | scrollEnabled 212 | onScroll={this.onScroll} 213 | stickyHeaderIndices={tabPosition === 'top' ? [0] : null} 214 | > 215 | { 216 | (tabPosition === 'top' ? ( 217 | 218 | ({ ...i, index }))} 220 | nestedScrollEnabled 221 | keyboardShouldPersistTaps="always" 222 | {...customTabNamesProps} 223 | ref={(ref) => { this.tabNamesRef = ref; }} 224 | keyExtractor={(item) => item.index} 225 | contentContainerStyle={{ 226 | backgroundColor: 'white', 227 | }} 228 | showsHorizontalScrollIndicator={false} 229 | bounces={false} 230 | horizontal 231 | renderItem={this.dataTabNameChildren} 232 | /> 233 | 234 | ) : null) 235 | } 236 | 237 | { (isParent ? dataSections : dataSections.map((i, index) => ({ ...i, index }))).map(this.dataSectionsChildren) } 238 | 239 | 240 | { 241 | (tabPosition === 'bottom' ? ( 242 | 243 | ({ ...i, index }))} 248 | {...customTabNamesProps} 249 | contentContainerStyle={{ 250 | backgroundColor: 'white', 251 | }} 252 | ref={(ref) => { this.tabNamesRef = ref; }} 253 | keyExtractor={(item) => item.index.toString()} 254 | showsHorizontalScrollIndicator={false} 255 | bounces={false} 256 | horizontal 257 | renderItem={this.dataTabNameChildren} 258 | /> 259 | 260 | 261 | ) : null) 262 | } 263 | 264 | ); 265 | } 266 | } 267 | 268 | ScrollableTabString.propTypes = { 269 | dataTabs: PropTypes.array, 270 | dataSections: PropTypes.array, 271 | isParent: PropTypes.bool, 272 | headerTransitionWhenScroll: PropTypes.bool, 273 | tabPosition: PropTypes.oneOf(['top', 'bottom']), 274 | renderSection: PropTypes.func, 275 | renderTabName: PropTypes.func, 276 | customTabNamesProps: PropTypes.object, 277 | customSectionProps: PropTypes.object, 278 | onPressTab: PropTypes.func, 279 | onScrollSection: PropTypes.func, 280 | selectedTabStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), 281 | unselectedTabStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), 282 | }; 283 | 284 | ScrollableTabString.defaultProps = { 285 | dataSections: [], 286 | dataTabs: [], 287 | isParent: false, 288 | headerTransitionWhenScroll: true, 289 | tabPosition: 'top', 290 | selectedTabStyle: { 291 | borderBottomColor: 'black', 292 | borderBottomWidth: 1, 293 | }, 294 | unselectedTabStyle: { 295 | backgroundColor: 'transparent', 296 | alignItems: 'center', 297 | justifyContent: 'center', 298 | } 299 | }; 300 | 301 | export default ScrollableTabString; 302 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import ScrollableTabString from './ScrollableTabString'; 2 | 3 | export default ScrollableTabString; 4 | --------------------------------------------------------------------------------