Search code examples
androidandroid-jetpack-composekotlin-coroutinesandroid-viewmodelkotlin-flow

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

Solution

  • 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