Bottom Nav Android Jetpack Compose issue - android

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.

Related

Compose navigation with shared component for multiple routes

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.

Jetpack Compose Using Same State Value For All Screens

I want to show circularProgressIndicator while an operation is working such as network call. I want to be able to change the indicator visibility state in all viewmodels and observe that value in NavHost because I want to show a transparant layout which has a circular indicator and prevent user from clicking another fields while network call is still going.
I tried to use baseviewmodel class and mutableStateOf(Boolean) in it but whenever I tried to access viewmodel from navhost it's instance is different than other.
Question is basically how can i create a single global mutableStateOf() object and change it's value from all inside of my viewmodels and observe it as a state inside of NavHost composable to change visibility of circularProgressIndicator?
Note: I am using hilt to get instance of viewmodel -> viewModel: MyViewModel = hiltViewModel()
#Composable
fun NavHost(modifier: Modifier = Modifier) {
val navController = rememberAnimatedNavController()
Box {
AnimatedNavHost(
navController = navController,
startDestination = Screen.EmailAndPassword.route,
modifier = modifier.fillMaxSize()
) {
composable(Screen.EmailAndPassword.route) {
EmailAndPasswordScreen {
navController.navigate(Screen.UserInformation.route) {
/*TODO*/
}
}
}
composable(Screen.UserInformation.route) { UserInformationScreen() }
}
LoadingScreen(isVisible = /* IndicatorState */)
}}
#Composable
fun LoadingScreen(modifier: Modifier = Modifier, isVisible: Boolean) {
if (isVisible){
Box(modifier = modifier.background(Color.Transparent).fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}}
I tried to use baseviewmodel class and mutableStateOf(Boolean) in it but whenever I tried to access viewmodel from navhost it's instance is different than other.
I use Compose-Destinations library and for sharing a ViewModel throughout the Activity, I do it like this (This is a sample from documentation):
#Composable
fun AppNavigation(
activity: Activity
) {
DestinationsNavHost(
//...
dependenciesContainerBuilder = { //this: DependenciesContainerBuilder<*>
// 👇 To tie SettingsViewModel to "settings" nested navigation graph,
// making it available to all screens that belong to it
dependency(NavGraphs.settings) {
val parentEntry = remember(navBackStackEntry) {
navController.getBackStackEntry(NavGraphs.settings.route)
}
hiltViewModel<SettingsViewModel>(parentEntry)
}
// 👇 To tie ActivityViewModel to the activity, making it available to all destinations
dependency(hiltViewModel<ActivityViewModel>(activity))
}
)
}

Skip landing page in NavHost when user opens the app for second time using Jetpack Compose with Flow

I have an app with HorizontalPager at startDestination screen and after I go to the last page, the app shows the home page.
When I open the app for second time it should show Home page immediately and never again show that startDestination screen with HorizontalPager.
I used dataStore and it works, but the problem is that every time I open the app it flashes for a second that HorizontalPager landing page and then it switches to the home page.
I used flow to get true/false state of the app start, so it will know it will know app was already opened for first time.
class MainActivity : ComponentActivity() {
#ExperimentalAnimationApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
WebSafeTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
val navController = rememberNavController()
Navigation(navController)
}
}
}
}
}
#ExperimentalAnimationApi
#ExperimentalMaterialApi
#ExperimentalFoundationApi
#ExperimentalPagerApi
#Composable
fun Navigation(
navController: NavHostController
) {
val context = LocalContext.current
val preferencesManager = PreferencesManager(context)
val preferencesFlow = preferencesManager.preferencesFlow
val scope = rememberCoroutineScope()
val result = remember { mutableStateOf(Constants.SKIP_LANDING_PAGE) }
scope.launch {
result.value = preferencesFlow.first().skipLandingPage
}
NavHost(
navController = navController,
//it goes it this line 2 times, first time when the app opens and second time after the flow is finished
startDestination = if (result.value) Screen.HomeScreen.route else Screen.LandingScreen.route,
modifier = Modifier.fillMaxSize()
) {
composable(
route = Screen.LandingScreen.route
) {
Landing(navController)
}
composable(
route = Screen.SkipChooseCountryScreen.route
) {
ChooseCountry()
}
composable(
route = Screen.HomeScreen.route
) {
Home(navController)
}
}
}
It goes to NavHost for the first time after app openes and it always returns FALSE as it is default value, after that flow returns TRUE(so it knows app was opened at least once before) and then it openes the correct screen.
I have no idea how to make NavHost to wait that flow to finishes. I tried to put NavHost into the scope but it didn't allow me.
I also need to read data from data store, so this is how I do it:
Crossfade(
targetState = state.value.loadState
) { loadState ->
when (loadState) {
LoadState.NOT_LOADED -> Box(modifier = Modifier.fillMaxSize())
LoadState.SHOW_PIN -> PinScreen(
loadState = loadState,
state = state,
modifier = Modifier.fillMaxSize(),
)
LoadState.SHOW_CONTENT -> MainContent(
state = state,
)
}
}
Initially my state is NOT_LOADED so I just display an empty box that fills the screen. You could alternatively display a spinner, or your app's logo. Once the data has been loaded, I show the PIN screen if the user has PIN enabled, or the main screen otherwise.
Also, note that you should not have your viewpager screen as your root destination, your home page should be your root destination, and you should conditionally navigate to the viewpager if the user has not seen your onboarding flow yet. Check this for details.

Jetpack compose NavHost prevent recomposition screens

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)
}

Android Compose setupWithNavController

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.

Categories

Resources