3
\$\begingroup\$

I have an Android viewmodel for a Fragment that requires the user to perform multiple tasks. In addition to a LiveData object to track the status of each task, I also need a LiveData that tracks a summary of the status of all the tasks.

I wrote the following MediatorLiveData class that takes a list of LiveData objects and watches them all for changes, then applies a given function to compute the summary each time a value changes:

class ListOfLiveData<T, E>(
    private val sources: List<LiveData<T>>,
    private val evaluator: (List<LiveData<T>>) -> E
) : MediatorLiveData<E>() {
    init {
        sources.forEach {
            addSource(it) { value = evaluator(sources) }
        }
    }
}

Here is how I am using it right now:

class TaskViewModel : ViewModel() {

    // Can be DENIED, UNKNOWN, or GRANTED for each one
    private val _phoneStatus = MutableLiveData(Status.UNKNOWN)
    private val _locationStatus = MutableLiveData(Status.UNKNOWN)
    private val _videoStatus = MutableLiveData(Status.UNKNOWN)
    private val _audioStatus = MutableLiveData(Status.UNKNOWN)

    val phoneStatus: LiveData<Status> = _phoneStatus
    val locationStatus: LiveData<Status> = _locationStatus
    val videoStatus: LiveData<Status> = _videoStatus
    val audioStatus: LiveData<Status> = _audioStatus

    val allStatus = ListOfLiveData<Status, Status>(
        listOf(
            _phoneStatus,
            _locationStatus,
            _videoStatus,
            _audioStatus,
        )
    ) {
        // If any individual task status is DENIED or UNKNOWN, return that value.
        // Otherwise, they must all be GRANTED
        it.forEach { p ->
            if (p.value == Status.DENIED) {
                return@ListOfLiveData Status.DENIED
            }
            if (p.value == Status.UNKNOWN) {
                return@ListOfLiveData Status.UNKNOWN
            }
        }
        return@ListOfLiveData Status.GRANTED
    }
}

(In this example the source types and the return type are the same, but I can think of other places in the app that it would be useful to have a different return type.)

Is this a good design?

Is there anything built into the framework that would have already done this?

Am I missing anything important?

\$\endgroup\$

1 Answer 1

4
\$\begingroup\$

Your way of merging is similar to Rx's combineTransform or Kotlin Flow's combine. The difference is you are forcing the lambda to have to parse out the values instead of passing it the values directly. Changing this behavior would make it easier to work with the values, especially in cases like your use case above where you are simply iterating them.

You might also consider putting your class behind a function for easier use, and using varargs as well.

fun <T, E> combineTransform(
    vararg sources: LiveData<T>,
    transform: (List<T?>) -> E
): LiveData<E> = MediatorLiveData<E>().apply {
    sources.forEach { liveData ->
        addSource(liveData) {
            value = transform(sources.map(LiveData<T>::getValue))
        }
    }
}

Then your usage site would look like:

val allStatus: LiveData<Status> = combineTransform(
    _phoneStatus,
    _locationStatus,
    _videoStatus,
    _audioStatus,
) {
    it.forEach { value ->
        when(value) { 
            Status.DENIED -> return@ListOfLiveData Status.DENIED
            Status.UNKNOWN -> return@ListOfLiveData Status.UNKNOWN
            else -> {}
        }
    }
    return@ListOfLiveData Status.GRANTED
}

Note that this is having to contend with the problem that a LiveData of a non-nullable type still returns nullable value because it might not have an initial value set yet. As a result, the input of the above lambda uses nullable values.

If we want to avoid this problem and behave the same way as Rx combineTransform, we should not emit until all sources have emitted at least once. We could change the function to:

fun <T, E> combineTransform(
    vararg sources: LiveData<T>,
    transform: (List<T>) -> E
) = MediatorLiveData<E>().apply {
    val sourcesAwaitingFirst = mutableSetOf(*sources)

    sources.forEach { liveData ->
        addSource(liveData) {
            sourcesAwaitingFirst.remove(liveData)
            if (sourcesAwaitingFirst.isEmpty()) {
                @Suppress("UNCHECKED_CAST")
                value = transform(sources.map(LiveData<T>::getValue) as List<T>)
            }
        }
    }
}
\$\endgroup\$
0

Not the answer you're looking for? Browse other questions tagged or ask your own question.