ViewModel isActive stills true after route change in Jetpack Compose - android

I have an app that has a bottom nav. It has some tabs, now, from tab A I have a ticker that updates a value in the view every 5 seconds.
When I switch to tab B I'm expecting that the scope of the viewmodel that is associated with the route A is no longer active to keep executing the code, although I expect the viewmodel to survive since there is no sense of removing it on tab change.
My current code
NavGraph
NavHost(navController, startDestination = BottomNavItem.HomeScreen.screen_route) {
composable(BottomNavItem.HomeScreen.screen_route) {
val homeViewModel: HomeViewModel = hiltViewModel()
val homeUiState = homeViewModel.uiState.collectAsState()
HomeScreen(uiState = homeUiState.value)
}
composable(BottomNavItem.FiatToCryptoScreen.screen_route) {
val viewModel: CryptoToFiatViewModel = hiltViewModel()
val uiState = viewModel.uiState.collectAsState()
CryptoToFiatScreen(uiState = uiState.value)
}
}
Now, HomeScreen takes HomeViewModel, which in the init block, it will fire a request every 5 seconds to get latest results from a coin
#HiltViewModel
class HomeViewModel #Inject constructor(private val repo: HomeRepository) : ViewModel() {
init {
updateFeaturedCoin()
}
private fun updateFeaturedCoin() {
viewModelScope.launch {
while (isActive) {
val featuredCoinPrice = repo.getTickerForCoin("BTC")
if (featuredCoinPrice.isSuccess) {
homeScreenState.update {
it.copy(
isLoading = false,
featuredCoinPrice = featuredCoinPrice.getOrNull()?.price
)
}
}
delay(5000)
}
}
}
....
}
Now, this is working fine, my problem is that when I change tabs, let's say, going to CryptoTofiatScreen, and if I put a breakpoint in the isActive condition, this will never be false, and I need this cicle to stop executing if I move to another tab, because now the HomeViewModel is not in the foreground any more to update its view.
How can I tell HomeViewModel that is not active any more if I switch to another composable in the route?
I thought that scoping the viewmodel to its route will trigger an event to tell the viewmodel is not active any more if I change routes.

One option would be, to use a displosableEffect on the Home Composable, to start/stop viewmodel code:
DisposableEffect(Unit) {
homeViewModel.setIsActive(true)
onDispose {
homeViewModel.setIsActive(false)
}
}
This would require passing the viewModel instance to the component, instead of the just the state, like you are doing now.

Related

Emitting ui state while collecting does not update ui

This init block is in my ViewModel:
init {
viewModelScope.launch {
userRepository.login()
userRepository.user.collect {
_uiState.value = UiState.Success(it)
}
}
}
This is very similar to what's actually written on the app, but even this simple example doesn't work. After userRepository.login(), user which is a SharedFlow emits a new user state. This latest value DOES get collected within this collect function shown above, but when emitting a new uiState containing the result, the view does not get such update.
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Doing this for some reason, does not work. I suspect the issue is related to the lifecycle of the viewmodel, because when I treat the viewmodel as a singleton, this doesn't happen. It happens only when the viewmodel gets destroyed and then created a 2nd (or more) time(s).
What I'm trying to achieve is that the screen containing the view model is aware of the user state. Meaning that when I navigate to the screen, I want it to collect the latest user state, and then decide which content to show.
I also realize this is not the best pattern, most likely. I'm currently looking into a solution that holds the User as part of the app state and collecting per screen (given that it basically changes all or many screens and functionalities) so if you have any resources on an example on such implementation I'd be thankful. But I can't get my head around why this current implementation doesn't work so any light shed on the situation is much appreciated.
EDIT
This is what I have in mind for the repository
private val _user = MutableSharedFlow<User>()
override val user: Flow<User> = _user
override suspend fun login() {
delay(2000)
_user.emit(LoggedUser.aLoggedUser())
}
override suspend fun logout() {
delay(2000)
_user.emit(GuestUser)
}
For your case better to use this pattern:
ViewModel class:
sealed interface UserUiState {
object NotLoggedIn : UserUiState
object Error : UserUiState
data class LoggedIn(val user: User) : UserUiState
}
class MyViewModel #Inject constructor(
userRepository: UserRepository
) : ViewModel() {
val userUiState = userRepository.login()
.map { user ->
if (user != null)
UserUiState.LoggedIn(user)
else
UserUiState.Error
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserUiState.NotLoggedIn
)
}
Repository class:
class UserRepository {
fun login(): Flow<User?> = flow {
val user = TODO("Your code to get user")
if (isSuccess) {
emit(user)
} else {
emit(null)
}
}
}
Your screen Composable:
#Composable
fun Screen() {
val userUiState by viewModel.userUiState.collectAsStateWithLifecycle()
when (userUiState) {
is UserUiState.LoggedIn -> { TODO("Success code") }
UserUiState.NotLoggedIn -> { TODO("Waiting for login code") }
UserUiState.Error -> { TODO("Error display code") }
}
}
How it works: login() in repository returns autorized user flow which can be used in ViewModel. I use UserUiState sealed class to handle possible user states. And then I convert User value in map {} to UserUiState to display it in the UI Layer. Then Flow of UserUiState needs to be converted to StateFlow to obtain it from the Composable function, so I made stateIn.
And of course, this will solve your problem
Tell me in the comments if I got something wrong or if the code does not meet your expectations
Note: SharedFlow and StateFlow are not used in the Data Layer like you do.
EDIT:
You can emiting flow like this if you are working with network:
val user = flow of {
while (true) {
// network call to get user
delay(2000)
}
}
If you use Room you can do this in your dao.
#Query(TODO("get actual user query"))
fun getUser(): Flow<User>
It is a better way and it recommended by android developers YouTube channel

Why the view keeps flashing when using jetpack navigation with Compose?

I have a login scren and when the login is successful and the view model updates the mutable state variable, my expectation is that a new composable function is called to show a new screen and the login one is removed. The problem is that when the new screen (aka Screen.AccountsScreen) is shown, its content keeps flashing/redrawing and same thing happen with the login form which never gets destroyed (I know this because the log message 'Recomponing...' gets printed endless). I assume this happens because the isLoginSuccessful state is always true. It seems I need an event that can be consumed only once, is this correct? If so, how can I do that?
LoginViewModel.kt
#HiltViewModel
class LoginViewModel #Inject constructor() : ViewModel() {
var isLoginSuccessful by mutableStateOf(false)
var errorMessage by mutableStateOf("")
fun onLoginClick(email: String, password:String) {
errorMessage = ""
if (credentialsValid(email, password)) {
isLoginSuccessful = true
} else {
errorMessage = "Email or password invalid"
isLoginSuccessful = false
}
}
}
LoginScreen.kt
#Composable
fun loginScreen(
navController: NavController,
viewModel: LoginViewModel = hiltViewModel()
) {
println("Recomponing...")
// Here gos the code for the login form
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Composite navigation recomposes both disappearing and appearing views during transition. This is the expected behavior.
You're calling navigate on each recomposition. Your problem lays in these lines:
if (viewModel.isLoginSuccessful) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
You shouldn't change state directly from view builders. In this case LaunchedEffect should be used:
if (viewModel.isLoginSuccessful) {
LaunchedEffect(Unit) {
navController.navigate(Screen.AccountsScreen.route) {
popUpTo(Screen.LoginScreen.route) { inclusive = true }
}
}
}
Check out more in side effects documentation.
For me, I see flicker because the activity background is white, but I am on dark mode.
Change your app theme to daynight, try adding
implementation 'com.google.android.material:material:1.5.0'
and change your theme to
<style name="Theme.MyStockApp" parent="Theme.Material3.DayNight.NoActionBar" />

Jetpack Compose MutableSharedFlow one shot events

in my viewmodel I have a function that sends a login request to a server, when successful i want to update a SharedFlow and using this trigger a navigation to another screen
class loginViewModel: ViewModel() {
private val _authToken = MutableSharedFlow<AuthToken>()
val authToken: SharedFlow<AuthToken> = _authToken
fun login() {
...loginRequest
.onSuccess {
_authToken.emit(value)}
}
in my nav graph I set up the viewmodel like so
private fun NavGraphBuilder.addLogin(navController: NavController) {
composable(AuthenticationScreens.Login.route) {
val loginViewModel: LoginViewModel = hiltViewModel()
val authToken by loginViewModel.authToken.collectAsState()
LoginScreen(
authToken = authToken,
viewModel = loginViewModel,
navigateToDashboard= {
navController.navigate(Dashboard.Dashboard.route)
}
)
}
}
i then do an if check in my loginscreen to navigate to the other screen when the SharedFlow updates
#Composable
fun LoginScreen(
authToken: AuthToken,
viewModel: LoginViewModel,
navigateToDashboard: () -> Unit) {
if (authToken.isNotBlank()) {
navigateToDashboard()}
}
The code fires but is called constantly even when i've navigated to the next screen, causing lots of flickering and bad UI. Is there a different way I'm supposed to handle navigation events like this or a way to have the composable only read the value once when required?
So i've figured out a way to do this but I still feel this isn't fully correct, I set the shared flow back to it's default state after navigating. If anyone has a more concise approach please let me know
LaunchedEffect(key1 = authToken) {
if (authToken.token?.isNotBlank()) {
navigateDashboard()
viewModel._authToken.emit(AuthToken("", 0))
}
}

Multi page viewPager2 with paging 3 doesn't show data on page 2 using SharedViewModel

I want to implement two view tab, with recycle view as list, and using paging 3 for collecting data and return will be kotlin flow. everthing is working perfectly at tab one, but nothing show in tab two.
the viewModel code (SharedViewModel):
#ExperimentalPagingApi
#HiltViewModel
class MovieViewModel #Inject constructor(
private val repository: MovieRepository,
private val dispatcher: CoroutineDispatcher
) : ViewModel() {
private val TAG = "MovieVM"
private var _moviePaging: Flow<PagingData<MovieEntities>>? =
repository.getMovie().cachedIn(viewModelScope)
private var _tvPaging: Flow<PagingData<MovieEntities>>? =
repository.getTv().cachedIn(viewModelScope)
init {
loadMovie()
}
// new paging
var moviePaging = MutableStateFlow<PagingData<MovieEntities>>(PagingData.empty())
var tvPaging = MutableStateFlow<PagingData<MovieEntities>>(PagingData.empty())
private fun loadMovie() {
viewModelScope.launch(dispatcher) {
_moviePaging?.collectLatest {
moviePaging.value = it
}
_tvPaging?.collectLatest {
tvPaging.value = it
}
}
}
the code is run. when i am debuging, only _moviePaging is call and show logger retrofit GET, but the _tvPaging is nothing, not call api, like never triggered to run (unreachable?)
so, i was change order, call _tvPaging first. then only _tvPaging is run.
I want two line of code _moviePaging and _tvPaging running, but now is just one of them. Please Help.
Any response will apreciate.
after reading some documentation, and understand about work flow coroutine,
one coroutine scope for hot flow only one methode can run, they suspend forever
so, the solution is make viewModelScope for each hot flow
viewModelScope.launch(dispatcher) {
_moviePaging?.collectLatest {
moviePaging.value = it
}
}
viewModelScope.launch(dispatcher) {
_tvPaging?.collectLatest {
tvPaging.value = it
}
}

How to observe Emited LiveData with MVVM

I am struggling to understand how to handle emitted liveData. I have written four different examples of liveData here,
class MainViewModel : ViewModel() {
val viewModelValue = MyRepo.liveValue
fun viewModelGetNextValue(){
MyRepo.getNextValue()
}
val viewModelSquareValue = MyRepo.squareLiveValue
fun viewModelGetSquareValue(x:Int){
MyRepo.getSquareValue(x)
}
val viewModelEmitValue = MyRepo.emitLiveValue
lateinit var viewModelEmitFunctionValue:LiveData<String>
fun viewModelEmitLiveFunction(x:Int){
viewModelEmitFunctionValue = MyRepo.emitLiveFunction(x)
}
}
object MyRepo{
var value = 1
val liveValue = MutableLiveData<Int>()
fun getNextValue(){
liveValue.postValue(++value)
}
val squareLiveValue = MutableLiveData<Int>()
fun getSquareValue(x:Int){
squareLiveValue.postValue(x*x)
}
val emitLiveValue = liveData {
emit("First Emit")
delay(2000)
emit("second value")
}
fun emitLiveFunction(x:Int) = liveData {
emit("value: $x")
delay(2000)
emit("square: ${x*x}")
}
}
And part of the Fragment code is,
viewModel.viewModelValue.observe(viewLifecycleOwner, Observer {
Toast.makeText(activity, "$it", Toast.LENGTH_SHORT).show()
})
viewModel.viewModelSquareValue.observe(viewLifecycleOwner, Observer {
Toast.makeText(activity, "$it", Toast.LENGTH_SHORT).show()
viewModel.viewModelSquareValue.removeObservers(viewLifecycleOwner)
})
viewModel.viewModelEmitValue.observe(viewLifecycleOwner, Observer {
Toast.makeText(activity, it, Toast.LENGTH_SHORT).show()
})
button1.setOnClickListener { viewModel.viewModelGetNextValue() }
button2.setOnClickListener { viewModel.viewModelGetSquareValue(++x) }
button3.setOnClickListener {
viewModel.viewModelEmitLiveFunction(++x)
viewModel.viewModelEmitFunctionValue.observe(viewLifecycleOwner, Observer {
Toast.makeText(activity, it, Toast.LENGTH_SHORT).show()
})
}
First Two examples of LiveData (viewModelValue and viewModelSquareValue) is easy to observe. and can be invoked with the button's click listener. The third livedata viewModelEmitValue where I have used emit automatically shows the value.
What do I have to do if I want those values after a button being
clicked? Do I just have to write the observer code within the click
listener?
The last liveData viewModelEmitFunctionValue is working. But is it
the only way (using lateinit var) to get the value if I want to get it after I click a
button?
The last liveData viewModelEmitFunctionValue is working. But is it the only way (using lateinit var) to get the value if I want to get it after I click a button?
In this way you are creating observers for each button click, adding an additional Toast every other click. Good news is that you are creating LiveData instance as well with every click so previous observers cleaned up. But it's a bit of a messy approach.
val emitLiveValue = liveData { is not a lazy way to declare a LiveData, so once you Repo is initialized it starts executing code inside liveData{}
In case of fun emitLiveFunction(x:Int) = liveData { you are creating LiveData only at the moment of calling the function, so that's why it works well.
My suggestion is to store x value in live data and calculate emitLiveFunction on each change of it. You can achieve it using Transformations.switchMap
class MainViewModel : ViewModel() {
...
private val x = MutableLiveData<Int>()
val functionResult = x.switchMap { MyRepo.emitLiveFunction(it) }
fun viewModelEmitLiveFunction(x:Int) {
this.x.postValue(x)
}
}
Now you can add an observer to functionResult right after activity created and calling viewModelEmitLiveFunction(x) on button 3 click you will initiate repo function execution with new value x

Categories

Resources