Search code examples
kotlinresourcesstateviewmodel

Can I use observerAsState() on a MutableLiveData?


I have ran into an issue while building a small movie library application. My structure is as follows: I have a MovieSearchScreen function which calls a SearchBar function as well as a MovieSearchResults function. In the Searchbar function, I have a TextField, a leadingIcon and a trailingIcon which has a clickable modifier which calls my updateMovieResource function which is in my viewModel:

    @Composable
    fun Create(navController: NavHostController, viewModel: MovieViewModel) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.DarkGray)
        ) {
            SearchBar(viewModel = viewModel)
            MovieSearchResults(navController = navController)
        }
    }

    @Composable
    private fun SearchBar(viewModel: MovieViewModel) {
        var userInput: String by remember {
            mutableStateOf("")
        }
        TextField(
            value = userInput,
            onValueChange = {
                userInput = it
                            },
            modifier = Modifier
                .fillMaxWidth(),
            colors = TextFieldDefaults.colors(
                focusedContainerColor = Color.Gray,
                unfocusedContainerColor = Color.Gray,
                focusedTextColor = Color.White,
                unfocusedTextColor = Color.White
            ),
            placeholder = {
                Text(
                    text = stringResource(id = R.string.search_bar),
                    color = Color.White
                )
                          },
            leadingIcon = {
                Icon(
                    imageVector = Icons.Rounded.Clear,
                    contentDescription = "Clear",
                    modifier = Modifier
                        .clickable {
                            userInput = ""
                                   },
                    tint = Color.White
                )
                          },
            trailingIcon = {
                Icon(
                    imageVector = Icons.Rounded.Search,
                    contentDescription = "Search",
                    tint = Color.White,
                    modifier = Modifier.clickable {
                        if (userInput.isNotBlank()) {
                            viewModel.updateMovieResource(userInput)
                        }
                    }
                )
            }
        )
    }

In this viewModel, I have a couple of values, the important ones for now are my _movieRepository from where I manage my api calls, _movieResource, movieResource and the updateMovieResource method:

class MovieViewModel: ViewModel() {

    private lateinit var _selectedMovie: MutableState<Movie>

    var selectedMovie: Movie
        get() = _selectedMovie.value
        set(movie){
            _selectedMovie.value = movie
        }

    private val _movieRepository: MovieRepository = MovieRepository()

    val movieResource: LiveData<Resource<ResponseResult>>
        get() = _movieResource

    private val _movieResource: MutableLiveData<Resource<ResponseResult>> =
        MutableLiveData(Resource.Empty())

    fun updateMovieResource(movie: String) {
        _movieResource.value = Resource.Loading()

        viewModelScope.launch {
            _movieResource.value = _movieRepository.getMovie(movie)
        }
    }
}

I use the movieResource to figure out wether my api call was succesful, still loading or returned an error so I know what to display. I use this movieResource in my MovieSearchResults composable here:

@Composable
    private fun MovieSearchResults(
        navController: NavHostController,
        viewModel: MovieViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
    ){
        val movieResource: Resource<ResponseResult>? by viewModel.movieResource.observeAsState()
        
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            when(movieResource) {
                is Resource.Success ->
                    LazyVerticalGrid(
                        columns = GridCells.Fixed(3)
                    ) {
                        items(
                            items = (movieResource as Resource.Success<ResponseResult>).data!!.results,
                            itemContent = {
                                AsyncImage(
                                    model = it.backdropPath,
                                    contentDescription = it.title,
                                    modifier = Modifier.clickable {
                                        viewModel.selectedMovie = it
                                        navController.navigate(MovieLibraryScreens.MovieDetailsScreen.name)
                                    }
                                )
                            }
                        )
                    }
                is Resource.Error ->
                    Text(
                        text = (movieResource as Resource.Error<ResponseResult>).message!!,
                        fontSize = 30.sp,
                        color = Color.Red
                    )
                is Resource.Empty ->
                    Text(
                        text = "Search for a movie!",
                        fontSize = 30.sp,
                        color = Color.White
                    )
                is Resource.Loading ->
                    Text(
                        text = "Loading...",
                        fontSize = 30.sp,
                        color = Color.White
                    )
                else ->
                    Text(
                        text = "Something went wrong...",
                        fontSize = 30.sp,
                        color = Color.White
                    )
            }
        }
    }

I retrieve this data from my repository here:

class MovieRepository {

    private val _apiService: ApiService = Api().createApi()

    private val _apiKey: String = API_KEY

    suspend fun getMovie(movie: String): Resource<ResponseResult> {
        val response = try {
            withTimeout(5_000) {
                _apiService.getMovie(movie, _apiKey)
            }
        } catch (e: Exception) {
            Log.e("MovieRepository", e.message ?: "No exception message available")
            return Resource.Error("An unknown error occurred while fetching data for the movie: $movie")
        }
        Log.d("Result", response.results.toString())
        return Resource.Success(response)
    }
}

When I Log.d my response.results, it logs the correct data which I call from my api but when I return my Resource.Success(response). The _movieResource back in my viewModel seems like it is not updating to Resource.Success(response). Furthermore, back in my MovieSearchResults composable, the when statement doesn't seem to notice that the _movieResource is updated to Resource.Loading() at the start when the updateMovieResource method is called. I am completely stuck with this and feel like I am doing something wrong with the MutableLiveData but I am not completely sure. Please help me!

I have logged both the result which returned the expected api response as well as my movieResource when the updateMovieResource finished running which ended up returning Resource.Empty(). After this I attempted changing the MutableLiveData to standard mutableStateOf() but this is not possible as I have multiple different Resource responses.


Solution

  • Although you apparrently fixed the current problem, I want to go more in depth and provide an explanation what the problem was.

    But first off, you should update your view model. You still use LiveData (which is a remnant of the pre-Compose era and is now obsolete) and MutableState, which should only be used in composables. Both need to be replaced by Flows:

    class MovieViewModel : ViewModel() {
        private val _selectedMovie = MutableStateFlow<Movie?>(null)
        val selectedMovie = _selectedMovie.asStateFlow()
    
        private val _movieRepository: MovieRepository = MovieRepository()
    
        private val _movieResource = MutableStateFlow<Resource<ResponseResult>>(Resource.Empty())
        val movieResource: StateFlow<Resource<ResponseResult>> = _movieResource.asStateFlow()
    
        fun updateMovieResource(movie: String) {
            _movieResource.value = Resource.Loading()
    
            viewModelScope.launch {
                _movieResource.value = _movieRepository.getMovie(movie)
            }
        }
    
        fun selectMovie(movie: Movie) {
            _selectedMovie.value = movie
        }
    }
    

    Instead of observeAsState you need to call collectAsStateWithLifecycle() in your composables to convert the flows into State objects.


    With this out of the way, back to the initial problem. MovieSearchResults has a viewModel parameter. The culprit here is the default value viewModels(): This creates a new instance of the view model, so there are actually two view models involved: The first is used by SearchBar, the second is used by MovieSearchResults. Only the first one retrieves data from the repository, so the second will always be empty.

    To fix that you could pass the same view model instance to MovieSearchResults that you also pass to SearchBar. But actually, you should never pass view models to composables. Instead, retrieve an instance in your top-most composable that needs the view model (seems like that is Create in your case) and pass only the view model's state and callbacks down to the other composables:

    @Composable
    fun Create(navController: NavHostController) {
        val viewModel: MovieViewModel = viewModel()
        val movieResource: Resource<ResponseResult> by viewModel.movieResource.collectAsStateWithLifecycle()
        
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.DarkGray),
        ) {
            SearchBar(
                updateMovieResource = viewModel::updateMovieResource,
            )
    
            MovieSearchResults(
                navController = navController,
                movieResource = movieResource,
                selectMovie = viewModel::selectMovie,
            )
        }
    }
    

    With SearchBar and MovieSearchResults declared like this:

    @Composable
    private fun SearchBar(
        updateMovieResource: (String) -> Unit,
    ) { /*...*/ }
    
    @Composable
    private fun MovieSearchResults(
        navController: NavHostController,
        movieResource: Resource<ResponseResult>,
        selectMovie: (Movie) -> Unit,
    ) { /*...*/ }
    

    This way your composables are independent of view models, are more robust to recompositions (because view models are not Stable), are better testable (because you don't need a view model, you can supply simple dummy parameters), can be more easily reused and can be better previewed (@Preview).

    It also fixes the issue you had.