How can I navigate to a nested stack screen inside a sibling tab while preserving its initial screen? (React Navigation) - react-navigation

The Code:
I'm using React Navigation 6 with the following hierarchy:
MainTabNavigator
HomeStack
HomeScreen (HomeStack initial screen, contains a "Pay" button)
OtherScreen
MembershipStack
MembershipHomeScreen (MembershipStack initial screen)
PayMembershipScreen (should always navigate back to MembershipHomeScreen)
The App launches into HomeScreen (inside HomeStack), so the MembershipStack tab won't be loaded yet.
There is a "Pay" button inside HomeScreen that redirects to PayMembershipScreen inside MembershipStack.
Code of the HomeScreen with the "Pay" button press handler:
const HomeScreen = ({navigation}) => {
const payPressHandler = useCallback(() => {
// The parent navigator here is MainTabNavigator
navigation.getParent().navigate("MembershipStack", { screen: "PayMembershipScreen" })
}, [navigation])
return (
<TouchableOpacity onPress={payPressHandler}>
<Text>Go to Pay screen!</Text>
</TouchableOpacity>
)
}
The "Pay" button inside HomeScreen does navigate to PayMembershipScreen with this code.
The Problem:
When the MembershipStack tab has not yet been loaded (i.e. when the App just launched), the following happens:
User clicks the "Pay" button on HomeScreen.
App navigates to PayMembershipScreen.
PayMembershipScreen becomes the initial screen on MembershipStack.
User can't go back to MembershipHomeScreen from PayMembershipScreen.
The question is: how can I navigate from HomeScreen directly to PayMembershipScreen and after that be able to go back to MembershipHomeScreen (i.e. have it available as the initial screen in the MembershipStack history)?
What I've tried so far:
1. Setting lazy: false on the MainTabNavigator options.
This approach does make sure that MembershipHomeScreen is always the initial screen on MembershipStack, since all stacks (and their initial screens) will be loaded when the App launches. However this comes with a noticeable drawback in performance, since there's at least 5 tabs in the actual project.
Also, since MembershipHomeScreen is already focused inside MembershipStack, there's a weird MembershipHomeScreen to PayMembershipScreen transition animation when the "Pay" button on HomeScreen is pressed. I just want the user to see a transition from HomeScreen to PayMembershipScreen at most, nothing flashing inbetween.
2. Define a param on MembershipHomeScreen to indicate when I want to redirect to PayMembershipScreen.
On this approach, I'm using a boolean param on MembershipHomeScreen called redirectToPayment:
Code inside MembershipHomeScreen:
const MembershipHomeScreen = ({navigation, route}) => {
const { redirectToPayment = false } = route.params
const redirectIfNeeded = useCallback(() => {
if (redirectToPayment) {
// reset the param
navigation.setParams({ redirectToPayment: false })
// redirect to the desired screen
navigation.navigate("PayMembershipScreen")
}
}, [redirectToPayment, navigation])
// Using layout effect to avoid rendering MembershipHomeScreen when redirecting.
useLayoutEffect(redirectIfNeeded)
return (
<Text>
This is just the membership home screen,
not the target screen of the "Pay" button.
</Text>
)
}
And on the HomeScreen:
const payPressHandler = useCallback(() => {
navigation
.getParent()
.navigate(
"MembershipStack",
{ screen: "MembershipHomeScreen", params: { redirectToPayment: true } }
)
}, [navigation])
The use of React's useLayoutEffect comes with the known drawback of freezing the screen since it will leave any rendering tasks on hold while it's running. I'm able to notice a 2 seconds freeze in the HomeScreen when I press the "Pay" button on a 4GB RAM Moto G7...
...and even after using useLayoutEffect, the MembershipHomeScreen still renders nonetheless to show a transition animation between MembershipHomeScreen and PayMembershipScreen.
Same behavior with useEffect, except it renders the MembershipHomeScreen instead of a 2 seconds freeze.
3. Using React Navigation's dispatch function to customize the route history.
On this approach, I intend to dispatch a custom action that does the following to the navigation state of MainTabNavigator:
Before:
index: 0
routes: [
0: {
name: "HomeStack",
state: {
index: 0,
routes: [
0: {
name: "Home" // <-- currently active screen
}
]
}
},
1: {
name: "MembershipStack",
state: undefined // <-- this is an unloaded tab
}
]
After:
index: 1
routes: [
0: {
name: "HomeStack",
state: {
index: 0,
routes: [
0: {
name: "Home"
}
]
}
},
1: {
name: "MembershipStack",
state: {
index: 1,
routes: [
0: {
name: "MembershipHomeScreen"
},
1: {
name: "PayMembershipScreen" // <-- currently active screen
}
]
}
},
]
Here's the code I'm using inside the HomeScreen for that:
const payPressHandler = useCallback(() => {
navigation.getParent().dispatch(state => {
const membershipTabIndex = state.routes.findIndex(r => r.name === "MembershipStack")
// get the current navigation state inside the MembershipStack
let membershipTabState = state.routes[membershipTabIndex].state
// point to PayMembershipScreen without overriding the initial screen on that tab
if (!membershipTabState) {
// tab is unloaded, so just set the ideal state
membershipTabState = { index: 1, routes: [{ name: "MembershipHomeScreen" }, { name: "PayMembershipScreen" }] }
} else {
// tab already has a navigation state, so we'll point to PayMembershipScreen
// if it's loaded in the stack. Otherwise, we'll add it and point to it.
let payMembershipScreenIndex = membershipTabState.routes.findIndex(r => r.name === "PayMembershipScreen")
if (payMembershipScreenIndex === -1) {
payMembershipScreenIndex = membershipTabState.routes.push({ name: "PayMembershipScreen" }) - 1
}
membershipTabState.index = payMembershipScreenIndex
}
// update the MembershipStack tab with the new state
const routes = state.routes.map((r, i) => i === membershipTabIndex ? { ...r, state: membershipTabState} : r)
// update the MainTabNavigator state
return CommonActions.reset({
...state,
routes,
index: membershipTabIndex
})
})
}, [navigation])
That code almost accomplishes the expected outcome:
It navigates from HomeScreen to PayMembershipScreen successfuly.
The user can go back to MembershipHomeScreen from PayMembershipScreen.
MembershipHomeScreen does not render in-between during the transition.
However:
It won't work a second time if you do go back to MembershipHomeScreen from PayMembershipScreen.
Turns out, if I go back to MembershipHomeScreen once I'm inside PayMembershipScreen, then go back to the HomeStack and press the "Pay" button again, it will now navigate to MembershipHomeScreen instead of PayMembershipScreen.
Additionally, the MembershipHomeScreen will now display a disabled back button in the header (probably a bug).
This last approach is so far the closest to getting the desired outcome, so I really hope that it only needs a fix in the logic and it's not really a bug.
Summary:
Is anyone able find a solution that achieves the expected outcome? To sum up:
It should make the "Pay" button navigate from HomeScreen to PayMembershipScreen.
The user should be able to go back to MembershipHomeScreen once they're in PayMembershipScreen.
The MembershipHomeScreen should not flash in the transition from HomeScreen to PayMembershipScreen.
The screen should not freeze inbetween the transitions (no use of useLayoutEffect).
Don't load all tabs on App launch (it's too much burden on the actual 5-tab project).
If the user navigates back to MembershipHomeScreen once they're inPayMembershipScreen, pressing the "Pay" button on HomeScreen again should open the PayMembershipScreen again (no buggy behavior).
Minimal reproducible example:
Here's a snack with all of the approaches mentioned. Please check it out and use it as a playground for your solution!
https://snack.expo.dev/#ger88555/tabs-with-stacks-and-a-button

Solution
I got the expected outcome with the following approach:
Navigate directly to PayMembershipScreen when the "Pay" button is pressed.
Inside PayMembershipScreen, add MembershipHomeScreen to the top of MembershipStack history if not already present.
The Code
Code inside HomeScreen (no changes):
const HomeScreen = ({navigation}) => {
const payPressHandler = useCallback(() => {
navigation.getParent().navigate("MembershipStack", { screen: "PayMembershipScreen" })
}, [navigation])
return (
<TouchableOpacity onPress={payPressHandler}>
<Text>Go to Pay screen!</Text>
</TouchableOpacity>
)
}
New useAssertInitialScreen hook:
const useAssertInitialScreen = ({ navigation, name }) => {
useEffect(() => {
if (navigation.canGoBack() === false) {
navigation.dispatch((state) => {
const routes = [{ name }, ...state.routes]
return CommonActions.reset({
...state,
routes,
index: state.index + 1
})
})
}
}, [navigation])
}
Note: I'm passing the navigation prop down the hook since it's also used in the screen, but it could also be obtained from React Navigation's useNavigation hook.
Code inside PayMembershipScreen:
const PayMembershipScreen = ({navigation}) => {
useAssertInitialScreen({ navigation, name: "MembershipHomeScreen" })
return (
<View>
<Text>
This is the PayMembershipScreen AKA the target
</Text>
</View>
)
}
Demo
Here's a snack with the code of this answer:
https://snack.expo.dev/#ger88555/tabs-with-stacks-and-a-button---a-solution
I still want to see whether there's a cleaner solution than handling the "Pay" button's functionality inside its target screen.
I'll mark as the accepted answer any other solution that gets the expected outcome :)

Related

Next.js: Get back to old scroll position on back button WITHOUT affecting the other/next page scroll?

Is there a possibility in next.js to get back to old scroll position on back button WITHOUT affecting the other/next page scroll?
The solution I tried is working but not without affecting the next page scroll position and also the next page affecting the previous/back button page scroll postion.
So if i do not scroll on next page and go back it's fine, but I scroll on next page and get back the position of back page(original page) is not the same
What I tried:
Update next.config.js with:
experimental: {
modern: true,
scrollRestoration: true,
}
Switched scroll prop to false for SiteLink component which wraps Link from next:
<SiteLink href={slug} scroll={false} />
Attach and remove scroll event listener on page I want scroll to be remebered:
const state = useContext(ScrollContext);
useEffect(() => {
window.scrollTo(0, state.scrollPos);
const handleScrollPos = () => {
state.scrollPos = window.scrollY;
};
window.addEventListener('scroll', () => handleScrollPos());
return () => {
// remove event listener on unmount
window.removeEventListener('scroll', () => handleScrollPos());
};
}, []);
Scroll Context
import { createContext } from 'react';
const state = {
scrollPos: 0,
};
const ScrollContext = createContext(state);
export default ScrollContext;```

How to Unmount a screen when moving to another in React Native

I'm developing a React Native app using React Navigation v4, React Hooks and ES6.
I have 2 bottom tabs (Movies, Shows) and 4 screens with the following Stack structure:
**Movies**
-- MovieList
-- MovieDetail
**Shows**
-- ShowList
-- ShowDetail
My scenario
1) Moving from Movie list to an individual movie page
MovieList contains a list of movies, when I click on one of them, I first fetch some API data then move to the MovieDetail screen like this
dispatch(apiFetchActions.fetchMovies(movieId)).then((response) => {
props.navigation.navigate({
routeName: "MovieDetail",
params: {
assetId: movieId,
assetName: movieTitle,
},
});
MovieDetail is now on top of the Movies stack and MovieList at the bottom
2) Moving to a different tab (navigation stack)
I then click on Shows (2nd Tab) which takes me to the ShowList using props.navigation.navigate('ShowList')
3) The problem
If I click on the Movies Tab, I expect to be moved back to MovieList but since MovieDetail was never unmounted, it is still at the top of the Movies stack meaning that I see an old screen. I have to click twice to go back to the MovieList screen.
I've read quite a few suggestions on how to use onFocus/onBlur subscription however I could not found a solution using React Hooks.
My ideal solution would be to find a way to listen to the onBlur status in MovieDetail screen possibly using useEffect hook and somehow unmount it before leaving.
I found a way to make it easier to always move to the initial top of the stack when you click on any bottom tab icons.
You simply need to add the on Press and the screen reference like this
Stars: {
screen: StarsNavigator,
navigationOptions: ({ navigation }) => ({
tabBarIcon: (tabInfo) => {
return (
<Ionicons name="ios-people" size={22} color={tabInfo.tintColor} />
);
},
tabBarLabel: Platform.OS === "android" ? <Text>Stars</Text> : "Stars",
tabBarOnPress: () => {
navigation.navigate("StarsList");
},
}),
},
Star is one of my screens in BottomTabNavigator and using navigation.navigate("You Screen") does the trick. So regardless in which level of the stack you find yourself, every time you click on the Star tab you always end up to the original top level.

Navigation back within react navigation from one stack to another stack

There are 2 ways to get to a Player screen.
You click on the 'More' tab which has a link for 'Teams' which displays all the teams available. Then you click on a Team and it brings you to the TeamStack.
In the TeamStack the default is the Team Home and then bunch of other screens, one is Roster, which lists all the players of the team. From there you get to the PlayerStack which brings you to the player home screen.
Now, when you get to the player screen you can swipe to go back, which will bring you back from where you came... to the Roster, then the team Home, then to the More page.
So far so good!
You click on the 'Stats' tab which links to the StatsStack. The default StatsStack screen lists the players leading the leaderboard. From there you can click on a player. From there you can click on the player's team, which brings you to the TeamStack. You can then click on the 'roster' again. If you go back from there everything works properly. Back to team home, back to player, back to stats. Still so far, so good!
Here is the problem...
Let's go back to the Stats tab, then click a player, then go to a team, then go to the roster. Now, if you click on a new player, then go back, it goes back directly to the Stats screen, instead of going back to the Roster, then to the Team Home, then to the original Player, then to the Stats.
Somehow, because the PlayerStack is already open, when you go to a Player from within the Team Roster in the Route 2 option it brings you back to the originally open PlayerStack, instead of adding a new screen to the stack.
Does this make sense? How do I make is so that the when you follow Route 2 to get to a new player, when you go back it brings you back in the proper sequence of screens?
const MainStackNavigator = createStackNavigator(
{
MainTabNavigator: MainTabNavigator,
Team: {
screen: TeamStack,
navigationOptions: ({ navigation }) => {
return {
header: null,
}
}
},
Player: {
screen: PlayerStack,
navigationOptions: ({ navigation }) => {
return {
header: null,
}
}
},
)
const MainTabNavigator = createBottomTabNavigator(
...
Stats: {
screen: StatsStack,
navigationOptions: {
tabBarIcon: ({ focused }) => {
return <Icon
name="ios-stats"
iconStyle={{}}
color={focused ? '#000' : '#ccc'}
size={28} />;
},
},
},
More: {
screen: MoreStack,
navigationOptions: {
tabBarIcon: ({ focused }) => {
return <Icon
name="ios-more"
iconStyle={{}}
color={focused ? '#000' : '#ccc'}
size={28} />;
},
},
},
)
I could delete this question because I just figured it out after writing all that out, but the answer is that in the Roster page, instead of getting to the Player screen with Navigation.navigate('Player') I had to use Navigation.push('Player').
According to the docs, if the screen is already open it won't add another duplicate screen to the stack, it will just go back to the open screen. In order to force the screen onto to the stack you have to use the push method.
This will work in either of my two cases above.

React Navigation - TopTabNavigator inside DrawerNavigator

react-navigation: 3.2.1
I've create TopTabNavigator (using createMaterialTopTabNavigator) inside a Drawer (using createDrawerNavigator).
Swiping navigates between tabs but the first time I swipe from the direction of the drawer - the drawer will be the only thing that will open / close.
swiping between the tabs is stopped (click the buttons in the bar still works).
What can cause that?
const MainTab = createMaterialTopTabNavigator ({
Tab1: {
screen: Tab1Screen,
},
Tab2: {
screen: Tab2Screen,
},
});
const DrawerNav = createDrawerNavigator(
Main: {
screen: MainTab,
}
);
Adding link for Expo - but unfortunately there is a bug when using createDrawerNavigation inside expo.snack.io

How to avoid stacking navigation history in tab-bar states

Tab A - Tab B - Tab C
States like below;
tabs.a, tabs.b, tab.c
I want to close app like there is no navigation history when switching in each of tab states
For example: I was in Tab A then I clicked to Tab B and then I clicked to Tab C from now on if user pushes back button the app should close. In normal behaviour navigation history stacks up and if I push back button I'll go to Tab B from Tab C. How to avoid this behaviour
Below is my codes;
.state('tabs', {
url: "/tab",
abstract: true,
templateUrl: "templates/tabs.html"
})
.state('tabs.a', {
url: "/a",
views: {
'a-tab': {
templateUrl: "templates/a.html",
controller: 'AController'
}
}
}).state('tabs.b', {
url: "/b",
views: {
'b-tab': {
templateUrl: "templates/b.html",
controller: 'BController'
}
}
}).state('tabs.c', {
url: "/c",
views: {
'c-tab': {
templateUrl: "templates/c.html",
controller: 'CController'
}
}
});
<ion-tabs class="tabs-royal tabs-striped">
<ion-tab title="A" href="#/tab/a">
<ion-nav-view name="a-tab"></ion-nav-view>
</ion-tab>
<ion-tab title="B" href="#/tab/b">
<ion-nav-view name="b-tab"></ion-nav-view>
</ion-tab>
<ion-tab title="C" href="#/tab/c">
<ion-nav-view name="b-tab"></ion-nav-view>
</ion-tab>
</ion-tabs>
You can intercept the back button in each of your controllers.
From the documentation:
registerBackButtonAction(callback, priority, [actionId]) Register a
hardware back button action. Only one action will execute when the
back button is clicked, so this method decides which of the registered
back button actions has the highest priority.
For example, if an actionsheet is showing, the back button should
close the actionsheet, but it should not also go back a page view or
close a modal which may be open.
The priorities for the existing back button hooks are as follows:
Return to previous view = 100 Close side menu = 150 Dismiss modal =
200 Close action sheet = 300 Dismiss popup = 400 Dismiss loading
overlay = 500
Your back button action will override each of the above actions whose
priority is less than the priority you provide. For example, an action
assigned a priority of 101 will override the 'return to previous view'
action, but not any of the other actions.
In your controllers you can register a listener for the back button and exit if it has been pressed:
.controller('AController', function($scope, $ionicPlatform){
var deregister = $ionicPlatform.registerBackButtonAction(
function () {
ionic.Platform.exitApp();
}, 100
);
$scope.$on('$destroy', deregister)
})
.controller('BController', function($scope, $ionicPlatform){
var deregister = $ionicPlatform.registerBackButtonAction(
function () {
ionic.Platform.exitApp();
}, 100
);
$scope.$on('$destroy', deregister)
})
.controller('CController', function($scope, $ionicPlatform){
var deregister = $ionicPlatform.registerBackButtonAction(
function () {
ionic.Platform.exitApp();
}, 100
);
$scope.$on('$destroy', deregister)
});
NOTES:
Your last tab (C TAB) should have the name: c-tab:
<ion-tab title="C" href="#/tab/c">
<ion-nav-view name="c-tab"></ion-nav-view>
</ion-tab>
I have done it with a simple way
Added a function in controller scope of tabs state
.state('tabs', {
url: "/tab",
abstract: true,
templateUrl: "templates/tabs.html",
controller: function($scope, $ionicTabsDelegate, $ionicHistory) {
$scope.rt = function(e, index) {
$ionicTabsDelegate.select(index);
//e.preventDefault();
$ionicHistory.nextViewOptions({historyRoot:true});
}
}
})
and added ng-click on each ion-tab directive in tabs.html template like below;
<ion-tab ng-click="rt($event, 0)" title="A" href="#/tab/a">
The second parameter of rt function is the index of tab
I used $ionicHistory.nextViewOptions({historyRoot:true});
From the documentation
nextViewOptions()
Sets options for the next view. This method can be
useful to override certain view/transition defaults right before a
view transition happens. For example, the menuClose directive uses
this method internally to ensure an animated view transition does not
happen when a side menu is open, and also sets the next view as the
root of its history stack. After the transition these options are set
back to null.
Available options:
disableAnimate: Do not animate the next transition.
disableBack: The next view should forget its back view, and set it to null.
historyRoot: The next view should become the root view in its history stack.
That way I achieved what I want

Resources