Search code examples
cssreactjsreact-nativeflexboxreact-native-reanimated

View centered within the absolutely positioned Animated.View inside a ScrollView shifts position


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


Solution

  • 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),
      },
    }));