Changes in a MutableState are reverted after calling popBackStack() - android

I have two #Composable screens which are connected by a NavHostController. Let's call them Screen 1 and Screen 2.
They're both sharing a ViewModel that is injected by hiltViewModel(). This ViewModel contains a state value (true by default) wrapped in a data class UiState and exposes method to change that state (to false).
data class UiState(
var state: Boolean
)
#HiltViewModel
class StateViewModel : ViewModel() {
val uiState: MutableState<UiState> = mutableStateOf(UiState(true))
fun setStateToFalse() {
uiState.value = uiState.value.copy(state = false)
}
}
Screen 1 is based on the UiState and displays data based on it. You can also navigate to the Screen 2 by clicking the button on the Screen 1:
#Composable
fun Screen1(
navController: NavHostController,
stateViewModel: StateViewModel = hiltViewModel()
) {
val uiState = remember { stateViewModel.uiState }
Button(
onClick = { navController.navigate("Screen2") }
) {
Text(
text = "State value: " + if (uiState.value.state) "true" else "false"
)
}
}
After navigating to Screen 2 we can change the state to false and immediately after that call popBackStack() to navigate back to the Screen 1:
#Composable
fun Screen2(
navController: NavHostController,
stateViewModel: StateViewModel = hiltViewModel()
) {
Button(
onClick = {
stateViewModel.setStateToFalse()
CoroutineScope(Dispatchers.Main).launch {
navController.popBackStack()
}
}
) {
Text(text = "Change state to false")
}
}
Now, after the calls to setStateToFalse() and popBackStack() I end up at the Screen 1 that tells me that the state is still true while it should be false:
And this is how I expected the Screen 1 to look like:
I have debugged the application and the state is changed to false in the Screen 2 but later I could see that on the Screen 1 it remains true. I'm still pretty new to Jetpack Compose and Navigation Components so I might be missing something obvious. Even if so, please help me :)

Related

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

View Model with Jetpack compose view

I am using ViewModelFactory to obtain view model instance which is to be used by my jetpack compose view.
class AchievementsScreenViewModelFactory() :
ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel?> create(modelClass: Class<T>): T = AchievementsScreenViewModel() as T
}
As soon as instantiate my viewmodel, i want to perform some operations. I am currently storing those operations in the viewmodel constructor(Like some firebase operation to check if the user instance is found or not).Is that a wrong practice? if so, what should i do?
constructor(context:Context) : this() {
this.context=context
mAuth= FirebaseAuth.getInstance()
if(mAuth.currentUser!=null){
triggerNavigateEvent(Screen.DashboardScreen)
}
}
So, the issue that I am facing is that, whenever I use my View Model Factory to instantiate an instance of my view and then when i pop the view from the NavController and return to it, the View Model Factory returns me the same instance of the View Model and the tasks that are present in my constructor are not being performed.
Is there a way to kill the instance of my View Model at the time of popping the screen from the NavController? or is there an other way?
I am calling the viewmodel from the composable screen like this
#SuppressLint("CoroutineCreationDuringComposition")
#Composable
fun LoginScreen(navController: NavHostController
){
var viewModel:LoginScreenViewModel= viewModel(
factory = LoginScreenViewModelFactory(LocalContext.current)
)
.
.
.
}
I am navigating to the screens using google accompanist navigation library.
AnimatedNavHost(
navController = navController,
startDestination = Screen.SplashScreen.route,
enterTransition = { fadeIn(animationSpec = tween(1000), initialAlpha = 0f) },
exitTransition ={ fadeOut(animationSpec = tween(1000), targetAlpha = 0f) }
){
composable(
route = Screen.LoginScreen.route
){
LoginScreen(navController = navController)
}
}
The navigation-compose NavHost (in your case AnimatedNavHost) will call its composablefunction for the target destination every time the destination changes, i.e. when you navigate to a destination and also when you navigate back. That means that you can put the code that you want to run into a method/function in your ViewModel (instead of its constructor) and use a LaunchedEffect composable to call it. If you use a constant key when invoking the LaunchedEffect composable, for example LaunchedEffect(Unit), it will only run once when it enters the composition, in your case once each time the destination changes.
Move the code from VM constructor to a new function in your VM
suspend fun callSomeApi() {
// your code here
}
And add a LaunchedEffect(Unit) to the composable you want to call this new function from
#Composable
fun LoginScreen(navController: NavHostController){
var viewModel: LoginScreenViewModel = viewModel(
factory = LoginScreenViewModelFactory(LocalContext.current)
)
// called once every time this composable enters the composition
LaunchedEffect(Unit) {
viewModel.callSomeApi()
}
}
Here is an example I use
val viewModel = hiltViewModel<PokemonListVm>()
Usage:
#Composable
fun PokemonListScreen(
navController: NavController
) {
val viewModel = hiltViewModel<PokemonListVm>()
val lazyPokemonItems: LazyPagingItems<PokedexListEntry> = viewModel.pokemonList.collectAsLazyPagingItems()
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxSize()
) {
Column {
Spacer(modifier = Modifier.height(20.dp))
PokemonBanner()
PokemonSearch()
PokemonLazyList(
pokemonList = lazyPokemonItems,
onItemClick = { entry ->
navController.navigate(
"pokemon_detail_screen/${entry.dominentColor.toArgb()}/${entry.pokemonName}"
)
}
)
}
}
}

Updating state in ViewModel of one screen after event in the second screen - Jetpack Compose Navigation

I am building an application for solving queastionnaires. The application has three screens: MainMenuView, QuestionnaireView and SettingsView. From the MainMenu a user can enter QuestionnaireView and SettingsView, however they cannot enter QuestionnaireView unless the userId has not been submitted in the SettingsView (I am saving the userId in SharedPreferences).
In my MainMenuViewModel I created isUserIdFilled mutableState which indicates (by checking through checkUserIdFilled()) whether the userId has been submitted.
class MainMenuViewModel constructor(val getUserId: GetUserIdUseCase) : ViewModel() {
var isUserIdFilled by mutableStateOf(false)
private set
init {
checkUserIdFilled()
}
fun checkUserIdFilled() {
if (getUserId().isNotEmpty()) {
isUserIdFilled = true
}
}
}
In the NavigationHost I am checking whether userId has been submitted and navigating to SettingsView or QuestionnaireView. The if on onQuestionnaireClick works and navigates to SettingsView if the userId has not been submitted. However, the isUserIdFilled state was not updating (the init in MainMenuViewModel is not executed again as the ViewModel is still in the backstack and is not recreated) and I was redirected to SettingsView all the time (unless I restarted the app). So I added the call to checkUserIdFilled() in the NavHost. However, I don't like this approach as it changes the state from the Composable function.
#Composable
fun MyNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = Screen.MainMenu.name,
modifier = modifier
) {
composable(Screen.MainMenu.name) {
val mainMenuViewModel = hiltViewModel<MainMenuViewModel>()
MainMenuView(
onQuestionnaireClick = {
// Is there a better way to do this and avoid the below call?
mainMenuViewModel.checkUserIdFilled()
if (mainMenuViewModel.isUserIdFilled)
navController.navigate(Screen.Questionnaire.name)
else {
navController.navigate(Screen.Settings.name)
}
},
onSettingsClick = { navController.navigate(Screen.Settings.name) },
mainMenuViewModel = mainMenuViewModel
)
}
composable(Screen.Questionnaire.name) {
val questionnaireViewModel = hiltViewModel<QuestionnaireViewModel>()
QuestionnaireView(questionnaireViewModel)
}
composable(Screen.Settings.name) {
val settingsViewModel = hiltViewModel<SettingsViewModel>()
// userId is submitted and saved to SharedPreferences here
SettingsView(settingsViewModel::onSubmitUserId, settingsViewModel)
}
}
}
Is there a better way to do this? How to update isUserIdFilled from the SettingsViewModel once the userId is submitted in a clean way?

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

Categories

Resources