├── .editorconfig ├── .gitignore ├── .stylelintrc ├── .travis.yml ├── .vscode └── settings.json ├── HISTORY.md ├── README.md ├── assets ├── common │ ├── TabBar.less │ ├── Tabs.less │ └── index.less └── index.less ├── examples ├── basic.html ├── basic.tsx ├── react-native │ ├── basic.tsx │ └── scrolltabbar.tsx ├── scroll-tab.html ├── scroll-tab.tsx ├── single-content.html ├── single-content.tsx ├── sticky.html ├── sticky.tsx ├── vertical.html └── vertical.tsx ├── index.android.js ├── index.ios.js ├── package.json ├── src ├── DefaultTabBar.native.tsx ├── DefaultTabBar.tsx ├── Models.ts ├── PropsType.ts ├── Styles.native.tsx ├── TabPane.native.tsx ├── TabPane.tsx ├── Tabs.base.tsx ├── Tabs.native.tsx ├── Tabs.tsx ├── index.native.tsx ├── index.tsx └── util │ └── index.ts ├── tests ├── Tabs.spec.tsx └── __snapshots__ │ └── Tabs.spec.tsx.snap ├── tsconfig.json ├── tslint.json └── typings └── models.d.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.log 3 | *.log.* 4 | .idea 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | node_modules 22 | .cache 23 | *.css 24 | build 25 | lib 26 | es 27 | coverage 28 | *.js 29 | *.jsx 30 | *.map 31 | !tests/index.js 32 | !/index*.js 33 | ios/ 34 | android/ 35 | xcuserdata 36 | yarn.lock 37 | _ts2js 38 | package-lock.json -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | at-rule-empty-line-before: null, 5 | at-rule-name-space-after: null, 6 | at-rule-no-unknown: null, 7 | comment-empty-line-before: null, 8 | declaration-bang-space-before: null, 9 | declaration-empty-line-before: null, 10 | function-comma-newline-after: null, 11 | function-name-case: null, 12 | function-parentheses-newline-inside: null, 13 | function-max-empty-lines: null, 14 | function-whitespace-after: null, 15 | indentation: null, 16 | number-leading-zero: null, 17 | number-no-trailing-zeros: null, 18 | rule-empty-line-before: null, 19 | selector-combinator-space-after: null, 20 | selector-list-comma-newline-after: null, 21 | selector-pseudo-element-colon-notation: null, 22 | unit-no-unknown: null, 23 | value-list-max-empty-lines: null, 24 | unit-case: null, 25 | color-hex-case: null, 26 | declaration-colon-newline-after: null, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | notifications: 6 | email: 7 | - zhang740@qq.com 8 | 9 | node_js: 10 | - 6.9.1 11 | 12 | before_install: 13 | - | 14 | if ! git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '(\.md$)|(^(docs|examples))/' 15 | then 16 | echo "Only docs were updated, stopping build process." 17 | exit 18 | fi 19 | 20 | script: 21 | - | 22 | if [ "$TEST_TYPE" = test ]; then 23 | npm test 24 | else 25 | npm run $TEST_TYPE 26 | fi 27 | env: 28 | matrix: 29 | - TEST_TYPE=lint 30 | - TEST_TYPE=test 31 | - TEST_TYPE=coverage:upload 32 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "components/**/*.js*": { 4 | "when": "$(basename).tsx" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | --- 4 | 5 | ## 1.2.29 / 2019-01-24 6 | 7 | - `fix` 修复 componentWillReceiveProps 时,page 使用 nextProps, tabs 使用 currentProps 导致逻辑逻辑判断出错 8 | 9 | ## 1.2.27 / 2018-07-11 10 | 11 | - `feat` 无障碍优化 12 | 13 | ## 1.2.26 / 2018-06-04 14 | 15 | - `feat` 修复滚动时触发 Tab 切换的问题 16 | 17 | ## 0.0.1 / 2017-08-10 18 | 19 | - TODO 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rmc-tabs 2 | --- 3 | 4 | React Mobile Tabs Component (web & react-native), inspired by [react-native-scrollable-tab-view](https://github.com/skv-headless/react-native-scrollable-tab-view) 5 | 6 | [![NPM version][npm-image]][npm-url] 7 | ![react](https://img.shields.io/badge/react-%3E%3D_15.2.0-green.svg) 8 | [![build status][travis-image]][travis-url] 9 | [![Test coverage][coveralls-image]][coveralls-url] 10 | [![gemnasium deps][gemnasium-image]][gemnasium-url] 11 | [![npm download][download-image]][download-url] 12 | 13 | [npm-image]: http://img.shields.io/npm/v/rmc-tabs.svg?style=flat-square 14 | [npm-url]: http://npmjs.org/package/rmc-tabs 15 | [travis-image]: https://img.shields.io/travis/react-component/m-tabs.svg?style=flat-square 16 | [travis-url]: https://travis-ci.org/react-component/m-tabs 17 | [coveralls-image]: https://img.shields.io/coveralls/react-component/m-tabs.svg?style=flat-square 18 | [coveralls-url]: https://coveralls.io/r/react-component/m-tabs?branch=master 19 | [gemnasium-image]: http://img.shields.io/gemnasium/react-component/m-tabs.svg?style=flat-square 20 | [gemnasium-url]: https://gemnasium.com/react-component/m-tabs 21 | [node-image]: https://img.shields.io/badge/node.js-%3E=_0.10-green.svg?style=flat-square 22 | [node-url]: http://nodejs.org/download/ 23 | [download-image]: https://img.shields.io/npm/dm/rmc-tabs.svg?style=flat-square 24 | [download-url]: https://npmjs.org/package/rmc-tabs 25 | 26 | ## Screenshots 27 | 28 | ## Development 29 | 30 | ``` 31 | npm i 32 | npm start 33 | ``` 34 | 35 | ## Example 36 | 37 | http://localhost:8000/examples/ 38 | 39 | online example: http://react-component.github.io/m-tabs/ 40 | 41 | 42 | ## install 43 | 44 | [![rmc-tabs](https://nodei.co/npm/rmc-tabs.png)](https://npmjs.org/package/rmc-tabs) 45 | 46 | 47 | # docs 48 | 49 | ## Usage 50 | ```jsx 51 | // normal 52 | 60 |

content1

61 |

content2

62 |

content3

63 |

content4

64 |

content5

65 |
66 | 67 | // single content 68 | { 75 | this.setState({ 76 | scData: JSON.stringify({ index: index + Math.random(), tab }) 77 | }); 78 | }} 79 | > 80 |
81 |

single content

82 |

{this.state.scData}

83 |
84 |
85 | 86 | // single content function 87 | 95 | { 96 | (index, tab) => 97 |
98 |

single content

99 |

{JSON.stringify({ index: index + Math.random(), tab })}

100 |
101 | } 102 |
103 | 104 | // renderTabBar e.g: Sticky, react-sticky 105 | ./examples/sticky.tsx 106 | ``` 107 | 108 | ## react-native 109 | 110 | ``` 111 | npm run rn-init 112 | npm run watch-tsc 113 | react-native start 114 | react-native run-ios 115 | ``` 116 | 117 | ## API 118 | 119 | ### Tabs: 120 | 属性 | 说明 | 类型 | 默认值 | 必选 121 | ----|-----|------|------|------ 122 | tabs | tabs data | Models.TabData[] | | true 123 | tabBarPosition | TabBar's position | 'top' \| 'bottom' \| 'left' \| 'right' | top | false 124 | renderTabBar | render for TabBar | ((props: TabBarPropsType) => React.ReactNode) \| false | | false 125 | initialPage | initial Tab, index or key | number \| string | | false 126 | page | current tab, index or key | number \| string | | false 127 | swipeable | whether to switch tabs with swipe gestrue in the content | boolean | true | false 128 | useOnPan (`web only`) | use scroll follow pan | boolean | true | false 129 | prerenderingSiblingsNumber | pre-render nearby # sibling, Infinity: render all the siblings, 0: render current page | number | 1 | false 130 | animated | whether to change tabs with animation | boolean | true | false 131 | onChange | callback when tab is switched | (tab: Models.TabData, index: number) => void | | false 132 | onTabClick | on tab click | (tab: Models.TabData, index: number) => void | | false 133 | destroyInactiveTab | destroy inactive tab | boolean | false | false 134 | distanceToChangeTab | distance to change tab, width ratio | number | 0.3 | false 135 | usePaged | use paged | boolean | true | false 136 | tabDirection | tab paging direction | 'horizontal' \| 'vertical' | horizontal | false 137 | tabBarUnderlineStyle | tabBar underline style | React.CSSProperties \| any | | false 138 | tabBarBackgroundColor | tabBar background color | string | | false 139 | tabBarActiveTextColor | tabBar active text color | string | | false 140 | tabBarInactiveTextColor | tabBar inactive text color | string | | false 141 | tabBarTextStyle | tabBar text style | React.CSSProperties \| any | | false 142 | 143 | ### TabBarPropsType (Common): 144 | 属性 | 说明 | 类型 | 默认值 | 必选 145 | ----|-----|------|------|------ 146 | goToTab | call this function to switch tab | (index: number) => void | | true 147 | tabs | tabs data | Models.TabData[] | | true 148 | activeTab | current active tab | number | | true 149 | animated | use animate | boolean | true | true 150 | renderTab | render the tab of tabbar | (tab: Models.TabData) => React.ReactNode | | false 151 | page | page size of tabbar's tab | number | 5 | false 152 | onTabClick | on tab click | (tab: Models.TabData, index: number) => void | | false 153 | tabBarPosition | tabBar's position defualt: top | 'top' \| 'bottom' \| 'left' \| 'right' | | false 154 | tabBarUnderlineStyle | tabBar underline style | React.CSSProperties \| any | | false 155 | tabBarBackgroundColor | tabBar background color | string | | false 156 | tabBarActiveTextColor | tabBar active text color | string | | false 157 | tabBarInactiveTextColor | tabBar inactive text color | string | | false 158 | tabBarTextStyle | tabBar text style | React.CSSProperties \| any | | false 159 | 160 | ## Test Case 161 | 162 | ``` 163 | npm test 164 | npm run chrome-test 165 | ``` 166 | 167 | ## Coverage 168 | 169 | ``` 170 | npm run coverage 171 | ``` 172 | 173 | open coverage/ dir 174 | 175 | ## License 176 | 177 | rmc-tabs is released under the MIT license. 178 | -------------------------------------------------------------------------------- /assets/common/TabBar.less: -------------------------------------------------------------------------------- 1 | .common-pagination() { 2 | pointer-events: none; 3 | position: absolute; 4 | top: 0; 5 | display: block; 6 | width: 59px; 7 | height: 100%; 8 | content: ' '; 9 | z-index: 1; 10 | } 11 | 12 | .@{tabBarPrefixClass} { 13 | position: relative; 14 | display: flex; 15 | flex-shrink: 0; 16 | flex-direction: row; 17 | width: 100%; 18 | height: 100%; 19 | overflow: visible; 20 | z-index: 1; 21 | 22 | &-tab { 23 | position: relative; 24 | display: flex; 25 | flex-shrink: 0; 26 | justify-content: center; 27 | align-items: center; 28 | font-size: 14px; 29 | line-height: 14px; 30 | } 31 | 32 | &-tab-active { 33 | color: @tabs-color; 34 | } 35 | 36 | &-underline { 37 | position: absolute; 38 | border: 1px @tabs-color solid; 39 | } 40 | 41 | &-animated &-content { 42 | transition: transform @effect-duration @easing-in-out; 43 | will-change: transform; 44 | } 45 | 46 | &-animated &-underline { 47 | transition: top @effect-duration @easing-in-out, 48 | left @effect-duration @easing-in-out, 49 | color @effect-duration @easing-in-out, 50 | width @effect-duration @easing-in-out; 51 | will-change: top, left, width, color; 52 | } 53 | 54 | &-top, 55 | &-bottom { 56 | flex-direction: row; 57 | 58 | .@{tabBarPrefixClass} { 59 | &-content { 60 | display: flex; 61 | width: 100%; 62 | flex-direction: row; 63 | } 64 | 65 | &-prevpage { 66 | .common-pagination(); 67 | 68 | left: 0; 69 | background: linear-gradient(to right, @page-show-color, @page-hide-color); 70 | } 71 | 72 | &-nextpage { 73 | .common-pagination(); 74 | 75 | right: 0; 76 | background: linear-gradient(to right, @page-hide-color, @page-show-color); 77 | } 78 | 79 | &-tab { 80 | padding: 8px 0; 81 | } 82 | 83 | &-underline { 84 | bottom: 0; 85 | } 86 | } 87 | } 88 | 89 | &-top { 90 | border-bottom: 1px #eee solid; 91 | } 92 | 93 | &-bottom { 94 | border-top: 1px #eee solid; 95 | } 96 | 97 | &-left, 98 | &-right { 99 | flex-direction: column; 100 | 101 | .@{tabBarPrefixClass} { 102 | &-content { 103 | display: flex; 104 | height: 100%; 105 | flex-direction: column; 106 | } 107 | 108 | &-tab { 109 | padding: 0 8px; 110 | } 111 | } 112 | } 113 | 114 | &-left { 115 | border-right: 1px #eee solid; 116 | 117 | .@{tabBarPrefixClass} { 118 | &-underline { 119 | right: 0; 120 | } 121 | } 122 | } 123 | 124 | &-right { 125 | border-left: 1px #eee solid; 126 | 127 | .@{tabBarPrefixClass} { 128 | &-underline { 129 | left: 0; 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /assets/common/Tabs.less: -------------------------------------------------------------------------------- 1 | .@{tabsPrefixClass} { 2 | box-sizing: border-box; 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | display: flex; 8 | flex: 1; 9 | position: relative; 10 | overflow: hidden; 11 | height: 100%; 12 | width: 100%; 13 | 14 | &-content-wrap { 15 | display: flex; 16 | flex: 1; 17 | width: 100%; 18 | height: 100%; 19 | 20 | &-animated { 21 | transition: transform @effect-duration @easing-in-out, left @effect-duration @easing-in-out, top @effect-duration @easing-in-out; 22 | will-change: transform, left, top; 23 | } 24 | } 25 | 26 | &-pane-wrap { 27 | width: 100%; 28 | flex-shrink: 0; 29 | overflow-y: auto; 30 | } 31 | 32 | &-tab-bar-wrap { 33 | flex-shrink: 0; 34 | } 35 | 36 | &-horizontal { 37 | .@{tabsPrefixClass} { 38 | &-pane-wrap-active { 39 | height: auto; 40 | } 41 | 42 | &-pane-wrap-inactive { 43 | height: 0; 44 | overflow: visible; 45 | } 46 | } 47 | } 48 | 49 | &-vertical { 50 | .@{tabsPrefixClass} { 51 | &-content-wrap { 52 | flex-direction: column; 53 | } 54 | 55 | &-tab-bar-wrap { 56 | height: 100%; 57 | } 58 | 59 | &-pane-wrap { 60 | height: 100%; 61 | } 62 | 63 | &-pane-wrap-active { 64 | overflow: auto; 65 | } 66 | 67 | &-pane-wrap-inactive { 68 | overflow: hidden; 69 | } 70 | } 71 | } 72 | 73 | &-top, 74 | &-bottom { 75 | flex-direction: column; 76 | } 77 | 78 | &-left, 79 | &-right { 80 | flex-direction: row; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /assets/common/index.less: -------------------------------------------------------------------------------- 1 | @tabsPrefixClass: rmc-tabs; 2 | @tabBarPrefixClass: rmc-tabs-tab-bar; 3 | @easing-in-out: cubic-bezier(0.35, 0, 0.25, 1); 4 | @effect-duration: .3s; 5 | @tabs-color: #108ee9; 6 | @page-hide-color: rgba(255, 255, 255, 0); 7 | @page-show-color: rgba(255, 255, 255, 1); 8 | 9 | @import './Tabs.less'; 10 | @import './TabBar.less'; 11 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @import './common/index.less'; 2 | -------------------------------------------------------------------------------- /examples/basic.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/basic.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import 'rmc-tabs/assets/index.less'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { Models, Tabs, DefaultTabBar } from '../src'; 8 | 9 | class BasicDemo extends React.Component<{}, any> { 10 | 11 | constructor(props: any) { 12 | super(props); 13 | 14 | this.state = { 15 | page: 0 16 | }; 17 | } 18 | 19 | canvasTest = (canvas: HTMLCanvasElement) => { 20 | if (canvas && canvas.getContext) { 21 | const context = canvas.getContext('2d'); 22 | context!.fillStyle = 'red'; 23 | context!.fillRect(10, 10, 50, 50); 24 | context!.fillStyle = 'rgba(0,0,255,0.5)'; 25 | context!.fillRect(30, 30, 50, 50); 26 | context!.strokeStyle = 'red'; 27 | context!.strokeRect(10, 90, 50, 50); 28 | context!.strokeStyle = 'rgba(0,0,255,0.5)'; 29 | context!.strokeRect(30, 120, 50, 50); 30 | context!.clearRect(30, 30, 30, 30); 31 | } 32 | } 33 | 34 | renderContent(data?: any) { 35 | const pStyle = { margin: 0, padding: 10 } as React.CSSProperties; 36 | 37 | return [ 38 |
39 |

tab 1 1

40 |

tab 1 2

41 |

tab 1 3

42 |

tab 1 4

43 |
, 44 |
45 | 46 |
, 47 |
48 |

tab 3 1

49 |

tab 3 2

50 |

{JSON.stringify(data)}

51 |
, 52 |
53 |

tab 4 1

54 |
, 55 |
56 |

tab 5 1

57 |
, 58 | ]; 59 | } 60 | 61 | render() { 62 | const baseStyle = { 63 | display: 'flex', flexDirection: 'column', marginTop: 10, marginBottom: 10, fontSize: 14 64 | } as React.CSSProperties; 65 | 66 | return ( 67 |
68 |
69 |

normal

70 |
this.setState({ page: 2, data: Math.random() })} 72 | > 73 | change to 3 74 |
75 |
this.setState({ data: Math.random() })} 77 | > 78 | change data 79 |
80 | { 86 | console.log('onChange', tab, index); 87 | this.setState({ page: index }); 88 | }} 89 | onTabClick={(tab, index) => { 90 | console.log('onTabClick', tab, index); 91 | }} 92 | renderTabBar={(props) => { 95 | if (tab.key === 't2') { 96 | return
97 | {tab.title} 98 |
109 |
; 110 | } 111 | return
{tab.title}
; 112 | }} 113 | />} 114 | > 115 | {this.renderContent(this.state.data)} 116 |
117 |
118 |
119 |

bottom

120 | 127 | {this.renderContent()} 128 | 129 |
130 |
131 |

destroyInactiveTab

132 | 140 | {this.renderContent()} 141 | 142 |
143 |
144 |

fixed height

145 | 153 | {this.renderContent()} 154 | 155 |
156 |
157 |

no animate

158 | 166 | {this.renderContent()} 167 | 168 |
169 |
170 |

no swipeable

171 | 178 | {this.renderContent()} 179 | 180 |
181 |
182 |

no paged

183 | 190 | {this.renderContent()} 191 | 192 |
193 |
194 |

no render content

195 | 203 | {this.renderContent()} 204 | 205 |
206 |
207 |

use left instead of tansform

208 | 216 | {this.renderContent()} 217 | 218 |
219 |
220 |

custom underline

221 | { 230 | const { style, ...otherProps } = ulProps; 231 | const ulStyle = { 232 | ...style, 233 | border: 'none', 234 | }; 235 | return ( 236 |
237 |
244 |
245 | ); 246 | }} 247 | />} 248 | > 249 | {this.renderContent()} 250 |
251 |
252 |
253 | ); 254 | } 255 | } 256 | 257 | ReactDOM.render(, document.getElementById('__react-content')); 258 | 259 | const ip = (document.body.children[3] as HTMLScriptElement).innerText.split('/')[2].split(':')[0]; 260 | const elm = document.createElement('script'); 261 | elm.src = `http://${ip}:1337/vorlon.js`; 262 | document.body.appendChild(elm); 263 | -------------------------------------------------------------------------------- /examples/react-native/basic.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* tslint:disable:no-console */ 3 | import { View, ScrollView, Text } from 'react-native'; 4 | import { Tabs, Models } from '../../src'; 5 | import React from 'react'; 6 | 7 | const renderContent = (tab: Models.TabData, index: number) => 8 | 9 | { 10 | [1, 2, 3, 4, 5, 6, 7, 8].map(i => 18 | 19 | {tab.title} - {i} 20 | 21 | ) 22 | } 23 | 24 | ; 25 | 26 | const TabsExample = () => ( 27 | 28 | 35 | {renderContent} 36 | 37 | 38 | ); 39 | 40 | export const Demo = TabsExample; 41 | export const title = 'Simple'; -------------------------------------------------------------------------------- /examples/react-native/scrolltabbar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* tslint:disable:no-console */ 3 | import { View, ScrollView, Text } from 'react-native'; 4 | import { Tabs, Models } from '../../src'; 5 | import React from 'react'; 6 | 7 | const renderContent = (tab: Models.TabData, index: number) => 8 | 9 | { 10 | [1, 2, 3, 4, 5, 6, 7, 8].map(i => 18 | 19 | {tab.title} - {i} 20 | 21 | ) 22 | } 23 | 24 | ; 25 | 26 | const TabsExample = () => ( 27 | 28 | } 40 | > 41 | {renderContent} 42 | 43 | 44 | ); 45 | 46 | export const Demo = TabsExample; 47 | export const title = 'Scroll TabBar'; 48 | -------------------------------------------------------------------------------- /examples/scroll-tab.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/scroll-tab.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import 'rmc-tabs/assets/index.less'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { Tabs, DefaultTabBar } from '../src'; 8 | 9 | const tabData = [ 10 | { title: 'title 1' }, 11 | { title: 'title 2' }, 12 | { title: 'title 3' }, 13 | { title: 'title 4' }, 14 | { title: 'title 5' }, 15 | { title: 'title 6' }, 16 | { title: 'title 7' }, 17 | { title: 'title 8' }, 18 | { title: 'title 9' }, 19 | ]; 20 | 21 | class BasicDemo extends React.Component<{}, any> { 22 | 23 | constructor(props: any) { 24 | super(props); 25 | this.state = { 26 | scData: JSON.stringify({ index: 0, tab: { title: 't1' } }), 27 | scData2: JSON.stringify({ index: 0, tab: { title: 't1' } }), 28 | dynamicTabs: [] as { title: string }[], 29 | }; 30 | } 31 | 32 | render() { 33 | const baseStyle = { 34 | display: 'flex', flexDirection: 'column', marginTop: 10, marginBottom: 10, fontSize: 14 35 | } as React.CSSProperties; 36 | 37 | return ( 38 |
39 |
40 |

normal

41 | { 42 | this.setState({ 43 | scData: JSON.stringify({ index: index + Math.random(), tab }) 44 | }); 45 | }} renderTabBar={(props) => } 46 | > 47 |
48 |

single content

49 |

{this.state.scData}

50 |

single content

51 |

single content

52 |

single content

53 |
54 |
55 |

page

56 | { 57 | this.setState({ 58 | scData2: JSON.stringify({ index: index + Math.random(), tab }) 59 | }); 60 | }} renderTabBar={(props) => } 61 | > 62 |
63 |

single content

64 |

{this.state.scData2}

65 |

single content

66 |

single content

67 |

single content

68 |
69 |
70 |

add page

71 |
{ 73 | this.setState({ 74 | dynamicTabs: [ 75 | ...this.state.dynamicTabs, 76 | { title: 'title-' + this.state.dynamicTabs.length + 1 } 77 | ], 78 | }); 79 | }} 80 | > 81 | add page 82 |
83 | { 84 | this.setState({ 85 | scData2: JSON.stringify({ index: index + Math.random(), tab }) 86 | }); 87 | }} renderTabBar={(props) => } 88 | > 89 |
90 |

single content

91 |

{this.state.scData2}

92 |

single content

93 |

single content

94 |

single content

95 |
96 |
97 |
98 |
99 | ); 100 | } 101 | } 102 | 103 | ReactDOM.render(, document.getElementById('__react-content')); 104 | -------------------------------------------------------------------------------- /examples/single-content.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/single-content.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import 'rmc-tabs/assets/index.less'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { Models, Tabs } from '../src'; 8 | 9 | const tabData = [ 10 | { title: 't1' }, 11 | { title: 't2' }, 12 | { title: 't3' }, 13 | { title: 't4' }, 14 | { title: 't5' }, 15 | ]; 16 | 17 | class BasicDemo extends React.Component<{}, any> { 18 | 19 | constructor(props: any) { 20 | super(props); 21 | this.state = { 22 | scData: JSON.stringify({ index: 0, tab: { title: 't1' } }), 23 | }; 24 | } 25 | 26 | renderContent = (tab: Models.TabData, index: number) => 27 |
28 |

single content

29 |

{JSON.stringify({ index: index + Math.random(), tab })}

30 |
31 | 32 | render() { 33 | const baseStyle = { 34 | display: 'flex', flexDirection: 'column', marginTop: 10, marginBottom: 10, fontSize: 14 35 | } as React.CSSProperties; 36 | 37 | return ( 38 |
39 |
40 |

single content

41 | { 42 | this.setState({ 43 | scData: JSON.stringify({ index: index + Math.random(), tab }) 44 | }); 45 | }} 46 | > 47 |
48 |

single content

49 |

{this.state.scData}

50 |
51 |
52 |
53 |
54 |

single content function

55 | 56 | {this.renderContent} 57 | 58 |
59 |
60 | ); 61 | } 62 | } 63 | 64 | ReactDOM.render(, document.getElementById('__react-content')); -------------------------------------------------------------------------------- /examples/sticky.html: -------------------------------------------------------------------------------- 1 | placeholder -------------------------------------------------------------------------------- /examples/sticky.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import 'rmc-tabs/assets/index.less'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { StickyContainer, Sticky } from 'react-sticky'; 8 | import { Tabs, DefaultTabBar } from '../src'; 9 | 10 | class BasicDemo extends React.Component<{}, { 11 | }> { 12 | state = { 13 | current: 0, 14 | }; 15 | 16 | tabBarProps: any; 17 | 18 | constructor(props: any) { 19 | super(props); 20 | } 21 | 22 | renderContent() { 23 | const pStyle = { margin: 0, padding: 10 } as React.CSSProperties; 24 | 25 | return [ 26 |
27 |

tab 1 1

28 |

tab 1 2

29 |

tab 1 3

30 |

tab 1 4

31 |
, 32 |
33 |

tab 2 1

34 |

tab 2 2

35 |

tab 2 3

36 |

tab 2 4

37 |

tab 2 5

38 |

tab 2 6

39 |

tab 2 7

40 |

tab 2 8

41 |

tab 2 9

42 |
, 43 |
tab 3
, 44 |
tab 4
, 45 |
tab 5
, 46 | ]; 47 | } 48 | 49 | render() { 50 | const baseStyle = { 51 | display: 'flex', flexDirection: 'column', position: 'relative', 52 | marginTop: 10, marginBottom: 10, fontSize: 14, 53 | } as React.CSSProperties; 54 | 55 | const tabs = [ 56 | { key: 't1', title: 't1' }, 57 | { key: 't2', title: 't2' }, 58 | { key: 't3', title: 't3' }, 59 | { key: 't4', title: 't4' }, 60 | { key: 't5', title: 't5' }, 61 | ]; 62 | 63 | return ( 64 |
65 |
66 |

Sticky

67 | 68 | { 70 | return 71 | {({ style }: { style: React.CSSProperties }) =>
} 72 |
; 73 | }} 74 | > 75 | {this.renderContent()} 76 |
77 |
78 |
79 |
80 |

Sticky unuse react-sticky

81 |
82 | 86 |
87 | { 89 | if (!this.tabBarProps) { // diff? 90 | this.tabBarProps = props; 91 | setTimeout(() => { 92 | this.forceUpdate(); 93 | }); 94 | } 95 | return null; 96 | }} 97 | page={this.state.current} 98 | onChange={(_tab, index) => this.setState({ current: index })} 99 | > 100 | {this.renderContent()} 101 | 102 |
103 |
104 | ); 105 | } 106 | } 107 | 108 | ReactDOM.render(, document.getElementById('__react-content')); 109 | -------------------------------------------------------------------------------- /examples/vertical.html: -------------------------------------------------------------------------------- 1 | placeholder 2 | -------------------------------------------------------------------------------- /examples/vertical.tsx: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-console */ 2 | 3 | import 'rmc-tabs/assets/index.less'; 4 | 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { Tabs } from '../src'; 8 | 9 | class BasicDemo extends React.Component<{}, { 10 | }> { 11 | 12 | constructor(props: any) { 13 | super(props); 14 | } 15 | 16 | renderContent() { 17 | const pStyle = { margin: 0, padding: 10 } as React.CSSProperties; 18 | 19 | return [ 20 |
21 |

tab 1 1

22 |

tab 1 2

23 |

tab 1 3

24 |

tab 1 4

25 |
, 26 |
27 |

tab 2 1

28 |

tab 2 2

29 |

tab 2 3

30 |

tab 2 4

31 |

tab 2 5

32 |

tab 2 6

33 |

tab 2 7

34 |

tab 2 8

35 |

tab 2 9

36 |
, 37 |
tab 3
, 38 |
tab 4
, 39 |
tab 5
, 40 |
tab 6
, 41 |
tab 7
, 42 |
tab 8
, 43 |
tab 9
, 44 | ]; 45 | } 46 | 47 | render() { 48 | const baseStyle = { 49 | display: 'flex', flexDirection: 'column', marginTop: 10, marginBottom: 10, fontSize: 14 50 | } as React.CSSProperties; 51 | 52 | return ( 53 |
54 |
55 |

vertical

56 | 64 | {this.renderContent()} 65 | 66 |
67 |
68 |

vertical fixed height

69 | 77 | {this.renderContent()} 78 | 79 |
80 |
81 |

vertical right

82 | 90 | {this.renderContent()} 91 | 92 |
93 |
94 |

no paged

95 | 103 | {this.renderContent()} 104 | 105 |
106 |
107 |

vertical right

108 | 119 | {this.renderContent()} 120 | 121 |
122 |
123 |

useLeftInsteadTransform

124 | 136 | {this.renderContent()} 137 | 138 |
139 |
140 | ); 141 | } 142 | } 143 | 144 | ReactDOM.render(, document.getElementById('__react-content')); 145 | -------------------------------------------------------------------------------- /index.android.js: -------------------------------------------------------------------------------- 1 | import getList from 'react-native-index-page'; 2 | 3 | getList({ 4 | demos: [ 5 | require('./_ts2js/examples/react-native/basic'), 6 | require('./_ts2js/examples/react-native/scrolltabbar'), 7 | ], 8 | title: require('./package.json').name, 9 | }); 10 | -------------------------------------------------------------------------------- /index.ios.js: -------------------------------------------------------------------------------- 1 | import getList from 'react-native-index-page'; 2 | 3 | getList({ 4 | demos: [ 5 | require('./_ts2js/examples/react-native/basic'), 6 | require('./_ts2js/examples/react-native/scrolltabbar'), 7 | ], 8 | title: require('./package.json').name, 9 | }); 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rmc-tabs", 3 | "version": "1.2.29", 4 | "description": "React Mobile Tabs Component(web & react-native)", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-m-tabs", 9 | "m-tabs" 10 | ], 11 | "homepage": "https://github.com/react-component/m-tabs", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/react-component/m-tabs.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/react-component/m-tabs/issues" 18 | }, 19 | "files": [ 20 | "lib", 21 | "es", 22 | "assets/*.css" 23 | ], 24 | "license": "MIT", 25 | "main": "./lib/index", 26 | "module": "./es/index", 27 | "config": { 28 | "port": 8021 29 | }, 30 | "scripts": { 31 | "watch-tsc": "rc-tools run watch-tsc", 32 | "compile": "rc-tools run compile", 33 | "build": "rc-tools run build", 34 | "gh-pages": "rc-tools run gh-pages", 35 | "start": "rc-tools run server", 36 | "prepublish": "rc-tools run guard", 37 | "prepare": "rc-tools run guard", 38 | "prepublishOnly": "rc-tools run guard", 39 | "pub": "rc-tools run pub --babel-runtime", 40 | "lint": "rc-tools run lint --no-js-lint", 41 | "test": "jest", 42 | "update-snap": "jest --updateSnapshot", 43 | "coverage": "jest --coverage", 44 | "coverage:upload": "npm run coverage && cat ./coverage/lcov.info | coveralls", 45 | "rn-init": "rc-tools run react-native-init", 46 | "rn-start": "node node_modules/react-native/local-cli/cli.js start" 47 | }, 48 | "jest": { 49 | "testMatch": [ 50 | "**/__tests__/**/*.ts?(x)", 51 | "**/?(*.)(spec|test).ts?(x)" 52 | ], 53 | "coverageDirectory": "./coverage/", 54 | "moduleFileExtensions": [ 55 | "ts", 56 | "tsx", 57 | "js" 58 | ], 59 | "collectCoverageFrom": [ 60 | "src/**/*" 61 | ], 62 | "transform": { 63 | "\\.tsx?$": "./node_modules/rc-tools/scripts/jestPreprocessor.js", 64 | "\\.jsx?$": "./node_modules/rc-tools/scripts/jestPreprocessor.js" 65 | } 66 | }, 67 | "dependencies": { 68 | "babel-runtime": "6.x", 69 | "rc-gesture": "~0.0.18" 70 | }, 71 | "devDependencies": { 72 | "@types/enzyme": "^2.8.6", 73 | "@types/enzyme-to-json": "^1.5.0", 74 | "@types/jest": "^20.0.7", 75 | "@types/react": "^15.5.0", 76 | "@types/react-dom": "^15.5.0", 77 | "@types/react-native": "^0.46.9", 78 | "@types/react-sticky": "^6.0.0", 79 | "jest": "^20.0.4", 80 | "enzyme": "^2.9.1", 81 | "enzyme-to-json": "^1.5.1", 82 | "coveralls": "^2.13.1", 83 | "pre-commit": "1.x", 84 | "rc-test": "6.x", 85 | "rc-tools": "6.x", 86 | "react": "^15.5.0", 87 | "react-dom": "^15.5.0", 88 | "react-sticky": "^6.0.1", 89 | "react-native": "~0.42.0", 90 | "react-native-index-page": "^0.2.1", 91 | "react-test-renderer": "^15.5.0", 92 | "stylelint-config-standard": "^17.0.0" 93 | }, 94 | "typings": "./lib/index.d.ts", 95 | "pre-commit": [ 96 | "lint", 97 | "test" 98 | ] 99 | } 100 | -------------------------------------------------------------------------------- /src/DefaultTabBar.native.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | default as RN, 4 | Animated, 5 | Dimensions, 6 | Platform, 7 | ScrollView, 8 | Text, 9 | TouchableOpacity, 10 | View, 11 | } from 'react-native'; 12 | import { TabBarPropsType } from './PropsType'; 13 | import { Models } from './Models'; 14 | import defaultStyles from './Styles.native'; 15 | 16 | const WINDOW_WIDTH = Dimensions.get('window').width; 17 | 18 | export interface PropsType extends TabBarPropsType { 19 | scrollValue?: any; 20 | styles?: typeof defaultStyles; 21 | tabStyle?: RN.ViewStyle; 22 | tabsContainerStyle?: RN.ViewStyle; 23 | /** default: false */ 24 | dynamicTabUnderlineWidth?: boolean; 25 | keyboardShouldPersistTaps?: boolean; 26 | } 27 | 28 | export interface StateType { 29 | _leftTabUnderline: Animated.Value; 30 | _widthTabUnderline: Animated.Value; 31 | _containerWidth: number; 32 | _tabContainerWidth: number; 33 | } 34 | export class DefaultTabBar extends React.PureComponent { 35 | static defaultProps = { 36 | animated: true, 37 | tabs: [], 38 | goToTab: () => { }, 39 | activeTab: 0, 40 | page: 5, 41 | tabBarUnderlineStyle: {}, 42 | tabBarBackgroundColor: '#fff', 43 | tabBarActiveTextColor: '', 44 | tabBarInactiveTextColor: '', 45 | tabBarTextStyle: {}, 46 | dynamicTabUnderlineWidth: false, 47 | styles: defaultStyles, 48 | } as PropsType; 49 | 50 | _tabsMeasurements: any[] = []; 51 | _tabContainerMeasurements: any; 52 | _containerMeasurements: any; 53 | _scrollView: ScrollView; 54 | 55 | constructor(props: PropsType) { 56 | super(props); 57 | this.state = { 58 | _leftTabUnderline: new Animated.Value(0), 59 | _widthTabUnderline: new Animated.Value(0), 60 | _containerWidth: WINDOW_WIDTH, 61 | _tabContainerWidth: WINDOW_WIDTH, 62 | }; 63 | } 64 | 65 | componentDidMount() { 66 | this.props.scrollValue.addListener(this.updateView); 67 | } 68 | 69 | updateView = (offset: any) => { 70 | const position = Math.floor(offset.value); 71 | const pageOffset = offset.value % 1; 72 | const tabCount = this.props.tabs.length; 73 | const lastTabPosition = tabCount - 1; 74 | 75 | if (tabCount === 0 || offset.value < 0 || offset.value > lastTabPosition) { 76 | return; 77 | } 78 | 79 | if (this.necessarilyMeasurementsCompleted(position, position === lastTabPosition)) { 80 | this.updateTabPanel(position, pageOffset); 81 | this.updateTabUnderline(position, pageOffset, tabCount); 82 | } 83 | } 84 | 85 | necessarilyMeasurementsCompleted(position: number, isLastTab: boolean) { 86 | return this._tabsMeasurements[position] && 87 | (isLastTab || this._tabsMeasurements[position + 1]) && 88 | this._tabContainerMeasurements && 89 | this._containerMeasurements; 90 | } 91 | 92 | updateTabPanel(position: number, pageOffset: number) { 93 | const containerWidth = this._containerMeasurements.width; 94 | const tabWidth = this._tabsMeasurements[position].width; 95 | const nextTabMeasurements = this._tabsMeasurements[position + 1]; 96 | const nextTabWidth = nextTabMeasurements && nextTabMeasurements.width || 0; 97 | const tabOffset = this._tabsMeasurements[position].left; 98 | const absolutePageOffset = pageOffset * tabWidth; 99 | let newScrollX = tabOffset + absolutePageOffset; 100 | 101 | newScrollX -= (containerWidth - (1 - pageOffset) * tabWidth - pageOffset * nextTabWidth) / 2; 102 | newScrollX = newScrollX >= 0 ? newScrollX : 0; 103 | 104 | if (Platform.OS === 'android') { 105 | this._scrollView.scrollTo({ x: newScrollX, y: 0, animated: false, }); 106 | } else { 107 | const rightBoundScroll = this._tabContainerMeasurements.width - (this._containerMeasurements.width); 108 | newScrollX = newScrollX > rightBoundScroll ? rightBoundScroll : newScrollX; 109 | this._scrollView.scrollTo({ x: newScrollX, y: 0, animated: false, }); 110 | } 111 | } 112 | 113 | updateTabUnderline(position: number, pageOffset: number, tabCount: number) { 114 | const { dynamicTabUnderlineWidth } = this.props; 115 | 116 | if (0 <= position && position <= tabCount - 1) { 117 | if (dynamicTabUnderlineWidth) { 118 | const nowLeft = this._tabsMeasurements[position].left; 119 | const nowRight = this._tabsMeasurements[position].right; 120 | const nextTabLeft = this._tabsMeasurements[position + 1].left; 121 | const nextTabRight = this._tabsMeasurements[position + 1].right; 122 | 123 | const newLineLeft = pageOffset * nextTabLeft + (1 - pageOffset) * nowLeft; 124 | const newLineRight = pageOffset * nextTabRight + (1 - pageOffset) * nowRight; 125 | 126 | this.state._leftTabUnderline.setValue(newLineLeft); 127 | this.state._widthTabUnderline.setValue(newLineRight - newLineLeft); 128 | } else { 129 | const nowLeft = position * this.state._tabContainerWidth / tabCount; 130 | const nextTabLeft = (position + 1) * this.state._tabContainerWidth / tabCount; 131 | const newLineLeft = pageOffset * nextTabLeft + (1 - pageOffset) * nowLeft; 132 | this.state._leftTabUnderline.setValue(newLineLeft); 133 | } 134 | } 135 | } 136 | 137 | onPress = (index: number) => { 138 | const { goToTab, onTabClick, tabs } = this.props; 139 | onTabClick && onTabClick(tabs[index], index); 140 | goToTab && goToTab(index); 141 | } 142 | 143 | renderTab(tab: Models.TabData, index: number, width: number, onLayoutHandler: any) { 144 | const { 145 | tabBarActiveTextColor: activeTextColor, 146 | tabBarInactiveTextColor: inactiveTextColor, 147 | tabBarTextStyle: textStyle, 148 | activeTab, renderTab, 149 | styles = defaultStyles 150 | } = this.props; 151 | const isTabActive = activeTab === index; 152 | const textColor = isTabActive ? 153 | (activeTextColor || styles.TabBar.activeTextColor) : 154 | (inactiveTextColor || styles.TabBar.inactiveTextColor); 155 | 156 | return this.onPress(index)} 162 | onLayout={onLayoutHandler} 163 | > 164 | 169 | { 170 | renderTab ? renderTab(tab) : 171 | 176 | {tab.title} 177 | 178 | } 179 | 180 | ; 181 | } 182 | 183 | measureTab = (page: number, event: any) => { 184 | const { x, width, height, } = event.nativeEvent.layout; 185 | this._tabsMeasurements[page] = { left: x, right: x + width, width, height }; 186 | this.updateView({ value: this.props.scrollValue._value }); 187 | } 188 | 189 | render() { 190 | const { 191 | tabs, page = 1, 192 | tabBarUnderlineStyle, 193 | tabBarBackgroundColor, 194 | styles = defaultStyles, 195 | tabsContainerStyle, 196 | renderUnderline, 197 | keyboardShouldPersistTaps, 198 | } = this.props; 199 | 200 | const tabUnderlineStyle = { 201 | position: 'absolute', 202 | bottom: 0, 203 | ...styles.TabBar.underline, 204 | ...tabBarUnderlineStyle, 205 | }; 206 | 207 | const dynamicTabUnderline = { 208 | left: this.state._leftTabUnderline, 209 | width: this.state._widthTabUnderline, 210 | }; 211 | 212 | const tabWidth = this.state._containerWidth / Math.min(page, tabs.length); 213 | const underlineProps = { 214 | style: { 215 | ...tabUnderlineStyle, 216 | ...dynamicTabUnderline, 217 | } 218 | }; 219 | 220 | return 227 | { this._scrollView = scrollView; }} 229 | horizontal={true} 230 | showsHorizontalScrollIndicator={false} 231 | showsVerticalScrollIndicator={false} 232 | directionalLockEnabled={true} 233 | bounces={false} 234 | scrollsToTop={false} 235 | scrollEnabled={tabs.length > page} 236 | keyboardShouldPersistTaps={keyboardShouldPersistTaps} 237 | > 238 | 246 | { 247 | tabs.map((name, index) => { 248 | let tab = { title: name } as Models.TabData; 249 | if (tabs.length - 1 >= index) { 250 | tab = tabs[index]; 251 | } 252 | return this.renderTab(tab, index, tabWidth, this.measureTab.bind(this, index)); 253 | }) 254 | } 255 | { 256 | renderUnderline ? renderUnderline(underlineProps) : 257 | 258 | } 259 | 260 | 261 | ; 262 | } 263 | 264 | onTabContainerLayout = (e: RN.LayoutChangeEvent) => { 265 | this._tabContainerMeasurements = e.nativeEvent.layout; 266 | let width = this._tabContainerMeasurements.width; 267 | if (width < WINDOW_WIDTH) { 268 | width = WINDOW_WIDTH; 269 | } 270 | this.setState({ _tabContainerWidth: width, }); 271 | if (!this.props.dynamicTabUnderlineWidth) { 272 | this.state._widthTabUnderline.setValue(width / this.props.tabs.length); 273 | } 274 | this.updateView({ value: this.props.scrollValue._value, }); 275 | } 276 | 277 | onContainerLayout = (e: RN.LayoutChangeEvent) => { 278 | this._containerMeasurements = e.nativeEvent.layout; 279 | this.setState({ _containerWidth: this._containerMeasurements.width, }); 280 | this.updateView({ value: this.props.scrollValue._value, }); 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /src/DefaultTabBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Gesture, { IGestureStatus } from 'rc-gesture'; 3 | import { Models } from './Models'; 4 | import { TabBarPropsType } from './PropsType'; 5 | import { setPxStyle, getTransformPropValue, getPxStyle } from './util'; 6 | 7 | export interface PropsType extends TabBarPropsType { 8 | /** default: rmc-tabs-tab-bar */ 9 | prefixCls?: string; 10 | } 11 | 12 | export class StateType { 13 | transform?= ''; 14 | isMoving?= false; 15 | showPrev?= false; 16 | showNext?= false; 17 | } 18 | 19 | export class DefaultTabBar extends React.PureComponent { 20 | static defaultProps = { 21 | prefixCls: 'rmc-tabs-tab-bar', 22 | animated: true, 23 | tabs: [], 24 | goToTab: () => { }, 25 | activeTab: 0, 26 | page: 5, 27 | tabBarUnderlineStyle: {}, 28 | tabBarBackgroundColor: '#fff', 29 | tabBarActiveTextColor: '', 30 | tabBarInactiveTextColor: '', 31 | tabBarTextStyle: {}, 32 | } as PropsType; 33 | 34 | layout: HTMLDivElement; 35 | 36 | onPan = (() => { 37 | let lastOffset: number | string = 0; 38 | let finalOffset = 0; 39 | 40 | const getLastOffset = (isVertical = this.isTabBarVertical()) => { 41 | let offset = +`${lastOffset}`.replace('%', ''); 42 | if (`${lastOffset}`.indexOf('%') >= 0) { 43 | offset /= 100; 44 | offset *= isVertical ? this.layout.clientHeight : this.layout.clientWidth; 45 | } 46 | return offset; 47 | }; 48 | 49 | return { 50 | onPanStart: () => { 51 | this.setState({ isMoving: true }); 52 | }, 53 | 54 | onPanMove: (status: IGestureStatus) => { 55 | if (!status.moveStatus || !this.layout) return; 56 | const isVertical = this.isTabBarVertical(); 57 | let offset = getLastOffset() + (isVertical ? status.moveStatus.y : status.moveStatus.x); 58 | const canScrollOffset = isVertical ? 59 | -this.layout.scrollHeight + this.layout.clientHeight : 60 | -this.layout.scrollWidth + this.layout.clientWidth; 61 | offset = Math.min(offset, 0); 62 | offset = Math.max(offset, canScrollOffset); 63 | setPxStyle(this.layout, offset, 'px', isVertical); 64 | finalOffset = offset; 65 | 66 | this.setState({ 67 | showPrev: -offset > 0, 68 | showNext: offset > canScrollOffset, 69 | }); 70 | }, 71 | 72 | onPanEnd: () => { 73 | const isVertical = this.isTabBarVertical(); 74 | lastOffset = finalOffset; 75 | this.setState({ 76 | isMoving: false, 77 | transform: getPxStyle(finalOffset, 'px', isVertical), 78 | }); 79 | }, 80 | 81 | setCurrentOffset: (offset: number | string) => lastOffset = offset, 82 | }; 83 | })(); 84 | 85 | constructor(props: PropsType) { 86 | super(props); 87 | this.state = { 88 | ...new StateType, 89 | ...this.getTransformByIndex(props), 90 | }; 91 | } 92 | 93 | componentWillReceiveProps(nextProps: PropsType) { 94 | if ( 95 | this.props.activeTab !== nextProps.activeTab || 96 | this.props.tabs !== nextProps.tabs || 97 | this.props.tabs.length !== nextProps.tabs.length 98 | ) { 99 | this.setState({ 100 | ... this.getTransformByIndex(nextProps), 101 | }); 102 | } 103 | } 104 | 105 | getTransformByIndex = (props: PropsType) => { 106 | const { activeTab, tabs, page = 0 } = props; 107 | const isVertical = this.isTabBarVertical(); 108 | 109 | const size = this.getTabSize(page, tabs.length); 110 | const center = page / 2; 111 | let pos = Math.min(activeTab, tabs.length - center - .5); 112 | const skipSize = Math.min(-(pos - center + .5) * size, 0); 113 | this.onPan.setCurrentOffset(`${skipSize}%`); 114 | return { 115 | transform: getPxStyle(skipSize, '%', isVertical), 116 | showPrev: activeTab > center - .5 && tabs.length > page, 117 | showNext: activeTab < tabs.length - center - .5 && tabs.length > page, 118 | }; 119 | } 120 | 121 | onPress = (index: number) => { 122 | const { goToTab, onTabClick, tabs } = this.props; 123 | onTabClick && onTabClick(tabs[index], index); 124 | goToTab && goToTab(index); 125 | } 126 | 127 | isTabBarVertical = (position = this.props.tabBarPosition) => position === 'left' || position === 'right'; 128 | 129 | renderTab = (t: Models.TabData, i: number, size: number, isTabBarVertical: boolean) => { 130 | const { 131 | prefixCls, renderTab, activeTab, 132 | tabBarTextStyle, 133 | tabBarActiveTextColor, 134 | tabBarInactiveTextColor, 135 | instanceId, 136 | } = this.props; 137 | 138 | const textStyle = { ...tabBarTextStyle } as React.CSSProperties; 139 | let cls = `${prefixCls}-tab`; 140 | let ariaSelected = false; 141 | if (activeTab === i) { 142 | cls += ` ${cls}-active`; 143 | ariaSelected = true; 144 | if (tabBarActiveTextColor) { 145 | textStyle.color = tabBarActiveTextColor; 146 | } 147 | } else if (tabBarInactiveTextColor) { 148 | textStyle.color = tabBarInactiveTextColor; 149 | } 150 | 151 | return ; 164 | } 165 | 166 | setContentLayout = (div: HTMLDivElement) => { 167 | this.layout = div; 168 | } 169 | 170 | getTabSize = (page: number, tabLength: number) => 100 / Math.min(page, tabLength); 171 | 172 | render() { 173 | const { 174 | prefixCls, animated, tabs = [], page = 0, activeTab = 0, 175 | tabBarBackgroundColor, tabBarUnderlineStyle, tabBarPosition, 176 | renderUnderline, 177 | } = this.props; 178 | const { isMoving, transform, showNext, showPrev } = this.state; 179 | const isTabBarVertical = this.isTabBarVertical(); 180 | 181 | const needScroll = tabs.length > page; 182 | const size = this.getTabSize(page, tabs.length); 183 | 184 | const Tabs = tabs.map((t, i) => { 185 | return this.renderTab(t, i, size, isTabBarVertical); 186 | }); 187 | 188 | let cls = prefixCls; 189 | if (animated && !isMoving) { 190 | cls += ` ${prefixCls}-animated`; 191 | } 192 | 193 | let style = { 194 | backgroundColor: tabBarBackgroundColor || '', 195 | } as React.CSSProperties; 196 | 197 | let transformStyle = needScroll ? { 198 | ...getTransformPropValue(transform), 199 | } : {}; 200 | 201 | const { setCurrentOffset, ...onPan } = this.onPan; 202 | const underlineProps = { 203 | style: { 204 | ...isTabBarVertical ? { height: `${size}%` } : { width: `${size}%` }, 205 | ...isTabBarVertical ? { top: `${size * activeTab}%` } : { left: `${size * activeTab}%` }, 206 | ...tabBarUnderlineStyle, 207 | }, 208 | className: `${prefixCls}-underline`, 209 | }; 210 | 211 | return
212 | {showPrev &&
} 213 | 216 |
217 | {Tabs} 218 | { 219 | renderUnderline ? renderUnderline(underlineProps) : 220 |
221 | } 222 |
223 |
224 | {showNext &&
} 225 |
; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/Models.ts: -------------------------------------------------------------------------------- 1 | export namespace Models { 2 | 3 | export interface TabData { 4 | key?: string; 5 | title: React.ReactNode; 6 | /** for user's custom extends */ 7 | [key: string]: any; 8 | } 9 | 10 | } -------------------------------------------------------------------------------- /src/PropsType.ts: -------------------------------------------------------------------------------- 1 | import { Models } from './Models'; 2 | 3 | export interface TabBarPropsType { 4 | /** call this function to switch tab */ 5 | goToTab: (index: number) => void; 6 | /** tabs data */ 7 | tabs: Models.TabData[]; 8 | /** current active tab */ 9 | activeTab: number; 10 | /** use animate | default: true */ 11 | animated: boolean; 12 | /** render the tab of tabbar */ 13 | renderTab?: (tab: Models.TabData) => React.ReactNode; 14 | /** render the underline of tabbar */ 15 | renderUnderline?: (style: React.CSSProperties | any) => React.ReactNode; 16 | /** page size of tabbar's tab | default: 5 */ 17 | page?: number; 18 | /** on tab click */ 19 | onTabClick?: (tab: Models.TabData, index: number) => void; 20 | /** tabBar's position | defualt: top */ 21 | tabBarPosition?: 'top' | 'bottom' | 'left' | 'right'; 22 | 23 | // TabBar shortcut settings. 24 | /** tabBar underline style */ 25 | tabBarUnderlineStyle?: React.CSSProperties | any; 26 | /** tabBar background color */ 27 | tabBarBackgroundColor?: string; 28 | /** tabBar active text color */ 29 | tabBarActiveTextColor?: string; 30 | /** tabBar inactive text color */ 31 | tabBarInactiveTextColor?: string; 32 | /** tabBar text style */ 33 | tabBarTextStyle?: React.CSSProperties | any; 34 | 35 | instanceId: number, 36 | } 37 | 38 | export interface PropsType { 39 | /** tabs data */ 40 | tabs: Models.TabData[]; 41 | /** TabBar's position | default: top */ 42 | tabBarPosition?: 'top' | 'bottom' | 'left' | 'right'; 43 | /** render for TabBar */ 44 | renderTabBar?: ((props: TabBarPropsType) => React.ReactNode) | false; 45 | /** initial Tab, index or key */ 46 | initialPage?: number | string; 47 | /** current tab, index or key */ 48 | page?: number | string; 49 | /** whether to switch tabs with swipe gestrue in the content | default: true */ 50 | swipeable?: boolean; 51 | /** use scroll follow pan | default: true */ 52 | useOnPan?: boolean; 53 | /** pre-render nearby # sibling, Infinity: render all the siblings, 0: render current page | default: 1 */ 54 | prerenderingSiblingsNumber?: number; 55 | /** whether to change tabs with animation | default: true */ 56 | animated?: boolean; 57 | /** callback when tab is switched */ 58 | onChange?: (tab: Models.TabData, index: number) => void; 59 | /** on tab click */ 60 | onTabClick?: (tab: Models.TabData, index: number) => void; 61 | /** destroy inactive tab | default: false */ 62 | destroyInactiveTab?: boolean; 63 | /** distance to change tab, width ratio | default: 0.3 */ 64 | distanceToChangeTab?: number; 65 | /** use paged | default: true */ 66 | usePaged?: boolean; 67 | /** tab paging direction | default: horizontal */ 68 | tabDirection?: 'horizontal' | 'vertical'; 69 | 70 | // TabBar shortcut settings. 71 | /** tabBar underline style */ 72 | tabBarUnderlineStyle?: React.CSSProperties | any; 73 | /** tabBar background color */ 74 | tabBarBackgroundColor?: string; 75 | /** tabBar active text color */ 76 | tabBarActiveTextColor?: string; 77 | /** tabBar inactive text color */ 78 | tabBarInactiveTextColor?: string; 79 | /** tabBar text style */ 80 | tabBarTextStyle?: React.CSSProperties | any; 81 | /** can't render content | default: false */ 82 | noRenderContent?: boolean; 83 | /** use left instead of transform | default: false */ 84 | useLeftInsteadTransform?: boolean; 85 | } 86 | -------------------------------------------------------------------------------- /src/Styles.native.tsx: -------------------------------------------------------------------------------- 1 | import * as RN from 'react-native'; 2 | 3 | export default { 4 | Tabs: { 5 | container: { 6 | flex: 1, 7 | } as RN.ViewStyle, 8 | topTabBarSplitLine: { 9 | borderBottomColor: '#eee', 10 | borderBottomWidth: 1, 11 | } as RN.ViewStyle, 12 | bottomTabBarSplitLine: { 13 | borderTopColor: '#eee', 14 | borderTopWidth: 1, 15 | } as RN.ViewStyle, 16 | }, 17 | TabBar: { 18 | container: { 19 | height: 43.5, 20 | }, 21 | tabs: { 22 | flex: 1, 23 | flexDirection: 'row', 24 | height: 43.5, 25 | backgroundColor: '#fff', 26 | justifyContent: 'space-around', 27 | } as RN.ViewStyle, 28 | tab: { 29 | height: 43.5, 30 | alignItems: 'center', 31 | justifyContent: 'center', 32 | paddingTop: 0, 33 | paddingBottom: 0, 34 | paddingRight: 2, 35 | paddingLeft: 2, 36 | flexDirection: 'row' 37 | } as RN.ViewStyle, 38 | underline: { 39 | height: 2, 40 | backgroundColor: '#108ee9', 41 | } as RN.ViewStyle, 42 | textStyle: { 43 | fontSize: 15, 44 | } as RN.ViewStyle, 45 | activeTextColor: '#108ee9', 46 | inactiveTextColor: '#000', 47 | }, 48 | }; -------------------------------------------------------------------------------- /src/TabPane.native.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { default as RN, View } from 'react-native'; 3 | 4 | export interface PropsType { 5 | key?: string; 6 | style?: RN.ViewStyle; 7 | active: boolean; 8 | } 9 | export class TabPane extends React.PureComponent { 10 | 11 | render() { 12 | const { active, ...props } = this.props; 13 | return 14 | {props.children} 15 | ; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/TabPane.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getPxStyle, getTransformPropValue } from './util'; 3 | 4 | export interface PropsType { 5 | key?: string; 6 | className?: string; 7 | role?: string; 8 | active: boolean; 9 | fixX?: boolean; 10 | fixY?: boolean; 11 | } 12 | export class TabPane extends React.PureComponent { 13 | static defaultProps = { 14 | fixX: true, 15 | fixY: true, 16 | }; 17 | layout: HTMLDivElement; 18 | offsetX = 0; 19 | offsetY = 0; 20 | 21 | componentWillReceiveProps(nextProps: PropsType & { children?: React.ReactNode }) { 22 | if (this.props.active !== nextProps.active) { 23 | if (nextProps.active) { 24 | this.offsetX = 0; 25 | this.offsetY = 0; 26 | } else { 27 | this.offsetX = this.layout.scrollLeft; 28 | this.offsetY = this.layout.scrollTop; 29 | } 30 | } 31 | } 32 | 33 | setLayout = (div: HTMLDivElement) => { 34 | this.layout = div; 35 | } 36 | 37 | render() { 38 | const { active, fixX, fixY, ...props } = this.props; 39 | let style = { 40 | ...fixX && this.offsetX ? getTransformPropValue(getPxStyle(-this.offsetX, 'px', false)) : {}, 41 | ...fixY && this.offsetY ? getTransformPropValue(getPxStyle(-this.offsetY, 'px', true)) : {}, 42 | }; 43 | 44 | return
45 | {props.children} 46 |
; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Tabs.base.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PropsType } from './PropsType'; 3 | import { Models } from './Models'; 4 | 5 | export class StateType { 6 | currentTab: number; 7 | } 8 | 9 | let instanceId: number = 0; 10 | 11 | export abstract class Tabs< 12 | P extends PropsType = PropsType, 13 | S extends StateType = StateType 14 | > extends React.PureComponent { 15 | static defaultProps = { 16 | tabBarPosition: 'top', 17 | initialPage: 0, 18 | swipeable: true, 19 | animated: true, 20 | prerenderingSiblingsNumber: 1, 21 | tabs: [], 22 | destroyInactiveTab: false, 23 | usePaged: true, 24 | tabDirection: 'horizontal', 25 | distanceToChangeTab: .3, 26 | } as PropsType; 27 | 28 | protected instanceId: number; 29 | protected prevCurrentTab: number; 30 | protected tabCache: { [index: number]: React.ReactNode } = {}; 31 | 32 | /** compatible for different between react and preact in `setState`. */ 33 | private nextCurrentTab: number; 34 | 35 | constructor(props: P) { 36 | super(props); 37 | 38 | this.state = { 39 | currentTab: this.getTabIndex(props), 40 | } as any; 41 | this.nextCurrentTab = this.state.currentTab; 42 | this.instanceId = instanceId++; 43 | } 44 | 45 | getTabIndex(props: P) { 46 | const { page, initialPage, tabs } = props; 47 | const param = (page !== undefined ? page : initialPage) || 0; 48 | 49 | let index = 0; 50 | if (typeof (param as any) === 'string') { 51 | tabs.forEach((t, i) => { 52 | if (t.key === param) { 53 | index = i; 54 | } 55 | }); 56 | } else { 57 | index = param as number || 0; 58 | } 59 | return index < 0 ? 0 : index; 60 | } 61 | 62 | isTabVertical = (direction = (this.props as PropsType).tabDirection) => direction === 'vertical'; 63 | 64 | shouldRenderTab = (idx: number) => { 65 | const { prerenderingSiblingsNumber = 0 } = this.props as PropsType; 66 | const { currentTab = 0 } = this.state as any as StateType; 67 | 68 | return currentTab - prerenderingSiblingsNumber <= idx && idx <= currentTab + prerenderingSiblingsNumber; 69 | } 70 | 71 | componentWillReceiveProps(nextProps: P) { 72 | if (this.props.page !== nextProps.page && nextProps.page !== undefined) { 73 | this.goToTab(this.getTabIndex(nextProps), true, {}, nextProps); 74 | } 75 | } 76 | 77 | componentDidMount() { 78 | this.prevCurrentTab = this.state.currentTab; 79 | } 80 | 81 | componentDidUpdate() { 82 | this.prevCurrentTab = this.state.currentTab; 83 | } 84 | 85 | getOffsetIndex = (current: number, width: number, threshold = this.props.distanceToChangeTab || 0) => { 86 | const ratio = Math.abs(current / width); 87 | const direction = ratio > this.state.currentTab ? '<' : '>'; 88 | const index = Math.floor(ratio); 89 | switch (direction) { 90 | case '<': 91 | return ratio - index > threshold ? index + 1 : index; 92 | case '>': 93 | return 1 - ratio + index > threshold ? index : index + 1; 94 | default: 95 | return Math.round(ratio); 96 | } 97 | } 98 | 99 | goToTab(index: number, force = false, newState: any = {}, props: P = this.props) { 100 | if (!force && this.nextCurrentTab === index) { 101 | return false; 102 | } 103 | this.nextCurrentTab = index; 104 | const { tabs, onChange } = props as P; 105 | if (index >= 0 && index < tabs.length) { 106 | if (!force) { 107 | onChange && onChange(tabs[index], index); 108 | if (props.page !== undefined) { 109 | return false; 110 | } 111 | } 112 | 113 | this.setState({ 114 | currentTab: index, 115 | ...newState, 116 | }); 117 | } 118 | return true; 119 | } 120 | 121 | tabClickGoToTab(index: number) { 122 | this.goToTab(index); 123 | } 124 | 125 | getTabBarBaseProps() { 126 | const { currentTab } = this.state; 127 | 128 | const { 129 | animated, 130 | onTabClick, 131 | tabBarActiveTextColor, 132 | tabBarBackgroundColor, 133 | tabBarInactiveTextColor, 134 | tabBarPosition, 135 | tabBarTextStyle, 136 | tabBarUnderlineStyle, 137 | tabs, 138 | } = this.props; 139 | return { 140 | activeTab: currentTab, 141 | animated: !!animated, 142 | goToTab: this.tabClickGoToTab.bind(this), 143 | onTabClick, 144 | tabBarActiveTextColor, 145 | tabBarBackgroundColor, 146 | tabBarInactiveTextColor, 147 | tabBarPosition, 148 | tabBarTextStyle, 149 | tabBarUnderlineStyle, 150 | tabs, 151 | instanceId: this.instanceId, 152 | }; 153 | } 154 | 155 | renderTabBar(tabBarProps: any, DefaultTabBar: React.ComponentClass) { 156 | const { renderTabBar } = this.props as P; 157 | if (renderTabBar === false) { 158 | return null; 159 | } else if (renderTabBar) { 160 | // return React.cloneElement(this.props.renderTabBar(props), props); 161 | return renderTabBar(tabBarProps); 162 | } else { 163 | return ; 164 | } 165 | } 166 | 167 | getSubElements = () => { 168 | const { children } = this.props; 169 | let subElements: { [key: string]: React.ReactNode } = {}; 170 | 171 | return (defaultPrefix: string = '$i$-', allPrefix: string = '$ALL$') => { 172 | if (Array.isArray(children)) { 173 | children.forEach((child: any, index) => { 174 | if (child.key) { 175 | subElements[child.key] = child; 176 | } 177 | subElements[`${defaultPrefix}${index}`] = child; 178 | }); 179 | } else if (children) { 180 | subElements[allPrefix] = children; 181 | } 182 | return subElements; 183 | }; 184 | } 185 | 186 | getSubElement( 187 | tab: Models.TabData, 188 | index: number, 189 | subElements: (defaultPrefix: string, allPrefix: string) => { [key: string]: any }, 190 | defaultPrefix: string = '$i$-', 191 | allPrefix: string = '$ALL$' 192 | ) { 193 | const key = tab.key || `${defaultPrefix}${index}`; 194 | const elements = subElements(defaultPrefix, allPrefix); 195 | let component = elements[key] || elements[allPrefix]; 196 | if (component instanceof Function) { 197 | component = component(tab, index); 198 | } 199 | return component || null; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/Tabs.native.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | default as RN, 4 | Dimensions, 5 | View, 6 | Animated, 7 | ScrollView, 8 | } from 'react-native'; 9 | import { PropsType as BasePropsType } from './PropsType'; 10 | import { Tabs as Component, StateType as BaseStateType } from './Tabs.base'; 11 | import { DefaultTabBar } from './DefaultTabBar'; 12 | import Styles from './Styles.native'; 13 | import { TabPane } from './TabPane.native'; 14 | 15 | export interface PropsType extends BasePropsType { 16 | children?: any; 17 | style?: RN.ViewStyle; 18 | styles?: typeof Styles; 19 | keyboardShouldPersistTaps?: boolean; 20 | } 21 | export interface StateType extends BaseStateType { 22 | scrollX: Animated.Value; 23 | scrollValue: Animated.Value; 24 | containerWidth: number; 25 | } 26 | export class Tabs extends Component { 27 | static DefaultTabBar = DefaultTabBar; 28 | 29 | static defaultProps = { 30 | ...Component.defaultProps, 31 | style: {}, 32 | } as PropsType; 33 | 34 | AnimatedScrollView: ScrollView = Animated.createAnimatedComponent(ScrollView); 35 | scrollView: { _component: ScrollView }; 36 | 37 | constructor(props: PropsType) { 38 | super(props); 39 | const width = Dimensions.get('window').width; 40 | 41 | const pageIndex = this.getTabIndex(props); 42 | this.state = { 43 | ...this.state, 44 | scrollX: new Animated.Value(pageIndex * width), 45 | scrollValue: new Animated.Value(pageIndex), 46 | containerWidth: width, 47 | }; 48 | } 49 | 50 | componentDidMount() { 51 | this.state.scrollX.addListener(({ value, }) => { 52 | const scrollValue = value / this.state.containerWidth; 53 | this.state.scrollValue.setValue(scrollValue); 54 | }); 55 | } 56 | 57 | onScroll = (evt?: RN.NativeSyntheticEvent) => { 58 | if (evt) { 59 | Animated.event([{ 60 | nativeEvent: { contentOffset: { x: this.state.scrollX } } 61 | }], )(evt); 62 | } 63 | } 64 | 65 | setScrollView = (sv: any) => { 66 | this.scrollView = sv; 67 | this.scrollTo(this.state.currentTab); 68 | } 69 | 70 | renderContent = (getSubElements = this.getSubElements()) => { 71 | const { tabs, usePaged, destroyInactiveTab, keyboardShouldPersistTaps } = this.props; 72 | const { currentTab = 0, containerWidth = 0 } = this.state; 73 | 74 | const AnimatedScrollView = this.AnimatedScrollView; 75 | return 91 | { 92 | tabs.map((tab, index) => { 93 | const key = tab.key || `tab_${index}`; 94 | 95 | // update tab cache 96 | if (this.shouldRenderTab(index)) { 97 | this.tabCache[index] = this.getSubElement(tab, index, getSubElements); 98 | } else if (destroyInactiveTab) { 99 | this.tabCache[index] = undefined; 100 | } 101 | 102 | return 106 | {this.tabCache[index]} 107 | ; 108 | }) 109 | } 110 | ; 111 | } 112 | 113 | onMomentumScrollEnd = (e: RN.NativeSyntheticEvent) => { 114 | const offsetX = e.nativeEvent.contentOffset.x; 115 | const page = this.getOffsetIndex(offsetX, this.state.containerWidth); 116 | if (this.state.currentTab !== page) { 117 | this.goToTab(page); 118 | } 119 | } 120 | 121 | goToTab(index: number, force: boolean = false, animated = this.props.animated) { 122 | const result = super.goToTab(index, force); 123 | if (result) { 124 | requestAnimationFrame(() => { 125 | this.scrollTo(this.state.currentTab, animated); 126 | }); 127 | } 128 | return result; 129 | } 130 | 131 | handleLayout = (e: RN.LayoutChangeEvent) => { 132 | const { width } = e.nativeEvent.layout; 133 | requestAnimationFrame(() => { 134 | this.scrollTo(this.state.currentTab, false); 135 | }); 136 | if (Math.round(width) !== Math.round(this.state.containerWidth)) { 137 | this.setState({ containerWidth: width }); 138 | } 139 | } 140 | 141 | scrollTo = (index: number, animated = true) => { 142 | const { containerWidth } = this.state; 143 | if (containerWidth) { 144 | const offset = index * containerWidth; 145 | if (this.scrollView && this.scrollView._component) { 146 | const { scrollTo } = this.scrollView._component; 147 | scrollTo && scrollTo({ x: offset, animated }); 148 | } 149 | } 150 | } 151 | 152 | render() { 153 | const { tabBarPosition, styles = Styles, noRenderContent, keyboardShouldPersistTaps } = this.props; 154 | const { scrollX, scrollValue, containerWidth } = this.state; 155 | // let overlayTabs = (this.props.tabBarPosition === 'overlayTop' || this.props.tabBarPosition === 'overlayBottom'); 156 | let overlayTabs = false; 157 | 158 | let tabBarProps = { 159 | ...this.getTabBarBaseProps(), 160 | 161 | keyboardShouldPersistTaps, 162 | scrollX: scrollX, 163 | scrollValue: scrollValue, 164 | containerWidth: containerWidth, 165 | }; 166 | 167 | if (overlayTabs) { 168 | // tabBarProps.style = { 169 | // position: 'absolute', 170 | // left: 0, 171 | // right: 0, 172 | // [this.props.tabBarPosition === 'overlayTop' ? 'top' : 'bottom']: 0, 173 | // }; 174 | } 175 | 176 | const content = [ 177 | 178 | {this.renderTabBar(tabBarProps, DefaultTabBar)} 179 | , 180 | !noRenderContent && this.renderContent() 181 | ]; 182 | 183 | return 186 | {tabBarPosition === 'top' ? content : content.reverse()} 187 | ; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Tabs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Gesture, { IGestureStatus } from 'rc-gesture'; 3 | import { PropsType as BasePropsType, TabBarPropsType } from './PropsType'; 4 | import { TabPane } from './TabPane'; 5 | import { DefaultTabBar } from './DefaultTabBar'; 6 | import { getTransformPropValue, setTransform, setPxStyle } from './util'; 7 | import { Tabs as Component, StateType as BaseStateType } from './Tabs.base'; 8 | 9 | const getPanDirection = (direction: number|undefined) => { 10 | switch (direction) { 11 | case 2: 12 | case 4: 13 | return 'horizontal'; 14 | case 8: 15 | case 16: 16 | return 'vertical'; 17 | default: 18 | return 'none'; 19 | } 20 | }; 21 | export interface PropsType extends BasePropsType { 22 | /** prefix class | default: rmc-tabs */ 23 | prefixCls?: string; 24 | } 25 | export class StateType extends BaseStateType { 26 | contentPos?= ''; 27 | isMoving?= false; 28 | } 29 | export class Tabs extends Component { 30 | static DefaultTabBar = DefaultTabBar; 31 | 32 | static defaultProps = { 33 | ...Component.defaultProps, 34 | prefixCls: 'rmc-tabs', 35 | useOnPan: true, 36 | } as PropsType; 37 | 38 | layout: HTMLDivElement; 39 | 40 | onPan = (() => { 41 | let lastOffset: number | string = 0; 42 | let finalOffset = 0; 43 | let panDirection: string; 44 | 45 | const getLastOffset = (isVertical = this.isTabVertical()) => { 46 | let offset = +`${lastOffset}`.replace('%', ''); 47 | if (`${lastOffset}`.indexOf('%') >= 0) { 48 | offset /= 100; 49 | offset *= isVertical ? this.layout.clientHeight : this.layout.clientWidth; 50 | } 51 | return offset; 52 | }; 53 | 54 | return { 55 | onPanStart: (status: IGestureStatus) => { 56 | if (!this.props.swipeable || !this.props.animated) return; 57 | panDirection = getPanDirection(status.direction); 58 | this.setState({ 59 | isMoving: true, 60 | }); 61 | }, 62 | 63 | onPanMove: (status: IGestureStatus) => { 64 | const { swipeable, animated, useLeftInsteadTransform } = this.props; 65 | if (!status.moveStatus || !this.layout || !swipeable || !animated) return; 66 | const isVertical = this.isTabVertical(); 67 | let offset = getLastOffset(); 68 | if (isVertical) { 69 | offset += panDirection === 'horizontal' ? 0 : status.moveStatus.y; 70 | } else { 71 | offset += panDirection === 'vertical' ? 0 : status.moveStatus.x; 72 | } 73 | const canScrollOffset = isVertical ? 74 | -this.layout.scrollHeight + this.layout.clientHeight : 75 | -this.layout.scrollWidth + this.layout.clientWidth; 76 | offset = Math.min(offset, 0); 77 | offset = Math.max(offset, canScrollOffset); 78 | setPxStyle(this.layout, offset, 'px', isVertical, useLeftInsteadTransform); 79 | finalOffset = offset; 80 | }, 81 | 82 | onPanEnd: () => { 83 | if (!this.props.swipeable || !this.props.animated) return; 84 | lastOffset = finalOffset; 85 | const isVertical = this.isTabVertical(); 86 | const offsetIndex = this.getOffsetIndex(finalOffset, isVertical ? this.layout.clientHeight : this.layout.clientWidth); 87 | this.setState({ 88 | isMoving: false, 89 | }); 90 | if (offsetIndex === this.state.currentTab) { 91 | if (this.props.usePaged) { 92 | setTransform( 93 | this.layout.style, 94 | this.getContentPosByIndex( 95 | offsetIndex, 96 | this.isTabVertical(), 97 | this.props.useLeftInsteadTransform 98 | ) 99 | ); 100 | } 101 | } else { 102 | this.goToTab(offsetIndex); 103 | } 104 | }, 105 | 106 | setCurrentOffset: (offset: number | string) => lastOffset = offset, 107 | }; 108 | })(); 109 | 110 | constructor(props: PropsType) { 111 | super(props); 112 | this.state = { 113 | ...this.state, 114 | ...new StateType, 115 | contentPos: this.getContentPosByIndex( 116 | this.getTabIndex(props), 117 | this.isTabVertical(props.tabDirection), 118 | props.useLeftInsteadTransform 119 | ), 120 | }; 121 | } 122 | 123 | goToTab(index: number, force = false, usePaged = this.props.usePaged, props = this.props) { 124 | const { tabDirection, useLeftInsteadTransform } = props; 125 | let newState = {}; 126 | if (usePaged) { 127 | newState = { 128 | contentPos: this.getContentPosByIndex( 129 | index, 130 | this.isTabVertical(tabDirection), 131 | useLeftInsteadTransform 132 | ), 133 | }; 134 | } 135 | return super.goToTab(index, force, newState, props); 136 | } 137 | 138 | tabClickGoToTab(index: number) { 139 | this.goToTab(index, false, true); 140 | } 141 | 142 | getContentPosByIndex(index: number, isVertical: boolean, useLeft = false) { 143 | const value = `${-index * 100}%`; 144 | this.onPan.setCurrentOffset(value); 145 | if (useLeft) { 146 | return `${value}`; 147 | } else { 148 | const translate = isVertical ? `0px, ${value}` : `${value}, 0px`; 149 | // fix: content overlay TabBar on iOS 10. ( 0px -> 1px ) 150 | return `translate3d(${translate}, 1px)`; 151 | } 152 | } 153 | 154 | onSwipe = (status: IGestureStatus) => { 155 | const { tabBarPosition, swipeable, usePaged } = this.props; 156 | if (!swipeable || !usePaged || this.isTabVertical()) return; 157 | // DIRECTION_NONE 1 158 | // DIRECTION_LEFT 2 159 | // DIRECTION_RIGHT 4 160 | // DIRECTION_UP 8 161 | // DIRECTION_DOWN 16 162 | // DIRECTION_HORIZONTAL 6 163 | // DIRECTION_VERTICAL 24 164 | // DIRECTION_ALL 30 165 | switch (tabBarPosition) { 166 | case 'top': 167 | case 'bottom': 168 | switch (status.direction) { 169 | case 2: 170 | if (!this.isTabVertical()) { 171 | this.goToTab(this.prevCurrentTab + 1); 172 | } 173 | case 8: 174 | if (this.isTabVertical()) { 175 | this.goToTab(this.prevCurrentTab + 1); 176 | } 177 | break; 178 | case 4: 179 | if (!this.isTabVertical()) { 180 | this.goToTab(this.prevCurrentTab - 1); 181 | } 182 | case 16: 183 | if (this.isTabVertical()) { 184 | this.goToTab(this.prevCurrentTab - 1); 185 | } 186 | break; 187 | } 188 | break; 189 | } 190 | } 191 | 192 | setContentLayout = (div: HTMLDivElement) => { 193 | this.layout = div; 194 | } 195 | 196 | renderContent(getSubElements = this.getSubElements()) { 197 | const { prefixCls, tabs, animated, destroyInactiveTab, useLeftInsteadTransform } = this.props; 198 | const { currentTab, isMoving, contentPos } = this.state; 199 | const isTabVertical = this.isTabVertical(); 200 | 201 | let contentCls = `${prefixCls}-content-wrap`; 202 | if (animated && !isMoving) { 203 | contentCls += ` ${contentCls}-animated`; 204 | } 205 | const contentStyle: React.CSSProperties = animated ? ( 206 | useLeftInsteadTransform ? { 207 | position: 'relative', 208 | ...this.isTabVertical() ? { top: contentPos, } : { left: contentPos, } 209 | } : getTransformPropValue(contentPos) 210 | ) : { 211 | position: 'relative', 212 | ...this.isTabVertical() ? { top: `${-currentTab * 100}%`, } : { left: `${-currentTab * 100}%`, } 213 | }; 214 | const { instanceId } = this.getTabBarBaseProps(); 215 | 216 | return
217 | { 218 | tabs.map((tab, index) => { 219 | let cls = `${prefixCls}-pane-wrap`; 220 | if (this.state.currentTab === index) { 221 | cls += ` ${cls}-active`; 222 | } else { 223 | cls += ` ${cls}-inactive`; 224 | } 225 | 226 | const key = tab.key || `tab_${index}`; 227 | 228 | // update tab cache 229 | if (this.shouldRenderTab(index)) { 230 | this.tabCache[index] = this.getSubElement(tab, index, getSubElements); 231 | } else if (destroyInactiveTab) { 232 | this.tabCache[index] = undefined; 233 | } 234 | 235 | return 242 | {this.tabCache[index]} 243 | ; 244 | }) 245 | } 246 |
; 247 | } 248 | 249 | render() { 250 | const { prefixCls, tabBarPosition, tabDirection, useOnPan, noRenderContent } = this.props; 251 | const isTabVertical = this.isTabVertical(tabDirection); 252 | 253 | const tabBarProps: TabBarPropsType = { 254 | ...this.getTabBarBaseProps(), 255 | }; 256 | 257 | const onPan = !isTabVertical && useOnPan ? this.onPan : {}; 258 | 259 | const content = [ 260 |
261 | {this.renderTabBar(tabBarProps, DefaultTabBar)} 262 |
, 263 | !noRenderContent && 267 | {this.renderContent()} 268 | , 269 | ]; 270 | 271 | return
272 | { 273 | tabBarPosition === 'top' || tabBarPosition === 'left' ? content : content.reverse() 274 | } 275 |
; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/index.native.tsx: -------------------------------------------------------------------------------- 1 | export { Models } from './Models'; 2 | export { PropsType as PropsType, TabBarPropsType } from './PropsType'; 3 | export { Tabs } from './Tabs.native'; 4 | export { DefaultTabBar } from './DefaultTabBar.native'; -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { Models } from './Models'; 2 | export { PropsType as PropsType, TabBarPropsType } from './PropsType'; 3 | export { Tabs } from './Tabs'; 4 | export { DefaultTabBar } from './DefaultTabBar'; -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | export function getTransformPropValue(v: any) { 2 | return { 3 | transform: v, 4 | WebkitTransform: v, 5 | MozTransform: v, 6 | }; 7 | } 8 | 9 | export function getPxStyle(value: number | string, unit = 'px', vertical: boolean = false) { 10 | value = vertical ? `0px, ${value}${unit}, 0px` : `${value}${unit}, 0px, 0px`; 11 | return `translate3d(${value})`; 12 | } 13 | 14 | export function setPxStyle(el: HTMLElement, value: number | string, unit = 'px', vertical: boolean = false, useLeft = false) { 15 | if (useLeft) { 16 | if (vertical) { 17 | el.style.top = `${value}${unit}`; 18 | } else { 19 | el.style.left = `${value}${unit}`; 20 | } 21 | } else { 22 | setTransform(el.style, getPxStyle(value, unit, vertical)); 23 | } 24 | } 25 | 26 | export function setTransform(style: any, v: any) { 27 | style.transform = v; 28 | style.webkitTransform = v; 29 | style.mozTransform = v; 30 | } 31 | 32 | -------------------------------------------------------------------------------- /tests/Tabs.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'enzyme'; 3 | import renderToJson from 'enzyme-to-json'; 4 | import { Tabs, Models } from '../src'; 5 | 6 | const tabData = [ 7 | { key: 't1', title: 't1' }, 8 | { key: 't2', title: 't2' }, 9 | { key: 't3', title: 't3' }, 10 | { key: 't4', title: 't4' }, 11 | { key: 't5', title: 't5' }, 12 | ]; 13 | const tabDataWithoutKey = [ 14 | { title: 't1' }, 15 | { title: 't2' }, 16 | { title: 't3' }, 17 | { title: 't4' }, 18 | { title: 't5' }, 19 | ]; 20 | 21 | const renderContent = () => { 22 | const pStyle = { margin: 0, padding: 10 } as React.CSSProperties; 23 | 24 | return [ 25 |
26 |

tab 1 1

27 |

tab 1 2

28 |

tab 1 3

29 |

tab 1 4

30 |
, 31 |
32 |

tab 2 1

33 |

tab 2 2

34 |

tab 2 3

35 |

tab 2 4

36 |

tab 2 5

37 |
, 38 |
tab 3
, 39 |
tab 4
, 40 |
tab 5
, 41 | ]; 42 | }; 43 | 44 | describe('basic', () => { 45 | it('base.', () => { 46 | const wrapper = render( 47 | 49 | {renderContent()} 50 | 51 | ); 52 | expect(renderToJson(wrapper)).toMatchSnapshot(); 53 | }); 54 | 55 | it('no animation.', () => { 56 | const wrapper = render( 57 | 60 | {renderContent()} 61 | 62 | ); 63 | expect(renderToJson(wrapper)).toMatchSnapshot(); 64 | }); 65 | 66 | it('inital tab.', () => { 67 | const wrapper = render( 68 | 71 | {renderContent()} 72 | 73 | ); 74 | expect(renderToJson(wrapper)).toMatchSnapshot(); 75 | }); 76 | 77 | it('tabBarPosition.', () => { 78 | const wrapper = render( 79 | 82 | {renderContent()} 83 | 84 | ); 85 | expect(renderToJson(wrapper)).toMatchSnapshot(); 86 | }); 87 | 88 | it('prerenderingSiblingsNumber.', () => { 89 | const wrapper = render( 90 | 93 | {renderContent()} 94 | 95 | ); 96 | expect(renderToJson(wrapper)).toMatchSnapshot(); 97 | }); 98 | 99 | it('destroyInactiveTab.', () => { 100 | const wrapper = render( 101 | 106 | {renderContent()} 107 | 108 | ); 109 | expect(renderToJson(wrapper)).toMatchSnapshot(); 110 | }); 111 | 112 | it('vertical.', () => { 113 | const wrapper = render( 114 | 118 | {renderContent()} 119 | 120 | ); 121 | expect(renderToJson(wrapper)).toMatchSnapshot(); 122 | }); 123 | 124 | it('renderTabBar renderTab.', () => { 125 | const wrapper = render( 126 | { 130 | if (tab.key === 't2') { 131 | return
132 | {tab.title} 133 |
144 |
; 145 | } 146 | return
{tab.title}
; 147 | }} 148 | />} 149 | > 150 | {renderContent()} 151 |
152 | ); 153 | expect(renderToJson(wrapper)).toMatchSnapshot(); 154 | }); 155 | }); 156 | 157 | describe('single content.', () => { 158 | it('function', () => { 159 | const wrapper = render( 160 | 162 | { 163 | (tab: Models.TabData, index: number) => 164 |
165 |

single content

166 |

{JSON.stringify({ index, tab })}

167 |
168 | } 169 |
170 | ); 171 | expect(renderToJson(wrapper)).toMatchSnapshot(); 172 | }); 173 | 174 | it('base', () => { 175 | const wrapper = render( 176 | 178 |
179 |

single content

180 |
181 |
182 | ); 183 | expect(renderToJson(wrapper)).toMatchSnapshot(); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /tests/__snapshots__/Tabs.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`basic base. 1`] = ` 4 |
7 |
10 |
14 |
19 | 28 | 37 | 46 | 55 | 64 |
68 |
69 |
70 |
71 |
75 |
81 |
84 |

87 | tab 1 1 88 |

89 |

92 | tab 1 2 93 |

94 |

97 | tab 1 3 98 |

99 |

102 | tab 1 4 103 |

104 |
105 |
106 | 142 |