├── .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 | 
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 |
--------------------------------------------------------------------------------