Loading state controlled with MutableStateFlow does not update the UI

Advertisements

I have a view model injected with hilt that controls the state of a loading spinner, which is a fullscreen dialog.

@HiltViewModel
class MyViewModel @Inject constructor(
    val coordinator: MyCoordinator
) : ViewModel() {
    private val _isChanging = MutableStateFlow(false)
    val isChanging = _isChanging.asStateFlow()
    
    fun change() {
        _isChanging.value = true
        // suspend function which takes about 5 seconds to resolve
        coordinator.change()
        _isChanging.value = false
    }
}

MyCoordinator

val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun change() {
    applicationScope.launch {
        // do something for 5 seconds
        delay(5000)
    }
}

And finally the ui

@Composable
fun MyScreen(
    viewModel: MyViewModel = hiltViewModel()
) {
    val changing by viewModel.isChanging.collectAsStateWithLifecycle()
    if (changing) {
        AnimatedLoadingSpinner()
    }
    // other ui, including a button that triggers viewModel::change in the onClick lambda
}

When I press the button, the change function does indeed run, But the loading spinner never shows. How can I get the loading spinner to display?

I have a feeling it has something to do with the coroutine in the coordinator running. I think when change gets called, it sets isChanging to true, launches the coroutine, and then immediately sets isChanging back to false. But I don’t know how to fix this, other than pushing the applicationScope into the viewmodel, or passing the isChanging.value = x into the change function as lambdas so it can be called in the applicationScope.

>Solution :

launch launches a new coroutine and immediately returns. The new coroutine runs asynchronously to the current code, that’s what it’s for. But that also means that the remaining code doesn’t wait for that coroutine to finish. So _isChanging.value = false is called directly after the coroutine was launched, not after it finished.

There are multiple ways to restructure your code to achieve what you want. From the top of my head, but there are probably much more:

  1. Make coordinator.change a suspend function and launch the coroutine from the view model (with the viewModelScope). Then you can put the updates to _isChanging inside the launch block.
  2. Supply a callback to coordinator.change that is executed after the delay. I would not recommend this, though, it doesn’t conform to clean architecture principles.
  3. When the coordinator is also the source for the data it changed you could restructure it to return the data as a flow. You can then make that flow emit a loading state when the data is being changed.
    In this case you wouldn’t even need the view model’s _isChanging flow anymore.

Leave a ReplyCancel reply