├── .babelrc ├── .gitignore ├── .npmignore ├── Example.tsx ├── README.md ├── ScrollTabView.tsx ├── components ├── DefaultTabBar.tsx ├── SceneComponent.tsx ├── ScrollableTabBar.js ├── StaticContainer.tsx └── TabView.tsx ├── index.ts └── package.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["module:metro-react-native-babel-preset"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node.js 2 | # 3 | node_modules/ 4 | npm-debug.log 5 | yarn-error.log 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .babelrc 3 | .gitignore 4 | .npmignore -------------------------------------------------------------------------------- /Example.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import { StyleSheet, View, Text } from 'react-native'; 3 | import { ScrollView, FlatList } from './components/TabView'; 4 | import ScrollTabView from './ScrollTabView'; 5 | 6 | function TabView1(props) { 7 | const data = new Array(200).fill({}); 8 | const renderItem = ({ item, index }) => { 9 | return ( 10 | 11 | {'tab1 => ' + index} 12 | 13 | ); 14 | }; 15 | return ; 16 | } 17 | 18 | function TabView2(props) { 19 | const data = new Array(100).fill({}); 20 | const renderItem = ({ item, index }) => { 21 | return ( 22 | 23 | {'tab2 => ' + index} 24 | 25 | ); 26 | }; 27 | return ; 28 | } 29 | 30 | function TabView3(props) { 31 | const data = new Array(20).fill({}); 32 | return ( 33 | 34 | {data.map((o, i) => ( 35 | 36 | {'tab3 => ' + i} 37 | 38 | ))} 39 | 40 | ); 41 | } 42 | 43 | export default function Example() { 44 | const [headerHeight, setHeaderHeight] = useState(200); 45 | const headerOnLayout = useCallback((event: any) => { 46 | const { height } = event.nativeEvent.layout; 47 | setHeaderHeight(height); 48 | }, []); 49 | 50 | const _renderScrollHeader = useCallback(() => { 51 | const data = new Array(10).fill({}); 52 | return ( 53 | 54 | 55 | {data.map((o, i) => ( 56 | 57 | {'header => ' + i} 58 | 59 | ))} 60 | 61 | 62 | ); 63 | }, []); 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | 76 | const styles = StyleSheet.create({ 77 | container: { 78 | flex: 1, 79 | }, 80 | }); 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-scroll-head-tab-view 2 | Based on react-native-scrollable-tab-view, the head view slides with each tab, and the TabBar reaches a certain sliding distance to attach to the top 3 | 4 | ![demo](https://i.postimg.cc/vZbK17d8/ezgif-7-117c0884083a-1.gif) 5 | 6 | # Add to project 7 | ```shell 8 | // note: skip this step if scrollable-tab-view is installed 9 | yarn add @react-native-community/viewpager; 10 | ``` 11 | 12 | ```shell 13 | yarn add react-native-scroll-head-tab-view; 14 | ``` 15 | 16 | # Basic usage 17 | ``` 18 | import React, { useState, useCallback } from 'react'; 19 | import { StyleSheet, View, Text } from 'react-native'; 20 | import { ScrollTabView, ScrollView, FlatList } from 'react-native-scroll-head-tab-view'; 21 | 22 | function TabView1(props) { 23 | const data = new Array(200).fill({}); 24 | const renderItem = ({ item, index }) => { 25 | return ( 26 | 27 | {'tab1 => ' + index} 28 | 29 | ); 30 | }; 31 | return ; 32 | } 33 | 34 | function TabView2(props) { 35 | const data = new Array(100).fill({}); 36 | const renderItem = ({ item, index }) => { 37 | return ( 38 | 39 | {'tab2 => ' + index} 40 | 41 | ); 42 | }; 43 | return ; 44 | } 45 | 46 | function TabView3(props) { 47 | const data = new Array(20).fill({}); 48 | return ( 49 | 50 | {data.map((o, i) => ( 51 | 52 | {'tab3 => ' + i} 53 | 54 | ))} 55 | 56 | ); 57 | } 58 | 59 | export default function Example() { 60 | const [headerHeight, setHeaderHeight] = useState(200); 61 | const headerOnLayout = useCallback((event: any) => { 62 | const { height } = event.nativeEvent.layout; 63 | setHeaderHeight(height); 64 | }, []); 65 | 66 | const _renderScrollHeader = useCallback(() => { 67 | const data = new Array(10).fill({}); 68 | return ( 69 | 70 | 71 | {data.map((o, i) => ( 72 | 73 | {'header => ' + i} 74 | 75 | ))} 76 | 77 | 78 | ); 79 | }, []); 80 | 81 | return ( 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | const styles = StyleSheet.create({ 93 | container: { 94 | flex: 1, 95 | }, 96 | }); 97 | ``` 98 | -------------------------------------------------------------------------------- /ScrollTabView.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, RefObject } from 'react'; 2 | import { 3 | View, 4 | Animated, 5 | ScrollView, 6 | Dimensions, 7 | Platform, 8 | StyleSheet, 9 | ViewStyle, 10 | TextStyle, 11 | InteractionManager, 12 | } from 'react-native'; 13 | import ViewPager from '@react-native-community/viewpager'; 14 | import SceneComponent from './components/SceneComponent'; 15 | import DefaultTabBar from './components/DefaultTabBar'; 16 | 17 | const AnimatedViewPagerAndroid = Platform.OS === 'android' ? Animated.createAnimatedComponent(ViewPager) : undefined; 18 | 19 | interface Props { 20 | tabBarPosition?: 'top' | 'bottom' | 'overlayTop' | 'overlayBottom'; 21 | initialPage?: number; 22 | page?: number; 23 | onChangeTab?: ({ i: number, ref: RefObject, from: number }) => void; 24 | onScroll?: (o: number) => void; 25 | onContentScroll?: (o: Animated.value) => void; //tab容器滑动改变offsetY值,触发该事件 26 | renderTabBar?: (p: any) => React.ReactElement; 27 | renderScrollHeader?: (p: any) => React.ReactElement; 28 | insetValue?: number; // 状态栏的高度,也就是TabBar距离顶部状态栏的距离 29 | headerHeight?: number; 30 | tabBarStyle?: ViewStyle; 31 | tabBarPaddingInset?: number; 32 | tabBarTabWidth?: number; 33 | tabBarTabUnderlineWidth?: number; 34 | tabBarUnderlineStyle?: ViewStyle; 35 | tabBarBackgroundColor?: string; 36 | tabBarActiveTextColor?: string; 37 | tabBarInactiveTextColor?: string; 38 | tabBarTextStyle?: TextStyle; 39 | style?: ViewStyle; 40 | contentProps?: object; 41 | scrollWithoutAnimation?: boolean; 42 | locked?: boolean; 43 | prerenderingSiblingsNumber?: number; 44 | } 45 | 46 | const DEFAULT_PROPS = { 47 | tabBarPosition: 'top', 48 | initialPage: 0, 49 | insetValue: 0, 50 | page: -1, 51 | onChangeTab: () => {}, 52 | onScroll: () => {}, 53 | contentProps: {}, 54 | scrollWithoutAnimation: false, 55 | locked: false, 56 | prerenderingSiblingsNumber: 0, 57 | }; 58 | 59 | const IS_IOS = Platform.OS === 'ios'; 60 | const dw = Dimensions.get('window').width; 61 | const dh = Dimensions.get('window').height; 62 | 63 | export default class ScrollableTabView extends Component { 64 | static defaultProps = DEFAULT_PROPS; 65 | 66 | constructor(props: Props) { 67 | super(props); 68 | const containerWidth = dw; 69 | const containerHeight = dh; 70 | const containerOffsetY = new Animated.Value(0); 71 | let scrollValue; 72 | let scrollXIOS; 73 | let positionAndroid; 74 | let offsetAndroid; 75 | 76 | if (IS_IOS) { 77 | scrollXIOS = new Animated.Value(props.initialPage * containerWidth); 78 | const containerWidthAnimatedValue = new Animated.Value(containerWidth); 79 | // Need to call __makeNative manually to avoid a native animated bug. See 80 | // https://github.com/facebook/react-native/pull/14435 81 | containerWidthAnimatedValue.__makeNative(); 82 | scrollValue = Animated.divide(scrollXIOS, containerWidthAnimatedValue); 83 | 84 | const callListeners = this._polyfillAnimatedValue(scrollValue); 85 | scrollXIOS.addListener(({ value }) => callListeners(value / containerWidth)); 86 | } else { 87 | positionAndroid = new Animated.Value(props.initialPage); 88 | offsetAndroid = new Animated.Value(0); 89 | scrollValue = Animated.add(positionAndroid, offsetAndroid); 90 | 91 | const callListeners = this._polyfillAnimatedValue(scrollValue); 92 | let positionAndroidValue = props.initialPage; 93 | let offsetAndroidValue = 0; 94 | positionAndroid.addListener(({ value }) => { 95 | positionAndroidValue = value; 96 | callListeners(positionAndroidValue + offsetAndroidValue); 97 | }); 98 | offsetAndroid.addListener(({ value }) => { 99 | offsetAndroidValue = value; 100 | callListeners(positionAndroidValue + offsetAndroidValue); 101 | }); 102 | } 103 | if (props.onContentScroll) { 104 | containerOffsetY.addListener(props.onContentScroll); 105 | } 106 | this.state = { 107 | currentPage: props.initialPage, 108 | scrollValue, 109 | scrollXIOS, 110 | positionAndroid, 111 | offsetAndroid, 112 | containerOffsetY, 113 | tabBarHeight: 0, 114 | containerWidth, 115 | containerHeight, 116 | sceneKeys: this.newSceneKeys({ currentPage: props.initialPage }), 117 | }; 118 | } 119 | 120 | componentDidMount() { 121 | // Fix(ViewPager version > 5.X)initialPage does not work on Android with Hermes 122 | if (!IS_IOS && this.props.initialPage >= 0) { 123 | InteractionManager.runAfterInteractions(() => { 124 | this.goToPage(this.props.initialPage); 125 | }); 126 | } 127 | } 128 | 129 | componentDidUpdate(prevProps) { 130 | if (this.props.children !== prevProps.children) { 131 | this.updateSceneKeys({ page: this.state.currentPage, children: this.props.children }); 132 | } 133 | 134 | if (this.props.page >= 0 && this.props.page !== this.state.currentPage) { 135 | this.goToPage(this.props.page); 136 | } 137 | } 138 | 139 | componentWillUnmount() { 140 | if (IS_IOS) { 141 | this.state.scrollXIOS.removeAllListeners(); 142 | } else { 143 | this.state.positionAndroid.removeAllListeners(); 144 | this.state.offsetAndroid.removeAllListeners(); 145 | } 146 | if (this.props.onContentScroll) { 147 | this.state.containerOffsetY.removeListener(this.props.onContentScroll); 148 | } 149 | } 150 | 151 | goToPage = (pageNumber) => { 152 | if (IS_IOS) { 153 | const offset = pageNumber * this.state.containerWidth; 154 | if (this.scrollView) { 155 | this.scrollView.scrollTo({ x: offset, y: 0, animated: !this.props.scrollWithoutAnimation }); 156 | } 157 | } else { 158 | if (this.scrollView) { 159 | if (this.props.scrollWithoutAnimation) { 160 | this.scrollView.setPageWithoutAnimation(pageNumber); 161 | } else { 162 | this.scrollView.setPage(pageNumber); 163 | } 164 | } 165 | } 166 | 167 | const currentPage = this.state.currentPage; 168 | this.updateSceneKeys({ 169 | page: pageNumber, 170 | callback: this._onChangeTab.bind(this, currentPage, pageNumber), 171 | }); 172 | }; 173 | 174 | _tabBarOnLayout = (event) => { 175 | this.setState({ tabBarHeight: event.nativeEvent.layout.height }); 176 | }; 177 | 178 | renderTabBar = ({ style, ...tabBarProps }) => { 179 | const { containerOffsetY } = this.state; 180 | const { insetValue, headerHeight } = this.props; 181 | const tabBarStyle = { zIndex: 100 }; 182 | if (this.props.renderScrollHeader) { 183 | tabBarStyle.transform = [ 184 | { 185 | translateY: containerOffsetY.interpolate({ 186 | inputRange: [0, headerHeight - insetValue], 187 | outputRange: [headerHeight, insetValue], 188 | extrapolateRight: 'clamp', 189 | }), 190 | }, 191 | ]; 192 | } 193 | const tabBarContent = this.props.renderTabBar ? ( 194 | this.props.renderTabBar(tabBarProps) 195 | ) : ( 196 | 197 | ); 198 | return ( 199 | 200 | {tabBarContent} 201 | 202 | ); 203 | }; 204 | 205 | // 渲染可滑动头部 206 | renderHeader = () => { 207 | const { renderScrollHeader, headerHeight } = this.props; 208 | const { containerOffsetY, containerWidth } = this.state; 209 | if (!renderScrollHeader) return null; 210 | 211 | return ( 212 | 227 | {renderScrollHeader()} 228 | 229 | ); 230 | }; 231 | 232 | updateSceneKeys = ({ page, children = this.props.children, callback = () => {} }) => { 233 | let newKeys = this.newSceneKeys({ previousKeys: this.state.sceneKeys, currentPage: page, children }); 234 | this.setState({ currentPage: page, sceneKeys: newKeys }, callback); 235 | }; 236 | 237 | newSceneKeys = ({ previousKeys = [], currentPage = 0, children = this.props.children }) => { 238 | let newKeys = []; 239 | this._children(children).forEach((child, idx) => { 240 | let key = this._makeSceneKey(child, idx); 241 | if (this._keyExists(previousKeys, key) || this._shouldRenderSceneKey(idx, currentPage)) { 242 | newKeys.push(key); 243 | } 244 | }); 245 | return newKeys; 246 | }; 247 | 248 | // Animated.add and Animated.divide do not currently support listeners so 249 | // we have to polyfill it here since a lot of code depends on being able 250 | // to add a listener to `scrollValue`. See https://github.com/facebook/react-native/pull/12620. 251 | _polyfillAnimatedValue = (animatedValue) => { 252 | const listeners = new Set(); 253 | const addListener = (listener) => { 254 | listeners.add(listener); 255 | }; 256 | 257 | const removeListener = (listener) => { 258 | listeners.delete(listener); 259 | }; 260 | 261 | const removeAllListeners = () => { 262 | listeners.clear(); 263 | }; 264 | 265 | animatedValue.addListener = addListener; 266 | animatedValue.removeListener = removeListener; 267 | animatedValue.removeAllListeners = removeAllListeners; 268 | 269 | return (value) => listeners.forEach((listener) => listener({ value })); 270 | }; 271 | 272 | _shouldRenderSceneKey = (idx, currentPageKey) => { 273 | let numOfSibling = this.props.prerenderingSiblingsNumber; 274 | return idx < currentPageKey + numOfSibling + 1 && idx > currentPageKey - numOfSibling - 1; 275 | }; 276 | 277 | _keyExists = (sceneKeys, key) => { 278 | return sceneKeys.find((sceneKey) => key === sceneKey); 279 | }; 280 | 281 | _makeSceneKey = (child, idx) => { 282 | return child.props.tabLabel + '_' + idx; 283 | }; 284 | 285 | renderScrollableContent = () => { 286 | if (IS_IOS) { 287 | const scenes = this._composeScenes(); 288 | return ( 289 | { 295 | this.scrollView = scrollView; 296 | }} 297 | onScroll={Animated.event([{ nativeEvent: { contentOffset: { x: this.state.scrollXIOS } } }], { 298 | useNativeDriver: true, 299 | listener: this._onScroll, 300 | })} 301 | onMomentumScrollBegin={this._onMomentumScrollBeginAndEnd} 302 | onMomentumScrollEnd={this._onMomentumScrollBeginAndEnd} 303 | scrollEventThrottle={16} 304 | scrollsToTop={false} 305 | showsHorizontalScrollIndicator={false} 306 | scrollEnabled={!this.props.locked} 307 | directionalLockEnabled 308 | alwaysBounceVertical={false} 309 | keyboardDismissMode="on-drag" 310 | {...this.props.contentProps}> 311 | {scenes} 312 | 313 | ); 314 | } else { 315 | const scenes = this._composeScenes(); 316 | return ( 317 | { 339 | this.scrollView = scrollView; 340 | }} 341 | {...this.props.contentProps}> 342 | {scenes} 343 | 344 | ); 345 | } 346 | }; 347 | 348 | _creatSceneParams = (index) => { 349 | const { renderScrollHeader, headerHeight, insetValue } = this.props; 350 | const { currentPage, containerOffsetY, containerHeight, tabBarHeight } = this.state; 351 | if (!renderScrollHeader) { 352 | return { isActive: currentPage == index }; 353 | } 354 | const params = { index, isActive: currentPage == index, containerOffsetY, headerHeight }; 355 | params.sceneHeight = headerHeight + containerHeight - tabBarHeight - insetValue; 356 | return params; 357 | }; 358 | 359 | _composeScenes = () => { 360 | return this._children().map((child, idx) => { 361 | let key = this._makeSceneKey(child, idx); 362 | // 如果有scrollHeader,标签页必须保持update状态 363 | const showUpdate = this.props.renderScrollHeader 364 | ? true 365 | : this._shouldRenderSceneKey(idx, this.state.currentPage); 366 | return ( 367 | 368 | {this._keyExists(this.state.sceneKeys, key) ? ( 369 | React.cloneElement(child, this._creatSceneParams(idx)) 370 | ) : ( 371 | 372 | )} 373 | 374 | ); 375 | }); 376 | }; 377 | 378 | _onMomentumScrollBeginAndEnd = (e) => { 379 | const offsetX = e.nativeEvent.contentOffset.x; 380 | const page = Math.round(offsetX / this.state.containerWidth); 381 | if (this.state.currentPage !== page) { 382 | this._updateSelectedPage(page); 383 | } 384 | }; 385 | 386 | _updateSelectedPage = (nextPage) => { 387 | let localNextPage = nextPage; 388 | if (typeof localNextPage === 'object') { 389 | localNextPage = nextPage.nativeEvent.position; 390 | } 391 | 392 | const currentPage = this.state.currentPage; 393 | this.updateSceneKeys({ 394 | page: localNextPage, 395 | callback: this._onChangeTab.bind(this, currentPage, localNextPage), 396 | }); 397 | }; 398 | 399 | _onChangeTab = (prevPage, currentPage) => { 400 | this.props.onChangeTab({ 401 | i: currentPage, 402 | ref: this._children()[currentPage], 403 | from: prevPage, 404 | }); 405 | }; 406 | 407 | _onScroll = (e) => { 408 | if (IS_IOS) { 409 | const offsetX = e.nativeEvent.contentOffset.x; 410 | if (offsetX === 0 && !this.scrollOnMountCalled) { 411 | this.scrollOnMountCalled = true; 412 | } else { 413 | this.props.onScroll(offsetX / this.state.containerWidth); 414 | } 415 | } else { 416 | const { position, offset } = e.nativeEvent; 417 | this.props.onScroll(position + offset); 418 | } 419 | }; 420 | 421 | _handleLayout = (e) => { 422 | const { width, height } = e.nativeEvent.layout; 423 | 424 | if (width && width > 0 && Math.round(width) !== Math.round(this.state.containerWidth)) { 425 | if (IS_IOS) { 426 | const containerWidthAnimatedValue = new Animated.Value(width); 427 | // Need to call __makeNative manually to avoid a native animated bug. See 428 | // https://github.com/facebook/react-native/pull/14435 429 | containerWidthAnimatedValue.__makeNative(); 430 | scrollValue = Animated.divide(this.state.scrollXIOS, containerWidthAnimatedValue); 431 | this.setState({ containerWidth: width, scrollValue }); 432 | } else { 433 | this.setState({ containerWidth: width }); 434 | } 435 | InteractionManager.runAfterInteractions(() => { 436 | this.goToPage(this.state.currentPage); 437 | }); 438 | } 439 | if (height && height > 0 && Math.round(height) !== Math.round(this.state.containerHeight)) { 440 | this.setState({ containerHeight: height }); 441 | } 442 | }; 443 | 444 | _children = (children = this.props.children) => { 445 | return React.Children.map(children, (child) => child); 446 | }; 447 | 448 | render() { 449 | let overlayTabs = this.props.tabBarPosition === 'overlayTop' || this.props.tabBarPosition === 'overlayBottom'; 450 | let tabBarProps = { 451 | goToPage: this.goToPage, 452 | tabs: this._children().map((child) => child.props.tabLabel), 453 | activeTab: this.state.currentPage, 454 | scrollValue: this.state.scrollValue, 455 | containerWidth: this.state.containerWidth, 456 | tabBarStyle: this.props.tabBarStyle, 457 | }; 458 | if (this.props.tabBarActiveTextColor) { 459 | tabBarProps.activeTextColor = this.props.tabBarActiveTextColor; 460 | } 461 | if (this.props.tabBarInactiveTextColor) { 462 | tabBarProps.inactiveTextColor = this.props.tabBarInactiveTextColor; 463 | } 464 | if (this.props.tabBarTextStyle) { 465 | tabBarProps.textStyle = this.props.tabBarTextStyle; 466 | } 467 | if (this.props.tabBarUnderlineStyle) { 468 | tabBarProps.underlineStyle = this.props.tabBarUnderlineStyle; 469 | } 470 | if (this.props.tabBarPaddingInset) { 471 | tabBarProps.paddingInset = this.props.tabBarPaddingInset; 472 | } 473 | if (this.props.tabBarTabWidth) { 474 | tabBarProps.tabWidth = this.props.tabBarTabWidth; 475 | } 476 | if (this.props.tabBarTabUnderlineWidth) { 477 | tabBarProps.tabUnderlineWidth = this.props.tabBarTabUnderlineWidth; 478 | } 479 | if (overlayTabs) { 480 | tabBarProps.style = { 481 | position: 'absolute', 482 | left: 0, 483 | right: 0, 484 | [this.props.tabBarPosition === 'overlayTop' ? 'top' : 'bottom']: 0, 485 | }; 486 | } 487 | 488 | return ( 489 | 490 | {this.renderHeader()} 491 | {this.props.tabBarPosition === 'top' && this.renderTabBar(tabBarProps)} 492 | {this.renderScrollableContent()} 493 | {(this.props.tabBarPosition === 'bottom' || overlayTabs) && this.renderTabBar(tabBarProps)} 494 | 495 | ); 496 | } 497 | } 498 | 499 | const styles = StyleSheet.create({ 500 | container: { 501 | position: 'relative', 502 | flex: 1, 503 | }, 504 | }); 505 | -------------------------------------------------------------------------------- /components/DefaultTabBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | StyleSheet, 4 | Text, 5 | View, 6 | Animated, 7 | TouchableNativeFeedback, 8 | TouchableOpacity, 9 | ViewProps, 10 | ViewStyle, 11 | TextStyle, 12 | Dimensions, 13 | } from 'react-native'; 14 | 15 | interface Props extends ViewProps { 16 | scrollValue: number; //切换tab的动画对应系数 17 | containerWidth: number; //tabBar外部容器宽度 18 | tabs: Array; 19 | paddingInset?: number; //tabBar外两边padding 20 | tabBarStyle?: ViewStyle; 21 | tabStyle?: ViewStyle; 22 | tabWidth?: number; 23 | underlineStyle?: ViewStyle; 24 | tabUnderlineWidth?: number; 25 | hiddenUnderLine?: boolean; 26 | textStyle?: TextStyle; 27 | activeTextStyle?: TextStyle; 28 | inactiveTextStyle?: TextStyle; 29 | } 30 | 31 | const SCALE_NUMBER = 2; 32 | 33 | export default class DefaultTabBar extends Component { 34 | static defaultProps = { 35 | paddingInset: 0, 36 | activeTextStyle: { color: '#202020' }, 37 | inactiveTextStyle: { color: '#909090' }, 38 | hiddenUnderLine: false, 39 | }; 40 | 41 | _renderTab(name: string, page: number, isTabActive: boolean, onPressHandler: Function) { 42 | const { tabWidth, tabStyle, textStyle, activeTextStyle, inactiveTextStyle } = this.props; 43 | const tabTextStyle = isTabActive ? activeTextStyle : inactiveTextStyle; 44 | const tabWidthStyle = tabWidth ? { width: tabWidth } : { flex: 1 }; 45 | const tabItem = { 46 | alignItems: 'center', 47 | justifyContent: 'center', 48 | ...tabWidthStyle, 49 | ...tabStyle, 50 | }; 51 | const tabText = { 52 | fontSize: 16, 53 | ...textStyle, 54 | ...tabTextStyle, 55 | }; 56 | return ( 57 | onPressHandler(page)}> 58 | {name} 59 | 60 | ); 61 | } 62 | 63 | _renderUnderline() { 64 | const { 65 | scrollValue, 66 | containerWidth, 67 | paddingInset, 68 | tabs, 69 | tabWidth, 70 | tabUnderlineWidth, 71 | underlineStyle, 72 | } = this.props; 73 | const numberOfTabs = tabs.length; 74 | const calcTabWidth = tabWidth || (containerWidth - paddingInset) / numberOfTabs; 75 | const calcUnderlineWidth = Math.min(tabUnderlineWidth || calcTabWidth * 0.6, calcTabWidth); 76 | const underlineLeft = (calcTabWidth - calcUnderlineWidth) / 2 + paddingInset; 77 | const tabUnderlineStyle = { 78 | position: 'absolute', 79 | width: calcUnderlineWidth, 80 | height: 3, 81 | borderRadius: 3, 82 | backgroundColor: '#FE1966', 83 | bottom: 1, 84 | left: underlineLeft, 85 | ...underlineStyle, 86 | }; 87 | 88 | // 计算underline动画系数 89 | const scaleValue = () => { 90 | const arr = new Array(numberOfTabs * 2); 91 | return arr.fill(0).reduce( 92 | function (pre, cur, idx) { 93 | idx == 0 ? pre.inputRange.push(cur) : pre.inputRange.push(pre.inputRange[idx - 1] + 0.5); 94 | idx % 2 ? pre.outputRange.push(SCALE_NUMBER) : pre.outputRange.push(1); 95 | return pre; 96 | }, 97 | { inputRange: [], outputRange: [] }, 98 | ); 99 | }; 100 | const scaleX = scrollValue.interpolate(scaleValue()); 101 | const translateX = scrollValue.interpolate({ 102 | inputRange: [0, 1], 103 | outputRange: [0, calcTabWidth], 104 | }); 105 | 106 | return ( 107 | 115 | ); 116 | } 117 | 118 | componentDidMount() { 119 | this.props.onMounted && this.props.onMounted(); 120 | } 121 | 122 | render() { 123 | const { tabBarStyle, paddingInset, hiddenUnderLine, tabs } = this.props; 124 | return ( 125 | 126 | {!hiddenUnderLine && this._renderUnderline()} 127 | {tabs.map((name, page) => { 128 | const isTabActive = this.props.activeTab === page; 129 | return this._renderTab(name, page, isTabActive, this.props.goToPage); 130 | })} 131 | 132 | ); 133 | } 134 | } 135 | 136 | const shadowSetting = { 137 | width: Dimensions.get('window').width, 138 | height: 50, 139 | color: '#E8E8E8', 140 | border: 5, 141 | radius: 15, 142 | opacity: 0.5, 143 | x: 0, 144 | y: 0, 145 | }; 146 | const styles = StyleSheet.create({ 147 | tabBar: { 148 | height: 50, 149 | flexDirection: 'row', 150 | alignItems: 'stretch', 151 | borderBottomWidth: StyleSheet.hairlineWidth, 152 | borderColor: '#f0f0f0', 153 | }, 154 | }); 155 | -------------------------------------------------------------------------------- /components/SceneComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { View } from 'react-native'; 3 | import StaticContainer from './StaticContainer'; 4 | 5 | export default function SceneComponent(Props) { 6 | const { children, shouldUpdated, ...props } = Props; 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /components/ScrollableTabBar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | View, 4 | Animated, 5 | Pressable, 6 | ScrollView, 7 | Text, 8 | Platform, 9 | Dimensions, 10 | StyleSheet, 11 | ViewStyle, 12 | TextStyle, 13 | } from 'react-native'; 14 | 15 | const WINDOW_WIDTH = Dimensions.get('window').width; 16 | 17 | interface Props { 18 | goToPage: (p: number) => void; 19 | activeTab: number; 20 | tabs: any[]; 21 | backgroundColor: string; 22 | activeTextColor: string; 23 | inactiveTextColor: string; 24 | scrollOffset: number; 25 | style: ViewStyle; 26 | tabStyle: ViewStyle; 27 | tabsContainerStyle: ViewStyle; 28 | textStyle: TextStyle; 29 | renderTab: (p: any) => void; 30 | underlineStyle: ViewStyle; 31 | tabUnderlineWidth?: number; 32 | } 33 | 34 | const DEFAULT_PROPS = { 35 | scrollOffset: 52, 36 | activeTextColor: 'navy', 37 | inactiveTextColor: 'black', 38 | backgroundColor: null, 39 | style: {}, 40 | tabStyle: {}, 41 | tabsContainerStyle: {}, 42 | underlineStyle: {}, 43 | }; 44 | 45 | export default class ScrollableTabBar extends Component { 46 | static defaultProps = DEFAULT_PROPS; 47 | 48 | constructor(props: Props) { 49 | super(props); 50 | this._tabsMeasurements = []; 51 | this.state = { 52 | _leftTabUnderline: new Animated.Value(0), 53 | _widthTabUnderline: new Animated.Value(0), 54 | _containerWidth: null, 55 | }; 56 | this.updateView = this.updateView.bind(this); 57 | this.necessarilyMeasurementsCompleted = this.necessarilyMeasurementsCompleted.bind(this); 58 | this.updateTabPanel = this.updateTabPanel.bind(this); 59 | this.updateTabUnderline = this.updateTabUnderline.bind(this); 60 | this.renderTab = this.renderTab.bind(this); 61 | this.measureTab = this.measureTab.bind(this); 62 | this.onTabContainerLayout = this.onTabContainerLayout.bind(this); 63 | this.onContainerLayout = this.onContainerLayout.bind(this); 64 | } 65 | 66 | componentDidMount() { 67 | this.props.scrollValue.addListener(this.updateView); 68 | } 69 | 70 | updateView(offset) { 71 | const position = Math.floor(offset.value); 72 | const pageOffset = offset.value % 1; 73 | const tabCount = this.props.tabs.length; 74 | const lastTabPosition = tabCount - 1; 75 | 76 | if (tabCount === 0 || offset.value < 0 || offset.value > lastTabPosition) { 77 | return; 78 | } 79 | 80 | if (this.necessarilyMeasurementsCompleted(position, position === lastTabPosition)) { 81 | this.updateTabPanel(position, pageOffset); 82 | this.updateTabUnderline(position, pageOffset, tabCount); 83 | } 84 | } 85 | 86 | necessarilyMeasurementsCompleted(position, isLastTab) { 87 | return ( 88 | this._tabsMeasurements[position] && 89 | (isLastTab || this._tabsMeasurements[position + 1]) && 90 | this._tabContainerMeasurements && 91 | this._containerMeasurements 92 | ); 93 | } 94 | 95 | updateTabPanel(position, pageOffset) { 96 | const containerWidth = this._containerMeasurements.width; 97 | const tabWidth = this._tabsMeasurements[position].width; 98 | const nextTabMeasurements = this._tabsMeasurements[position + 1]; 99 | const nextTabWidth = (nextTabMeasurements && nextTabMeasurements.width) || 0; 100 | const tabOffset = this._tabsMeasurements[position].left; 101 | const absolutePageOffset = pageOffset * tabWidth; 102 | let newScrollX = tabOffset + absolutePageOffset; 103 | 104 | // center tab and smooth tab change (for when tabWidth changes a lot between two tabs) 105 | newScrollX -= (containerWidth - (1 - pageOffset) * tabWidth - pageOffset * nextTabWidth) / 2; 106 | newScrollX = newScrollX >= 0 ? newScrollX : 0; 107 | 108 | if (Platform.OS === 'android') { 109 | this._scrollView.scrollTo({ x: newScrollX, y: 0, animated: false }); 110 | } else { 111 | const rightBoundScroll = this._tabContainerMeasurements.width - this._containerMeasurements.width; 112 | newScrollX = newScrollX > rightBoundScroll ? rightBoundScroll : newScrollX; 113 | this._scrollView.scrollTo({ x: newScrollX, y: 0, animated: false }); 114 | } 115 | } 116 | 117 | updateTabUnderline(position, pageOffset, tabCount) { 118 | const lineLeft = this._tabsMeasurements[position].left; 119 | const lineRight = this._tabsMeasurements[position].right; 120 | if (position < tabCount - 1) { 121 | const nextTabLeft = this._tabsMeasurements[position + 1].left; 122 | const nextTabRight = this._tabsMeasurements[position + 1].right; 123 | 124 | const newLineLeft = pageOffset * nextTabLeft + (1 - pageOffset) * lineLeft; 125 | const newLineRight = pageOffset * nextTabRight + (1 - pageOffset) * lineRight; 126 | 127 | const originWidth = newLineRight - newLineLeft; 128 | const calcWidthUnderline = Math.min(this.props.tabUnderlineWidth || originWidth * 0.6, originWidth); 129 | const calcLeftUnderline = newLineLeft + (originWidth - calcWidthUnderline) / 2; 130 | 131 | this.state._leftTabUnderline.setValue(calcLeftUnderline); 132 | this.state._widthTabUnderline.setValue(calcWidthUnderline); 133 | } else { 134 | const originWidth = lineRight - lineLeft; 135 | const calcWidthUnderline = Math.min(this.props.tabUnderlineWidth || originWidth * 0.6, originWidth); 136 | const calcLeftUnderline = lineLeft + (originWidth - calcWidthUnderline) / 2; 137 | 138 | this.state._leftTabUnderline.setValue(calcLeftUnderline); 139 | this.state._widthTabUnderline.setValue(calcWidthUnderline); 140 | } 141 | } 142 | 143 | renderTab(name, page, isTabActive, onPressHandler, onLayoutHandler) { 144 | const { activeTextColor, inactiveTextColor, textStyle } = this.props; 145 | const textColor = isTabActive ? activeTextColor : inactiveTextColor; 146 | const fontWeight = isTabActive ? 'bold' : 'normal'; 147 | 148 | return ( 149 | onPressHandler(page)} onLayout={onLayoutHandler}> 150 | 151 | {name} 152 | 153 | 154 | ); 155 | } 156 | 157 | measureTab(page, event) { 158 | const { x, width, height } = event.nativeEvent.layout; 159 | this._tabsMeasurements[page] = { left: x, right: x + width, width, height }; 160 | this.updateView({ value: this.props.scrollValue.__getValue() }); 161 | } 162 | 163 | render() { 164 | const tabUnderlineStyle = { 165 | position: 'absolute', 166 | height: 4, 167 | backgroundColor: 'navy', 168 | bottom: 0, 169 | }; 170 | 171 | const dynamicTabUnderline = { 172 | left: this.state._leftTabUnderline, 173 | width: this.state._widthTabUnderline, 174 | }; 175 | 176 | return ( 177 | 180 | { 182 | this._scrollView = scrollView; 183 | }} 184 | horizontal={true} 185 | showsHorizontalScrollIndicator={false} 186 | showsVerticalScrollIndicator={false} 187 | directionalLockEnabled={true} 188 | bounces={false} 189 | scrollsToTop={false}> 190 | 194 | {this.props.tabs.map((name, page) => { 195 | const isTabActive = this.props.activeTab === page; 196 | const renderTab = this.props.renderTab || this.renderTab; 197 | return renderTab( 198 | name, 199 | page, 200 | isTabActive, 201 | this.props.goToPage, 202 | this.measureTab.bind(this, page), 203 | ); 204 | })} 205 | 206 | 207 | 208 | 209 | ); 210 | } 211 | 212 | componentDidUpdate(prevProps) { 213 | // If the tabs change, force the width of the tabs container to be recalculated 214 | if (JSON.stringify(prevProps.tabs) !== JSON.stringify(this.props.tabs) && this.state._containerWidth) { 215 | this.setState({ _containerWidth: null }); 216 | } 217 | } 218 | 219 | onTabContainerLayout(e) { 220 | this._tabContainerMeasurements = e.nativeEvent.layout; 221 | let width = this._tabContainerMeasurements.width; 222 | if (width < WINDOW_WIDTH) { 223 | width = WINDOW_WIDTH; 224 | } 225 | this.setState({ _containerWidth: width }); 226 | this.updateView({ value: this.props.scrollValue.__getValue() }); 227 | } 228 | 229 | onContainerLayout(e) { 230 | this._containerMeasurements = e.nativeEvent.layout; 231 | this.updateView({ value: this.props.scrollValue.__getValue() }); 232 | } 233 | } 234 | 235 | const styles = StyleSheet.create({ 236 | tab: { 237 | height: 49, 238 | alignItems: 'center', 239 | justifyContent: 'center', 240 | paddingLeft: 20, 241 | paddingRight: 20, 242 | }, 243 | container: { 244 | height: 50, 245 | borderWidth: 1, 246 | borderTopWidth: 0, 247 | borderLeftWidth: 0, 248 | borderRightWidth: 0, 249 | borderColor: '#ccc', 250 | }, 251 | tabs: { 252 | flexDirection: 'row', 253 | justifyContent: 'space-around', 254 | }, 255 | }); 256 | -------------------------------------------------------------------------------- /components/StaticContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | 3 | export default class StaticContainer extends React.Component { 4 | shouldComponentUpdate(nextProps: Object): boolean { 5 | return !!nextProps.shouldUpdate; 6 | } 7 | 8 | render(): ReactElement | null { 9 | var child = this.props.children; 10 | if (child === null || child === false) { 11 | return null; 12 | } 13 | return React.Children.only(child); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /components/TabView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactNative, { Animated } from 'react-native'; 3 | import memoize from 'memoize-one'; 4 | interface Props { 5 | index: number; 6 | isActive: boolean; 7 | containerOffsetY: Animated.Value; 8 | sceneHeight: number; 9 | headerHeight: number; 10 | } 11 | 12 | const compose = (WrappedComponent) => { 13 | const AnimateTabView = Animated.createAnimatedComponent(WrappedComponent); 14 | 15 | class TabView extends React.Component { 16 | constructor(props: Props) { 17 | super(props); 18 | this.scrollOffsetY = 0; 19 | this.state = { 20 | bottomPadding: 0, 21 | }; 22 | this.mounted = false; 23 | } 24 | 25 | componentDidMount() { 26 | this.mounted = true; 27 | this.addListener(); 28 | } 29 | 30 | componentWillUnmount() { 31 | this.removeListener(); 32 | } 33 | 34 | addListener() { 35 | const { containerOffsetY } = this.props; 36 | containerOffsetY?.addListener(this.tabViewScrollHandler); 37 | } 38 | 39 | removeListener() { 40 | const { containerOffsetY } = this.props; 41 | containerOffsetY?.removeListener(this.tabViewScrollHandler); 42 | } 43 | 44 | scrollTo(e) { 45 | if (this._scrollViewRef) { 46 | if (this._scrollViewRef?.scrollTo) { 47 | this._scrollViewRef.scrollTo({ x: 0, y: e.y, animated: false }); 48 | } else if (this._scrollViewRef?.scrollToOffset) { 49 | this._scrollViewRef.scrollToOffset({ offset: e.y, animated: false }); 50 | } 51 | } 52 | } 53 | 54 | // other TabView sync OffsetY 55 | tabViewScrollHandler = (e) => { 56 | const { headerHeight, isActive } = this.props; 57 | if (!isActive) { 58 | if (e.value > headerHeight && this.scrollOffsetY < headerHeight) { 59 | this.scrollTo({ y: headerHeight }); 60 | } else { 61 | this.scrollTo({ y: e.value }); 62 | } 63 | } 64 | }; 65 | 66 | // contentHeight changed,adjust offset 67 | adjustScrollOffset = () => { 68 | if (this.mounted) { 69 | this.mounted = false; 70 | const { containerOffsetY, headerHeight } = this.props; 71 | const scrollValue = containerOffsetY._value > headerHeight ? headerHeight : containerOffsetY._value; 72 | this.scrollTo({ y: scrollValue }); 73 | } 74 | }; 75 | 76 | // calculate the bottom occupancy height 77 | onContentSizeChange = (contentWidth, contentHeight) => { 78 | const { bottomPadding } = this.state; 79 | const { headerHeight, sceneHeight } = this.props; 80 | const remainingHeight = contentHeight - sceneHeight; 81 | 82 | if (bottomPadding <= 0) { 83 | const makePaddingBottom = sceneHeight - contentHeight; 84 | this.setState({ bottomPadding: makePaddingBottom }); 85 | } else if (remainingHeight > 1) { 86 | // The content height exceeds the container height,adjust Tab Offset,and reduce excess occupancy bottomPadding 87 | this.adjustScrollOffset(); 88 | const newBottomPadding = remainingHeight > bottomPadding ? 0 : bottomPadding - remainingHeight; 89 | if (newBottomPadding != bottomPadding) { 90 | this.setState({ bottomPadding: newBottomPadding }); 91 | } 92 | } 93 | }; 94 | 95 | getScrollListener = memoize((isActive) => { 96 | if (isActive) { 97 | return Animated.event([{ nativeEvent: { contentOffset: { y: this.props.containerOffsetY } } }], { 98 | useNativeDriver: true, 99 | listener: this.onScroll, 100 | }); 101 | } else { 102 | return this.onScroll; 103 | } 104 | }); 105 | 106 | onScroll = (e) => { 107 | this.scrollOffsetY = e.nativeEvent.contentOffset.y; 108 | }; 109 | 110 | render() { 111 | const { isActive, children, headerHeight, forwardedRef, contentContainerStyle, ...restProps } = this.props; 112 | const { bottomPadding } = this.state; 113 | const scrollListener = this.getScrollListener(isActive); 114 | return ( 115 | { 117 | this._scrollViewRef = ref; 118 | if (forwardedRef) { 119 | if (forwardedRef instanceof Function) { 120 | forwardedRef(ref); 121 | } else if (typeof forwardedRef === 'object' && forwardedRef.hasOwnProperty('current')) { 122 | forwardedRef.current = ref; 123 | } 124 | } 125 | }} 126 | onScroll={scrollListener} 127 | onContentSizeChange={this.onContentSizeChange} 128 | contentContainerStyle={{ 129 | ...contentContainerStyle, 130 | paddingTop: headerHeight, 131 | paddingBottom: bottomPadding, 132 | }} 133 | overScrollMode="never" 134 | scrollEventThrottle={16} 135 | directionalLockEnabled={true} 136 | showsVerticalScrollIndicator={false} 137 | automaticallyAdjustContentInsets={false} 138 | {...restProps}> 139 | {children} 140 | 141 | ); 142 | } 143 | } 144 | 145 | return React.forwardRef((props, ref) => { 146 | return ; 147 | }); 148 | }; 149 | 150 | export const FlatList = compose(ReactNative.FlatList); 151 | export const ScrollView = compose(ReactNative.ScrollView); 152 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import ScrollTabView from './ScrollTabView'; 2 | import ScrollableTabBar from './components/ScrollableTabBar'; 3 | import { ScrollView, FlatList } from './components/TabView'; 4 | 5 | export { ScrollTabView, ScrollableTabBar, ScrollView, FlatList }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-scroll-head-tab-view", 3 | "version": "1.0.9", 4 | "description": "Based on react-native-scrollable-tab-view, the header slides with each tab, and the TabBar reaches a certain sliding distance to attach to the top", 5 | "main": "index.ts", 6 | "scripts": {}, 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/wangyukun-hxb/react-native-scroll-head-tab-view.git" 10 | }, 11 | "keywords": [ 12 | "react-native", 13 | "scrollable", 14 | "tab-view" 15 | ], 16 | "author": "wangyukun (https://github.com/wangyukun-hxb)", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/wangyukun-hxb/react-native-scroll-head-tab-view/issues" 20 | }, 21 | "homepage": "https://github.com/wangyukun-hxb/react-native-scroll-head-tab-view#readme", 22 | "peerDependencies": { 23 | "react-native": ">=0.50.0", 24 | "@react-native-community/viewpager": "^2.0.1" 25 | }, 26 | "dependencies": { 27 | "memoize-one": "^5.1.1" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^7.6.0", 31 | "typescript": "^3.9.7" 32 | } 33 | } 34 | --------------------------------------------------------------------------------