react-nativeexporeact-native-flatlistreact-native-reanimated

how to dynamically calculate the size of a parent inside a nested list ,while items are added or removed


so im working on menu where there are categories and sub categories and the sub categories is a collapsible and the collapsible has smooth animation my issue right now im giving a fixed height for the parent of the collapsible

  heightAnim.value = expanded ? data.length * 200 : 0;

to accommodate the items inside them ,how do I dynamically calculate it so that the height is given for each collapsible based on the items inside and if there any collapsible inside them the parent should accommodate based on toggle ,I'm a beginner to react I'm unable to figure it out any help and guidance will be useful

Here is my code below

import React, { useState, useEffect, useMemo } from "react";
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  ActivityIndicator,
} from "react-native";
import { FlashList } from "@shopify/flash-list";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from "react-native-reanimated";
import { FlatList } from "react-native-gesture-handler";


// Function to fetch menu data
const fetchMenuData = async () => {
  try {
    const response = await fetch(
      "https://piadnhuknkvucpovcwpf.supabase.co/storage/v1/object/public/project_assets//flat_menu.json"
    );
    return await response.json();
  } catch (error) {
    console.error("Error loading menu data:", error);
    return [];
  }
};

// CollapsibleSection functional component
const CollapsibleSection = ({
  title,
  data,
  expanded: initialExpanded,
}: any) => {
  const [expanded, setExpanded] = useState(initialExpanded);
  const heightAnim = useSharedValue(0);
  const opacityAnim = useSharedValue(0);

  const animatedStyle = useAnimatedStyle(() => ({
    height: withTiming(heightAnim.value, { duration: 300 }),
    opacity: withTiming(opacityAnim.value, { duration: 300 }),
  }));

  useEffect(() => {
    heightAnim.value = expanded ? data.length * 200 : 0; // Adjust based on item height
    opacityAnim.value = expanded ? 1 : 0;
  }, [expanded, data.length]);

  const toggleExpand = () => {
    setExpanded(!expanded);
  };

  return (
    <View style={styles.section}>
      <TouchableOpacity onPress={toggleExpand} style={styles.header}>
        <Text style={styles.headerText}>{title}</Text>
      </TouchableOpacity>

      <Animated.View style={[styles.content, animatedStyle]}>
        <FlatList
          data={data}
          keyExtractor={(item) => item.key}
          scrollEnabled={false}
          showsVerticalScrollIndicator={false}
          renderItem={({ item }) => (
            <View style={{ height: 50 }}>
              <Text style={styles.item}>{item.name}</Text>
            </View>
          )}
        />
      </Animated.View>
    </View>
  );
};

// Main App functional component
const MenuSectionList = () => {
  interface MenuItem {
    key: string;
    name: string;
    price: number;
    mainCategoryHeader?: string;
    subCategoryHeader?: string;
  }

  const [menuData, setMenuData] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const loadData = async () => {
      const data = await fetchMenuData();
      setMenuData(data);
      setLoading(false);
    };
    loadData();
  }, []);

  const categoryMap = useMemo(() => {
    const map: any = {};
    menuData.forEach((item) => {
      const mainCategory = item.mainCategoryHeader || "Other";
      const subCategory = item.subCategoryHeader || "Miscellaneous";

      if (!map[mainCategory]) {
        map[mainCategory] = {};
      }
      if (!map[mainCategory][subCategory]) {
        map[mainCategory][subCategory] = [];
      }

      map[mainCategory][subCategory].push(item);
    });
    return map;
  }, [menuData]);

  if (loading) {
    return <ActivityIndicator size="large" color="#0000ff" />;
  }

  return (
    <View style={styles.container}>
      {/* Ensuring the FlashList parent container has a valid size */}
      <View style={styles.flashListWrapper}>
        <FlashList
          data={Object.entries(categoryMap)}
          keyExtractor={(item) => item[0]}
          estimatedItemSize={150}
          showsVerticalScrollIndicator={false}
          renderItem={({ item }) => {
            const [mainCategory, subCategories] = item;
            return (
              <View style={{ marginBottom: 20 }}>
                <Text style={styles.mainCategory}>{mainCategory}</Text>
                {Object.entries(subCategories).map(
                  ([subCategory, items]) => (
                    <CollapsibleSection
                      key={subCategory}
                      title={subCategory}
                      data={items}
                      expanded={true} // Ensure each section is expanded on load
                    />
                  )
                )}
              </View>
            );
          }}
        />
      </View>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 10,
    backgroundColor: "#f5f5f5",
  },
  section: {
    backgroundColor: "#fff",
    borderRadius: 8,
    marginBottom: 10,
    elevation: 3,
    flexGrow: 1,
  },
  header: {
    backgroundColor: "#007AFF",
    padding: 15,
    borderRadius: 8,
  },
  headerText: {
    color: "white",
    fontSize: 18,
    fontWeight: "bold",
    textAlign: "center",
  },
  content: {
    overflow: "hidden",
  },
  item: {
    padding: 10,
    borderBottomWidth: 1,
    borderBottomColor: "#ccc",
  },
  mainCategory: {
    fontSize: 20,
    fontWeight: "bold",
    marginBottom: 5,
  },
  // Ensure the parent container for FlashList is valid and has a size
  flashListWrapper: {
    flex: 1,
  },
});

export default MenuSectionList;

Here is a snack for the code expo snack

Please any help will appreciated


Solution

  • TL;DR
    Link to the snack (which I've annotated with ADDED and MODIFIED tags to mark the changes I've made.) https://snack.expo.dev/@j_sagar/solution

    The solution:
    Since you are initially keeping the dropdown/accordion open when the parent mounts, we can get its height using the onLayout prop on it.

    We can pass a callback function to onLayout which returns an event object which has the height of the mounted View as one of its nested properties. When the component mounts, we can store this value in a ref variable as we'll only need to save this value once.

    This way, instead of setting the height to a static value, you can dynamically animate the parent using its actual height.

    The pitfall, of course, of this method is that this will work only when initially the dropdown/accordion is open (which is what's in your current implementation). You can, however, tweak the code I've shared to make it work when it is not visible.