Search code examples
androidkotlinandroid-jetpack-composeandroid-jetpackkotlin-stateflow

How to properly use StateFlow with Jetpack compose?


I'm doing a API call in the ViewModel and observing it in the composable like this:

class FancyViewModel(): ViewModel(){
 private val _someUIState =
     MutableStateFlow<FancyWrapper>(FancyWrapper.Nothing)
 val someUIState: StateFlow<FancyWrapper> =
     _someUIState

 fun attemptAPICall() = viewModelScope.launch {
  _someUIState.value = FancyWrapper.Loading
  when(val res = doAPICall()){
   is APIWrapper.Success -> _someUIState.value = FancyWrapper.Loading(res.vaue.data)
   is APIWrapper.Error -> _someUIState.value = FancyWrapper.Error("Error!")
  }
 }
}

And in composable, I'm listening to 'someUIState' like this:

@Composable
fun FancyUI(viewModel: FancyViewModel){

 val showProgress by remember {
    mutableStateOf(false)
 }
 val openDialog = remember { mutableStateOf(false) }

 val someUIState =
    viewModel.someUIState.collectAsState()
 
 when(val res = someUIState.value){
  is FancyWrapper.Loading-> showProgress = true
  is FancyWrapper.Success-> {
     showProgress = false
     if(res.value.error)
      openDialog.value = true
     else
     navController.navigate(Screen.OtherScreen.route)
    }
  is FancyWrapper.Error-> showProgress = false
 }

 if (openDialog.value){
  AlertDialog(
   ..
  )
 }

 Scaffold(
  topBar = {
   Button(onClick={viewModel.attemptAPICall()}){
    if(showProgress)
     CircularProgressIndicator()
    else
     Text("Click")
    }
   }
 ){
  SomeUI()
 }

}

The problem I'm facing is someUIState's 'when' block code in FancyUI composable is triggered multiple times during composable recomposition even without clicking the button in Scaffold(for eg: when AlertDialog shows up). Where am I doing wrong? What are the correct better approaches to observe data with StateFlow in Composable?


Solution

  • Other solution that I do with snackbars is informing the view model that the data has been consumed: In your FancyUI:

    ...
    when(val res = someUIState.value){
      is FancyWrapper.Loading-> showProgress = true
      is FancyWrapper.Success-> {
        ...
        viewModel.onResultConsumed()
      }
      is FancyWrapper.Error-> showProgress = false
     }
    ...
    

    And in your view model:

    class FancyViewModel() : ViewModel() {
        private val _someUIState = MutableStateFlow<FancyWrapper>(FancyWrapper.Nothing)
        ...
    
        fun onResultConsumed() {
           _someUIState.tryEmit(FancyWrapper.Nothing)
        }
    }
    

    EDIT

    Here is another solution if someone still looking for this:

    Create Event class:

    /*
     * Copyright 2017, The Android Open Source Project
     *
     * Licensed under the Apache License, Version 2.0 (the "License");
     * you may not use this file except in compliance with the License.
     * You may obtain a copy of the License at
     *
     *      http://www.apache.org/licenses/LICENSE-2.0
     *
     * Unless required by applicable law or agreed to in writing, software
     * distributed under the License is distributed on an "AS IS" BASIS,
     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     * See the License for the specific language governing permissions and
     * limitations under the License.
     */
    
    /**
     * Used as a wrapper for data that is exposed via a LiveData that represents an event.
     */
    open class Event<out T>(private val content: T) {
    
        var hasBeenHandled = false
            private set // Allow external read but not write
    
        /**
         * Returns the content and prevents its use again.
         */
        fun getContentIfNotHandled(): T? {
            return if (hasBeenHandled) {
                null
            } else {
                hasBeenHandled = true
                content
            }
        }
    
        /**
         * Returns the content, even if it's already been handled.
         */
        fun peekContent(): T = content
    }
    

    Event class was originally created for LiveData but works fine with Flow, the value of the event will be bull if has been already consumed, this way you can save the call to the view model.

    Use it in your screen:

    ...
    when(val res = someUIState.value){
      is FancyWrapper.Loading-> showProgress = true
      is FancyWrapper.Success-> {
        res.event.getContentIfNotHandled?.let {
          //do stuff here
          ...
        }
      }
      is FancyWrapper.Error-> showProgress = false
     }
    ...
    

    To use in a view model you have just to create an event for the state you want to show, for ex:

    _someUIState.tryEmit(FancyWrapper.Success(event = Event(data)))