react native top tab bar navigator: indicator width to match text

Viewed 3427

I have three tabs in a top tab bar navigation with different width text. Is it possible to make the indicator width match the text? On a similar note, how can I make the tabs match the width of the text too without making it display weird. I've tried width auto but it doesn't stay center.

This is how it looks with auto width: enter image description here

    <Tab.Navigator 
                initialRouteName="Open"
                tabBarOptions={{
                    style: { 
                      backgroundColor: "white", 
                      paddingTop: 20, 
                      paddingHorizontal: 25
                      },
                    indicatorStyle: {
                      borderBottomColor: colorScheme.teal,
                      borderBottomWidth: 2,
                      width: '30%',
                      left:"9%"
                    },
                    tabStyle : {
                      justifyContent: "center",
                      width: tabBarWidth/3,
                    }
                  }}
            >
                <Tab.Screen 
                    name="Open" 
                    component={WriterRequestScreen} 
                    initialParams={{ screen: 'Open' }} 
                    options={{ 
                      tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> Open </Text>, 
                   }}
                    />
               <Tab.Screen 
                    name="In Progress" 
                    component={WriterRequestScreen} 
                    initialParams={{ screen: 'InProgress' }}
                    options={{ 
                      tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> In Progress </Text>}}
                    />
               <Tab.Screen 
                name="Completed" 
                component={WriterRequestScreen} 
                initialParams={{ screen: 'Completed' }}
                options={{ tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> Completed </Text>}}
                />
            </Tab.Navigator>
6 Answers

I also needed to make the indicator fit the text size, a dynamic width for the labels, and a scrollable top bar because of long labels. The result looks like this:

tab bar with dynamic indicator width

If you don't care about the indicator width fitting the labels, you can simply use screenOptions.tabBarScrollEnabled: true in combination with width: "auto" in screenOptions.tabBarIndicatorStyle.

Otherwise, you'll need to make your own tab bar component and pass it to the tabBar property of your <Tab.Navigator>. I used a ScrollView but if you have only a few tabs with short labels, a View would be more simple. Here is the Typescript code for this custom TabBar component:

import { MaterialTopTabBarProps } from "@react-navigation/material-top-tabs";
import { useEffect, useRef, useState } from "react";
import {
  Animated,
  Dimensions,
  View,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  I18nManager,
  LayoutChangeEvent,
} from "react-native";

const screenWidth = Dimensions.get("window").width;

const DISTANCE_BETWEEN_TABS = 20;

const TabBar = ({
  state,
  descriptors,
  navigation,
  position,
}: MaterialTopTabBarProps): JSX.Element => {
  const [widths, setWidths] = useState<(number | undefined)[]>([]);
  const scrollViewRef = useRef<ScrollView>(null);
  const transform = [];
  const inputRange = state.routes.map((_, i) => i);

  // keep a ref to easily scroll the tab bar to the focused label
  const outputRangeRef = useRef<number[]>([]);

  const getTranslateX = (
    position: Animated.AnimatedInterpolation,
    routes: never[],
    widths: number[]
  ) => {
    const outputRange = routes.reduce((acc, _, i: number) => {
      if (i === 0) return [DISTANCE_BETWEEN_TABS / 2 + widths[0] / 2];
      return [
        ...acc,
        acc[i - 1] + widths[i - 1] / 2 + widths[i] / 2 + DISTANCE_BETWEEN_TABS,
      ];
    }, [] as number[]);
    outputRangeRef.current = outputRange;
    const translateX = position.interpolate({
      inputRange,
      outputRange,
      extrapolate: "clamp",
    });
    return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1);
  };

  // compute translateX and scaleX because we cannot animate width directly
  if (
    state.routes.length > 1 &&
    widths.length === state.routes.length &&
    !widths.includes(undefined)
  ) {
    const translateX = getTranslateX(
      position,
      state.routes as never[],
      widths as number[]
    );
    transform.push({
      translateX,
    });
    const outputRange = inputRange.map((_, i) => widths[i]) as number[];
    transform.push({
      scaleX:
        state.routes.length > 1
          ? position.interpolate({
              inputRange,
              outputRange,
              extrapolate: "clamp",
            })
          : outputRange[0],
    });
  }

  // scrolls to the active tab label when a new tab is focused
  useEffect(() => {
    if (
      state.routes.length > 1 &&
      widths.length === state.routes.length &&
      !widths.includes(undefined)
    ) {
      if (state.index === 0) {
        scrollViewRef.current?.scrollTo({
          x: 0,
        });
      } else {
        // keep the focused label at the center of the screen
        scrollViewRef.current?.scrollTo({
          x: (outputRangeRef.current[state.index] as number) - screenWidth / 2,
        });
      }
    }
  }, [state.index, state.routes.length, widths]);

  // get the label widths on mount
  const onLayout = (event: LayoutChangeEvent, index: number) => {
    const { width } = event.nativeEvent.layout;
    const newWidths = [...widths];
    newWidths[index] = width - DISTANCE_BETWEEN_TABS;
    setWidths(newWidths);
  };

  // basic labels as suggested by react navigation
  const labels = state.routes.map((route, index) => {
    const { options } = descriptors[route.key];
    const label = route.name;
    const isFocused = state.index === index;

    const onPress = () => {
      const event = navigation.emit({
        type: "tabPress",
        target: route.key,
        canPreventDefault: true,
      });

      if (!isFocused && !event.defaultPrevented) {
        // The `merge: true` option makes sure that the params inside the tab screen are preserved
        // eslint-disable-next-line
        // @ts-ignore
        navigation.navigate({ name: route.name, merge: true });
      }
    };
    const inputRange = state.routes.map((_, i) => i);
    const opacity = position.interpolate({
      inputRange,
      outputRange: inputRange.map((i) => (i === index ? 1 : 0.5)),
    });

    return (
      <TouchableOpacity
        key={route.key}
        accessibilityRole="button"
        accessibilityState={isFocused ? { selected: true } : {}}
        accessibilityLabel={options.tabBarAccessibilityLabel}
        onPress={onPress}
        style={styles.button}
      >
        <View
          onLayout={(event) => onLayout(event, index)}
          style={styles.buttonContainer}
        >
          <Animated.Text style={[styles.text, { opacity }]}>
            {label}
          </Animated.Text>
        </View>
      </TouchableOpacity>
    );
  });

  return (
    <View style={styles.contentContainer}>
      <Animated.ScrollView
        horizontal
        ref={scrollViewRef}
        showsHorizontalScrollIndicator={false}
        style={styles.container}
      >
        {labels}
        <Animated.View style={[styles.indicator, { transform }]} />
      </Animated.ScrollView>
    </View>
  );
};

const styles = StyleSheet.create({
  button: {
    alignItems: "center",
    justifyContent: "center",
  },
  buttonContainer: {
    paddingHorizontal: DISTANCE_BETWEEN_TABS / 2,
  },
  container: {
    backgroundColor: "black",
    flexDirection: "row",
    height: 34,
  },
  contentContainer: {
    height: 34,
    marginTop: 30,
  },
  indicator: {
    backgroundColor: "white",
    bottom: 0,
    height: 3,
    left: 0,
    position: "absolute",
    right: 0,
    // this must be 1 for the scaleX animation to work properly
    width: 1,
  },
  text: {
    color: "white",
    fontSize: 14,
    textAlign: "center",
  },
});

export default TabBar;

I managed to make it work with a mix of:

Please let me know if you find a more convenient solution.

You have to add width:auto to tabStyle to make tab width flexible.

Then inside each tabBarLabel <Text> component add style textAlign: "center" and width: YOUR_WIDTH .

YOUR_WIDTH can be different for each tab and can be your text.length * 10 (if you want to make it depended on your text length) or get screen width from Dimensions and divide it by any other number to make it equal widths in screen. Example:

const win = Dimensions.get('window');

...

bigTab: {
    fontFamily: "Mulish-Bold",
    fontSize: 11,
    color: "#fff",
    textAlign: "center",
    width: win.width/2 - 40
},
smallTab: {
    fontFamily: "Mulish-Bold",
    fontSize: 11,
    color: "#fff",
    textAlign: "center",
    width: win.width / 5 + 10
}

Remove width from indicatorStyle and use flex:1

indicatorStyle: {    borderBottomColor: colorScheme.teal,
                      borderBottomWidth: 2,
                      flex:1,
                      left:"9%"
                    },

I've achieved this using some hacks around onLayout, please note I've made this with the assumptions of two tabs, and that the second tabs width is greater than the first. It probably will need tweaking for other use cases.

enter image description here

import React, { useEffect, useState } from 'react'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import { Animated, Text, TouchableOpacity, View } from 'react-native'

const Stack = createMaterialTopTabNavigator()

const DISTANCE_BETWEEN_TABS = 25

function MyTabBar ({ state, descriptors, navigation, position }) {
  const [widths, setWidths] = useState([])
  const [transform, setTransform] = useState([])

  const inputRange = state.routes.map((_, i) => i)

  useEffect(() => {
    if (widths.length === 2) {
      const [startingWidth, transitionWidth] = widths
      const translateX = position.interpolate({
        inputRange,
        outputRange: [0, startingWidth + DISTANCE_BETWEEN_TABS + (transitionWidth - startingWidth) / 2]
      })
      const scaleX = position.interpolate({
        inputRange,
        outputRange: [1, transitionWidth / startingWidth]
      })
      setTransform([{ translateX }, { scaleX }])
    }
  }, [widths])

  return (
    <View style={{ flexDirection: 'row' }}>
      {state.routes.map((route, index) => {
        const { options } = descriptors[route.key]
        const label =
          options.tabBarLabel !== undefined
            ? options.tabBarLabel
            : options.title !== undefined
              ? options.title
              : route.name

        const isFocused = state.index === index

        const onPress = () => {
          const event = navigation.emit({
            type: 'tabPress',
            target: route.key,
            canPreventDefault: true
          })

          if (!isFocused && !event.defaultPrevented) {
            // The `merge: true` option makes sure that the params inside the tab screen are preserved
            navigation.navigate({ name: route.name, merge: true })
          }
        }

        const onLayout = event => {
          const { width } = event.nativeEvent.layout
          setWidths([...widths, width])
        }

        const opacity = position.interpolate({
          inputRange,
          outputRange: inputRange.map(i => (i === index ? 0.87 : 0.53))
        })

        return (
          <TouchableOpacity
            key={index}
            accessibilityRole='button'
            accessibilityState={isFocused ? { selected: true } : {}}
            accessibilityLabel={options.tabBarAccessibilityLabel}
            testID={options.tabBarTestID}
            onPress={onPress}
            style={{ marginRight: DISTANCE_BETWEEN_TABS }}
          >
            <Animated.Text
              onLayout={onLayout}
              style={{
                opacity,
                color: '#000',
                fontSize: 18,
                fontFamily: 'OpenSans-Bold',
                marginBottom: 15
              }}
            >
              {label}
            </Animated.Text>
          </TouchableOpacity>
        )
      })}
      <View style={{ backgroundColor: '#DDD', height: 2, position: 'absolute', bottom: 0, left: 0, right: 0 }} />
      <Animated.View style={{ position: 'absolute', bottom: 0, left: 0, width: widths.length ? widths[0] : 0, backgroundColor: '#222', height: 2, transform }} />
    </View>
  )
}

export default () => {
  return (
    <>
      <Stack.Navigator tabBar={props => <MyTabBar {...props} />} style={{ paddingHorizontal: 25 }}>
        <Stack.Screen name='Orders' component={() => <Text>A</Text>} />
        <Stack.Screen name='Reviews' component={() => <Text>B</Text>} />
      </Stack.Navigator>
    </>
  )
}

Update:

If the menu names are static, it is probably a more robust solution to hard code the widths inside of widths, although this is a little more costly to maintain.

Resources:

In the screenOptions, add following props for

tabBarScrollEnabled: true tabBarItemStyle: {{width: "auto", minWidht: "100"}}

minWidth is just to keep the design consistent.

Please note, I am using react-navigation 6.x and Camille Hg answer was really helpful.

I had the same issue and I was finally able to make the indicator takes exactly the text size.

I don't know in which version this was possible .. but apparently you can add a custom indicator component (beside the ability to add a custom tabBar component)

when creating the TopTabNavigator it is important to add the properties as described in the code under

// assuming that you want to add paddingHorizontal: 10 for each item!
const TAB_BAR_ITEM_PADDING = 10;

const Tab = createMaterialTopTabNavigator();

function TopTabNavigator() {
    return (
        <Tab.Navigator
            ..... 
             ... .
            screenOptions={{ 
              .... 
              ... 
               tabBarItemStyle: {
                  // these properties are important for this method to work !!
                  width: "auto",
                  marginHorizontal: 0, // this is to make sure that the spacing of the item comes only from the paddingHorizontal!. 
                  paddingHorizontal: TAB_BAR_ITEM_PADDING, // the desired padding for the item .. stored in a constant to be passed in the custom Indicator 
                 
                },

                tabBarIndicator: props => {
                  return (
                    <CustomTabBarIndicator
                       // the default props 
                       getTabWidth={props.getTabWidth}
                       jumpTo={props.jumpTo}
                       layout={props.layout}
                       navigationState={props.state}
                       position={props.position}
                       width={props.width}
                       style={{
                         left: TAB_BAR_ITEM_PADDING,
                         backgroundColor: Colors.primary,
                       }}

                       // this is an additional property we will need to make the indicator exactly 
                       tabBarItemPadding={TAB_BAR_ITEM_PADDING}
                     />
                    );
                   },
                 }}
                >
            
            <Tab.Screen ....  />
            <Tab.Screen ..... />
            <Tab.Screen .... />
        </Tab.Navigator>
    );
}

now for the CustomTabBarIndIndicator component we simply go to the official github repository for react-native-tab-view and then go to TabBarIndicator.tsx and copy the component over in a file called CustomTabBarIndicator "just to be consistence with the example, but you can call it what ever you want", and don't forget to add the additional property to the Props type for tabBarItemPadding "if you are using typescript"

and now make this small change to the line that is highlighted in the image

enter image description here

change:

const outputRange = inputRange.map(getTabWidth);

to be:

const outputRange = inputRange.map(x => {
  // this part is customized to get the indicator to be the same width like the label
  // subtract the tabBarItemPadding from the tabWidth
  // so that you indicator will be exactly the same size like the label text
  return getTabWidth(x) - this.props.tabBarItemPadding * 2;
});

and that was it :)

P.S. I added the image because I didn't know how to exactly describe where to make the change

and if you don't want the typescript .. jsut remove all the types from the code and you are good to go :)

Related