Search code examples
androidkotlinkotlin-flowkotlin-stateflow

How to update elements inside a MutableStateFlow and dynamically change the UI?


I'm relatively new to Android development. While designing the application, I encountered some problems. Everything I found did not quite cover my needs. I would like to listen to your pieces of advice. So, okeeey let's go!

The main screen displays a list of dishes for a specific category. I would like the icon on the items I added to the database to update. The same behavior should work when clicking on the add or remove element button.

Initially, the list is MealModel. When you click on the addOrRemove button, mapping occurs to the database model into MealEntity, where the isSaved property changes to the opposite. Next, the list of MealEntity is displayed in the second fragment of saved dishes. But on the main screen the list shows MealModel with old values.

Models:

data class MealModel(
    val id: String,
    val name: String,
    val image: String,
    val isSaved: Boolean = false,
)
@Entity(tableName = "saved_meals")
data class MealEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    val image: String,
    val isSaved: Boolean
)

Resource via State:

sealed class Resource<T>(
    val data: T? = null,
    val message: String? = null,
) {
    class Success<T>(data: T) : Resource<T>(data)
    class Error<T>(message: String?, data: T? = null) : Resource<T>(data, message)
    class Loading<T> : Resource<T>()
}

ViewModel:

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getMealsUseCase: GetMealsUseCase,
    private val isMealInSavedUseCase: IsMealInSavedUseCase,
    private val addOrRemoveMealUseCase: AddOrRemoveMealUseCase,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
    private val _meals = MutableStateFlow<Resource<List<MealModel>>>(Resource.Loading())
    val meals: StateFlow<Resource<List<MealModel>>> = _meals.asStateFlow()

    fun getMeals() {
        getMealsUseCase.invoke(category = category)
            .onEach { resource ->
                when (resource) {
                    is Resource.Loading -> {
                        _meals.value = Resource.Loading()
                    }

                    is Resource.Success -> {
                        if (resource.data != null) {
                            _meals.value = Resource.Success(resource.data)
                        }
                    }

                    is Resource.Error -> {
                        _meals.value = Resource.Error(resource.message)
                    }
                }
            }
            .launchIn(viewModelScope)
    }

    fun savedIconClicked(meal: MealModel) {
        viewModelScope.launch(dispatcher) {
            addOrRemoveMealUseCase.addOrRemoveMeal(meal)
        }
    }
}

Fragment:

@AndroidEntryPoint
class HomeFragment : Fragment() {
    private lateinit var binding: FragmentHomeBinding
    private val viewModel: HomeViewModel by viewModels()
    private val mealAdapter: MealAdapter by lazy { MealAdapter(::onMealClick, ::onSaveIconClicked) }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentHomeBinding.inflate(inflater, container, false)
        val view = binding.root
        collectMeals()
        return view
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.rvMeals.adapter = mealAdapter
        binding.rvMeals.itemAnimator = null // so that the element does not blink when updating
    }

    private fun collectMeals() = binding.apply {
        viewModel.getMeals()
        collectLatestLifecycleFlow(viewModel.meals) { resource ->
            when (resource) {
                is Resource.Loading -> {
                    *changind UI*
                }

                is Resource.Success -> {
                    *changind UI*
                    mealAdapter.submitList(resource.data)
                }

                is Resource.Error -> {
                    *changind UI*
                }
            }
        }
    }

private fun onMealClick(mealId: Int) {
        val action = HomeFragmentDirections.actionHomeFragmentToDetailsFragment(mealId = mealId)
        findNavController().navigate(action)
    }

    private fun onSaveIconClicked(meal: MealModel) {
        viewModel.savedIconClicked(meal)
    }
fun <T> Fragment.collectLatestLifecycleFlow(flow: Flow<T>, collect: suspend (T) -> Unit) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
            flow.collectLatest(collect)
        }
    }
}

Use Cases:

I get the category from the Api as TheMealDB.com when I click on a specific dish category, which in the code is simply a String.

class GetMealsUseCase @Inject constructor(
    private val mealRepository: MealRepository
) {
    operator fun invoke(category: String): Flow<Resource<List<MealModel>>> = flow {
        try {
            emit(Resource.Loading())
            val meals = mealRepository.getMeals(category = category)
            emit(Resource.Success(data = meals))
        } catch (e: IOException) {
            emit(Resource.Error(message = e.message))
        } catch (e: HttpException) {
            emit(Resource.Error(message = e.message))
        }
    }
}

Clicking the save button checks if the item is in the database. Next, either the element is added or removed.

class AddOrRemoveMealUseCase @Inject constructor(
    private val mealRepository: MealRepository,
    private val mealMapper: MealMapper,
    private val isMealInSavedUseCase: IsMealInSavedUseCase
) {
    suspend fun addOrRemoveMeal(meal: MealModel) {
        if (isMealInSavedUseCase.isMealInSaved(meal.id)) {
            mealRepository.removeFromSavedMeals(mealMapper.mapMealModelToMealEntity(meal.copy(isSaved = false)))
        } else {
            mealRepository.addToSavedMeals(mealMapper.mapMealModelToMealEntity(meal.copy(isSaved = true)))
        }
    }
}

Checks by mealId whether the MealEntity is in the database.

class IsMealInSavedUseCase @Inject constructor(
    private val mealRepository: MealRepository
) {
    suspend fun isMealInSaved(mealId: String): Boolean = mealRepository.isMealInSaved(mealId)
}

Mapper:

class MealMapper {
fun mapMealModelToMealEntity(domainModel: MealModel) = MealEntity(
        id = domainModel.id,
        name = domainModel.name,
        image = domainModel.image,
        isSaved = domainModel.isSaved
    )

...*other maps*
}

After searching for an answer, I realized that I could do something like this:

val index = _meals.value.data!!.indexOf(item)
val items = _meals.value.data!!.toMutableList()
items[index] = items[index].copy(isSaved = item.isSaved.not())
_meals.value.data = items

The truth is that the problem arises in Resource. The data is val due to which I cannot assign a new list. If you make a var everything works according to the logs.

And now inside the function getMeals(), when the state is successful, it looks like this:

is Resource.Success -> {
    if (resource.data != null) {
       _meals.value = Resource.Success(resource.data!!)
    }
    for (item in resource.data!!) {
        if (isMealInSavedUseCase.isMealInSaved(item.id)) {
           val index = _meals.value.data!!.indexOf(item)
           val items = _meals.value.data!!.toMutableList()
           items[index] = items[index].copy(isSaved = item.isSaved.not())
           _meals.value.data = items
        }
    }
}

But, firstly, changing data in Resource is probably not particularly advisable. Secondly, when requesting again, the list will be updated again with MealModel elements with the isSaved = false. That is, we will not notice any attempts to change the added element. And thirdly, I don’t really understand how dynamically the add icon should be updated. Working with list elements is in the adapter, but we cannot add LiveData there, which will store a specific icon.

Finally, for understanding, I’ll show you what a list element looks like: List item


Solution

  • I see two relevant issues in your code.

    The first one is that you collect a flow in the view model (with launchIn). Don't do that, flows should only be transformed in the view model, not collected. Instead, replace getMeals and the properties with something like this:

    private val category: MutableStateFlow<String?> = MutableStateFlow(null)
    
    val meals: StateFlow<Resource<List<MealModel>>> = category
        .flatMapLatest { category ->
            if (category != null) getMealsUseCase.invoke(category = category)
            else flowOf()
        }
        .map(::processResource)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = Resource.Loading(),
        )
    
    fun selectCategory(category: String) {
        this.category.value = category
    }
    
    private fun processResource(resource: Resource<List<MealModel>>): Resource<List<MealModel>> {
        // ...
    }
    

    The important thing is that I made everything that the getMealsUseCase flow dependes on to a flow itself. That's only the category, which, for my example, I assumed is a String. You can replace its type by whatever you want, though. The getMealsUseCase flow can now be built upon it by using flatMapLatest. That replaces the underlying flow by a new flow. The new flow can access the underlying flow's value here (the category). Then that new flow's content is transformed by calling map. I just put the placeholder function processResource here, we'll come back to that later. And finally, the resulting flow is converted to a StateFlow. From the outside, the meals property now lookes exactly like before.

    Instead of calling getMeals from the UI you now call selectCategory. This will update the view model's category flow which in turn will update the meals flow that the UI collects.

    As you probably have already realized, most of the parts of the old getMeals function are now missing. And with that we are coming to the second issue. What you previously did was trying to mutate the previous values instead of creating new ones. StateFlows work best if they only contain immutable types. Your reluctance to make the data property a var is dead on, you should change that back to val. With the new code above there is no need for mutating the data property, instead we just have to fill the processResource function that I had left empty before. As you can see it receives a resource parameter and expects another resource as a return value. Please note that there are no flows involved: This function is called from a flow transformation, but it itself is independent of any flows.

    Now, I'm not entirely sure what it is exactly that this transformation should do. My best guess would be something like this:

    private fun processResource(resource: Resource<List<MealModel>>): Resource<List<MealModel>> =
        when (resource) {
            is Resource.Loading -> Resource.Loading()
    
            is Resource.Success -> {
                if (resource.data != null) {
                    val data = resource.data
                        .map { item ->
                            if (isMealInSavedUseCase.isMealInSaved(item.id))
                                item.copy(isSaved = item.isSaved.not())
                            else
                                item
                        }
                    Resource.Success(data)
                } else
                    TODO("implement here whatever the result should be when resource.data == null")
            }
    
            is Resource.Error -> Resource.Error(resource.message)
        }
    

    In the Success case, the data list is mapped to a new list that contains all items that have their isSaved property inverted when isMealInSaved is true. This might not be exactly what you want; as I already mentioned, I didn't really understand what your original code tried to to, especially when the data is null. You can take this as an example and go from here, though.

    As a closing note, please don't use the !! operator, it will crash your app. If you see the need for that, then there's most likely something else wrong in your code (as seen here).