imn
imn

Reputation: 890

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?

Upvotes: 6

Views: 8915

Answers (3)

Nestor Perez
Nestor Perez

Reputation: 877

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)))

Upvotes: 0

Johann
Johann

Reputation: 29867

While you can use the solution provided by Arpit, I personally prefer to manage the state of the API call in the viewmodel. It is easy to abuse LaunchEffect. Also, LaunchEffect - in my opinion - should really be UI related stuff and not for handling API calls to some backend. Since you already have a variable for handling state - someUIState - only make the API call when the state is set to Nothing. Something like this:

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

    fun attemptAPICall() = viewModelScope.launch {
        if (_someUIState.value != FancyWrapper.Nothing) {
            return
        }
        
        _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!")
        }
    }
}

Upvotes: 1

Arpit Shukla
Arpit Shukla

Reputation: 10493

If you want to process each someUIState value only once, you should put it inside a LaunchedEffect and pass someUIState as the key so that whenever it changes the block is retriggered.

val someUIState by viewModel.someUIState.collectAsState()
LaunchedEffect(someUiState) {
    when(someUiState) {
        // Same as in the question
    }
}

Alternatively, you could just collect the flow inside a LaunchedEffect.

LaunchedEffect(Unit) {
    viewModel.someUIState.collect { uiState -> 
        when(uiState) {
            // Same as in the question
        }
    }
}

Upvotes: 7

Related Questions