7

Whenever you call .observe() on LiveData, the Observer receives the last value of that LiveData. This may be useful in some cases, but not in mine.

  1. Whenever I call .observe(), I want the Observer to receive only future LiveData changes, but not the value it holds when .observe() is called.

  2. I may have more than one Observer for a LiveData instance. I want them all to receive LiveData updates when they happen.

  3. I want each LiveData update to be consumed only once by each Observer. I think is just a re-phrasing of the first requirement, but my head is spinning already and I'm not sure about it.


While googling this problem, I came upon two common approaches:

  1. Wrap the data in an LiveData<SingleEvent<Data>> and check in this SingleEvent class if it was already consumed.

  2. Extend MediatorLiveData and use a look-up-map if the Observer already got the Event

Examples for these approaches can be found here: https://gist.github.com/JoseAlcerreca/5b661f1800e1e654f07cc54fe87441af#gistcomment-2783677 https://gist.github.com/hadilq/f095120348a6a14251a02aca329f1845#file-liveevent-kt https://gist.github.com/JoseAlcerreca/5b661f1800e1e654f07cc54fe87441af#file-event-kt

Unfortunately none of these examples solves all my requirements. Most of the time, the problem is that any new Observer still receives the last LiveData value upon subscribing. That means that a Snackbar which was already shown is displayed again and again whenever the user navigates between screens.


To give you some insights what I am talking about / what I am coding about:

I am following the LiveData MVVM design of the Android Architecture Componentns:

  • 2 ListFragment are showing a list of entries.
  • They are using 2 instances of the same ViewModel class to observe UI-related LiveData.
  • The user can delete an entry in such a ListFragment. The deletion is done by the ViewModel calling Repository.delete()
  • The ViewModel observes the Repository for RepositoryEvents.

So when the deletion is done, the Repository informs the ViewModel about it and the ViewModel inform the ListFragment about it.

Now, when the user switches to the second ListFragment the following happens:

  • The second Fragment gets created and calls .observe() on its ViewModel
  • The ViewModel gets created and calls .observe() on the Repository

  • The Repository sends its current RepositoryEvent to the ViewModel

  • The ViewModel send the according UI Event to the Fragment
  • The Fragment shows a confirmation Snackbar for a deletion that happened somewhere else.

Heres some simplified code:

Fragment:

viewModel.dataEvents.observe(viewLifecycleOwner, Observer { showSnackbar() })
viewModel.deleteEntry()

ViewModel:

val dataEvents: LiveData<EntryListEvent> = Transformations.switchMap(repository.events, ::handleRepoEvent)
fun deleteEntry() = repository.deleteEntry()
private fun handleRepoEvent(event: RepositoryEvent): LiveData<EntryListEvent> {
    // convert the repository event to an UI event
}

Repository:

private val _events = MutableLiveData<RepositoryEvent>()
val events: LiveData<RepositoryEvent>
    get() = _events

fun deleteEntry() {
    // delete it from database
    _events.postValue(RepositoryEvent.OnDeleteSuccess)
}
muetzenflo
  • 5,653
  • 4
  • 41
  • 82
  • Use separate `LiveData` for the view state (e.g., items in the list) and for events (e.g., show the confirmation snackbar), using the single-live-event pattern for the latter. IMHO, your first criterion ("I want the Observer to receive only future LiveData changes") is not good -- on a configuration change, you won't receive the current data. – CommonsWare Apr 27 '19 at 18:09
  • I am already using another LiveData for the items in the list. That is working fine. The UI-LiveData I am talking about here is only used for confirmation messages. They should not be displayed a second time (which happens right now on a configuration change or back-and-forth navigation). The single-live-event pattern would be sufficient if I had only one observer. But I have 2 of them and when the first observer consumes the single-live-event, the second observer does not handle it. This is exactly the problem i am facing. – muetzenflo Apr 27 '19 at 18:32
  • "But I have 2 of them and when the first observer consumes the single-live-event, the second observer does not handle it" -- that's a feature, not a bug. If the observer cannot handle the event, it should not be observing the `LiveData` or should not be marking the event as consumed. So, in your case, the first observer should be handling the event, so the second observer does not need the event. – CommonsWare Apr 27 '19 at 18:39
  • with "handle the event" I mean that the 2nd observer has no access to the event anymore, becaust it was already consumed by the 1st observer (as in single-event). Both observers are able to and should handle the event (see my 3 requirements at the top of my question) – muetzenflo Apr 27 '19 at 18:55
  • "Both observers are able to and should handle the event" -- this runs counter to "That means that a Snackbar which was already shown is displayed again and again whenever the user navigates between screens". You need to decide whether you want to show the snackbar once or twice. If the answer is "once", then only one observer handles the event. If the answer is "well, I only want to show the snackbar once, but I want to do other things in both observers", then the "other things" are part of your view state and should be represented in the other `LiveData`. – CommonsWare Apr 27 '19 at 18:59
  • Here's an example of when I need 2 observers and I need both of them handling the event: The user edits a list item and clicks the save button in the current fragment. The according UI event is to close the fragment by calling onBackPressed(). By closing this editing fragment, the underlying list fragment is immediately shown and it is its job to display the Snackbar. => Requirement 2. Now when I rotate the device, the event is received again and the Snackbar shows a second time which it should not => Requirement 1 – muetzenflo Apr 27 '19 at 19:07
  • Those are separate events, and so need separate `LiveData`. – CommonsWare Apr 27 '19 at 19:10
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/192488/discussion-between-muetzenflo-and-commonsware). – muetzenflo Apr 27 '19 at 19:12

4 Answers4

2

UPDATE 2021:

Using the coroutines library and Flow it is now very easy to achieve this by implementing Channels:

MainActivity

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import com.plcoding.kotlinchannels.databinding.ActivityMainBinding
import kotlinx.coroutines.flow.collect

class MainActivity : AppCompatActivity() {

    private lateinit var viewModel: MainViewModel

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        viewModel = ViewModelProvider(this).get(MainViewModel::class.java)

        binding.btnShowSnackbar.setOnClickListener {
            viewModel.triggerEvent()
        }

        lifecycleScope.launchWhenStarted {
            viewModel.eventFlow.collect { event ->
                when(event) {
                    is MainViewModel.MyEvent.ErrorEvent -> {
                        Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG).show()
                    }
                }
            }
        }

    }
}

MainViewModel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch

class MainViewModel : ViewModel() {

    sealed class MyEvent {
        data class ErrorEvent(val message: String): MyEvent()
    }

    private val eventChannel = Channel<MyEvent>()
    val eventFlow = eventChannel.receiveAsFlow()

    fun triggerEvent() = viewModelScope.launch {
        eventChannel.send(MyEvent.ErrorEvent("This is an error"))
    }
}
muetzenflo
  • 5,653
  • 4
  • 41
  • 82
  • 1
    Keep in mind that by using Channels only one observer will receive the event in a round robbin fashion, if you have several observers it may not be the best approach. – htafoya Feb 20 '23 at 00:04
0

For me problem was solved with this:

Event wrapper class to keep event related data(Copy from google samples)

public class Event<T> {

    private T mContent;

    private boolean hasBeenHandled = false;


    public Event( T content) {
        if (content == null) {
            throw new IllegalArgumentException("null values in Event are not allowed.");
        }
        mContent = content;
    }

    @Nullable
    public T getContentIfNotHandled() {
        if (hasBeenHandled) {
            return null;
        } else {
            hasBeenHandled = true;
            return mContent;
        }
    }

    public boolean hasBeenHandled() {
        return hasBeenHandled;
    }
}

Next, i create event observer class, that handles data checks(null, etc):

public class EventObserver<T> implements Observer<Event<T>> {

  @Override
  public void onChanged(Event<T> tEvent) {
    if (tEvent != null && !tEvent.hasBeenHandled())
      onEvent(tEvent.getContentIfNotHandled());
  }

  protected void onEvent(@NonNull T content) {}
}

And, event handler class, to simplify access from viewmodel:

public class EventHandler<T> {

  private MutableLiveData<Event<T>> liveEvent = new MutableLiveData<>();

  public void observe(@NonNull LifecycleOwner owner, @NonNull EventObserver<T> observer){
      liveEvent.observe(owner, observer);
  }

    public void create(T content) {
    liveEvent.setValue(new Event<>(content));
  }
}

Example:

In ViewModel.class:

private EventHandler<Boolean> swipeEventHandler = new EventHandler<>();

  public EventHandler<Boolean> getSwipeEventHandler() {
    return swipeEventHandler;
  }

In Activity/Fragment:

Start observing:

 viewModel
    .getSwipeEventHandler()
    .observe(
        getViewLifecycleOwner(),
        new EventObserver<Boolean>() {
          @Override
          protected void onEvent(@NonNull Boolean content) {
            if(content)confirmDelete(modifier);
          }
        });

Create event:

viewModel.getSwipeEventHandler().create(true);
Jurij Pitulja
  • 5,546
  • 4
  • 19
  • 25
0

Created a basic sealed class flag in the need of:

sealed class Event(private var handled: Boolean = false) {

        val coldData: Event?
            get() {
                return if (handled) null else {
                    handled = true
                    this
                }
            }

        class ShowLoader() : Event()
        class HideLoader() : Event()
        class ShowErrorAlert(@StringRes val message: Int) : Event()
    }

Then it can be observed at different fragments

viewModel.eventFlow.observe(this) { event ->

            val data = event.coldData

            when (data) {
                is Event.ShowLoader -> {
                    progressBar.visible = true
                }
                is Event.HideLoader -> {
                    progressBar.visible = false
                }
                is Event.ShowErrorAlert -> {
                    showAlert(data.message)
                }
                else -> {
                    // do nothing
                }
            }
        }

Or use a subclass of MutableLiveDatawith the same purpose to process them individually.

htafoya
  • 18,261
  • 11
  • 80
  • 104
0

You can add this functionality in an extension function. Just replace the call to observe with it. It will emit only events emitted after observing the LiveData object.

fun <T> LiveData<T>.observeFutureEvents(owner: LifecycleOwner, observer: Observer<T>) {
    observe(owner, object : Observer<T> {
        var isFirst = true

        override fun onChanged(value: T) {
            if (isFirst) {
                isFirst = false
            } else {
                observer.onChanged(value)
            }
        }
    })
}
guy.gc
  • 3,359
  • 2
  • 24
  • 39