Android Logic Based On Multiple Live Data Values - android

I am using the Android data binding library to make reactive views with LiveData
I make a repo request for a list of jobs
var jobsRequest: LiveData<Resource<List<Job>>>
= Transformations.switchMap(position) { repo.getJobsWithStatus(it) }
Then I have 3 more LiveData based on the above, like so
First, to check whether the request has completed
private val requestComplete: LiveData<Boolean>
= Transformations.map(jobsRequest) {
it.status == Status.SUCCESS || it.status == Status.ERROR
}
Next, to transform to a list of jobs without the resource wrapper
var jobs: LiveData<List<Job>>
= Transformations.map(jobsRequest) { it.data }
Lastly, to check if that job list is empty
val jobsEmpty: LiveData<Boolean>
= Transformations.map(jobs) { (it ?: emptyList()).isEmpty() }
In the layout I want to show a loading spinner if the request has not completed and the jobs list is empty and need a variable in my view model to dictate this
I have tried the code below and, as expected, it does not work
val spinnerVisible: LiveData<Boolean>
= Transformations.map(requestComplete) {
!(requestComplete.value ?: false) && (jobsEmpty.value ?: true)
}
What is the correct practice for having a LiveData variable based on the state of 2 others - I want to keep all logic in the view model, not in the activity or layout.

Is the jobsEmpty observer needed? Seems like you could reuse the jobs one for it.
Anway, to your question:
For this there is a MediatorLiveData. It does what you need: it can merge multiple (in your case: 2) LiveData objects and can determine another livedata value based on that.
Some pseudo-code:
MediatorLiveData showSpinner = new MediatorLiveData<Boolean>()
showSpinner.addSource(jobsEmpty, { isEmpty ->
if (isEmpty == true || requestComplete.value == true) {
// We should show!
showSpinner.value = true
}
// Remove observer again
showSpinner.removeSource(jobsEmpty);
})
showSpinner.addSource(requestComplete, { isCompleted ->
if (isCompleted == true && jobsEmpty == true) {
// We should show!
showSpinner.value = true
}
// Remove observer again
showSpinner.removeSource(requestComplete);
})
return showSpinner
Note that you need to return the mediatorlivedata as result, as this is the object you are interested in for your layout.
Additionally, you can check the documentation on the MediatorLiveData, it has some more examples: https://developer.android.com/reference/android/arch/lifecycle/MediatorLiveData

Related

How to create a list from scanned BLE results

as invers to the question asked here How to convert Flow<List<Object>> to Flow<Object> I want to convert my Flow<Object> to Flow<List<Object>>.
At least I think I want that, so I try to explain what I want to achieve and give some background. I am working on an Android application that uses bluetooth to scan and connect to BLE devices. I'm fairly new to the Android platform and kotlin so I haven't quite grasped all the details despite all the many things I've already learnt.
My repository has a method which returns a Flow of ScanResults from the bluetooth adapter:
fun bluetoothScan(): Flow<ScanResult> {
return bluetoothStack.bluetoothScan()
}
My ViewModel consumes that function, maps the data to my BleScanResult and returns it as LiveData.
val scanResults: LiveData<BleScanResult> =
scanEnabled.flatMapLatest { doScan ->
if (doScan) {
repository.bluetoothScan().map { BleScanResult(it.device.name, it.device.address) }
} else {
emptyFlow()
}
}.asLiveData()
In my activity I want to observer on that data and display it in a RecyclerView:
val adapter = ScanResultListAdapter()
binding.rcBleScanResults.adapter = adapter
viewModel.scanResults.observe(this) { result ->
//result.let { adapter.submitList(it) }
}
The problem is that scanResults is from type Flow<BleScanResult> and not Flow<List<BleScanResult>>, so the call to adapter.submitList(it) throws an error as it is expected to be a list.
So, how do I convert Flow to Flow<List> (with additional filtering of duplicates)? Or is there something I miss about the conception of Flow/LiveData?
You can try to use a MutableList and fill it with the data you get form a Flow, something like the following:
val results: MutableList<BleScanResult> = mutableListOf()
val scanResults: LiveData<List<BleScanResult>> =
scanEnabled.flatMapLatest { doScan ->
if (doScan) {
repository.bluetoothScan().map {
results.apply {
add(BleScanResult(it.device.name, it.device.address))
}
}
} else {
emptyFlow()
}
}.asLiveData()
You can also use a MutableSet instead of MutableList if you want to have a unique list of items (assuming BleScanResult is a data class).
You could use the liveData builder to collect the Flow's values into a MutableList.
Here I copy the MutableList using toList() before emitting it since RecyclerView Adapters don't play well with mutable data sources.
val scanResults: LiveData<List<BleScanResult>> = liveData {
val cumulativeResults = mutableListOf<BleScanResult>()
scanEnabled.flatMapLatest { doScan ->
if (doScan) {
repository.bluetoothScan().map { BleScanResult(it.device.name, it.device.address) }
} else {
emptyFlow()
}
}.collect {
cumulativeResults += it
emit(cumulativeResults.toList())
}
}
If you want to avoid duplicate entries and reordering of entries, you can use a set like this:
val scanResults: LiveData<List<BleScanResult>> = liveData {
val cumulativeResults = mutableSetOf<BleScanResult>()
scanEnabled.flatMapLatest { doScan ->
if (doScan) {
repository.bluetoothScan().map { BleScanResult(it.device.name, it.device.address) }
} else {
emptyFlow()
}
}.collect {
if (it !in cumulativeResults) {
cumulativeResults += it
emit(cumulativeResults.toList())
}
}
}

Add multiple source to MediatorLiveData and change its value

Basically I have a screen, and there are a few EditTexts and a Button.
Users have to fill in all fields otherwise the Button is disabled.
I am using DataBinding to achieve this. Below is my code in the viewmodel.
val isNextEnabled = MediatorLiveData<Boolean>()
isNextEnabled.apply {
addSource(field1LiveData) {
isNextEnabled.value =
it != null
&& field2LiveData.value != null
&& field3LiveData.value != null
}
addSource(field2LiveData) {
isNextEnabled.value =
it != null
&& field1LiveData.value != null
&& field3LiveData.value != null
}
addSource(field3LiveData) {
isNextEnabled.value =
it != null
&& field2LiveData.value != null
&& field1LiveData.value != null
}
}
In the xml
<Button
android:enabled="#{viewmodel.isNextEnabled}"
.
.
.
</Button>
Everything works fine as expected. But the logic above looks cumbersome. What if I have more EditText ? The code would be painful to write/maintain.
Is there any way I can simplify it?
Ultimately you have a UseCase/Logic where you decide when the next button is enabled.
I think you should separate the logic into useCases where it makes sense.
E.g.
// update these when they change in the UI for e.g.
val field1Flow: Flow<Boolean> = flow { ... }
val field2Flow: Flow<Boolean> = flow { ... }
val nextButtonState = combine(field1Flow, field2Flow) { f1, f2 ->
f1 && f2
}.collect { state ->
// use your state.
}
Now... if you need special logic and not just two-boolean algebra here, you can always extract it into use-cases that return more flows.
Or map it or various operations you could do:
E.g.
class YourUseCase() {
operator fun invoke(field1: Boolean, field2: Boolean) {
// Your Logic
return field1 && field2
}
}
// And now...
val _nextButtonState = combine(field1Flow, field2Flow) { f1, f2 ->
YourUseCase(f1, f2)
}
val _uiState = _nextButtonState.transformLatest {
emit(it) // you could add a when(it) { } and do more stuff here
}
// And if you don't want to change your UI to use flows, you can expose this as live data
val uiState = _uiState.asLiveData()
Keep in mind this is Pseudo-code written on SO.. not even Notepad ;)
I hope that makes a bit of sense. The idea is to separate the bits into use-cases (that you can ultimately test in isolation) and to have a flow of data. When buttons change state, the fieldNFlow emits the values and this triggers the whole chain for you.
If you have the latest Coroutines (2.4.0+) you can use the new operators to avoid using LiveData, but overall, I'd try to think in that direction.
Lastly, your liveData code with a mediator is not bad, I'd at the very least, extract the "logic" into 3 different useCases so it's not all together in a series of if/else statements.
A word of caution: I haven't used Databinding in over 3(?) years, I'm personally not a fan of it so I cannot tell you if it would cause a problem with this approach.

How to Save Repository Live Data in a View Model

Howdy everyone hope all is well and swell, so I am having an issue with storing the live data I retrieve from my repository into my view Model so that my fragment can observe it. The scenario is as follows:
I have a suspended repository call like this
suspend fun getProfiles(profileId: Int): Resource<LiveData<List<Profile>>?>
{
return if(profileCaching()){
Resource.success(profileDao.getProfiles())
}else {
val result = fetchProfilesDataSource(profileId)//Suspend func API call
when (result.status) {
Status.SUCCESS -> when (result.data) {
null -> Resource.noContent()
else -> Resource.success(profileDao.getProfiles())
}
Status.LOADING -> Resource.loading(null)
Status.ERROR -> Resource.error(result.message!!, null)
}
}
}
The problem I am having is trying to structure my view Model so that a copy of this can be saved on it (To be observed by a fragment). I have tried calling it directly like this
val profiles = repo.getProfiles(10)
, but because it is suspended I have to wrap it in a viewModelScope. Additionally, I have tried using MediatorLiveData to try the copy the live data, but it didn't seem to retrieve it
var source: MediatorLiveData<List<Profile>> = MediatorLiveData()
fun processProfiles(){
viewModelScope.launch(Dispatchers.IO) {
val results = repo.getProfiles(10)
if (results.status == Status.SUCCESS && results.data != null) {
source.addSource(results.data, Observer{
source.value = it
})
} else {
//Set the empty list or error live data to true
}
}
}
Wanted to know if I was doing something wrong, or should I try a different approach?

How to get LiveData to switch between two other LiveData

I have the following scenario. Podcasts can come from internet or local(db) both are LiveData
// Live
private val _live = MutableLiveData<List<Podcast>>()
val live: LiveData<List<Podcast>> = _live
// Local
val local: LiveData<List<Podcast>> = dao.observePodcasts()
// Combined
val podcasts: LiveData<List<Podcast>> = ...
My question is:- How can i use only one LiveData podcasts such that on demand I can get data from live or local
fun search(query: String) {
// podcasts <- from live
}
fun subcribed() {
// podcasts <- from local
}
You can use MediatorLiveData in this case.
What you need to do with MediatorLiveData is need the LiveData sources to be able to listen for changes to the LiveData source.
Try the following:
YourViewModel.kt
private val _podcasts = MediatorLiveData<List<Podcast>>().apply {
addSource(_live) { dataApi ->
// Or you can do something when `_live` has a change in value.
if(local.value == null) {
this.value = dataApi
}
}
addSource(local) { dataLocal ->
// Or you can do something when `local` has a change in value.
if(_live.value == null) {
this.value = dataLocal
}
}
}
val podcasts: LiveData<List<Podcast>> = _podcasts
MediatorLiveData
I've personally used MediatorLiveData in projects to achieve the same function you're describing.
As quoted directly from the docs since they are pretty straight forward...
Consider the following scenario: we have 2 instances of LiveData, let's name them liveData1 and liveData2, and we want to merge their emissions in one object: liveDataMerger. Then, liveData1 and liveData2 will become sources for the MediatorLiveData liveDataMerger and every time onChanged callback is called for either of them, we set a new value in liveDataMerger.
LiveData liveData1 = ...;
LiveData liveData2 = ...;
MediatorLiveData liveDataMerger = new MediatorLiveData<>();
liveDataMerger.addSource(liveData1, value -> liveDataMerger.setValue(value));
liveDataMerger.addSource(liveData2, value -> liveDataMerger.setValue(value));
As already suggested, this can be accomplished with MediatorLiveData. Another option would be using Flows instead of combining LiveData.
val podcasts = combine(local, live) { local, live ->
// Add your implementation of how you would like to combine them
live ?: local
}.asLiveData(viewModelScope.coroutineContext)
If you're using Room, you can simply change the return type to Flow to get a Flow result. And for the MutableLiveData you can replace it with MutableStateFlow.
Using MediatorLiveData didn't suit my needs as I expected because I wanted to be able to switch between local and live whenever I want!
So I did the implementation as follows
enum class Source {
LIVE, LOCAL
}
private val _live = MutableLiveData<List<Podcast>>()
private val _local = dao.observePodcasts()
private val source = MutableLiveData<Source>(Source.LOCAL)
// Universal
val podcasts: LiveData<List<Podcasts>> = source.switchMap {
liveData {
when (it) {
Source.LIVE -> emitSource(_live)
else -> emitSource(_local)
}
}
}
emitSource() removes the previously-added source.
Then I implemented the following two methods
fun goLocal() {
source.postValue(Source.LOCAL)
}
fun goLive() {
source.postValue(Source.LIVE)
}
I then call respected function whenever to observer from live or local storage
One of the usecase
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
viewModel.goLive()
return true
}
override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
viewModel.goLocal()
return true
}
})

How to show empty view while using Android Paging 3 library

I am using Paging 3 lib. and i am able to check if refresh state is "Loading" or "Error" but i am not sure how to check "empty" state.I am able to add following condition but i am not sure if its proper condition
adapter.loadStateFlow.collectLatest { loadStates ->
viewBinding.sflLoadingView.setVisibility(loadStates.refresh is LoadState.Loading)
viewBinding.llErrorView.setVisibility(loadStates.refresh is LoadState.Error)
viewBinding.button.setOnClickListener { pagingAdapter.refresh() }
if(loadStates.refresh is LoadState.NotLoading && (viewBinding.recyclerView.adapter as ConcatAdapter).itemCount == 0){
viewBinding.llEmptyView.setVisibility(true)
}else{
viewBinding.llEmptyView.setVisibility(false)
}
}
Also I am running into other problem
I have implemented search functionality and until more than 2 characters are entered i am using same paging source like following but the above loadstate callback is executed only once.So thats why i am not able to hide empty view if search query is cleared.I am doing so to save api call from front end.
private val originalList : LiveData<PagingData<ModelResponse>> = Transformations.switchMap(liveData){
repository.fetchSearchResults("").cachedIn(viewModelScope)
}
val list : LiveData<LiveData<PagingData<ModelResponse>>> = Transformations.switchMap{ query ->
if(query != null) {
if (query.length >= 2)
repository.fetchSearchResults(query)
else
originalList
}else
liveData { emptyList<ModelResponse>() }
}
Here is the proper way of handling the empty view in Android Paging 3:
adapter.addLoadStateListener { loadState ->
if (loadState.source.refresh is LoadState.NotLoading && loadState.append.endOfPaginationReached && adapter.itemCount < 1) {
recycleView?.isVisible = false
emptyView?.isVisible = true
} else {
recycleView?.isVisible = true
emptyView?.isVisible = false
}
}
adapter.loadStateFlow.collect {
if (it.append is LoadState.NotLoading && it.append.endOfPaginationReached) {
emptyState.isVisible = adapter.itemCount < 1
}
}
The logic is,If the append has finished (it.append is LoadState.NotLoading && it.append.endOfPaginationReached == true), and our adapter items count is zero (adapter.itemCount < 1), means there is nothing to show, so we show the empty state.
PS: for initial loading you can find out more at this answer: https://stackoverflow.com/a/67661269/2472350
I am using this way and it works.
EDITED:
dataRefreshFlow is deprecated in Version 3.0.0-alpha10, we should use loadStateFlow now.
viewLifecycleOwner.lifecycleScope.launch {
transactionAdapter.loadStateFlow
.collectLatest {
if(it.refresh is LoadState.NotLoading){
binding.textNoTransaction.isVisible = transactionAdapter.itemCount<1
}
}
}
For detailed explanation and usage of loadStateFlow, please check
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#load-state-listener
If you are using paging 3 with Jetpack compose, I had a similar situation where I wanted to show a "no results" screen with the Pager 3 library. I wasn't sure what the best approach was in Compose but I use now this extension function on LazyPagingItems. It checks if there are no items and makes sure there are no items coming by checking if endOfPaginationReached is true.
private val <T : Any> LazyPagingItems<T>.noItems
get() = loadState.append.endOfPaginationReached && itemCount == 0
Example usage is as follows:
#Composable
private fun ArticlesOverviewScreen(screenContent: Content) {
val lazyBoughtArticles = screenContent.articles.collectAsLazyPagingItems()
when {
lazyBoughtArticles.noItems -> NoArticlesScreen()
else -> ArticlesScreen(lazyBoughtArticles)
}
}
Clean and simple :).

Categories

Resources