Building a tab control component for iOS and Android with React Native

A tab control is a crucial component for mobile apps. It enables users to navigate between screens or makes different portions of on-screen content accessible by switching between views.

As a mobile app developer, you’d typically utilize a segmented control on iOS.

Originally published at blog.logrocket.com

iOS segmented control designs by Roman Kamushken

On Android, you’d normally use tab layouts according to Material Design guidelines.

Example of a Material Tab Design.

The goal of this article is to develop a React Native component that renders a segmented control on iOS and a tab component on Android. When it comes to implementation, I reuse code for state management and data flow because they are platform-independent.

Platform-specific code — e.g., due to different UI patterns — is used with the help of React Native mechanisms like the Platform module to distinguish between iOS and Android (as well as Web). My requirements on the component are:

  • The Android component meets Material Design guidelines
  • The iOS component design is strongly oriented towards iOS 13’s design of segmented control. My template is Apple Maps on iOS 13
  • I do not attempt to implement different designs for different operating system versions (e.g., no iOS 12 design of segmented control for users with iOS 12)

Demo app

The following animated gif demonstrates the tab control that is the subject of this article. You can find this project on Github or as a publicly available Expo project.

This demo shows how the component looks like on iOS and Android

I want to focus on the important aspects of developing such a component. For this reason, I have broken the example down to the essentials.

With respect to segmented control, I implemented a version using a motion animation for the interaction concept to visualize the transition between active tabs. In the animated gif above, you can see how this UX pattern looks (iOS Variant: Motion Animation).

You can find the code in my Github project or you can have quick access via my Expo Snack.

TabControl — interface and implementation

The interface of the TabControl component looks like this.

    <TabControl
      values={["Giannis", "LeBron", "Luka"]}
      onChange={value => {
        if (value === "Giannis") {
          setImgSource(sourceUriGiannis);
        } else if (value === "LeBron") {
          setImgSource(sourceUriLeBron);
        } else {
          setImgSource(sourceUriLuka);
        }
      }}
      renderSeparators={showSeparatorsIos}
    />

You have to pass in an array of values that are rendered as tab labels. The onChange prop is called when the user taps on a tab. The callback function gets the label of the active tab. In the demo project, I used the value to set the image source (setImgSource) in order to change the background image whenever the user presses a tab.

Let’s drill into the implementation details of the TabControl component.

    import {
      // ...
      Platform
    } from "react-native";
    import iosTabControlStyles from "./iOSTabControlStyles";
    import androidTabControlStyles from "./androidTabControlStyles";
    const isIos = Platform.OS === "ios";
    const wrapperStyles = StyleSheet.create({
      outerGapStyle: isIos ? { padding: theme.spacing.s } : { padding: 0 }
    });
    const tabControlStyles = isIos ? iosTabControlStyles : androidTabControlStyles;
    const TabControl = ({ values, onChange, renderSeparators }) => {
      const [selectedIndex, setSelectedIndex] = useState(0);
      const handleIndexChange = index => {
        setSelectedIndex(index);
        onChange(values[index]);
      };
      return (
        <View style={wrapperStyles.outerGapStyle}>
          <SegmentedControl
            values={values}
            selectedIndex={selectedIndex}
            onIndexChange={handleIndexChange}
            renderSeparators={renderSeparators}
          />
        </View>
      );
    };

I utilized React Native’s Platform API to render the component differently on both mobile platforms. I created a boolean variable (isIos) to easily perform platform checks throughout the component.

    import iosTabControlStyles from "./iOSTabControlStyles";
    import androidTabControlStyles from "./androidTabControlStyles";
    const isIos = Platform.OS === "ios";
    const wrapperStyles = StyleSheet.create({
      outerGapStyle: isIos ? { padding: theme.spacing.s } : { padding: 0 }
    });
    const tabControlStyles = isIos ? iosTabControlStyles : androidTabControlStyles;

With the wrapperStyles object, you can see how I define different style properties. On Android, the component should stretch across the whole viewport width, but on iOS, there should be a small gap on the left and right sides.

Depending on the platform, iOSTabControlStyles or androidTabControlStyles are assigned to tabControlStyles. This object holds the actual styles and is accessed from different parts throughout the component. The idea behind this concept is that both imported style objects have the same “interface.” Let’s take a look at the Android styles (androidTabControlStyles.js).

    import { StyleSheet } from "react-native";
    import theme from "../theme";
    const { tabsContainerColor, borderColor, activeTextColor } = theme.color;
    export const androidTabBarHeight = 40;
    const fontStyles = {
      fontFamily: theme.fontFamily.normal,
      fontSize: theme.fontSize.l,
      color: activeTextColor
    };
    const gap = theme.spacing.s;
    export default StyleSheet.create({
      tabsContainerStyle: {
        backgroundColor: tabsContainerColor,
        height: androidTabBarHeight
      },
      tabStyle: {
        flex: 1,
        paddingVertical: gap,
        paddingHorizontal: 2 * gap
      },
      tabTextStyle: { ...fontStyles, alignSelf: "center" },
      activeTabStyle: {
        borderBottomWidth: theme.spacing.xs,
        borderBottomColor: borderColor
      },
      activeTabTextStyle: {
        ...fontStyles
      },
      firstTabStyle: {},
      lastTabStyle: {}
    });

Concrete values for colors, spacing, or fonts are defined in the imported theme.js file. As an example, theme.fontSize.l assigns a large font size.

The stylesheet object for iOS has the same structure (iOSTabControlStyles.js). Of course, the values are different. The following prop type shows the “interface.”

      import { ViewPropTypes } from "react-native";
      // ...
      const styleShape = PropType.shape({
        tabsContainerStyle: ViewPropTypes.styles,
        tabStyle: ViewPropTypes.styles,
        tabTextStyle: ViewPropTypes.styles,
        activeTabStyle: ViewPropTypes.styles,
        activeTabTextStyle: ViewPropTypes.styles,
        firstTabStyle: ViewPropTypes.styles,
        lastTabStyle: ViewPropTypes.styles
      });

The TabControl component assumes that these properties exist on the stylesheet object. For reasons of clarity, I’ll skip the details of the iOS stylesheet object.

TabControl is a stateful component that stores the selected index (selectedIndex). In addition, it defines a function (handleIndexChange) to change the selected index and invokes the passed onChange callback — in our case, to change the background image.

    const TabControl = ({ values, onChange, renderSeparators }) => {
      const [selectedIndex, setSelectedIndex] = useState(0);
      const handleIndexChange = index => {
        setSelectedIndex(index);
        onChange(values[index]);
      };
      return (
        <View style={wrapperStyles.outerGapStyle}>
          <SegmentedControl
            values={values}
            selectedIndex={selectedIndex}
            onIndexChange={handleIndexChange}
            renderSeparators={renderSeparators}
          />
        </View>
      );
    };

The component renders a SegmentedControl component inside of a container with the aforementioned horizontal paddings. values and renderSeparators, a boolean flag to control whether separators should be rendered on iOS, are passed as props along with the press handler and the index of the active tab.

SegmentedControl — rendering of tabs in a platform-agnostic container

SegmentedControl is responsible to render a Tab component for every entry of the tabValues array.

    function SegmentedControl({
      values: tabValues,
      selectedIndex,
      onIndexChange,
      renderSeparators,
    }) {
      return (
        <Container
          style={tabControlStyles}
          numberValues={tabValues.length}
          activeTabIndex={selectedIndex}
        >
          {tabValues.map((tabValue, index) => (
            <Tab
              label={tabValue}
              onPress={() => {
                onIndexChange(index);
              }}
              isActive={selectedIndex === index}
              isFirst={index === 0}
              isLast={index === tabValues.length - 1}
              renderLeftSeparator={
                renderSeparators && shouldRenderLeftSeparator(index, selectedIndex)
              }
              key={tabValue}
            />
          ))}
        </Container>
      );
    }

The tab label and the tab press callback function are passed on to the Tab component. isActive, isFirst, and isLast tell the Tab component the correct context to render the tabs differently (e.g., a bottom border for an active tab on Android). The context is determined by the current index and selected index. renderLeftSeperators represents a boolean flag that determines whether a separator on the left side of the tab should be rendered (this is the implementation decision).

I’ll skip the implementation detail of the function shouldRenderLeftSeparator. In short, the separator for a tab is rendered for all tabs on the iOS platform if they are not the first or active tab, or if the previous tab constitutes the active tab.

Container — horizontal layout component with motion animation on iOS

The Container component renders differently on iOS and Android. Of course, the children prop constitutes the map of Tab components. numberValues and activeTabIndex are used to calculate the new state of the animation.

    function Container({
      children,
      numberValues,
      style,
      activeTabIndex
    }) {
      const { tabStyle, activeTabStyle, tabsContainerStyle } = style;
      const margin = theme.spacing.s;
      const [moveAnimation] = useState(new Animated.Value(0));
      const [containerWidth, setContainerWidth] = useState(0);
      useEffect(() => {
        const leftVal = (containerWidth / numberValues) * activeTabIndex;
        Animated.timing(moveAnimation, {
          toValue: leftVal,
          duration: 250
          // not supported by native animated module
          // useNativeDriver: true
        }).start();
      }, [containerWidth, activeTabIndex]);
      return isIos ? (
        <View
          style={[
            {
              marginHorizontal: margin,
              flexDirection: "row",
              position: "relative"
            },
            tabsContainerStyle
          ]}
          onLayout={event => {
            setContainerWidth(event.nativeEvent.layout.width);
          }}
        >
          <Animated.View
            
            style={{
              // works too
              // width: `${100 / numberValues}%`,
              width: containerWidth / numberValues,
              left: moveAnimation,
              top: iosTabVerticalSpacing,
              bottom: iosTabVerticalSpacing,
              position: "absolute",
              ...tabStyle,
              ...activeTabStyle
            }}
            
          ></Animated.View>
          {children}
        </View>
      ) : (
        <View
          style={[
            { marginHorizontal: margin, flexDirection: "row" },
            tabsContainerStyle
          ]}
        >
          {children}
        </View>
      );
    }

The easy part is the Android version (the second expression of the ternary operator). It’s just a tiny bit of JSX code that wraps the children in a View tag with some margin on the left and right sides (marginHorizontal).

flexDirection: "row" is required to render the tabs side by side since the default direction is column, in contrast to the W3C/web version of Flexbox.

tabsContainerStyle is destructured from the passed style prop and defines the background color and height of the container component (androidTabControlStyles.js).

    return isIos ? (
        // iOS version / animation code
      ) : (
        // Android version
        <View
          style={[
            { marginHorizontal: margin, flexDirection: "row" },
            tabsContainerStyle
          ]}
        >
          {children}
        </View>
      )

The iOS version is more complex.

    // ...
      const [moveAnimation] = useState(new Animated.Value(0));
      const [containerWidth, setContainerWidth] = useState(0);
      useEffect(() => {
        const leftVal = (containerWidth / numberValues) * activeTabIndex;
        Animated.timing(moveAnimation, {
          toValue: leftVal,
          duration: 250
          // not supported by native animated module
          // useNativeDriver: true
        }).start();
      }, [containerWidth, activeTabIndex]);
      return isIos ? (
        <View
          style={[
            {
              marginHorizontal: margin,
              flexDirection: "row",
              position: "relative"
            },
            tabsContainerStyle
          ]}
          onLayout={event => {
            setContainerWidth(event.nativeEvent.layout.width);
          }}
        >
          <Animated.View
            style=
          ></Animated.View>
          {children}
        </View>
      ) : (
        // Android version
      )

I decided to use two state objects to manage the animation. containerWidth holds the width of the container object. setContainerWidth is called inside the callback function assigned to View’s onLayout prop. event.nativeEvent.layout.width returns the actual width of the component.

The array assigned to the style prop looks very similar to the Android version as described above. The only difference is position: "relative" because the implementation of the active tab animation (Animated.View) uses absolute positioning.

    <View style={[
        {
          marginHorizontal: margin,
          flexDirection: "row",
          position: "relative"
        },
        tabsContainerStyle
      ]}
      onLayout={event => {
        setContainerWidth(event.nativeEvent.layout.width);
      }}>
      <Animated.View>
        // ...
      </Animated.View>
      {children}
    </View>

Animated is React Native’s animation library. Animated.View is an enhanced View component positioned absolutely in the relatively positioned parent component.

It does not have any children because the only purpose is to have a styled component with background color and rounded corners that is animated on the horizontal axis by updating the left property.

width is calculated dynamically based on the number of tabs (numberValues) and the container’s width (containerWidth). top and bottom are used to add some vertical spacing.

    <Animated.View
      style=>
    </Animated.View>
    {children}

Finally, we have to take a look at the useEffect Hook, where the value of the left prop is calculated and then used by the animation (Animated.timing()).

    useEffect(() => {
      const leftVal = (containerWidth / numberValues) * activeTabIndex;
      Animated.timing(moveAnimation, {
        toValue: leftVal,
        duration: 250
      }).start();
    }, [containerWidth, activeTabIndex]);

As you can see, useEffect’s dependency array contains containerWidth and activeTabIndex (numberValues does not change). Whenever one of these values changes, the animation is updated, and the active tab indicator component moves on the horizontal axis within 250ms to the new position.

Tab — platform-agnostic abstraction

Next is the Tab component that renders for both operating systems a Text component with its associated styles (tabTextStyle).

    function Tab({
      label,
      onPress,
      isActive,
      isFirst,
      isLast,
      renderLeftSeparator
    }) {
      const {
        tabStyle,
        tabTextStyle,
        activeTabStyle,
        activeTabTextStyle,
        firstTabStyle,
        lastTabStyle
      } = tabControlStyles;
      return (
        <OsSpecificTab
          isActive={isActive}
          onPress={onPress}
          style={[
            tabStyle,
            !isIos && isActive && activeTabStyle,
            isFirst && firstTabStyle,
            isLast && lastTabStyle
          ]}
          renderLeftSeparator={renderLeftSeparator}
        >
          <Text style={[tabTextStyle, isActive && activeTabTextStyle]}>
            {label}
          </Text>
        </OsSpecificTab>
      );
    }
    const OsSpecificTab = (props) => {
      return isIos ? <IosTab {...props} /> : <AndroidTab {...props} />;
    };

If the tab is active, additional styles are added (activeTabTextStyle). The container component, OsSpecificTab, is called with the onPress handler and a hint whether this tab is active (isActive).

An array gets assigned to the style prop. If the tab represents the first or last tab, additional styles are inserted to this array. For the Android version only, styles are added in case the tab is active (activeTabStyle) to render the bottom border as a visual indicator. As I described above, the implementation of the active tab for iOS is different, and thus, this information is not relevant on this layer of the component tree.

OsSpecificTab is pretty simple: it just renders an IosTab or AndroidTab component and assigns all props to it.

AndroidTab & IosTab — platform-dependent implementations

Finally, let’s take a look at the actual tab implementations. The implementation of AnroidTab utilizes TouchableNativeFeedback, which is an Android-only API to add a native look and feel.

Instead of an odd-looking colored background, we assign a ripple effect to the background prop. The child container is an ordinary View component that gets styled (tabControlStyle).

    const AndroidTab = ({ children, style: tabControlStyle, onPress }) => (
      <TouchableNativeFeedback
        onPress={onPress}
        background={TouchableNativeFeedback.Ripple(theme.color.ripple, true)}>
        <View style={tabControlStyle}>{children}</View>
      </TouchableNativeFeedback>
    );

IosTab looks a bit different.

    const IosTab = ({
      children,
      style: tabControlStyle,
      onPress,
      renderLeftSeparator
    }) => (
      <View style=>
        {renderLeftSeparator && (
          <View
            style=
          ></View>
        )}
        <TouchableWithoutFeedback onPress={onPress}>
          <View style={tabControlStyle}>{children}</View>
        </TouchableWithoutFeedback>
      </View>
    );

Based on the value of the boolean flag renderLeftSeparator, the vertical separator element is rendered (vertically centered, 50 percent height of the container) on the left side of the TouchableWithoutFeedback component. As with the Android tab, the children prop gets wrapped by a styled View component.

Contribute even more to the native user experience

Besides things like TouchableHighlight or TouchablenativeFeedback (for ripple effects on Android), there are even more possibilities to improve the native look and feel. As an example, with Expo Haptics, it is possible to add haptic touch feedback for iOS and Android.

In my app, I just have to add one line in the onPress callback of the SegmentedControl component. That’s it. Pretty cool, huh?

    import * as Haptics from "expo-haptics";
    // ...
    <Tab
      label={tabValue}
      onPress={() => {
        onIndexChange(index);
        Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
      }}
      // ...
      >
    // ...

But wait — there are more cool things possible. As with Apple Maps, you can also swipe left and right to change the active tab. With react-native-gesture-handler, it is possible to extend our component to have swipe capabilities on iOS. Therefore, I have to extend the Container component a bit.

    import { PanGestureHandler } from "react-native-gesture-handler";
    // ...
    function Container({
      children,
      numberValues,
      style,
      activeTabIndex,
      onIndexChange // this callback is passed by SegmentedControl
    }) {
      // ...
      useEffect(() => {
        // ...
      }, [containerWidth, activeTabIndex]);
      const onGestureEvent = evt => {
        const tabWidth = containerWidth / numberValues;
        let index = Math.floor(evt.nativeEvent.x / tabWidth);
        if (index > numberValues - 1) index = numberValues - 1;
        else if (index < 0) index = 0;
        if (index !== activeTabIndex) {
          onIndexChange(index);
        }
      };
      return isIos ? (
        <PanGestureHandler onGestureEvent={onGestureEvent}>
          <View style={[
              {
                marginHorizontal: margin,
                flexDirection: "row",
                position: "relative"
              },
              tabsContainerStyle
            ]}
            onLayout={event => {
              setContainerWidth(event.nativeEvent.layout.width);
            }}>
            // ...
          </View>
        </PanGestureHandler>
      ) : (
        // Android
      );
    }

We wrap the JSX code for the iOS version (i.e., the first expression of the ternary operator) in a PanGestureHandler. The onGestureEvent function is called whenever the user performs a swipe gesture.

In this function, we calculate the new index and invoke the onIndexChange callback. The callback function needs to be passed by SegmentedControl. In the previous version, this was not required because the index was changed only by tap, and therefore, the activeTabIndex was sufficient.

    const tabWidth = containerWidth / numberValues;
    let index = Math.floor(evt.nativeEvent.x / tabWidth);

We utilize the x property of PanGestureHandler that constitutes the coordinate of the current position of the finger relative to our Container component. With this information and the tab width, we can calculate the new index.

Technical hurdles and possible further development

I have not succeeded to combine “iOS Variant 2 and 3” (take a look at the animated gif above). As you can see with Apple Maps, you have a motion animation while switching the tabs and a scaling animation (i.e., the tab shrinks a bit) on tap.

You are welcome to try it out with my Github project. With my approach based on absolute positioning, I had a layering problem (z-index) in a way that the tab was not positioned below the tab label. Here is the code for the iOS variant with a scaling animation. If you have an idea how to combine both variants, please let me know in the comments section.

    const IosScaleTab = ({
      isActive,
      children,
      style: tabControlStyle,
      onPress,
      renderLeftSeparator
    }) => {
      const scaleValue = new Animated.Value(0);
      const activeTabScale = scaleValue.interpolate({
        inputRange: [0, 0.5, 1],
        outputRange: [1, 0.97, 0.95]
      });
      const transformStyle = { transform: [{ scale: activeTabScale }] };
      const animatedViewStyle = [isActive ? transformStyle : {}, tabControlStyle];
      const timingProps = {
        toValue: 1,
        duration: 50,
        easing: Easing.linear,
        useNativeDriver: true
      };
      return (
        <View style=>
          {renderLeftSeparator && (
            <View
              style=
            ></View>
          )}
          <TouchableWithoutFeedback
            onPressIn={() => {
              Animated.timing(scaleValue, timingProps).start();
              onPress();
            }}
            onPressOut={() => {
              Animated.timing(scaleValue, {
                ...timingProps,
                toValue: 0
              }).start();
            }}
          >
            <Animated.View style={animatedViewStyle}>{children}</Animated.View>
          </TouchableWithoutFeedback>
        </View>
      );
    };

Again, this animation is implemented with the Animated API. With the help of the interpolate function, we change the scale value of the transform style property over a duration of 50ms from 100 percent to 95 percent (see outputRange).

These animation styles (transformStyle) are only applied if the tab is active. I use Easing.linear as an easing function over the inputRange consisting of three values.

The whole animation is kicked off with the onPressIn prop of the TouchableWithoutFeedback component. If the user stops pressing, the onPressOut callback is called, which animates the size of the active tab back to its original size.

As you can see with the timingProps object, for this animation, the native driver (useNativeDriver: true) can be utilized to improve performance. Unfortunately, this was not possible with our movement animation, where the left property is changed over time.

Existing libraries

During the preparation of this article, I searched for existing React Native libraries that provide Android-like tabs and/or iOS-like segmented controls. This article gives a good overview of component libraries in 2020.

NativeBase.io provides a tab component according to Material Design guidelines that looks similar on Android and iOS. The Android version seems to look like my implementation above. If your project’s goal is to have a native iOS look and feel, then this component does not offer you a suitable iOS version.

React Native UI Kitten offers a Tab View component that you can use for Android. It enables you to use labels with and without icons. Again, there is no optimized version for iOS.

A nice tab view component is also available from the React Native Community. Once again, the component is designed according to Android Material Design guidelines and looks similar on both operating systems.

During my research, I have not found an example for a segmented control implementation based on iOS 13 design. The following components are inspired by iOS 12.

Until recently, the React Native team offered a segmented control component for iOS. However, this component is deprecated. Instead, they recommend to use @react-native-community/segmented-control.

At the time of this writing, the component only offers an iOS 12 design. Nachos UI Kit provides a rudimentary segmented control component. The following GitHub projects have implemented segmented control components, but it appears that some of these projects are no longer maintained:

Conclusion

As you can see, the more complex part is the iOS version of this component. Since I’m mainly an iOS user, I might have missed a few design concepts for the Android version. Let me know in the comments section if there is more work to do for a better Android user experience.

In comparison to a native implementation, there are, of course, a few shortcomings with this approach. Implementing and maintaining different designs for every OS version is hard (e.g., the look and feel of the segmented control on iOS 12 and iOS 13 is different). I don’t think that’s necessary. However, in the end, it depends on the project!

Do you think it would be useful if I developed a library on GitHub based on the concepts described in this article? Let me know what you think.

Written on June 25, 2020