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