Let's say I have this navigation structure:
That will look something like this with compose-navigation:
NavHost(navController = navController, startDestination = "login") {
composable("login") {
Text("Login Screen!")
}
navigation(route = "home", startDestination = "favorites") {
composable("favorites") {
Text("Favorites Screen!")
}
composable("search") {
Text("Search Screen!")
}
composable("profile") {
Text("Profile Screen!")
}
}
}
My issue is that I want to use compose-navigation to navigate to an "inner" screen inside home while still maintaining a shared component between all of home's routes (e.g. favorites, search & profile), this shared component may be a BottomAppBar for example. Please notice that the login for example doesn't have a bottom bar.
I have found multiple solutions to this problem but each and every one of them comes with a caveat that makes it difficult to use.
Make the home a route instead of a navigation and nest another NavHost inside of it.
This allows to do the following:
NavHost(navController = navController, startDestination = "login") {
composable("login") {
Text("Login Screen!")
}
composable(route = "home") {
val homeNavController = rememberNavController() //Second NavController!
Column {
NavHost(navController = homeNavController, startDestination = "favorites") {
composable("favorites") {
Text("Favorites Screen!")
}
composable("search") {
Text("Search Screen!")
}
composable("profile") {
Text("Profile Screen!")
}
}
}
BottomAppBar {
//Showing all items and onClick navigating
}
}
}
As you can see, because NavHost's builder lambda is a #Composable, we can just add a Column and show the BottomAppBar below the NavHost (which is basically the "inner" screen).
From what I've seen, this solution is error-prone because you'll have to maintain two distinct NavControllers that can't know anything about each other, also, I think its considered an anti-pattern.
Create a single Scaffold for the entire app, then we would be able to display the BottomBar if we're either one of favorites, search or profile.
val currentDestination = ... //String
Scaffold(
bottomBar = {
when (currentDestination) {
"favorites", "search", "profile" -> BottomAppBar {
//Showing all items and onClick navigating
}
else -> Unit
}
}
) {
NavHost(
navController = navController,
startDestination = "login",
modifier = Modifier.padding(it)
) {
composable("login") {
Text("Login Screen!")
}
navigation(route = "home", startDestination = "favorites") {
composable("favorites") {
Text("Favorites Screen!")
}
composable("search") {
Text("Search Screen!")
}
composable("profile") {
Text("Profile Screen!")
}
}
}
}
Here, because the BottomBar is composed whenever the app's current route is changed, we do a when and decide which composable to show (for example if it's login we don't show a BottomBar. This solution is overall good but it makes the screen separated from the Top/Bottom bar, thus creating more complexity when we'll add ViewModels to the mix because we'll have to inject the same instance to both the screen and the Top/Bottom bar composables (which is not that horrible but it does get really messy if you do anything extra).
And not really an option - Not using compose-navigation for the home route and just displaying the content based on a when statement (basically the same as no.1 but removing the NavHost and doing a "manual" check to decide which screen to draw). This just loses all of the navigation capabilities.
After a lot of thinking about this (relatively simple) problem, I'm surprised no one has yet to ask this, and also that Google doesn't have any documentation about a best-practice solution for this.
Would love to hear solutions regarding this. Thanks.
Related
This is a question about general navigation design in Jetpack compose which I find a bit confusing.
As I understand it, having multiple screens with each own Scaffold causes flickers when navigating (I definitely noticed this issue). Now in the app, I have a network observer that is tied to Scaffold (e.g. to show Snackbar when there is no internet connection) so that's another reason I'm going for a single Scaffold design.
I have a MainViewModel that holds the Scaffold state (e.g. top bar, bottom bar, fab, title) that each screen underneath can turn on and off.
#Composable
fun AppScaffold(
networkMgr: NetworkManager,
mainViewModel: MainViewModel,
navAction: NavigationAction = NavigationAction(mainViewModel.navHostController),
content: #Composable (PaddingValues) -> Unit
) {
LaunchedEffect(Unit) {
mainViewModel.navHostController.currentBackStackEntryFlow.collect { backStackEntry ->
Timber.d("Current screen " + backStackEntry.destination.route)
val route = requireNotNull(backStackEntry.destination.route)
var show = true
// if top level screen, do not show
topLevelScreens().forEach {
if (it.route.contains(route)) {
show = false
return#forEach
}
}
mainViewModel.showBackButton = show
mainViewModel.showFindButton = route == DrawerScreens.Home.route
}
}
Scaffold(
scaffoldState = mainViewModel.scaffoldState,
floatingActionButton = {
if (mainViewModel.showFloatingButton) {
FloatingActionButton(onClick = { }) {
Icon(Icons.Filled.Add, contentDescription = "Add")
}
}
},
floatingActionButtonPosition = FabPosition.End,
topBar = {
if (mainViewModel.showBackButton) {
BackTopBar(mainViewModel, navAction)
} else {
AppTopBar(mainViewModel, navAction)
}
},
bottomBar = {
if (mainViewModel.showBottomBar) {
// TODO
}
},
MainActivity looks like this
setContent {
AppCompatTheme {
var mainViewModel: MainViewModel = viewModel()
mainViewModel.coroutineScope = rememberCoroutineScope()
mainViewModel.navHostController = rememberNavController()
mainViewModel.scaffoldState = rememberScaffoldState()
AppScaffold(networkMgr, mainViewModel) {
NavigationGraph(mainViewModel)
}
}
}
Question 1) How do I make this design scalable? As one screen's FAB may have different actions from another screen's FAB. The bottom bar may be different between screens. The main problem is I need good a way for screens to talk to the parent Scaffold.
Question 2) Where is the best place to put the code under "LaunchedEffect" block whether it's ok here?
I found this StackOverflow answer that covers your question pretty well.
The key answers to your questions according to this answer are:
You define a data class that holds variables for each element that might change between the different screens that will be displayed inside the scaffold. This most probably will be at least the title:
data class ScaffoldViewState(
#StringRes val topAppBarTitle: Int? = null
)
Then, you store this data class using remember, so that a recomposition will be triggered whenever one value within the data class changes:
var scaffoldViewState by remember {
mutableStateOf(ScaffoldViewState())
}
Finally, you can assign the field within the data class to the title slot of the Scaffold.
Changing the variables of the data class should happen from the NavHost, as seen in the linked post.
I'm using jetpack compose in my project. My app contains multiple screens and nested navigation.
So I have this in my MainActivity:
val startDestination = if (setting.isUserLoggedIn)
HomeNavGraph.HomeRoute.route
else
AuthenticationNavGraph.AuthenticationRoute.route
setContent {
val navController = rememberNavController()
DoneTheme {
NavHost(
navController = navController,
startDestination = startDestination
) {
authentication(navController = navController)
home()
}
}
}
This code decides that, based on whether the user has already logged in, navigate the user to the authentication screen or home screen. The home navigation is like this:
fun NavGraphBuilder.home() {
navigation(
startDestination = HomeNavGraph.HomeScreen.route,
route = HomeNavGraph.HomeRoute.route
) {
composable(route = HomeNavGraph.HomeScreen.route)
{
HomeRouteScreen()
}
}
}
The HomeRouteScreen() per se, has bottom navigation and its navHost.
Now I want to navigate the user from the profile screen in HomeRouteScreen() to the Authentication screen that its composable is defined in the MainActivity nav graph.
my problem is:
If I call navcontroller.navigate(AuthenticationNavGraph.AuthenticationScreen.route), I get the error that the destination is not explicitly in the current nav graph. On the other hand, if I use the navController which exists in Main Activity, in HomeRouteScreen I get the error that ViewModelStore should be set before setGraph call. So How should I handle this issue?
The below is an overview of the BottomNav implementation.The app shows the bottom Nav bar properly but when an item is selected, it calls the NavHost multiple times. I see a similar issue for Jetpack compose samples https://github.com/android/compose-samples/tree/main/Jetsnack. Is there any workaround to avoid multiple Navhost calls?
#Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
bottomBar = { BottomMenu(navController = navController) }
) {
BottomNavGraphBar(navController = navController)
}
}
// handling the click event
BottomNavigationItem(
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id)
launchSingleTop = true
}
}
)
//NavHost implementation
#Composable
fun BottomNavGraphBar(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = Screen.Home.route
) {
composable(route = Screen.Home.route) {
Log.d("BottomNavGraph","BottomNavGraph->HomeScreen")
HomeScreen()
}
composable(route = Screen.Settings.route) {
Log.d("BottomNavGraph","BottomNavGraph->AppSettingsScreen")
AppSettingsScreen()
}
composable(route = Screen.Profile.route) {
Log.d("BottomNavGraph","BottomNavGraph->ProfileScreen")
ProfileScreen()
}
}
}
<!---LogCat-->
// When app is launched
BottomNavGraph->HomeScreen
BottomNavGraph->HomeScreen
// clicked on the profile.
BottomNavGraph->HomeScreen
BottomNavGraph->ProfileScreen
BottomNavGraph->HomeScreen
BottomNavGraph->ProfileScreen
Compose can (and will, depending on multiple things) call composable functions to "re-compose" them. Although it is smart and can cache the output of composable functions for their previous inputs, so that it does not have to recompute their results (e.g. their emitted UI).
In your example, the composable(..) { ... } might get recomposed, but if you use composables inside it (like a few Texts) it will use its cache from its last rendering.
You don't need to worry about your functions being called, but you do have to take care of your computations. This is why you'd want to use remember to calculate something and store it in the cache, so it is not re-calculated again.
as you see this is how i implemented NavHost with MaterialBottomNavigation, i have many items on both Messages and Feeds screens, when i navigate between them both screens, they automatically recomposed but i don't wanna because of much data there it flickring and fps drops to under 10 when navigating, i tried to initialize data viewModels before NavHost but still same result, is there any way to compose screens once and update them just when viewModels data updated?
#Composable
private fun MainScreenNavigationConfigurations(
navController: NavHostController,
messagesViewModel: MessagesViewModel = viewModel(),
feedsViewModel: FeedsViewModel = viewModel(),
) {
val messages: List<Message> by messagesViewModel.messages.observeAsState(listOf())
val feeds: List<Feed> by feedsViewModel.messages.observeAsState(listOf())
NavHost(
navController = navController,
startDestination = "Messages"
) {
composable("Messages") {
Messages(navController, messages)
}
composable("Feeds") { Feeds(navController, feeds) }
}
}
I had a similar problem. In my case I needed to instantiate a boolean state "hasAlreadyNavigated".
The problem was:
-> Screen 1 should navigate to Screen 2;
-> Screen 1 has a conditional statement for navigating directly to screen 2 or show a content screen with an action button that navigates to Screen 2;
-> After it navigates to Screen 2, Screen 1 recomposes and it reaches the if statement again, causing a "navigation loop".
val hasAlreadyNavigated = remember { mutableStateOf(false) }
if (!hasAlreadyNavigated.value) {
if (!screen1ViewModel.canNavigate()) {
Screen1Content{
hasAlreadyNavigated.value = true
screen1ViewModel.allowNavigation()
navigateToScreen2()
}
} else {
hasAlreadyNavigated.value = true
navigateToScreen2()
}
}
With this solution, i could prevent recomposition and the "re-navigation".
I don't know if we need to be aware and build composables thinking of this recomposition after navigation or it should be library's responsibility.
Please use this code above your code. It will remember state of your current screen.
val navController = rememberNavController()
for more info check this out:
https://developer.android.com/jetpack/compose/navigation
Passing the navcontroller as a parameter causes recomposition. Use it as a lambda instead.
composable("Messages") {
Messages( onClick = {navController.navigate(route = "Click1")},
onClick2 = {navController.navigate(route = "Click2")},
messages)
}
I am looking for a Compose variant of setupWithNavController(Toolbar, NavController) to automatically update the up button in an AppBar whenever the Navigation destination changes.
So far I haven't found anything useful.
Is it considered a bad design in Compose? Or is there some simple way how to achieve the same thing that I am not seeing?
I've hacked up a solution, but I am not satisfied.
androidx.navigation:navigation-compose:1.0.0-alpha08 provides an extension function to observe the current back stack entry.
#Composable
fun NavController.currentBackStackEntryAsState(): State<NavBackStackEntry?>
I've created a similar extension to observe the previous back stack entry
/**
* Gets the previous navigation back stack entry as a [MutableState]. When the given navController
* changes the back stack due to a [NavController.navigate] or [NavController.popBackStack] this
* will trigger a recompose and return the second top entry on the back stack.
*
* #return a mutable state of the previous back stack entry
*/
#Composable
fun NavController.previousBackStackEntryAsState(): State<NavBackStackEntry?> {
val previousNavBackStackEntry = remember { mutableStateOf(previousBackStackEntry) }
// setup the onDestinationChangedListener responsible for detecting when the
// previous back stack entry changes
DisposableEffect(this) {
val callback = NavController.OnDestinationChangedListener { controller, _, _ ->
previousNavBackStackEntry.value = controller.previousBackStackEntry
}
addOnDestinationChangedListener(callback)
// remove the navController on dispose (i.e. when the composable is destroyed)
onDispose {
removeOnDestinationChangedListener(callback)
}
}
return previousNavBackStackEntry
}
and a composable for the back button
#Composable
fun NavigationIcon(navController: NavController): #Composable (() -> Unit)? {
val previousBackStackEntry: NavBackStackEntry? by navController.previousBackStackEntryAsState()
return previousBackStackEntry?.let {
{
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.Default.ArrowBack, contentDescription = "Up button")
}
}
}
}
I had to return a #Composable (() -> Unit)? (instead of no return value common to composable functions) because the TopAppBar uses the nullability to check whether to offset the title by 16dp (without icon) or by 72dp (with icon).
Finally, the content looks something like this
#Composable
fun MainContent() {
val navController: NavHostController = rememberNavController()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Weather") },
navigationIcon = NavigationIcon(navController)
)
},
) {
NavHost(
navController,
startDestination = "list"
) {
composable("list") {
...
}
composable("detail") {
...
}
}
}
}
List:
Detail:
It might be cleaner to create a custom NavigationTopAppBar composable and hoist the NavController out of NavigationIcon but the idea stays the same. I didn't bother tinkering further.
I've also attempted to automatically update the title according to the current NavGraph destination. Unfortunately, there is not a reliable way to set a label to destinations without extracting quite a big chunk of internal implementation out of androidx.navigation:navigation-compose library.