Compose navigation - replace starting route and clear back stack - android

I'm using JetPack Compose with composable NavHost.
I have a scenario where I need a Launch screen that connects bluetooth device so I've set it as my starting route in NavHost.
After connection is done I want to enter Home screen and never get back to that Launch screen.
So after connection is done Launch screen I'm doing this:
navController.graph.setStartDestination(newHomeRoute)
navController.navigate(newHomeRoute) {
popUpTo(0)
launchSingleTop = true
}
That doesn't work as I get a constant loop going back to LaunchScreen and forward to Home.
So maybe I should do this in some other way?

I managed to do this with extension like this:
fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: String) {
popBackStack(graph.startDestinationId, true)
graph.setStartDestination(newHomeRoute)
navigate(newHomeRoute)
}

I had this exact problem.
Sharing my code here.
SHORT ANSWER,
navHostController.popBackStack("routeOfLaunchingScreen", true)
navHostController.navigate("newHomeRoute")
The true denotes that pop back stack till and including the given route.
Once the back stack is popped as required, we navigate to the new screen.
Hope this solves your issue. :)
LONG ANSWER (copy-paste solution)
MyNavActions.
class MyNavActions(navHostController: NavHostController) {
val navigateTo = { navBackStackEntry: NavBackStackEntry, route: String ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.navigate(route)
}
}
val navigateUp = { navBackStackEntry: NavBackStackEntry ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.navigateUp()
}
}
val popBackStackAndNavigate =
{ navBackStackEntry: NavBackStackEntry, route: String?, popUpTo: String, inclusive: Boolean ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.popBackStack(popUpTo, inclusive)
route?.let {
navHostController.navigate(route)
}
}
}
}
}
/**
* If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event.
*
* This is used to de-duplicate navigation events.
*/
private fun NavBackStackEntry.lifecycleIsResumed() =
this.lifecycle.currentState == Lifecycle.State.RESUMED
Usage
val myNavActions = remember(navHostController) {
MyNavActions(navHostController)
}
Pop back stack till given route and navigate
chcNavActions.popBackStackAndNavigate(
navBackStackEntry,
routeToPopUpTo,
routeToNavigateTo,
true, // inclusive flag - boolean denoting if the specified route `routeToPopUpTo` should also be popped
)
Back Navigation
chcNavActions.navigateUp(navBackStackEntry)
Simple navigation
chcNavActions.navigateTo(navBackStackEntry, route)

You can use this extension:
fun NavHostController.navigateAndClean(route: String) {
navigate(route = route) {
popUpTo(graph.startDestinationId) { inclusive = true }
}
graph.setStartDestination(route)
}

Related

Navigation is being called every time in Jetpack Compose

I'm implementing registration in my application and, after filling in the respective fields, I click on a button that will make a registration request to the API. Meanwhile, I place a Loading View and when I receive the successful response, I execute the navigation to the OnBoarding screen. The issue is that the navController is always running the navigation and doing the navigation and popUp several times, when it should only do it once. I always get this warning on logs: Ignoring popBackStack to destination 29021787 as it was not found on the current back stack and I am not able to do any click or focus in the OnBoardingScreen.
My code:
val uiState by registerViewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
is BaseViewState.Loading -> LoadingView()
is BaseViewState.Error -> BannerView()
else -> {}
}
On button click I call the viewModel like this:
registerViewModel.onTriggerEvent(
RegisterEvent.CreateUser(
usernameInputState.value.text,
emailInputState.value.text,
passwordInputState.value.text
)
)
And, in ViewModel, I do my request like this:
override fun onTriggerEvent(eventType: RegisterEvent) {
when (eventType) {
is RegisterEvent.CreateUser -> createUser(eventType.username, eventType.email, eventType.password)
}
}
private fun createUser(username: String, email: String, password: String) = safeLaunch {
setState(BaseViewState.Loading)
execute(createUser(CreateUser.Params(username, email, password))) {
setState(BaseViewState.Data(RegisterViewState(it)))
}
}
I guess it should be caused by recomposition, because I put a breakpoint on first when scenario and it stops here multiple times, but only one on ViewModel. How can I fix this?
This issue is here
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
Every time you call navController.navigate NavHost will keep on passing through this block, executing an endless loop.
I suggest having the navigate call from a LaunchedEffect with a key (like this),
LaunchedEffect(key1 = "some key") {
navController.navigate(…)
}
or creating a separate structure namely "Events" where they are emitted as SharedFlow and observed via a Unit keyed LaunchedEffect
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
navController.navigate(…)
}
}
}
}

How to check if a composable is on top of the back stack or not?

I have a navigation graph containing a HomeScreen and a MyBottomSheet. For bottom sheets I am using Accompanist Navigation.
Both of the destinations share a common ViewModel which is scoped to that navigation graph. From that ViewModel I am exposing a Flow<MyEvent> where MyEvent is:
sealed interface MyEvent
object MyEvent1: MyEvent
object MyEvent2: MyEvent
The two composables look like this:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
...
}
#Composable
fun MyBottomSheet(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.eventsFlow.collect {
if(it is Event2) {
handleEvent2()
}
}
}
...
}
Note that I want HomeScreen to handle Event1 and MyBottomSheet to handle Event2 (these events are basically navigation events).
The problem is that when MyBottomSheet is visible, both the composables are collecting the flow at the same time because of which Event2 also gets collected by HomeScreen. What I want is that when MyBottomSheet is the topmost destination in back stack, HomeScreen shouldn't be collecting flow. One of the possible solutions in my mind is:
#Composable
fun HomeScreen(viewModel: MyViewModel) {
LaunchedEffect(isHomeScreenOnTheTopOfBackStack) {
if(isHomeScreenOnTheTopOfBackStack) {
viewModel.eventsFlow.collect {
if(it is Event1) {
handleEvent1()
}
}
}
}
...
}
Now here, how can I check if HomeScreen is on top of the back stack or not?
Or is there a better way to approach this problem?
Turned out it was quite easy. We can use navController.currentBackStackEntryAsState() in the nav graph and pass the Boolean to the HomeScreen.
composable("home") {
val isOnTop = navController.currentBackStackEntryAsState().value?.destination?.route == "home"
HomeScreen(
viewModel = //,
isHomeScreenOnTheTopOfBackStack = isOnTop
)
}

java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack

I have created a composable called ResolveAuth. ResolveAuth is the first screen when user opens the app after Splash. All it does is check whether an email is present in Datastore or not. If yes redirect to main screen and if not then redirect to tutorial screen
Here is my composable and viewmodel code
#Composable
fun ResolveAuth(resolveAuthViewModel: ResolveAuthViewModel, navController: NavController) {
Scaffold(content = {
ProgressBar()
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
})
}
#HiltViewModel
class ResolveAuthViewModel #Inject constructor(
private val dataStoreManager: DataStoreManager): ViewModel(){
val userEmail = MutableLiveData<String>()
init {
viewModelScope.launch{
val job = async {dataStoreManager.email.first()}
val email = job.await()
if(email != ""){
userEmail.value = email
}
}
}
}
But I keep getting an exception saying
java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry reaches the CREATED state).
I am using below jetpack lib for navigation
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
There is no issue in my Main and Tutorial screen as I tried to run them separately and it works fine.
Easily resolvable, just add this when call to a Side-Effect instead.
LaunchedEffect(Unit){
while(!isNavStackReady) // Hold execution while the NavStack populates.
delay(16) // Keeps the resources free for other threads.
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
}
Here, the call to navigate is made only after the currentBackStackEntry has been completely filled, so it yields no error. The original error occurred since you were calling navigate before the concerned composable was even made available to the nav stack.
As for how to update the isNavStackReady variable to reflect the correct state of the navStack, it is fairly simple. Create the variable at a top-level declaration, such that only the required components may access it. May as well throw it inside a viewModel if you please. Set the default value of the var to false, for obvious reasons. Here's the update mechanism.
#Composable
fun StartDestination(){
isNavStackReady = true
}
That's it, that's really it. If you could successfully navigate to your start destination that you define in the nav graph, it means the navStack has likely been populated well. Hence, you just update this variable here, and the LaunchedEffect block up there will respond to this update, and the while loop that's been holding execution off, will finally break. It will then call the navigate on the appropriate destination route. Remember, however, that the isNavStackReady variable, for this mechanism to work, needs to be a state-holder, i.e., initialised with mutableStateOf(false). Using delegates, of course, is completely fine (personally encouraged).
Now, all this is fine, but actually, it's not quite the right implementation. You see, this entire thing is taken care of completely internally by the navigation APIs for us, but it breaks because we are trying to do its job, and we suck at it.
We are creating an intermediate route to land on, at the start of the app, and from there, immediately navigating to another screen based on calculations. So, all we want is to open the app at a desired page, that is, start the navigator on a desired page when it is first created. We have a handy parameter called startDestination, just for that.
Hence, the ideal, simple, beautiful solution would be to just
startDestination = when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
in your NavBuilder's arguments. Tiniest silliest logical flaw, that so many people couldn't get. It's intriguing to think how the human mind works...
Happy New Year,

conditional navigation in compose, without click

I am working on a compose screen, where on application open, i redirect user to profile page. And if profile is complete, then redirect to user list page.
my code is like below
#Composable
fun UserProfile(navigateToProviderList: () -> Unit) {
val viewModel: MainActivityViewModel = viewModel()
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
return
}
else {
//compose elements here
}
}
but the app is blinking and when logged, i can see its calling the above redirect condition again and again. when going through doc, its mentioned that we should navigate only through callbacks. How do i handle this condition here? i don't have onCLick condition here.
Content of composable function can be called many times.
If you need to do some action inside composable, you need to use side effects
In this case LaunchedEffect should work:
LaunchedEffect(viewModel.userProfileComplete == true) {
if(viewModel.userProfileComplete == true) {
navigateToProviderList()
}
}
In the key(first argument of LaunchedEffect) you need to specify some key. Each time this key changes since the last recomposition, the inner code will be called. You may put Unit there, in this case it'll only be called once, when the view appears at the first place
The LaunchedEffect did not work for me since I wanted to use it in UI thread but it wasn't for some reason :/
However, I made this for my self:
#Composable
fun <T> SelfDestructEvent(liveData: LiveData<T>, onEvent: (argument: T) -> Unit) {
val previousState = remember { mutableStateOf(false) }
val state by liveData.observeAsState(null)
if (state != null && !previousState.value) {
previousState.value = true
onEvent.invoke(state!!)
}
}
and you use it like this in any other composables:
SingleEvent(viewModel.someLiveData) {
//your action with that data, whenever it was triggered, but only once
}

In Android Navigation Architecture, how can I check if current Fragment is the last one?

I need to display custom AlertDialog, but only when there are no more fragments after calling NavController.navigateUp(). My current code does something similar, but there is a bug to it:
override fun onBackPressed() {
if (navController.navigateUp()) {
return
}
showQuitDialog()
}
This somewhat works, but if I cancel the AlertDialog and don't quit the app, navController already navigated up to NavGraph root, which is not the fragment in which I was when AlertDialog appeared. So, if I try to use any navigation action from that fragment, I get error:
java.lang.IllegalArgumentException: navigation destination
com.example.test/actionNavigateToNextFragment is unknown to this NavController
This could be resolved, if I had access to mBackStack field in NavController, but it's package private, and I can't use reflection in my current project. But if it was public, I would use it the following way:
override fun onBackPressed() {
// 2 because first one is NavGraph, second one is last fragment on the stack
if(navController.mBackStack.size > 2) {
navController.navigateUp()
return
}
showQuitDialog()
}
Is there a way to do this without reflection?
You can compare the ID of the start destination with the ID of the current destination. Something like:
override fun onBackPressed() = when {
navController.graph.startDestination == navController.currentDestination?.id -> showQuitDialog()
else -> super.onBackPressed()
}
Hope it helps.
Try this for any destination (you can find your destination id in the navigation graph):
private fun isDesiredDestination(): Boolean {
return with(navController) {
currentDestination == graph[R.id.yourDestinationId]
}
}
Comparing IDs of destination may not be the best solution because you may have multiple screens with the same ID but different arguments on the backstack.
Here's an alternative:
val isRootScreen = navController.previousBackStackEntry == null
if you want this is a button so to speak you can have this
yourbutton.setOnClickListener {
val currentFragment = navController.currentDestination?.id
when (navController.graph.startDestination == currentFragment) {
true -> { //Go here}
// Go to the app home
else -> onBackPressed()
}
}

Categories

Resources