skip to Main Content

The code to render a TabList:

import React, { Children, useEffect } from 'react';
import { LayoutChangeEvent, View } from 'react-native';
import {
  ScrollView,
  TouchableWithoutFeedback,
} from 'react-native-gesture-handler';
import Animated, {
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

import { isValidChild } from '@utils';

import { useTabIndex } from '../tab-context';

import { useStyle } from './tab-list.styles';
import { TabListProps } from './tab-list.type';

const animConfig = {
  duration: 200,
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
};

const TabList: React.FC<TabListProps> = props => {
  const styles = useStyle();
  const { children, onChange } = props;
  const selectedTabIndex = useTabIndex();
  const animatedTabIndicatorPosition = useSharedValue(0);

  // Save layout of the container
  const [containerLayout, setContainerLayout] = React.useState({
    x: 0,
    y: 0,
    width: 0,
    height: 0,
  });

  const onContainerLayout = (event: LayoutChangeEvent) => {
    const { x, y, width, height } = event.nativeEvent.layout;
    setContainerLayout({ x, y, width, height });
  };

  // get children length
  const childrenLength = Children.count(children);
  const tabWidth =
    childrenLength > 3
      ? containerLayout.width / 3
      : containerLayout.width / childrenLength;

  const renderChildren = () => {
    // Render only children of component type TabList
    return Children.map(children, child => {
      // Check if child is a valid React element and has type TabList
      if (isValidChild(child, 'Tab')) {
        return (
          <TouchableWithoutFeedback
            containerStyle={{ width: tabWidth }}
            onPress={() => onChange((child as JSX.Element)?.props.tabIndex)}
          >
            {child}
          </TouchableWithoutFeedback>
        );
      }

      // Throw error if child is not a TabList
      throw new Error('TabList component can only have children of type Tab');
    });
  };

  useEffect(() => {
    animatedTabIndicatorPosition.value = selectedTabIndex * tabWidth;
  }, [selectedTabIndex]);

  const indicatorAnimatedStyle = useAnimatedStyle(() => {
    return {
      width: tabWidth,
      transform: [
        {
          translateX: withTiming(
            animatedTabIndicatorPosition.value,
            animConfig,
          ),
        },
      ],
    };
  }, []);

  return (
    <View onLayout={onContainerLayout} style={styles.container}>
      <ScrollView
        horizontal
        showsHorizontalScrollIndicator={false}
        testID="TestID__component-TabList"
      >
        <Animated.View
          style={[styles.indicatorContainer, indicatorAnimatedStyle]}
        >
          <View
            style={[
              styles.indicator,
              {
                width: tabWidth - 4,
              },
            ]}
          />
        </Animated.View>
        {renderChildren()}
      </ScrollView>
    </View>
  );
};

export default TabList;

The styles for the component elements:

import { createUseStyle } from '@theme';

// createUseStyle basically returns (fn) => useStyle(fn)
export const useStyle = createUseStyle(theme => ({
  container: {
    position: 'relative',
    flexGrow: 1,
    backgroundColor: theme.palette.accents.color8,
    height: 32,
    borderRadius: theme.shape.borderRadius(4.5),
  },

  indicatorContainer: {
    position: 'absolute',
    height: 32,
    justifyContent: 'center',
    alignItems: 'center',
  },

  indicator: {
    height: 28,
    backgroundColor: theme.palette.background.main,
    borderRadius: theme.shape.borderRadius(4),
  },
}));

I am using react-native-reanimated to animate the tab indicator. What I noticed is, on app reload, the initial tab indicator position keeps on changing as seen in the GIF I have attached. At times, it is positioned where it should be and at times, half the box is hidden behind the scrollview container. When I remove the alignItems: center from the Animated.View, things work as expected.

I am perplexed as to why the position keeps changing because of align-items?

Initial tab indicator position gets jumped

2

Answers


  1. Chosen as BEST ANSWER

    The issue was that the child indicator component wasn't wrapped within the boundary of the indicator container. I resolved this by adding flexWrap: 'wrap' to the parent indicator container.

    So, the new style looks like this:

    import { createUseStyle } from '@theme';
    
    // createUseStyle basically returns (fn) => useStyle(fn)
    export const useStyle = createUseStyle(theme => ({
      container: {
        position: 'relative',
        flexGrow: 1,
        backgroundColor: theme.palette.accents.color8,
        height: 32,
        borderRadius: theme.shape.borderRadius(4.5),
      },
    
      indicatorContainer: {
        position: 'absolute',
        height: 32,
        justifyContent: 'center',
        alignItems: 'center',
        flexWrap: 'wrap'
      },
    
      indicator: {
        height: 28,
        backgroundColor: theme.palette.background.main,
        borderRadius: theme.shape.borderRadius(4),
      },
    }));
    

  2. Edit: disregard the below, the behavior is not due to hot reloading! I’ll leave this up in case anyone else has the same misconception


    Hot reloading is not reliable with Reanimated – there are values on native threads that won’t get refreshed. This has no impact on the final app.

    To test whether it’s really working, simply shake the device/sim and hit Reload after you make changes. This is enough to clear any sticky values. If your component still isn’t doing what you want, you can then have the confidence to edit it and be sure it looks right.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search