grahammendick / navigation

Scene-Based Navigation for React and React Native
https://grahammendick.github.io/navigation/
Apache License 2.0
591 stars 41 forks source link

Can't use TabBar on iOS without a nested navigator #727

Closed yangm97 closed 1 year ago

yangm97 commented 1 year ago

It's not a good idea to pass stateNavigators from scene to scene. Always use the one from the NavigationContext. Here's how I'd navigate to a tweet in the root stack in the Twitter sample on iOS. In the Tabs scene get a hold of the state navigator (the root one)

const {stateNavigator} = useContext(NavigationContext);

The pass a goToTweet function prop to the Home scene, for example

<NavigationStack>
  <Scene stateKey="home">
    <Home goToTweet={(id) => stateNavigator.navigate('tweet', {id})} />
  </Scene>

Then when the user presses the tweet call goToTweet. In this video you can see that the navigation happens in the root stack because the tabs aren't visible on the tweet scene anymore

https://user-images.githubusercontent.com/1761227/188262181-72bf3ba4-9019-4011-95ad-c8f2a3590b30.mov

But there's a better way to do what you're trying to do. On iOS, you can hide the tab bar on particular scenes. So you can still navigate within the tab stack but hide the tab bar. For example, if I want to hide the tab bar on the tweet scene I can use the hidesTabBar prop of the NavigationStack. The prop takes a function and you return true from the function for the scenes you want to the hide the tab bar on,

<NavigationHandler stateNavigator={homeNavigator}>
  <NavigationStack hidesTabBar={(state) => state.key === 'tweet'}>
    <Scene stateKey="home"><Home /></Scene>
    <Scene stateKey="tweet"><Tweet /></Scene>
    <Scene stateKey="timeline"><Timeline /></Scene>
  </NavigationStack>
</NavigationHandler>

Here's a vid of what that navigation looks like. I've not changed anything else, just added the hidesTabBar prop. So all the navigation is happening within the tab stack and the tab bar disappears on the tweet scene. The native iOS platform handles making the tabs disappear and reappear smoothly as you navigate back and forward.

https://user-images.githubusercontent.com/1761227/188262515-6860da33-5321-4aad-9820-7e4e6c746aea.mov

Originally posted by @grahammendick in https://github.com/grahammendick/navigation/issues/636#issuecomment-1236074277

I guess the twitter example has changed since this was posted. Specifically, some scenes are getting declared multiple times, probably to make them render inside the active tab as a native iOS app would under these circumstances.

Still, I couldn't get navigation to work like the first video, where tabs are hidden simply because navigating "up" in the stack. I get no errors, no nothing.

The second approach does work but in my case would result in a lot of duplication, as I want some scenes to be accessible regardless of the tab the user is on and to hide the TabUI.

I tried to remove the nested navigator but that seems to crash on iOS (but not on other platforms).

PS: this library is a gold mine. Thank you for your hard work!

grahammendick commented 1 year ago

Hey there, it's great to hear from you! Could you tell me what you're trying to do, please? If you tell me what you want to happen I'll recommend the best way(s) to get there using the Navigation router.

yangm97 commented 1 year ago

Firstly, after finally realizing the wonders of scene based navigation, I want to keep everything as flat as possible. I'm using TabBar as the main way to navigate in my app (similar to the twitter example).

So given the following navigator:

export const stateNavigator = new StateNavigator([
  // Unauthenticated
  {key: 'login'},
  {key: 'register'},
  {key: 'password_reset'},

  // Authenticated but no central selected
  {key: 'central_picker'},

  // Authenticated and central selected, TabBar
  // TODO: consider using central_id as a route param here
  {key: 'main'},

  // Authenticated and central selected, TabBarItem collection
  {key: 'home'},
  {key: 'ambients'},
  {key: 'notifications'},
  {key: 'dashboard'},
  // Here I may probably need a nested navigator eventually
  {key: 'settings'},

  //  Authenticated and central selected, state should be reachable from any tab, screen should cover TabBar
  {key: 'ambient', trackCrumbTrail: true, defaultTypes: {id: 'number'}},

  // More to come... ;)
]);

I want to be able to open an ambient screen from both home and ambients tabs. The only way I was able to achieve the desired behavior on iOS and other platforms was to do something like this:

export default () => {
  const isUserLoggedIn = useAppSelector(selectIsAuthenticated);
  const isCentralSelected = useAppSelector(selectIsCentralSelected);

  // TODO: handle current url properly on refresh and figure out flickers
  useEffect(() => {
    if (!isUserLoggedIn) stateNavigator.navigate('login');
    else if (!isCentralSelected) {
      // stateNavigator.navigate('')
    } else {
      stateNavigator.navigate('main');
    }
  }, [isUserLoggedIn, isCentralSelected]);

  return (
    <NavigationHandler stateNavigator={stateNavigator}>
      <NavigationStack
        crumbStyle={from => (from ? 'scale_in' : 'scale_out')}
        unmountStyle={from => (from ? 'slide_in' : 'slide_out')}>
        {/*  */}
        <Scene stateKey="login">
          <View style={{flex: 1, backgroundColor: brandPrimary}}>
            <Login />
          </View>
        </Scene>
        <Scene stateKey="password_reset">
          <View style={{flex: 1, backgroundColor: brandPrimary}}>
            <ResetPassword />
          </View>
        </Scene>

        <Scene stateKey="central_picker">
          <View style={{flex: 1, backgroundColor: brandPrimary}}>
            <CentralPicker />
          </View>
        </Scene>

        <Scene stateKey="main">
          <BottomTab />
        </Scene>

        {/* This gets ignored on iOS because each TabBarItem needs its own navigator otherwise the app crashes */}
        <Scene stateKey="ambient">
          <AmbientDetail />
        </Scene>
      </NavigationStack>
    </NavigationHandler>
  );
};

// ...

const useStateNavigator = () => {
  const {stateNavigator} = useContext(NavigationContext);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  return useMemo(() => new StateNavigator(stateNavigator), []);
};

export const BottomTab = () => {
  const homeNavigator = useStateNavigator();
  const ambientsNavigator = useStateNavigator();
  const notificationsNavigator = useStateNavigator();
  const dashboardNavigator = useStateNavigator();
  const settingsNavigator = useStateNavigator();
  return (
    <>
      <NavigationBar hidden />
      <TabBar primary>
        <TabBarItem title="Favorites">
          {Platform.OS === 'ios' ? (
            <NavigationHandler stateNavigator={homeNavigator}>
              <NavigationStack
                hidesTabBar={(state, data, crumbs) => {
                  switch (state.key) {
                    case 'ambient':
                      return true;
                    default:
                      return false;
                  }
                }}>
                <Scene stateKey="home">
                  <Home />
                </Scene>
                <Scene stateKey="ambient">
                  <AmbientDetail />
                </Scene>
              </NavigationStack>
            </NavigationHandler>
          ) : (
            <Home />
          )}
        </TabBarItem>

        <TabBarItem title="Ambients">
          {Platform.OS === 'ios' ? (
            <NavigationHandler stateNavigator={ambientsNavigator}>
              <NavigationStack
                hidesTabBar={(state, data, crumbs) => {
                  switch (state.key) {
                    case 'ambient':
                      return true;
                    default:
                      return false;
                  }
                }}>
                <Scene stateKey="ambients">
                  <Ambients />
                </Scene>
                <Scene stateKey="ambient">
                  <AmbientDetail />
                </Scene>
              </NavigationStack>
            </NavigationHandler>
          ) : (
            <Ambients />
          )}
        </TabBarItem>

        <TabBarItem title="Notifications">
          {Platform.OS === 'ios' ? (
            <NavigationHandler stateNavigator={notificationsNavigator}>
              <NavigationStack>
                <Scene stateKey="notifications">
                  <Notifications />
                </Scene>
              </NavigationStack>
            </NavigationHandler>
          ) : (
            <Notifications />
          )}
        </TabBarItem>

        <TabBarItem title="Dashboard">
          {Platform.OS === 'ios' ? (
            <NavigationHandler stateNavigator={dashboardNavigator}>
              <NavigationStack>
                <Scene stateKey="dashboard">
                  <Dashboard />
                </Scene>
              </NavigationStack>
            </NavigationHandler>
          ) : (
            <Dashboard />
          )}
        </TabBarItem>

        <TabBarItem title="Settings">
          {Platform.OS === 'ios' ? (
            <NavigationHandler stateNavigator={settingsNavigator}>
              <NavigationStack>
                <Scene stateKey="settings">
                  <Settings />
                </Scene>
              </NavigationStack>
            </NavigationHandler>
          ) : (
            <Settings />
          )}
        </TabBarItem>
      </TabBar>
    </>
  );
};

Then, on the Ambient component shown in both screens I navigate like this:

import {NavigationContext} from 'navigation-react';
import {useContext} from 'react';

export const useStateNavigation = () => useContext(NavigationContext);

export const useNavigation = () => {
  const {stateNavigator} = useStateNavigation();
  return stateNavigator;
};

// ...

export const Ambient = (props: AmbientProps) => {
  const navigation = useNavigation();
  // ...
  const onPress = () => {
    // console.log(
    //   navigation.states,
    //   '\n',
    //   navigation.stateContext,
    //   '\n',
    //   navigation.historyManager,
    // );
    // navigation.navigate('Ambients');
    // navigation.navigate('main')

    // const link = stateNavigator
    //   .fluent()
    //   .navigate('main')
    //   .navigate('Ambients')
    //   .navigate('ambient', {id}).url;
    // stateNavigator.navigateLink(link);

    navigation.navigate('ambient', {id});
  };

// ...

Offtopic: As you can see I had some fun before figuring out the simple, obvious, solution there hehe

grahammendick commented 1 year ago

So you've got the same kind of set up as the Twitter sample, right? That's where there's a stack per tab on iOS and a single stack on Android. You can have a stack per tab on Android too if you want. But you can't have a single stack on iOS because the native UITabBarController from UIKit only supports a stack per tab.

If I understand,

Is that right?

What you've done looks good to me. Any reason you don't want to open the ambient scene in a Modal? You can have a stack inside a Modal, too, like I've done in the medley sample.

yangm97 commented 1 year ago

But you can't have a single stack on iOS because the native UITabBarController from UIKit only supports a stack per tab.

😔

Is that right?

Yep.

Any reason you don't want to open the ambient scene in a Modal?

First reason would be to ease transition from the wrong navigation lib. The screen has a bottom thing with some buttons (i.e. a filter button).

Another reason would be because this screen is somewhat heavy, has a SectionList and I will have other stuff coming up inside a modal.

I know I could bring another modal on top here, but at this point the device would be rendering 3+ screens simultaneously and low end Android devices can get really emotional then.

grahammendick commented 1 year ago

ease transition from the wrong navigation lib

I live for that. Thank you

Ok, your code sample looks good to me. Are you happy with it?

yangm97 commented 1 year ago

Are you happy with it?

Not truly 100% cool with declaring some scenes n times, because even hiding stuff behind functions/components, I can still myself forgetting to declare some scene here or there.

The alternatives I've thought so far:

  1. Conditionally getting the parent navigator (if Platform.OS == 'ios')
  2. The current navigator could somehow notice it doesn't have the requested scene and forward the navigation request up (if there's somewhere up in the stack, error otherwise)
  3. Making use of deeplink everywhere (instead of calling navigate bare)
grahammendick commented 1 year ago

What about something like this?

const tweetScenes = () => (
  <>
    <Scene stateKey="tweet"><Tweet /></Scene>
    <Scene stateKey="timeline"><Timeline /></Scene>
  </>
)

<NavigationStack>
  <Scene stateKey="home"><Home /></Scene>
  {tweetScenes()}
</NavigationStack>
grahammendick commented 1 year ago

Another way you could do it is have a shared stack

const Stack = () => (
  <NavigationStack>
    <Scene stateKey="home"><Home /></Scene>
    <Scene stateKey="notifications"><Notifications /></Scene>
    <Scene stateKey="home"><Home /></Scene>
    <Scene stateKey="tweet"><Tweet /></Scene>
    <Scene stateKey="timeline"><Timeline /></Scene>
  </NavigationStack>
)

<TabBarItem title="Home">
  <NavigationHandler stateNavigator={homeNavigator}>
    <Stack />
  </NavigationHandler>
</TabBarItem>
<TabBarItem title="Notifications">
  <NavigationHandler stateNavigator={notificationsNavigator}>
    <Stack />
  </NavigationHandler>
</TabBarItem>

Then you can decide the start scene when you create the navigator

const useStateNavigator = (start) => {
  const {stateNavigator} = useContext(NavigationContext);
  return useMemo(() => {
    const nav = new StateNavigator(stateNavigator)
    nav.navigate(start)
    return nav;
  }, [])
};

const homeNavigator = useStateNavigator('home');
const notificationsNavigator = useStateNavigator('notifications');

If you don't like it that you can accidentally navigate to the home scene when you're inside the notifications tab then you can control the valid states by creating different state navigators. So instead of cloning the state navigators and having different stacks you can reverse that. You clone the stack and have different state navigators.

const homeNavigator = new StateNavigator([
  {key: 'home'},
  {key: 'tweet', trackCrumbTrail: true},
  {key: 'timeline', trackCrumbTrail: true}
]);

const notificationsNavigator = new StateNavigator([
  {key: 'notifications'},
  {key: 'tweet', trackCrumbTrail: true},
  {key: 'timeline', trackCrumbTrail: true}
]);
grahammendick commented 1 year ago

Then you can do this to share scenes

const tweetScenes = [
  {key: 'tweet', trackCrumbTrail: true},
  {key: 'timeline', trackCrumbTrail: true}
]

const homeNavigator = new StateNavigator([
  {key: 'home'},
  ...tweetScenes
]);

const notificationsNavigator = new StateNavigator([
  {key: 'notifications'},
  ...tweetScenes
]);
grahammendick commented 1 year ago

@yangm97 you happy with this?

grahammendick commented 1 year ago

If I don't hear from you in the next week I'll close this. Don't worry if you're too busy, we can always reopen again whenever you're free