mvvmandroid-jetpack-compose

How to display a progress indicator in MVVM using Jetpack Compose correctly?


I need to add an item in Firebase using MVVM and Jetpack Compose. So I'm looking to start a progress indicator when the operation of the addition starts and dismiss it when the operation is complete. This is the function in the interface:

suspend fun addItem(item: Item): Response<String>

This is the implementation:

override suspend fun addItem(item: Item) = try {
    val itemId = itemsRef.add(item).await().id
    Response.Success(itemId)
} catch (ex: Exception) {
    Response.Failure(ex)
}

In the ViewModel I call the addItem like this:

class ItemViewModel @Inject constructor(
    private val repo: ItemRepository
): ViewModel() {
    var addItemResponse by mutableStateOf<Response<String>>(Response.Loading)
        private set

    fun addItem(item: Item) = viewModelScope.launch {
        addItemResponse = repo.addItem(item)
    }
}

And inside the UI, I have a button. When I click it, I add the item to Firebase:

var addingItem by remember { mutableStateOf(false) }

Button(
    onClick = {
        viewModel.addItem(Item("New Item"))
        addingItem = true
    }
) {
    Text(text = "Add Item")
}

if (addingItem) {
    when(val addItemResponse = viewModel.addItemResponse) {
        is Response.Loading -> CircularProgressIndicator()
        is Response.Success -> addingItem = false
        is Response.Failure -> print(addItemResponse.ex)
    }
}

The problem is the use of addingItem. If don't use it in the if statement, I end up with a progress indicator that never stops. So I'm using it like this, because (not sure) of the side effect. I feel that this is not the right approach. Is there any other way of loading a progress bar as long as the addition of the item takes place?


Solution

  • Compose is state-based. I'm not sure what you want to do with the result (success/error), especially when multiple items are added in succession. What should the state be that your app is in? But assuming you just want to display the status of adding the latest item, your app can be in any of the following four states:

    1. Nothing was added yet.
    2. An item is currently being added.
    3. The last item was successfully added
    4. An error occurred while adding the last item.

    The view model only accommodates for the last three states, the first is handled in Compose with addingItem.

    You should move 1. to the view model as well. An easy fix would be to initialize the view model's addItemResponse with null, not with Response.Loading. After all, there isn't anything loading when the view model is first initialized:

    var addItemResponse by mutableStateOf<Response<String>?>(null)
        private set
    

    You would only set the loading state when you actually start loading something:

    fun addItem(item: Item) {
        addItemResponse = Response.Loading
    
        viewModelScope.launch {
            addItemResponse = repo.addItem(item)
        }
    }
    

    In your composable you can now remove addingItem and add an additional case to the when statement, maybe like this:

    when (val addItemResponse = viewModel.addItemResponse) {
        null -> Text("No item was added yet")
        is Response.Loading -> CircularProgressIndicator()
        is Response.Success -> Text("Successfully added the latest item $addItemResponse")
        is Response.Failure -> print(addItemResponse.ex)
    }
    

    The failure case still looks suspicious since what you do here is triggereing a side effect (i.e. printing to stdout). It would be executed on every recomposition, not just once. This should be moved to the view model or the repository, and replaced here with something like Text("Error").


    Closing note: You shouldn't use MutableState in the view model. There are some nasty edge cases where this won't work as expected. Instead you should employ Kotlin Flows. Your repository should already return flows which are transformed into a StateFlow in the view model and observed in your composables by collectAsStateWithLifecycle(). You can have a look at how it is done in the official sample app Now in Android.