0

I am experiencing an issue with the tab indicator on Android when tabs autoscroll. The indicator disappears during the autoscroll process, and when I manually scroll the tabs back to their initial position, the indicator reappears at that position.

This issue does not occur on iOS, where the code functions as expected. I have attempted several solutions, such as removing the indicator from the ListHeaderComponent and displaying it externally, but I have not been able to resolve the problem.

I have included both the code and a GIF demonstrating the behavior. If anyone has encountered a similar issue or has any suggestions for resolving this, I would greatly appreciate your insights.

Thank you in advance for your help!

import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { faker } from "@faker-js/faker";
import React, { useEffect, useRef, useState, useCallback } from "react";
import {
  FlatList,
  Image,
  LayoutRectangle,
  SectionList,
  StyleSheet,
  TouchableOpacity,
  useWindowDimensions,
  ViewToken,
} from "react-native";
import Animated, {
  AnimatedProps,
  interpolate,
  runOnJS,
  SharedValue,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated";
import getItemLayout from "react-native-get-item-layout-section-list";
import { useSafeAreaInsets } from "react-native-safe-area-context";

faker.seed(123);
const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
const getRandomData = () =>
  [...Array(faker.number.int({ min: 2, max: 5 }))].map(() => ({
    key: faker.string.uuid(),
    song: faker.music.songName(),
    artist: faker.music.artist(),
    album: faker.music.album(),
    cover: faker.image.personPortrait(),
  }));

const _sections = [...Array(5).keys()].map((index) => ({
  key: faker.string.uuid(),
  title: faker.music.genre(),
  data: getRandomData(),
  sectionIndex: index,
}));

const _spacing = 12;
const _indicatorSize = 4;
const ITEM_HEIGHT = 99;
const SECTION_HEADER_HEIGHT = 46;

const buildGetItemLayout = getItemLayout({
  getItemHeight: ITEM_HEIGHT,
  getSectionHeaderHeight: SECTION_HEADER_HEIGHT,
});

type TabsLayout = Record<number, LayoutRectangle>;

// Memoized list item component
const DummyItem = React.memo(
  ({ item }: { item:any}) => (
    <ThemedView style={styles.itemContainer}>
      <ThemedView style={{ gap: _spacing, flexDirection: "row" }}>
        <Image
          source={{ uri: item.cover }}
          style={{
            height: 50,
            aspectRatio: 1,
            borderRadius: _spacing / 2,
            borderWidth: 2,
            borderColor: "rgba(255,255,255,0.5)",
          }}
        />
        <ThemedView style={{ flex: 1, justifyContent: "space-between" }}>
          <ThemedText style={styles.artistText}>{item?.artist}</ThemedText>
          <ThemedText style={styles.songText}>{item?.song}</ThemedText>
          <ThemedText style={styles.albumText}>{item?.album}</ThemedText>
        </ThemedView>
      </ThemedView>
    </ThemedView>
  )
);

function Indicator({ measurements }: { measurements: LayoutRectangle }) {

  const _stylez = useAnimatedStyle(() => ({
    width: withTiming(measurements.width, { duration: 150 }),
    left: withTiming(measurements.x, { duration: 150 }),
    top: measurements.height,
  }));

  return <Animated.View style={[styles.indicator, _stylez]} />;
}

const Tabs = React.memo(
  ({
    activeSectionIndex,
    onTabPress,
  }: {
    activeSectionIndex: number;
    onTabPress: (index: number) => void;
  }) => {
    const _tabsLayout = useRef<TabsLayout>({});
    
    const ref = useRef<FlatList>(null);
    const [isDoneMeasuring, setIsDoneMeasuring] = useState(false);

    const scrollToIndex = useCallback((index: number) => {
      ref.current?.scrollToIndex({
        index,
        animated: true,
        viewPosition: 0.5,
      });
    }, []);

    useEffect(() => {
      scrollToIndex(activeSectionIndex);
    }, [activeSectionIndex, scrollToIndex]);

    const renderTabItem = useCallback(
      ({ item, index }: { item: (typeof _sections)[0]; index: number }) => (
        <TouchableOpacity onPress={() => onTabPress(index)}>
          <ThemedView style={styles.tabItem}>
            <ThemedText style={styles.tabText}>{item?.title}</ThemedText>
          </ThemedView>
        </TouchableOpacity>
      ),
      [onTabPress]
    );

    return (
      <FlatList
        ref={ref}
        data={_sections}
        initialNumToRender={5}
        maxToRenderPerBatch={5}
        windowSize={21}
        getItemLayout={(_, index) => ({
          length: _tabsLayout.current[index]?.width || 0,
          offset: _tabsLayout.current[index]?.x || 0,
          index,
        })}
        CellRendererComponent={({ children, index, ...rest }) => (
          <ThemedView
            {...rest}
            onLayout={(e) => {
              _tabsLayout.current[index] = e.nativeEvent.layout;
              if (
                Object.keys(_tabsLayout.current).length === _sections.length &&
                !isDoneMeasuring
              ) {
                setIsDoneMeasuring(true);
              }              
            }}
          >
            {children}
          </ThemedView>
        )}
        renderItem={renderTabItem}
        horizontal
        style={styles.tabsContainer}
        showsHorizontalScrollIndicator={false}
        scrollEventThrottle={16}
        contentContainerStyle={styles.tabsContent}
        ListHeaderComponent={
          isDoneMeasuring ? (
            <Indicator measurements={_tabsLayout.current[activeSectionIndex]} />
          ) : null
        }
        ListHeaderComponentStyle={styles.indicatorPosition}
      />
    );
  }
);

const _headerHeight = 350;
const _headerTabsHeight = 49;
const _headerTopNav = 80;
const _headerImageHeight = _headerHeight - _headerTabsHeight;
const _topThreshold = _headerHeight - _headerTopNav;

const ScrollHeader = React.memo(
  ({
    selectedSection,
    onTabPress,
    scrollY,
  }: {
    selectedSection: number;
    onTabPress: (index: number) => void;
    scrollY: SharedValue<number>;
  }) => {
    const { top } = useSafeAreaInsets();

    const headerStylez = useAnimatedStyle(() => ({
      transform: [
        {
          translateY: interpolate(
            scrollY.value,
            [-1, 0, 1, _topThreshold - 1, _topThreshold],
            [1, 0, -1, -_topThreshold, -_topThreshold]
          ),
        },
      ],
    }));

    const imageStyles = useAnimatedStyle(() => ({
      opacity: interpolate(
        scrollY.value,
        [-1, 0, _headerImageHeight - 1, _headerImageHeight],
        [1, 1, 0.3, 0]
      ),
      transform: [
        {
          scale: interpolate(
            scrollY.value,
            [-1, 0, 1],
            [1 + 1 / _headerHeight, 1, 1]
          ),
        },
      ],
    }));

    return (
      <Animated.View style={[styles.headerContainer, headerStylez]}>
        <Animated.Image
          source={{
            uri: "https://images.pexels.com/photos/7497788/pexels-photo-7497788.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1",
          }}
          style={[styles.headerImage, imageStyles]}
          resizeMode="cover"
        />
        <Tabs activeSectionIndex={selectedSection} onTabPress={onTabPress} />
      </Animated.View>
    );
  }
);

const Home = () => {
  const [selectedSection, setSelectedSection] = useState(0);
  const activeSectionIndexRef = useRef(0);
  const scrollingFromTabPress = useRef(false);
  const sectionRef = useRef<SectionList>(null);
  const { height } = useWindowDimensions();
  const scrollY = useSharedValue(0);

  const setScrollingFromTabPress = useCallback((value: boolean) => {
    scrollingFromTabPress.current = value;
  }, []);

  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      scrollY.value = e.contentOffset.y;
    },
    onBeginDrag: () => {
      runOnJS(setScrollingFromTabPress)(false);
    },
  });

  const scrollToSection = useCallback((index: number) => {
    sectionRef.current?.scrollToLocation({
      itemIndex: 0,
      sectionIndex: index,
      animated: true,
      viewOffset: 0,
      viewPosition: 0,
    });
  }, []);

  const handleViewableItemsChanged = useCallback(
    ({ viewableItems }: { viewableItems: ViewToken[] }) => {
      if (scrollingFromTabPress.current) return;

      const section = viewableItems[0]?.section;
      if (!section) return;

      const { sectionIndex } = section as (typeof _sections)[0];
      if (sectionIndex !== selectedSection) {
        activeSectionIndexRef.current = sectionIndex;
        setSelectedSection(sectionIndex);
      }
    },
    [selectedSection]
  );

  const viewabilityConfig = useRef({
    minimumViewTime: 50,
    itemVisiblePercentThreshold: 50,
    waitForInteraction: true,
  }).current;

  return (
    <ThemedView style={styles.container}>
      <AnimatedSectionList
        ref={sectionRef as any}
        sections={_sections}
        keyExtractor={(item:any) => item.key}
        renderItem={({ item }) => <DummyItem item={item} />}
        contentContainerStyle={[
          styles.sectionListStyle,
          { paddingBottom: height / 3, paddingTop: _headerHeight },
        ]}
        renderSectionHeader={({ section: { title } }:{section:any}) => (
          <ThemedView style={styles.sectionHeader}>
            <ThemedText style={styles.sectionHeaderText}>{title}</ThemedText>
          </ThemedView>
        )}
        getItemLayout={buildGetItemLayout}
        onViewableItemsChanged={handleViewableItemsChanged}
        viewabilityConfig={viewabilityConfig}
        onScroll={onScroll}
        scrollEventThrottle={16}
        initialNumToRender={10}
        maxToRenderPerBatch={10}
        windowSize={21}
        bounces={false}
        removeClippedSubviews={true}
        stickyHeaderHiddenOnScroll
      />
      <ScrollHeader
        scrollY={scrollY}
        onTabPress={(index) => {
          if (selectedSection !== index) {
            setSelectedSection(index);
            scrollToSection(index);
            scrollingFromTabPress.current = true;
          }
        }}
        selectedSection={selectedSection}
      />
    </ThemedView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  itemContainer: {
    padding: _spacing,
    borderBottomWidth: 1,
    borderBottomColor: "rgba(0,0,0,0.05)",
  },
  songText: {
    fontSize: 16,
    fontWeight: "bold",
  },
  albumText: {
    fontSize: 10,
    opacity: 0.3,
  },
  sectionListStyle: {},
  artistText: {
    fontSize: 14,
    opacity: 0.3,
  },
  sectionHeader: {
    backgroundColor: "#fff",
    paddingVertical: _spacing * 2,
    paddingHorizontal: _spacing,
  },
  sectionHeaderText: {
    fontSize: 18,
    fontWeight: "700",
  },
  indicator: {
    height: _indicatorSize,
    backgroundColor: "#000",
  },
  tabsContainer: {
    flexGrow: 0,
    paddingBottom: _indicatorSize,
    height: _headerTabsHeight,
  },
  tabsContent: {
    gap: _spacing * 2,
    paddingHorizontal: _spacing,
  },
  indicatorPosition: {
    position: "absolute",
  },
  tabItem: {
    paddingVertical: _spacing,
  },
  tabText: {
    fontWeight: "600",
  },
  headerContainer: {
    height: _headerHeight,
    position: "absolute",
    backgroundColor: "#fff",
    top: 0,
    left: 0,
    right: 0,
  },
  headerImage: {
    height: _headerImageHeight,
    width: "100%",
  },
});

export default Home;

enter image description here

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.