import * as React from 'react'; import { Animated, type DimensionValue, FlatList, I18nManager, type LayoutChangeEvent, type ListRenderItemInfo, Platform, type PressableAndroidRippleConfig, type StyleProp, StyleSheet, View, type ViewStyle, } from 'react-native'; import { type Props as IndicatorProps, TabBarIndicator, } from './TabBarIndicator'; import { type Props as TabBarItemProps, TabBarItem } from './TabBarItem'; import type { Event, Layout, LocaleDirection, NavigationState, Route, Scene, SceneRendererProps, TabDescriptor, } from './types'; import { useAnimatedValue } from './useAnimatedValue'; export type Props = SceneRendererProps & { navigationState: NavigationState; scrollEnabled?: boolean; bounces?: boolean; activeColor?: string; inactiveColor?: string; pressColor?: string; pressOpacity?: number; options?: Record>; 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; contentContainerStyle?: StyleProp; style?: StyleProp; direction?: LocaleDirection; gap?: number; testID?: string; android_ripple?: PressableAndroidRippleConfig; }; const useNativeDriver = Platform.OS !== 'web'; const Separator = ({ width }: { width: number }) => { return ; }; const getFlattenedTabWidth = (style: StyleProp) => { const tabStyle = StyleSheet.flatten(style); return tabStyle?.width; }; const getFlattenedPaddingStart = (style: StyleProp) => { const flattenStyle = StyleSheet.flatten(style); return flattenStyle ? flattenStyle.paddingLeft || flattenStyle.paddingStart || flattenStyle.paddingHorizontal || 0 : 0; }; const getFlattenedPaddingEnd = (style: StyleProp) => { const flattenStyle = StyleSheet.flatten(style); return flattenStyle ? flattenStyle.paddingRight || flattenStyle.paddingEnd || flattenStyle.paddingHorizontal || 0 : 0; }; const convertPaddingPercentToSize = ( value: DimensionValue | undefined, layout: Layout ): number => { switch (typeof value) { case 'number': return value; case 'string': if (value.endsWith('%')) { const width = parseFloat(value); if (Number.isFinite(width)) { return layout.width * (width / 100); } } } return 0; }; const getComputedTabWidth = ( index: number, layout: Layout, routes: Route[], scrollEnabled: boolean | undefined, tabWidths: { [key: string]: number }, flattenedWidth: DimensionValue | undefined, flattenedPaddingStart: DimensionValue | undefined, flattenedPaddingEnd: DimensionValue | undefined, gap?: number ) => { 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; } const gapTotalWidth = (gap ?? 0) * (routes.length - 1); const paddingTotalWidth = convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout); return (layout.width - gapTotalWidth - paddingTotalWidth) / routes.length; }; const getMaxScrollDistance = (tabBarWidth: number, layoutWidth: number) => tabBarWidth - layoutWidth; const getTranslateX = ( scrollAmount: Animated.Value, maxScrollDistance: number, direction: LocaleDirection ) => Animated.multiply( Platform.OS === 'android' && direction === 'rtl' ? Animated.add(maxScrollDistance, Animated.multiply(scrollAmount, -1)) : scrollAmount, direction === 'rtl' ? 1 : -1 ); const getTabBarWidth = ({ navigationState, layout, gap, scrollEnabled, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, tabWidths, }: Pick, 'navigationState' | 'gap' | 'layout' | 'scrollEnabled'> & { tabWidths: Record; flattenedPaddingStart: DimensionValue | undefined; flattenedPaddingEnd: DimensionValue | undefined; flattenedTabWidth: DimensionValue | undefined; }) => { const { routes } = navigationState; const paddingsWidth = Math.max( 0, convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout) ); return routes.reduce( (acc, _, i) => acc + (i > 0 ? (gap ?? 0) : 0) + getComputedTabWidth( i, layout, routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, gap ), paddingsWidth ); }; const normalizeScrollValue = ({ layout, navigationState, gap, scrollEnabled, tabWidths, value, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, direction, }: Pick, 'layout' | 'navigationState' | 'gap' | 'scrollEnabled'> & { tabWidths: Record; value: number; flattenedTabWidth: DimensionValue | undefined; flattenedPaddingStart: DimensionValue | undefined; flattenedPaddingEnd: DimensionValue | undefined; direction: LocaleDirection; }) => { const tabBarWidth = getTabBarWidth({ layout, navigationState, tabWidths, gap, scrollEnabled, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, }); const maxDistance = getMaxScrollDistance(tabBarWidth, layout.width); const scrollValue = Math.max(Math.min(value, maxDistance), 0); if (Platform.OS === 'android' && direction === 'rtl') { // 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; }; const getScrollAmount = ({ layout, navigationState, gap, scrollEnabled, flattenedTabWidth, tabWidths, flattenedPaddingStart, flattenedPaddingEnd, direction, }: Pick, 'layout' | 'navigationState' | 'scrollEnabled' | 'gap'> & { tabWidths: Record; flattenedTabWidth: DimensionValue | undefined; flattenedPaddingStart: DimensionValue | undefined; flattenedPaddingEnd: DimensionValue | undefined; direction: LocaleDirection; }) => { const paddingInitial = direction === 'rtl' ? convertPaddingPercentToSize(flattenedPaddingEnd, layout) : convertPaddingPercentToSize(flattenedPaddingStart, layout); const centerDistance = Array.from({ length: navigationState.index + 1, }).reduce((total, _, i) => { const tabWidth = getComputedTabWidth( i, layout, navigationState.routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, gap ); // 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 + (i > 0 ? (gap ?? 0) : 0) + (navigationState.index === i ? tabWidth / 2 : tabWidth) ); }, paddingInitial); const scrollAmount = centerDistance - layout.width / 2; return normalizeScrollValue({ layout, navigationState, tabWidths, value: scrollAmount, gap, scrollEnabled, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, direction, }); }; const getLabelTextDefault = ({ route }: Scene) => route.title; const getAccessibleDefault = ({ route }: Scene) => typeof route.accessible !== 'undefined' ? route.accessible : true; const getAccessibilityLabelDefault = ({ route }: Scene) => typeof route.accessibilityLabel === 'string' ? route.accessibilityLabel : typeof route.title === 'string' ? route.title : undefined; const renderIndicatorDefault = (props: IndicatorProps) => ( ); const getTestIdDefault = ({ route }: Scene) => route.testID; // How many items measurements should we update per batch. // Defaults to 10, since that's whats FlatList is using in initialNumToRender. const MEASURE_PER_BATCH = 10; export function TabBar({ renderIndicator = renderIndicatorDefault, gap = 0, scrollEnabled, jumpTo, navigationState, position, activeColor, bounces, contentContainerStyle, inactiveColor, indicatorContainerStyle, indicatorStyle, onTabLongPress, onTabPress, pressColor, pressOpacity, direction = I18nManager.getConstants().isRTL ? 'rtl' : 'ltr', renderTabBarItem, style, tabStyle, layout: propLayout, testID, android_ripple, options, }: Props) { const [layout, setLayout] = React.useState( propLayout ?? { width: 0, height: 0 } ); const [tabWidths, setTabWidths] = React.useState>({}); const flatListRef = React.useRef(null); const isFirst = React.useRef(true); const scrollAmount = useAnimatedValue(0); const { routes } = navigationState; const flattenedTabWidth = getFlattenedTabWidth(tabStyle); const isWidthDynamic = flattenedTabWidth === 'auto'; const flattenedPaddingEnd = getFlattenedPaddingEnd(contentContainerStyle); const flattenedPaddingStart = getFlattenedPaddingStart(contentContainerStyle); const scrollOffset = getScrollAmount({ layout, navigationState, tabWidths, gap, scrollEnabled, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, direction, }); const hasMeasuredTabWidths = Boolean(layout.width) && routes .slice(0, navigationState.index) .every((r) => typeof tabWidths[r.key] === 'number'); React.useEffect(() => { if (isFirst.current) { isFirst.current = false; return; } if (isWidthDynamic && !hasMeasuredTabWidths) { return; } if (scrollEnabled) { flatListRef.current?.scrollToOffset({ offset: scrollOffset, animated: true, }); } }, [hasMeasuredTabWidths, isWidthDynamic, scrollEnabled, scrollOffset]); const handleLayout = (e: LayoutChangeEvent) => { const { height, width } = e.nativeEvent.layout; setLayout((layout) => layout.width === width && layout.height === height ? layout : { width, height } ); }; const tabBarWidth = getTabBarWidth({ layout, navigationState, tabWidths, gap, scrollEnabled, flattenedTabWidth, flattenedPaddingStart, flattenedPaddingEnd, }); const separatorsWidth = Math.max(0, routes.length - 1) * gap; const paddingsWidth = Math.max( 0, convertPaddingPercentToSize(flattenedPaddingStart, layout) + convertPaddingPercentToSize(flattenedPaddingEnd, layout) ); const translateX = React.useMemo( () => getTranslateX( scrollAmount, getMaxScrollDistance(tabBarWidth, layout.width), direction ), [direction, layout.width, scrollAmount, tabBarWidth] ); const measuredTabWidths = React.useRef>({}); const animationFrameHandle = React.useRef>(null); const renderItem = React.useCallback( ({ item: route, index }: ListRenderItemInfo) => { const { testID = getTestIdDefault({ route }), labelText = getLabelTextDefault({ route }), accessible = getAccessibleDefault({ route }), accessibilityLabel = getAccessibilityLabelDefault({ route }), ...rest } = options?.[route.key] ?? {}; const onLayout = isWidthDynamic ? (e: LayoutChangeEvent) => { measuredTabWidths.current[route.key] = e.nativeEvent.layout.width; if (animationFrameHandle.current != null) { cancelAnimationFrame(animationFrameHandle.current); } animationFrameHandle.current = requestAnimationFrame(() => { setTabWidths({ ...measuredTabWidths.current }); }); } : undefined; const onPress = () => { const event: Scene & Event = { route, defaultPrevented: false, preventDefault: () => { event.defaultPrevented = true; }, }; onTabPress?.(event); if (event.defaultPrevented) { return; } jumpTo(route.key); }; const onLongPress = () => onTabLongPress?.({ route }); // Calculate the default width for tab for FlatList to work const defaultTabWidth = !isWidthDynamic ? getComputedTabWidth( index, layout, routes, scrollEnabled, tabWidths, getFlattenedTabWidth(tabStyle), getFlattenedPaddingEnd(contentContainerStyle), getFlattenedPaddingStart(contentContainerStyle), gap ) : undefined; const props = { ...rest, position, route, navigationState, testID, labelText, accessible, accessibilityLabel, activeColor, inactiveColor, pressColor, pressOpacity, onLayout, onPress, onLongPress, style: tabStyle, defaultTabWidth, android_ripple, } satisfies TabBarItemProps; return ( <> {gap > 0 && index > 0 ? : null} {renderTabBarItem ? ( renderTabBarItem({ key: route.key, ...props }) ) : ( )} ); }, [ position, navigationState, options, activeColor, inactiveColor, pressColor, pressOpacity, isWidthDynamic, tabStyle, layout, routes, scrollEnabled, tabWidths, contentContainerStyle, gap, android_ripple, renderTabBarItem, onTabPress, jumpTo, onTabLongPress, ] ); const keyExtractor = React.useCallback((item: T) => item.key, []); const contentContainerStyleMemoized = React.useMemo( () => [ styles.tabContent, scrollEnabled ? { width: tabBarWidth } : null, contentContainerStyle, ], [contentContainerStyle, scrollEnabled, tabBarWidth] ); const handleScroll = React.useMemo( () => Animated.event( [ { nativeEvent: { contentOffset: { x: scrollAmount }, }, }, ], { useNativeDriver } ), [scrollAmount] ); return ( {renderIndicator({ position, layout, navigationState, jumpTo, direction, width: isWidthDynamic ? 'auto' : Math.max( 0, (tabBarWidth - separatorsWidth - paddingsWidth) / routes.length ), style: [ indicatorStyle, { start: flattenedPaddingStart, end: flattenedPaddingEnd }, ], getTabWidth: (i: number) => getComputedTabWidth( i, layout, routes, scrollEnabled, tabWidths, flattenedTabWidth, flattenedPaddingEnd, flattenedPaddingStart, gap ), gap, })} []} keyExtractor={keyExtractor} horizontal role="tablist" keyboardShouldPersistTaps="handled" scrollEnabled={scrollEnabled} bounces={bounces} initialNumToRender={MEASURE_PER_BATCH} alwaysBounceHorizontal={false} scrollsToTop={false} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} automaticallyAdjustContentInsets={false} overScrollMode="never" contentContainerStyle={contentContainerStyleMemoized} scrollEventThrottle={16} renderItem={renderItem} onScroll={handleScroll} ref={flatListRef} testID={testID} /> ); } const styles = StyleSheet.create({ scroll: { overflow: Platform.select({ default: 'scroll', web: undefined }), }, tabBar: { zIndex: 1, backgroundColor: '#2196f3', elevation: 4, ...Platform.select({ default: { shadowColor: 'black', shadowOpacity: 0.1, shadowRadius: StyleSheet.hairlineWidth, shadowOffset: { height: StyleSheet.hairlineWidth, width: 0, }, }, web: { boxShadow: '0 1px 1px rgba(0, 0, 0, 0.1)', }, }), }, tabContent: { flexGrow: 1, flexDirection: 'row', flexWrap: 'nowrap', }, indicatorContainer: { position: 'absolute', top: 0, start: 0, end: 0, bottom: 0, }, });