Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to reuse the same screen across tabs in a bottom tabs navigator? #110

Closed
vbylen opened this issue Jul 19, 2022 · 18 comments
Closed

How to reuse the same screen across tabs in a bottom tabs navigator? #110

vbylen opened this issue Jul 19, 2022 · 18 comments

Comments

@vbylen
Copy link
Contributor

vbylen commented Jul 19, 2022

Hi @nandorojo,

My app is entirely built with Solito and am loving the developer experience so far.

I'm just encountering one roadblock at the moment which is:

Let's say you have a standard bottomtabs navigator with the following tabs:

  • Home
  • Search
  • Notifications
  • Profile

Most of these these tabs will be reusing the same screens such as <PostScreen /> or <UserScreen /> in the HomeStack, SearchStack, etc...

So let's say you're in the Notifications tab and you navigate to a <PostScreen />, ideally you'd want to stay inside that notifications tab and preserve your state in all the other tabs.

However in react-navigation v6, the app yanks you back to the Home tab, which has the same instance of that <PostScreen/> as part of the HomeStack.

The obvious solution is to make a separate <PostScreen/> and <UserScreen/> for each tab.

But I fear it's going to drastically increase the complexity of the code, and I will end up regretting this.

How would you proceed in such a scenario?

Thanks!

@nandorojo
Copy link
Owner

nandorojo commented Jul 19, 2022

I knew the day would come where someone asks this...and yes, I totally agree. I have an answer implemented in my own app. And it's all possible thanks to solito's (undocumented) middleware feature. Here I'm going to share the code we use to do it in my app. The reason I haven't documented this yet is because it's a very advanced feature to write, and it can't be generalized. That is, the code you write to achieve it depends very much on how you organize your navigators.

So I'm sharing this code as-is. I'm happy to talk through it a bit to explain it.

Here's the gist: SolitoProvider takes a middleware option. This middleware lets you "override" the useLinkTo hook provided by react navigation. It's almost like a patch-package for useLinkTo. Under the hood, this useLinkTo function is what powers all transitions on the native side.

And so, when you provide a custom useLinkTo hook, solito will use your hook instead of react navigation's.

In my example below, I copy-pasted useLinkTo from react navigation's source code, and then made some edits. Namely, my edits are: go to the root navigator (tabs in my case), grab the screen name, override the navigation action we create such that we stay in the current tab.

Hope that all makes sense. Here you go...

solito-provider.native.tsx
import { SolitoProvider } from 'solito'

import {
  getActionFromState,
  getStateFromPath,
  NavigationContainerRefContext,
} from '@react-navigation/core'
import * as React from 'react'

import LinkingContext from '@react-navigation/native/src/LinkingContext' 

type To<
  ParamList extends ReactNavigation.RootParamList = ReactNavigation.RootParamList,
  RouteName extends keyof ParamList = keyof ParamList
> =
  | string
  | (undefined extends ParamList[RouteName]
      ? {
          screen: Extract<RouteName, string>
          params?: ParamList[RouteName]
        }
      : {
          screen: Extract<RouteName, string>
          params: ParamList[RouteName]
        })

function useLinkTo<ParamList extends ReactNavigation.RootParamList>() {
  const navigation = React.useContext(NavigationContainerRefContext)
  const linking = React.useContext(LinkingContext)

  const linkTo = React.useCallback(
    (to: To<ParamList>) => {
      if (navigation === undefined) {
        throw new Error(
          "Couldn't find a navigation object. Is your component inside NavigationContainer?"
        )
      }

      if (typeof to !== 'string') {
        // @ts-expect-error: This is fine
        navigation.navigate(to.screen, to.params)
        return
      }

      if (!to.startsWith('/')) {
        throw new Error(`The path must start with '/' (${to}).`)
      }

      const { options } = linking

      const state = options?.getStateFromPath
        ? options.getStateFromPath(to, options.config)
        : getStateFromPath(to, options?.config)

      if (state) {
        const action = getActionFromState(state, options?.config)

        const getTabs = () => {
          let navigationParent = navigation?.getParent() ?? navigation
          while (navigationParent) {
            const parent = navigationParent?.getParent()
            if (!parent) {
              break
            }
            navigationParent = parent
          }
          const rootTabState = navigationParent?.getState()
          if (rootTabState?.type == 'tab') {
            // https://reactnavigation.org/docs/navigation-state
            type TabName = keyof NativeTabsParams // import this type from your own tab params

            const { index, routes, routeNames } = rootTabState
            const route = routes[index]

            const tabName = route.name as TabName

            return {
              activeTabName: tabName,
              tabNames: routeNames,
            }
          }
        }

        const { activeTabName, tabNames } = getTabs() || {}

        const isTabName = (name: string) => Boolean(tabNames?.includes(name))

        const getRewrittenAction = () => {
          if (
            action &&
            'payload' in action &&
            action.payload &&
            'params' in action.payload &&
            action.payload.params &&
            'screen' in action.payload.params
          ) {
            /**
             * If the navigation action is on a tab (and has a nested screen),
             * then we want to rewrite it to ensure that it fires in the current tab.
             *
             * This allows for an instagram/spotify style infinite tab navigation on a current tab
             *
             * Example action:
             * ```json
             * {
             *   "payload": Object {
             *     "name": "discover",
             *     "params": Object {
             *       "initial": false,
             *       "params": Object {
             *         "slug": "tracksuitpanda",
             *       },
             *       "path": "/artists/tracksuitpanda/reviews",
             *       "screen": "ArtistReviews",
             *       "state": undefined,
             *     },
             *     "path": undefined,
             *   },
             *   "type": "NAVIGATE",
             * }
             * ```
             */
            const { payload } = action

            const hasActiveTab = Boolean(activeTabName)

            if (
              hasActiveTab &&
              'name' in payload &&
              payload.name &&
              isTabName(payload.name)
            ) {
              return {
                ...action,
                payload: {
                  ...action.payload,
                  name: activeTabName,
                },
              }
            }
          }
        }

        const nextAction = getRewrittenAction() || action

        if (nextAction !== undefined) {
          navigation.dispatch(nextAction)
        } else {
          navigation.reset(state)
        }
      } else {
        throw new Error('Failed to parse the path to a navigation state.')
      }
    },
    [linking, navigation]
  )

  return linkTo
}

const middleware = {
  useLinkTo,
}

export function Solito({ children }) {
  return <SolitoProvider middleware={middleware}>{children}</SolitoProvider>
}
solito-provider.tsx
export const Solito = ({ children }) => <>{children}</>

This should be enough (or a good starting point) to achieve what you want. Bear in mind that this (opinionated) code assumes that a tab navigator is at the root of your entire navigator set. If this isn't the case, you'll have to change the code accordingly.

The one piece of logic missing is to make sure the screen we're navigating to isn't the initialRouteName of a tab. If it is, then it indeed should change tabs. I haven't added a check for that quite yet.

Hope this helps!

@vbylen
Copy link
Contributor Author

vbylen commented Jul 19, 2022

Thank you!

@nandorojo
Copy link
Owner

If you have bottom tabs nested, you’d just have to adjust the getTabs function to stop the while loop when it reaches a tab navigator, rather than going all the way to the root. It should work from there.

@nandorojo
Copy link
Owner

nandorojo commented Jul 19, 2022

if (!parent) {
  break
}

becomes:

if (!parent || parent.type == ‘tab’) {
  break
}

(this assumes you only have one tab navigator)

@jeffscottward
Copy link

jeffscottward commented Jul 19, 2022

As I understand the problem, this is referring to past history states per tab, like each is it's own browsing experience like how twitter does it?

And the solution depends on where you need to jump to basically?

@nandorojo
Copy link
Owner

nandorojo commented Jul 19, 2022

The problem is: you’re on spotify’s home screen. You open an artist. You expect it to open on the same tab. But your linking config has it configured to open on the search tab, so it jumps over.

The solution is to overwrite the navigation action to stay in the same tab.

My solution makes some assumptions about your stack navigators: namely, that every screen can be opened in every tab. This is the way I organized my stacks.

To achieve this, I have one stack navigator component, wrapped into its own component. Each tab renders the same component with a different initialRouteName prop, which gets passed down to the Stack.Navigator.

@vbylen
Copy link
Contributor Author

vbylen commented Jul 20, 2022

@nandorojo

Thank you for going a little more into detail.

How would you suggest we set up the linking config in this case?

Especially in terms of avoiding duplicate urls?

@nandorojo
Copy link
Owner

The solution I shared doesn't require you to duplicate URLs, since it takes a single URL and overwrites the action to stay in the current tab.

You can put the urls inside of any tab technically, assuming every tab has every screen the way our app does.

Which is to say: the only time the tab in your linking config would matter is on a deep link from outside the app. Does that make sense?

@vbylen
Copy link
Contributor Author

vbylen commented Jul 20, 2022

@nandorojo Makes perfect sense, thank you.

@redbar0n
Copy link
Contributor

redbar0n commented Jul 22, 2022

ideally you'd want to stay inside that notifications tab and preserve your state in all the other tabs

This is how Twitter and Facebook does it. But I’d like to make the case for the other way: that content navigation stays in the Home tab. Like react-navigation v6 and Solito does by default. Since if you can start content navigation inside the notifications tab, and then switch tabs, the notifications tab has now lost its ability to take you directly to notifications (you’d have to switch to it and then navigate all the way back up the content stack to see notifications again). This happens frequently in my experience.

I think the problem with switching tabs when clicking a notification has to do more with transitions, since when I’ve seen it done before (not in Solito necessarily) it can be confusing, because you see a flash of the old content in the Home tab first, before the new screen is put on top. Such transitions gives a feeling of «yank» and is disorienting (compared to no animations, for instance). But if the content is immediately switched in the Home tab (with option to click back, to see the previous content & state there), then it could work quite well.

@nandorojo
Copy link
Owner

It’s all pretty app-dependent, which is why Solito doesn’t do it by default. In most cases I don’t want to switch tabs, unless I’m going to the first screen of a given tab’s stack.

In my experience, animations between tabs with react navigation result in frame drops, even when using Reanimated.

@nandorojo
Copy link
Owner

you’d have to switch to it and then navigate all the way back up the content stack to see notifications again

that’s true, unless you used some sort of getId trick on that screen to let it push on top of itself.

@vbylen
Copy link
Contributor Author

vbylen commented Jul 22, 2022

So I've been playing around with just bottomtabs as the root navigator and the solution works great.

One thing I had to do though was turn off state persistence in the navigation container.

For some reason if it was turned on, the initialRouteName would not be respected.

When the bottomtabs are nested inside a root native stack however, I'm still not able to get it to work even with the modified code you've graciously provided.

Not sure what is going wrong there.

@nandorojo
Copy link
Owner

nandorojo commented Jul 22, 2022

One thing I had to do though was turn off state persistence in the navigation container.

Mmm I'm not exactly sure how that could affect this, since useLinkTo only applies when firing new changes. It's possible that you have an out-of-date state perhaps? In any case, I personally don't like the pattern of state persistence. If I quit an app it's usually with the intention of clearing my state.

When the bottomtabs are nested inside a root native stack however, I'm still not able to get it to work even with the modified code you've graciously provided.

Can you share your code? Did you try logging a bit to see when it reaches a tab navigator? What happens exactly, does it just not navigate or does it stay in the same stack?

@nandorojo
Copy link
Owner

Out of curiosity, why would the tabs go inside of a native stack? Is the order stack → tabs → stack?

@vbylen
Copy link
Contributor Author

vbylen commented Jul 22, 2022

@nandorojo correct.

The root stack is basically just where I would put tab-independent modals such as "create-post", "signin-screen" or "404-screen".

But in trying to answer your question I just realized there is no real need for me to have a root stack.

I can just add all the screens to the same stack nested inside the bottom tabs, and have the bottom tabs at the root with no issues.

In terms of what does happen when you have the stack → tabs → stack set up with the above middleware, the navigation fires fine but just does not stay in the same tab.

Not sure if it's still worth pursuing a fix for that.

Thanks again for all your help 😊 .

@nandorojo
Copy link
Owner

I can just add all the screens to the same stack nested inside the bottom tabs, and have the bottom tabs at the root with no issues.

That's right. Before native stack we couldn't do this, but now, modals will show on top of everything, so there's no need for a root stack. It's generally recommended to keep navigation flat when possible.

@nandorojo nandorojo pinned this issue Jun 14, 2023
@tlmader
Copy link

tlmader commented Oct 18, 2024

I can just add all the screens to the same stack nested inside the bottom tabs, and have the bottom tabs at the root with no issues.

That's right. Before native stack we couldn't do this, but now, modals will show on top of everything, so there's no need for a root stack. It's generally recommended to keep navigation flat when possible.

Is this the case for Android? I cannot figure out how to get the native stack to behave this way for Android (after I flattened my stack hierarchy 😅)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants