import * as React from 'react'; import { Animated, StyleSheet, View, ScrollView, StyleProp, ViewStyle, TextStyle, LayoutChangeEvent, I18nManager, Platform, } from 'react-native'; import TabBarItem, { Props as TabBarItemProps } from './TabBarItem'; import TabBarIndicator, { Props as IndicatorProps } from './TabBarIndicator'; import type { Route, Scene, SceneRendererProps, NavigationState, Layout, Event, } from './types'; export type Props = SceneRendererProps & { navigationState: NavigationState; scrollEnabled?: boolean; bounces?: boolean; activeColor?: string; inactiveColor?: string; pressColor?: string; pressOpacity?: number; getLabelText: (scene: Scene) => string | undefined; getAccessible: (scene: Scene) => boolean | undefined; getAccessibilityLabel: (scene: Scene) => string | undefined; getTestID: (scene: Scene) => string | undefined; renderLabel?: ( scene: Scene & { focused: boolean; color: string; } ) => React.ReactNode; renderIcon?: ( scene: Scene & { focused: boolean; color: string; } ) => React.ReactNode; renderBadge?: (scene: Scene) => React.ReactNode; renderIndicator: (props: IndicatorProps) => React.ReactNode; renderTabBarItem?: ( props: TabBarItemProps & { key: string } ) => React.ReactElement; onTabPress?: (scene: Scene & Event) => void; onTabLongPress?: (scene: Scene) => void; tabStyle?: StyleProp; indicatorStyle?: StyleProp; indicatorContainerStyle?: StyleProp; labelStyle?: StyleProp; contentContainerStyle?: StyleProp; style?: StyleProp; }; type State = { layout: Layout; tabWidths: { [key: string]: number }; }; export default class TabBar extends React.Component< Props, State > { static defaultProps = { getLabelText: ({ route }: Scene) => route.title, getAccessible: ({ route }: Scene) => typeof route.accessible !== 'undefined' ? route.accessible : true, getAccessibilityLabel: ({ route }: Scene) => typeof route.accessibilityLabel === 'string' ? route.accessibilityLabel : typeof route.title === 'string' ? route.title : undefined, getTestID: ({ route }: Scene) => route.testID, renderIndicator: (props: IndicatorProps) => ( ), }; state: State = { layout: { width: 0, height: 0 }, tabWidths: {}, }; componentDidUpdate(prevProps: Props, prevState: State) { const { navigationState } = this.props; const { layout, tabWidths } = this.state; if ( prevProps.navigationState.routes.length !== navigationState.routes.length || prevProps.navigationState.index !== navigationState.index || prevState.layout.width !== layout.width || prevState.tabWidths !== tabWidths ) { if ( this.getFlattenedTabWidth(this.props.tabStyle) === 'auto' && !( layout.width && navigationState.routes.every( (r) => typeof tabWidths[r.key] === 'number' ) ) ) { // When tab width is dynamic, only adjust the scroll once we have all tab widths and layout return; } this.resetScroll(navigationState.index); } } // to store the layout.width of each tab // when all onLayout's are fired, this would be set in state private measuredTabWidths: { [key: string]: number } = {}; private scrollAmount = new Animated.Value(0); private scrollViewRef = React.createRef(); private getFlattenedTabWidth = (style: StyleProp) => { const tabStyle = StyleSheet.flatten(style); return tabStyle ? tabStyle.width : undefined; }; private getComputedTabWidth = ( index: number, layout: Layout, routes: Route[], scrollEnabled: boolean | undefined, tabWidths: { [key: string]: number }, flattenedWidth: string | number | undefined ) => { if (routes.length === 0) { return 0; } if (flattenedWidth === 'auto') { return tabWidths[routes[index].key] || 0; } switch (typeof flattenedWidth) { case 'number': return flattenedWidth; case 'string': if (flattenedWidth.endsWith('%')) { const width = parseFloat(flattenedWidth); if (Number.isFinite(width)) { return layout.width * (width / 100); } } } if (scrollEnabled) { return (layout.width / 5) * 2; } return layout.width / routes.length; }; private getMaxScrollDistance = (tabBarWidth: number, layoutWidth: number) => tabBarWidth - layoutWidth; private getTabBarWidth = (props: Props, state: State) => { const { layout, tabWidths } = state; const { scrollEnabled, tabStyle } = props; const { routes } = props.navigationState; return routes.reduce( (acc, _, i) => acc + this.getComputedTabWidth( i, layout, routes, scrollEnabled, tabWidths, this.getFlattenedTabWidth(tabStyle) ), 0 ); }; private normalizeScrollValue = ( props: Props, state: State, value: number ) => { const { layout } = state; const tabBarWidth = this.getTabBarWidth(props, state); const maxDistance = this.getMaxScrollDistance(tabBarWidth, layout.width); const scrollValue = Math.max(Math.min(value, maxDistance), 0); if (Platform.OS === 'android' && I18nManager.isRTL) { // On Android, scroll value is not applied in reverse in RTL // so we need to manually adjust it to apply correct value return maxDistance - scrollValue; } return scrollValue; }; private getScrollAmount = (props: Props, state: State, index: number) => { const { layout, tabWidths } = state; const { scrollEnabled, tabStyle } = props; const { routes } = props.navigationState; const centerDistance = Array.from({ length: index + 1 }).reduce( (total, _, i) => { const tabWidth = this.getComputedTabWidth( i, layout, routes, scrollEnabled, tabWidths, this.getFlattenedTabWidth(tabStyle) ); // To get the current index centered we adjust scroll amount by width of indexes // 0 through (i - 1) and add half the width of current index i return total + (index === i ? tabWidth / 2 : tabWidth); }, 0 ); const scrollAmount = centerDistance - layout.width / 2; return this.normalizeScrollValue(props, state, scrollAmount); }; private resetScroll = (index: number) => { if (this.props.scrollEnabled) { this.scrollViewRef.current?.scrollTo({ x: this.getScrollAmount(this.props, this.state, index), animated: true, }); } }; private handleLayout = (e: LayoutChangeEvent) => { const { height, width } = e.nativeEvent.layout; if ( this.state.layout.width === width && this.state.layout.height === height ) { return; } this.setState({ layout: { height, width, }, }); }; private getTranslateX = ( scrollAmount: Animated.Value, maxScrollDistance: number ) => Animated.multiply( Platform.OS === 'android' && I18nManager.isRTL ? Animated.add(maxScrollDistance, Animated.multiply(scrollAmount, -1)) : scrollAmount, I18nManager.isRTL ? 1 : -1 ); render() { const { position, navigationState, jumpTo, scrollEnabled, bounces, getAccessibilityLabel, getAccessible, getLabelText, getTestID, renderBadge, renderIcon, renderLabel, renderTabBarItem, activeColor, inactiveColor, pressColor, pressOpacity, onTabPress, onTabLongPress, tabStyle, labelStyle, indicatorStyle, contentContainerStyle, style, indicatorContainerStyle, } = this.props; const { layout, tabWidths } = this.state; const { routes } = navigationState; const isWidthDynamic = this.getFlattenedTabWidth(tabStyle) === 'auto'; const tabBarWidth = this.getTabBarWidth(this.props, this.state); const tabBarWidthPercent = `${routes.length * 40}%`; const translateX = this.getTranslateX( this.scrollAmount, this.getMaxScrollDistance(tabBarWidth, layout.width) ); return ( {this.props.renderIndicator({ position, layout, navigationState, jumpTo, width: isWidthDynamic ? 'auto' : `${100 / routes.length}%`, style: indicatorStyle, getTabWidth: (i: number) => this.getComputedTabWidth( i, layout, routes, scrollEnabled, tabWidths, this.getFlattenedTabWidth(tabStyle) ), })} {routes.map((route: T) => { const props: TabBarItemProps & { key: string } = { key: route.key, position: position, route: route, navigationState: navigationState, getAccessibilityLabel: getAccessibilityLabel, getAccessible: getAccessible, getLabelText: getLabelText, getTestID: getTestID, renderBadge: renderBadge, renderIcon: renderIcon, renderLabel: renderLabel, activeColor: activeColor, inactiveColor: inactiveColor, pressColor: pressColor, pressOpacity: pressOpacity, onLayout: isWidthDynamic ? (e) => { this.measuredTabWidths[route.key] = e.nativeEvent.layout.width; // When we have measured widths for all of the tabs, we should updates the state // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app if ( routes.every( (r) => typeof this.measuredTabWidths[r.key] === 'number' ) ) { this.setState({ tabWidths: { ...this.measuredTabWidths }, }); } } : undefined, onPress: () => { const event: Scene & Event = { route, defaultPrevented: false, preventDefault: () => { event.defaultPrevented = true; }, }; onTabPress?.(event); if (event.defaultPrevented) { return; } this.props.jumpTo(route.key); }, onLongPress: () => onTabLongPress?.({ route }), labelStyle: labelStyle, style: tabStyle, }; return renderTabBarItem ? ( renderTabBarItem(props) ) : ( ); })} ); } } const styles = StyleSheet.create({ container: { flex: 1, }, scroll: { overflow: Platform.select({ default: 'scroll', web: undefined }), }, tabBar: { backgroundColor: '#2196f3', elevation: 4, shadowColor: 'black', shadowOpacity: 0.1, shadowRadius: StyleSheet.hairlineWidth, shadowOffset: { height: StyleSheet.hairlineWidth, width: 0, }, zIndex: 1, }, tabContent: { flexDirection: 'row', flexWrap: 'nowrap', }, indicatorContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, });