8

So lately I've been working with StateFlow, SharedFlow, and Channels API's but I'm struggling with one common use case while trying to migrate my code from LiveData to StateFlow in the presentation layer.

The problem I'm facing is when I emit my data and collect it in viewModel so I can set the value to a mutableStateFlow, when it finally gets to the fragment it shows some informative messages using a Toast to let the user knows whether an error happened or everything went fine. Next, there's a button which navigates to another fragment, but if I go back to the previous screen which already has the result of the failed intent, again it displays the Toast. And that's exactly what I'm trying to figure out. If I collected already the result and showed the message to the user I don't want to keep doing it. If I navigate to another screen and return (it also happens when the app comes back from the background, it collects again the last value). This problem didn't happen with LiveData where I just did exact same thing, expose a flow from a repository and collected via LiveData in ViewModel.

Code:

class SignInViewModel @Inject constructor(
    private val doSignIn: SigninUseCase
) : ViewModel(){

    private val _userResult = MutableStateFlow<Result<String>?>(null)
    val userResult: StateFlow<Result<String>?> = _userResult.stateIn(viewModelScope, SharingStarted.Lazily, null) //Lazily since it's just one shot operation

    fun authenticate(email: String, password: String) {
        viewModelScope.launch {
            doSignIn(LoginParams(email, password)).collect { result ->
                Timber.e("I just received this $result in viewmodel")
                _userResult.value = result
            }
        }
    }
    
}

Then in my Fragment:

override fun onViewCreated(...){
super.onViewCreated(...)

launchAndRepeatWithViewLifecycle {
            viewModel.userResult.collect { result ->
                when(result) {
                    is Result.Success -> {
                        Timber.e("user with code:${result.data} logged in")
                        shouldShowLoading(false)
                        findNavController().navigate(SignInFragmentDirections.toHome())
                    }
                    is Result.Loading -> {
                        shouldShowLoading(true)
                    }
                    is Result.Error -> {
                        Timber.e("error: ${result.exception}")
                        if(result.exception is Failure.ApiFailure.BadRequestError){
                            Timber.e(result.exception.message)
                            shortToast("credentials don't match")
                        } else {
                            shortToast(result.exception.toString())
                        }

                        shouldShowLoading(false)
                    }
                }
            }
}

launchAndRepeatWithViewLifecycle extension function:

inline fun Fragment.launchAndRepeatWithViewLifecycle(
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    crossinline block: suspend CoroutineScope.() -> Unit
) {
    viewLifecycleOwner.lifecycleScope.launch {
        viewLifecycleOwner.lifecycle.repeatOnLifecycle(minActiveState) {
            block()
        }
    }
}

Any thoughts on why this happens and how to fancy solve it using StateFlow? I tried also with SharedFlow with replay = 0 and Channels with receiveAsFlow() but then other problems arise.

General Grievance
  • 4,555
  • 31
  • 31
  • 45
Elias Bellido
  • 81
  • 2
  • 8
  • This is intended behavior with StateFlow. The correct way to do what you’re describing is to use SharedFlow with replay 0. Maybe you can describe what other problems arise from doing it that way. – Tenfour04 Jul 06 '21 at 19:19
  • could you find an answer ? I faced this problem, maybe using livedata is better. I can't find any answers in internet for this problem. – NimaAzhd Oct 10 '21 at 16:50
  • Use `SharedFlow` for events. – Sam Chen Apr 06 '22 at 15:54

5 Answers5

6

You can create an extension function like this:

suspend fun <T> MutableStateFlow<T?>.set(value: T, idle: T? = null, delay: Long = 100) {
    this.value = value
    delay(delay)
    this.value = idle
}

It sets the desired default value after the new value has been emitted. In my case, I use null as the desired value.

Inside ViewModel instead of setting the value directly you can use the set() extension function.

fun signIn() = authRepository.signIn(phoneNumber.value).onEach {
    _signInState.set(it)
}.launchIn(viewModelScope)
Javlon
  • 1,108
  • 2
  • 15
  • 29
2

It looks like you would be looking for SingleLiveEvent with Kotlin flow.

class MainViewModel : ViewModel() {

    sealed class Event {
        data class ShowSnackBar(val text: String): Event()
        data class ShowToast(val text: String): Event()
    }

    private val eventChannel = Channel<Event>(Channel.BUFFERED)
    val eventsFlow = eventChannel.receiveAsFlow()

    init {
        viewModelScope.launch {
            eventChannel.send(Event.ShowSnackBar("Sample"))
            eventChannel.send(Event.ShowToast("Toast"))
        }
    }

}
class MainFragment : Fragment() {

    companion object {
        fun newInstance() = MainFragment()
    }

    private val viewModel by viewModels<MainViewModel>()
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return inflater.inflate(R.layout.main_fragment, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // Note that I've chosen to observe in the tighter view lifecycle here.
        // This will potentially recreate an observer and cancel it as the
        // fragment goes from onViewCreated through to onDestroyView and possibly
        // back to onViewCreated. You may wish to use the "main" lifecycle owner
        // instead. If that is the case you'll need to observe in onCreate with the
        // correct lifecycle.
        viewModel.eventsFlow
            .onEach {
                when (it) {
                    is MainViewModel.Event.ShowSnackBar -> {}
                    is MainViewModel.Event.ShowToast -> {}
                }
            }
            .flowWithLifecycle(lifecycle = viewLifecycleOwner.lifecycle, minActiveState = Lifecycle.State.STARTED)
            .onEach {
                // Do things
            }
            .launchIn(viewLifecycleOwner.lifecycleScope)
    }

}

Credit : Michael Ferguson has authored a great article with the updated library enhancement. Would recommend you to go through it. I have copied the extract of it.

https://proandroiddev.com/android-singleliveevent-redux-with-kotlin-flow-b755c70bb055

osrl
  • 8,168
  • 8
  • 36
  • 57
Rahul Singhal
  • 151
  • 1
  • 10
  • 2
    Channels don´t work for me with sharedViewModels. If I open a BottomSheetFragment from a Fragment, both sharing the viewmodel, channel doesn´t work fine. Once yes, once no – Patrick Aug 09 '22 at 07:55
0

You can set your state's value to the Idle(), which means an empty state

lifecycleScope.launch {
           viewModel.signInState.collect {
               when (it) {
                   is ViewState.Success -> {
                       stopLoading()
                       viewModel._signInState.value = ViewState.Idle()
                       // do your tasks here
                   }
                   is ViewState.Error<*> -> requireActivity().onBackPressedDispatcher.onBackPressed()
                   is ViewState.BusinessException<*> -> requireActivity().onBackPressedDispatcher.onBackPressed()
                   is ViewState.Idle<*> -> stopLoading()
                   is ViewState.Loading<*> -> startLoading()
               }
           }
       }
0

Here's the late answer. Recently, I've started using coroutine and flow for my project and faced the same issue. Below is the code that I use to solve the problem:

Fragment code

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initUi()
        subscribeUi()
    }

    private fun subscribeUi() {
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.pagingData.collectLatest { pagingData ->
                    categoryListAdapter.submitData(pagingData.map {
                        CategoryUiItem.DataItem(
                            it.itemNo,
                            it.itemName,
                            it.brandName,
                            it.imageUrl,
                            it.heartCount,
                            it.reviewCount,
                            it.reviewAveragePoint,
                            it.saleInfo.consumerPrice,
                            it.saleInfo.sellPrice,
                            it.saleInfo.saleRate,
                            it.saleInfo.couponSaleRate,
                            it.saleInfo.isCoupon,
                            it.saleInfo.totalSellPrice,
                            it.saleInfo.totalSaleRate,
                            it.isLiked // isLiked
                        )
                    })
                }
            }
        }
    }

ViewModel

    private val _pagingData: MutableStateFlow<PagingData<CategoryItemDomain>> =
        MutableStateFlow(PagingData.empty())
    val pagingData = _pagingData.shareIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5000),
        replay = 0
    )

    init {
        getCategoryList()
    }

    private fun getCategoryList() {
        viewModelScope.launch(Dispatchers.IO) {
            getCategoryListUseCase(category)
                .cachedIn(viewModelScope)
                .collectLatest {
                    _pagingData.value = it
                }
        }
    }

First, I've used StateFlow then change it to SharedFlow using shareIn. I'm using paging3 but other case would be the same.

Hope it helps.

JustinB
  • 45
  • 7
0

The simple answer is SharedFlow with the parameter replay = 0.

Code example

val sharedFlow = MutableSharedFlow<Result<PromotionItem>>(replay = 0).

FYI: But in the case when the value is emitted, and there is no subscriber, then after the subscription the value will NOT be received.

In my case, I use click event, and after processing data need to open new activity. When going back it should not trigger the collect method again. So I used SharedFlow instead of StateFlow the issue is fixed!

Rid Hrant
  • 112
  • 1
  • 1
  • 11