Home > Software engineering >  changing value of a LiveData on click of button only. But the observer's onChange is getting ca
changing value of a LiveData on click of button only. But the observer's onChange is getting ca

Time:07-05

I have a LiveData in my ViewModel:-

private val _toastMessage = MutableLiveData<Long>()
val toastMessage
    get() = _toastMessage

And this is the only way I am changing it's value(on click of a submit button in the fragment):-

fun onSubmitClicked(<params>){
        Log.i(LOG_TAG, "submit button clicked")
        uiScope.launch {
            if(!myChecksForEditTextValuesSucceeded())
            {
                _toastMessage.value = 0
            }else{
                _toastMessage.value = 1
            }
        }
    } 

And in the fragment, I have an observer for this LiveData:-

transactionViewModel.toastMessage.observe(viewLifecycleOwner, Observer { it->
            when{
                (it.compareTo(0) == 0) -> Toast.makeText(context, resources.getString(R.string.toast_msg_transaction_not_inserted), Toast.LENGTH_SHORT).show()
                else -> Toast.makeText(context, resources.getString(R.string.toast_msg_transaction_inserted), Toast.LENGTH_SHORT).show()
            }
        })

Ideally, I am expecting the onChange of this Observer to be called only on clicking the submit button on my fragment. But, as I can see, it is also getting called even on onCreateView of my fragment.

What could be the possible reasons for this?

CodePudding user response:

The issue is that LiveData pushes new values while you're observing it, but it also pushes the most recent value when you first observe it, or if the observer's Lifecycle resumes and the data has changed since it was paused.

So when you set toastMessage's value to 1, it stays that way - and ViewModels have a longer lifetime than Fragments (that's the whole point!) so when your Fragment gets recreated, it observes the current value of toastMessage, sees that it's currently 1, and shows a Toast.


The problem is you don't want to use it as a persistent data state - you want it to be a one-shot event that you consume when you observe it, so the Toast is only shown once in response to a button press. This is one of the tricky things about LiveData and there have been a bunch of workarounds, classes, libraries etc built around making it work

There's an old post here from one of the Android developers discussing the problem with this use case, and the workarounds available and where they fall short - in case anyone is interested! But like it says at the top, that's all outdated, and they recommend following the official guidelines.

The official way basically goes:

  • something triggers an event on the ViewModel
  • the VM updates the UI state including a message to be displayed
  • the UI observes this update, displays the message, and informs the VM it's been displayed
  • the VM updates the UI state with the message removed

That's not the only way to handle consumable events, but it's what they're recommending, and it's fairly simple. So you'd want to do something like this:

// making this nullable so we can have a "no message" state
private val _toastMessage = MutableLiveData<Long?>(null)
// you should specify the type here btw, as LiveData instead of MutableLiveData -
// that's the reason for making the Mutable reference private and having a public version
val toastMessage: LiveData<Long?> 
    get() = _toastMessage

// call this when the current message has been shown
fun messageDisplayed() {
    _toastMessage.value = null
}
// make a nice display function to avoid repetition
fun displayToast(@StringRes resId: Int) {
    Toast.makeText(context, resources.getString(resId), Toast.LENGTH_SHORT).show()
    // remember to tell the VM it's been displayed
    transactionViewModel.messageDisplayed()
}

transactionViewModel.toastMessage.observe(viewLifecycleOwner, Observer { it->
    // if the message code is null we just don't do anything
    when(it) {
        0 -> displayToast(R.string.toast_msg_transaction_not_inserted)
        1 -> displayToast(R.string.toast_msg_transaction_inserted)
    }
})

You also might want to create an enum of Toast states instead of just using numbers, way more readable - you can even put their string IDs in the enum:

enum class TransactionMessage(@StringRes val stringId: Int) {
    INSERTED(R.string.toast_msg_transaction_inserted),
    NOT_INSERTED(R.string.toast_msg_transaction_not_inserted)
}
private val _toastMessage = MutableLiveData<TransactionMessage?>(null)
val toastMessage: LiveData<TransactionMessage?> 
    get() = _toastMessage

uiScope.launch {
    if(!myChecksForEditTextValuesSucceeded()) toastMessage.value = NOT_INSERTED
    else _toastMessage.value = INSERTED
}
transactionViewModel.toastMessage.observe(viewLifecycleOwner, Observer { message ->
    message?.let { displayToast(it.stringId) }
    // or if you're not putting the string resource IDs in the enum:
    when(message) {
        NOT_INSERTED -> displayToast(R.string.toast_msg_transaction_not_inserted)
        INSERTED -> displayToast(R.string.toast_msg_transaction_inserted)
    }
})

It can be a bit clearer and self-documenting compared to just using numbers, y'know?

  • Related