expo / router

[ARCHIVE]: Expo Router has moved to expo/expo -- The File-based router for universal React Native apps
https://docs.expo.dev/routing/introduction/
1.36k stars 113 forks source link

Issues with Dynamic Tabs using a slug #522

Closed kennethstarkrl closed 6 months ago

kennethstarkrl commented 1 year ago

Which package manager are you using? (Yarn is recommended)

npm

Summary

What I am trying to do is to use a slug and render different tabs depending on a users specific configuration. It seems in React Navigation, the name property in Tabs.Screen must be unique, and so it doesn't play nice with slugs and throws the following error: Screen names must be unique: [tab],[tab],[tab]

The goal is to use one slug file for multiple tabs.

Minimal reproducible example

app ->(home) -->tabs --->_layout.tsx --->[tab].tsx

_layout.tsx

export default function TabsLayout(){
   const [tabs,setTabs] = useState([])
   useEffect(()=>{
       //fetch whatever data for each tab
       setTabs([{id:1,title:'tab1'},{id:2,title:'tab2'},{id:3,title:'tab3'}]);
   },[]);
   return(
      <Tabs>
      {tabs.map((tab,index)=>{
             return(
                  <Tabs.Screen
                         key={tab.id}
                         name='[tabs]'
                         options={{href:`tabs/${tab.id}`,title:tab.title}}
                   />
             )
       })}
      </Tabs>
   )
}
kennethstarkrl commented 1 year ago

I was able to come up with a solution. There's likely a better way to do this and needs error handling, but it's working for my needs. I added a slug param to the Tab Screen options and set the name as the title, which is unique for each tab. Then modified expo-router/src/useScreens.tsx to basically inject duplicates on the fly of the child found based on the one slug file, and use the slug param for the route instead of the name.

So far it seems to work with out issues for me, and with complexity of using nested navigators both a drawer and tabs. I'm sure someone can make this better. It's a starting point at least.

tabs/_layout.tsx

export default function TabsLayout(){
   const [tabs,setTabs] = useState([])
   useEffect(()=>{
       //fetch whatever data for each tab
       setTabs([{id:1,title:'tab1'},{id:2,title:'tab2'},{id:3,title:'tab3'}]);
   },[]);
   return(
      <Tabs>
      {tabs.map((tab,index)=>{
             return(
                  <Tabs.Screen
                         key={tab.id}
                         name={tab.title}
                         options={{href:`tabs/${tab.id}`,title:tab.title,slug:'[tabs]'}}
                   />
             )
       })}
      </Tabs>
   )
}

expo-router/src/useScreens.tsx

const ordered = order
    .map(({ name, redirect, initialParams, listeners, options },index) => {
      if (!entries.length) {
        console.warn(
          `[Layout children]: Too many screens defined. Route "${name}" is extraneous.`
        );
        return null;
      }
      let matchIndex = entries.findIndex((child) => {return(child.route === name || child.route === options?.slug)});

      if (matchIndex === -1) {
        console.warn(
          `[Layout children]: No route named "${name}" exists in nested children:`,
          children.map(({ route }) => route)

        );
        let dynamicChild = {...children[children.findIndex((child)=>{return(child.route === options?.slug)})],route:name? name : `dynamicScreen${index}`};
        entries.push(dynamicChild);
        matchIndex = entries.findIndex((child) => {return(child.route === name || child.route === options?.slug)});
      } 

      if(matchIndex >= 0) {
        // Get match and remove from entries
        const match = entries[matchIndex];
        entries.splice(matchIndex, 1);

        // Ensure to return null after removing from entries.
        if (redirect) {
          if (typeof redirect === "string") {
            throw new Error(
              `Redirecting to a specific route is not supported yet.`
            );
          }
          return null;
        }

        return { route: match, props: { initialParams, listeners, options } };
      }
    })
    .filter(Boolean) as {
    route: RouteNode;
    props: Partial<ScreenProps>;
  }[];
raajnadar commented 1 year ago

Is the below issue similar or different?

https://github.com/EvanBacon/expo-router-layouts-example/issues/4

P-v-R commented 1 year ago

hey mate, did you make any progress on this ? facing a similar issue

singh-jay commented 1 year ago

@EvanBacon Thanks for this awesome library. Is there any specific reason to restrict this kind of implementation as it's working fine with react-navigation. I'm trying to migrate my old expo app to use expo-router and this is a blocker for me.

seandillon1224 commented 10 months ago

Same issue here - wondering if I'm missing something or anyone has figured out a workaround! - would like to have:

  1. Dynamic tabs based on a small subset of fetched data
  2. Display a name from the data as the Title through options for each
  3. Have the href navigate to the dynamic [user] route passing the params to give it the dynamicism inside the container

{aggregatedTabData.map((tabData) => (
        <Tabs.Screen
          key={tabData.href}
          // Name of the dynamic route.
          name={"[user]"}
          options={{
            title: tabData.name,
            // Ensure the tab always links to the same href.
            href: {
              pathname: "/[user]",
              params: {
                user: tabData.href,
              },
            },
          }}
        />
      ))}
guyhguy25 commented 8 months ago

same issue, trying to make dynamic tabs from api. ->app -->(tabs) ---> layout.tsx -> fetch api and return tabs with [tab.name] --->[tab.name] (coming from api) ---->index.tsx

does someone find any working solution?

lud commented 8 months ago

I am not sure this use case is supported. You can add a Tab.Screen for a corresponding file in the same directory as the _layout file.

danielx-art commented 7 months ago

Same issue here, trying to build a tab navigator only on one route, and link to that route with a search param, them use that param to generate the tabs.

User sees mural of notes -> user clicks to a note and navigates to /note with id param -> user sees a tab navigation with a screen to edit that note and a screen to view the rendered markdown version of that note.

All works fine except tabs dont show.

└── app/
    └── index.js
    └── note/
        └── edit/        
            └── [id].js  
        └── view/        
            └── [id].js 
        └── newnote.js   
        └── __layout.js  /<-- Tabs (that dont work)
    └── _layout.js       
davlasq commented 7 months ago

Any progress on this one? I also need this feature

raajnadar commented 7 months ago

This repo is not maintained you need to check expo/expo

likeSo commented 6 months ago

Same issue here - wondering if I'm missing something or anyone has figured out a workaround! - would like to have:

  1. Dynamic tabs based on a small subset of fetched data
  2. Display a name from the data as the Title through options for each
  3. Have the href navigate to the dynamic [user] route passing the params to give it the dynamicism inside the container
{aggregatedTabData.map((tabData) => (
        <Tabs.Screen
          key={tabData.href}
          // Name of the dynamic route.
          name={"[user]"}
          options={{
            title: tabData.name,
            // Ensure the tab always links to the same href.
            href: {
              pathname: "/[user]",
              params: {
                user: tabData.href,
              },
            },
          }}
        />
      ))}

I have the same problem but i am using the material-top-tab layout, which has no href prop... All of my tabs are fetch from server such like this:

    allCustomerTypes.forEach((value, index) => {
      tabList.push(
        <TopTab.Screen
          name={value.text}
          component={CustomerListScreen}
          options={{title: value.text}}
          initialParams={{ customerTypeList: [value.code] }}
          key={index.toString()}
        />,
      );
    });

It works but the URL gets really weird: http://localhost:8081/xxx?customerTypeList=yyy&screen=zzz&params=%5Bobject+Object%5D

ansmlc commented 6 months ago

I have the same problem but i am using the material-top-tab layout, which has no href prop... All of my tabs are fetch from server such like this:

    allCustomerTypes.forEach((value, index) => {
      tabList.push(
        <TopTab.Screen
          name={value.text}
          component={CustomerListScreen}
          options={{title: value.text}}
          initialParams={{ customerTypeList: [value.code] }}
          key={index.toString()}
        />,
      );
    });

It works but the URL gets really weird: http://localhost:8081/xxx?customerTypeList=yyy&screen=zzz&params=%5Bobject+Object%5D

I am using material-top-bar too. I didn't know about initialParams, thanks for sharing that! However, while this works we still have to manually create each "{value.text}.jsx/tsx" file, right? This would be fine and we wouldn't even need the "href" prop if we could use a dynamic name="[tab]" but it doesn't allow tabs with "same name" :/

marklawlor commented 6 months ago

This repo is in maintenance mode and is only used for critical issues for v2. Its not being actively monitored for issues / support.

Expo Router uses the React Navigation Tabs navigator, which as people have noted only allows tabs with unique names. We are aware of this restriction but it is not high on the roadmap (there are bigger bugs/features).

The work around is to create your own custom navigator or implement a <Tabs /> component using <Slot />

There is an example here: https://github.com/expo/expo/issues/27377#issuecomment-1977613490

likeSo commented 6 months ago

@ansmlc Do not need create "{value.text}.jsx/tsx" files. In my case, Just a CustomerListScreen, it's the common data list screen. And... BTW, Looks like the expo router's roadmap does not listen to the community👀

ansmlc commented 6 months ago

@ansmlc Do not need create "{value.text}.jsx/tsx" files. In my case, Just a CustomerListScreen, it's the common data list screen. And... BTW, Looks like the expo router's roadmap does not listen to the community👀

@likeSo

Not sure if this helps you but I'm using the example custom "tabBar" from Material Top Tabs docs. This way you can navigate without needed to use the "href" prop at all.

For me, I can't use the component prop like component={CustomerListScreen}. It just says the component prop doesn't exist. Probably because of the way I set up the Top Tab Navigator to integrate with Expo Router and Typescript (code snippet). If I don't have a "file.tsx/jsx" for each tab than the Tabs don't work at all. I am confused as to why you're using Expo Router if you don't use file-based routing.

import { withLayoutContext } from 'expo-router';

const { Navigator } = createMaterialTopTabNavigator();

export const MaterialTopTabs = withLayoutContext<
  MaterialTopTabNavigationOptions,
  typeof Navigator,
  TabNavigationState<ParamListBase>,
  MaterialTopTabNavigationEventMap
>(Navigator);

And yeah, expo-router seems to bring more problems than it solves. It's ridiculous they consider this an "edge case".

kennethstarkrl commented 5 months ago

And yeah, expo-router seems to bring more problems than it solves. It's ridiculous they consider this an "edge case".

imo this issue defeats the whole purpose of having slug pages.

kennethstarkrl commented 5 months ago

I created a patch for router v3.4.9. Clone the repo and replace contents of node_modules/expo-router. https://github.com/kennethstarkrl/expo-router-3.4.9-ds-patch

jowparks commented 1 month ago

This repo is in maintenance mode and is only used for critical issues for v2. Its not being actively monitored for issues / support.

Expo Router uses the React Navigation Tabs navigator, which as people have noted only allows tabs with unique names. We are aware of this restriction but it is not high on the roadmap (there are bigger bugs/features).

The work around is to create your own custom navigator or implement a <Tabs /> component using <Slot />

There is an example here: expo/expo#27377 (comment)

This example wasn't great, but the one below actually is very close to a standard tab bar navigator. I massaged it a bit and it is behaving like a standard tab navigator but allows reusing routes for each tab.

https://github.com/EvanBacon/expo-router-layouts-example/blob/main/app/slot/_layout.tsx

import { Link, Navigator, Slot } from "expo-router";
import { View, Text, StyleSheet, Pressable, ViewStyle } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";

export const unstable_settings = {
  initialRouteName: "index",
};

export default function Layout() {
  return (
    // You can wrap the navigator with any custom views.
    <View style={{ flex: 1 }}>
      {/* The custom navigator context must wrap the CustomTabBar so it has access to the Slot state. */}
      <Navigator>
        {/* A custom UI for our navigator. */}
        <CustomTabBar />
        {/* The selected contents render here. */}
        <Slot />
      </Navigator>
    </View>
  );
}

function CustomTabBar() {
  return (
    <View
      style={{
        flexDirection: "row",
        justifyContent: "space-between",
        backgroundColor: "#191A20",
        paddingVertical: 24,
        borderBottomColor: "#D8D8D8",
        borderBottomWidth: 1,
      }}
    >
      <Link href="/slot" style={[styles.link]}>
        My Website
      </Link>

      <View
        style={{
          flexDirection: "row",
        }}
      >
        {/* Purposefully kept verbose, this can be automated with a for loop. */}
        <TabLink
          // `name` is used to determine if the tab is selected.
          name="index"
          // `href` is used to determine the route to navigate to.
          href="/slot"
        >
          {({ focused }) => (
            <Text style={[styles.link, { opacity: focused ? 1 : 0.5 }]}>
              First
            </Text>
          )}
        </TabLink>

        <TabLink name="second" href="/slot/second">
          {({ focused }) => (
            <Text style={[styles.link, { opacity: focused ? 1 : 0.5 }]}>
              Second
            </Text>
          )}
        </TabLink>
      </View>
    </View>
  );
}

// Utilities...

function useIsTabSelected(name: string): boolean {
  const { state } = Navigator.useContext();
  const current = state.routes.find((route, i) => state.index === i);
  return current.name === name;
}

function TabLink({
  children,
  name,
  href,
  style,
}: {
  children?: any;
  name: string;
  href: string;
  style?: ViewStyle;
}) {
  const focused = useIsTabSelected(name);
  return (
    <Link href={href} asChild style={style}>
      <Pressable>{(props) => children({ ...props, focused })}</Pressable>
    </Link>
  );
}

const styles = StyleSheet.create({
  link: {
    fontSize: 24,
    color: "#E7E9E6",
    fontWeight: "bold",
    paddingHorizontal: 24,
  },
});