Imagine a list of posts (like Facebook) being presented to you in the Main Activity. The underlying "single source of truth" is the Database and Android's Room is used to fetch and observe this list of Posts.
This is how I'm observing the data (details omitted due to license issues and brevity):
faViewModel = ViewModelProviders.of(getActivity()).get(FAViewModel.class);
faViewModel.getAllPosts().observe(getActivity(),
newPosts - > {
if (newPosts != null && newPosts.size() > 0) {
postsLoadingProgressBar.setVisibility(View.INVISIBLE);
}
// if ((posts == null) || newPosts.get(0).getId() != posts.get(0).getId()) {
// Update the cached copy of the posts in the adapter.
posts = newPosts;
mPostsAdapter = new PostsAdapter(this.getChildFragmentManager(), newPosts);
mViewPager.setAdapter(mPostsAdapter);
// }
});
faViewModel.fetchNextData(currentPage);
Now, you also have a like button attached to every post, as well as the total number of likes a single post has received.
Now, the user clicks on the like button and you dispatch an action. This action can do the following:
1.1 Issue an update query that increments the number of likes a post has gotten.
1.2 Mark that this particular post in the database has been liked by the user.
Actually send a POST request to the server and update the artifacts there. (Total likes and posts this user has liked, among other things.)
Step 2 is doable so let's not talk about it. However, Step 1.1 and 1.2 are tricky because, when I issue my update query:
#Query("UPDATE post SET liked= :liked, likes = likes + 1 WHERE id= :id")
void updateLike(int id, int liked);
(Note: This doesn't care about dislikes. It works like medium. A user can give N likes to one post.)
Anyway, when I issue that query, it updates the database. Since, I'm actually observing this database using LiveData, I receive a new LiveData entity which I then attach to my adapter. My adapter thinks (and is right in thinking) that the dataset has changed, and hence it updates the list. While doing this, it also scrolls to the first post in the list. THIS IS THE PROBLEM.
How can I prevent that?
One possible solution is to check if the post id's of the incoming list and the current list are same, do not update. However, this means I won't get the updated No of likes and other things from the DB. This would in turn mean that when a user clicks on the like button, I'll manually have to change the No of likes and the Like drawable's state IN THE VIEW.
Is this the correct approach? Or is there something I can do to still have my database as the "single source of truth" while still having the Like Button facility.
Any other ideas/hacks are appreciated.
You can use DiffUtil to solve your problem, if you are using a RecyclerView.Adapter, which means you have either a RecyclerView or a ViewPager2.
First you should not recreate the adapter each time the db emits new data.
When new data is available, you can use DiffUtil.calculateDiff(DiffUtil.Callback cb) to get a DiffUtil.DiffResult object, and then call dispatchUpdatesTo(Adapter adapter) on the result.
This way all the changes between old and new data will be applied individually. For instance, say your old list is A,B,C, if the new list is A,X,B,C then only X will be added to the RecyclerView (by default even with an animation).
Here's a sample implementation:
class ListDiffCalculator : DiffUtil.Callback() {
private var oldList = emptyList<MyModel>()
private var newList = emptyList<MyModel>()
fun computeDiff(oldList: List<MyModel>, newList: List<MyModel>): DiffUtil.DiffResult {
this.oldList = oldList
this.newList = newList
return DiffUtil.calculateDiff(this)
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].id == newList[newItemPosition].id
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldList[oldItemPosition].hashCode() == newList[newItemPosition].hashCode()
}
Your Adapter can implement Observer<List<MyModel>> so that you can make it directly observe the db, omitting the other Adapter stuff here for simplicity:
class Adapter : RecyclerView.Adapter<ViewHolder>(), Observer<List<MyModel>> {
private var data = emptyList<MyData>()
override fun onChanged(newData: List<MyModel>?) {
newData?.let {
val diff = listDiffCalculator.computeDiff(data, newData)
this.data = newData
diff.dispatchUpdatesTo(this)
}
}
}
And observe:
aViewModel.getAllPosts().observe(getActivity(), adapter)
Related
I have this recycler view that shows paginated data with a PagingDataAdapter. It works well when making a single request to the service but I sometimes need to make two requests to get old and new events and show both in my recycler view. Is there a way I can combine the two flows of paginated data before showing them to the user? As it is, if I make the two requests, only the last one remains because of the invalidate() in the submitData method.
This is the current implementation that doesn't work the way I need it to:
private fun fetchEvents() {
fetchEventsJob?.cancel()
binding.cameraVmsEventsRecycler.adapter = null
fetchEventsJob = lifecycleScope.launch {
cameraViewModel.searchCameraEvents(
cameraId = videoDetector.id,
dateTo = if(eventsByDate) "" else dayPickerDateTo,
pageSize = REQUEST_LIMIT
cameraViewModel.filtersApplied
).collectLatest { eventsCollection ->
val events = eventsCollection.map { event ->
CameraEventModel.CameraEventItem(
VideoEventModel(
event.eventId,
event.faceId,
null,
event.state
)
)
}.insertSeparators {
before: CameraEventModel.CameraEventItem?, after: CameraEventModel.CameraEventItem? ->
renderSeparators(before, after)
}
binding.cameraEventsRecycler.adapter = eventsAdapter
eventsAdapter.submitData(events)
}
}
}
Upong calling fetchEvents() with different parameters, only the last flow of data remains due to the submitData().
Is there a way I can manage to do what I want? I can't use Room in this project.
You are explicitly calling collectLatest: it cancels collection when new items emit. The effect is, if you suspend in the collector lambda before your call to submitData() you only get the last item from the flow.
If you want to use all items from searchCameraEvents you might need to use toList():
val flow = cameraViewModel.searchCameraEvents(/*...*/)
val eventsCollection: List<PagingData<List<Event>>> = flow.toList()
val events = eventsCollection
// get list out of paging data
.flatMap { it.data }
// flatten list of lists
.flatten()
// map as needed
.map { event ->
CameraEventModel.CameraEventItem(/*...*/)
}
Mind, that you are handling paged data here. You want to make sure to keep a proper ordering. Also, if you retrieve more than one page from your flow, you're actually skipping the paging mechanism somehow. So you might also need to keep track of this in order not to produce duplicates in the UI.
I am facing an issue where a change in an item is not reflected when using submitList in ListAdapter.
I know that below code works in case I want to remove an item when using ListAdapter as inside submitList the framework checks whether lists are similar. It is also demonstrated in this sample from one of the developers working at Google
https://github.com/android/views-widgets-samples/blob/main/RecyclerViewKotlin/app/src/main/java/com/example/recyclersample/data/DataSource.kt
fun removeItem(position: Int)
{
val copiedList = adapter.currentList.toMutableList()
copiedList.removeAt(position)
adapter.submitList(copiedList)
}
The problem is when you want to change something using same method it won't work because toMutableList() creates a shallow copy of the list.
and thus when I do this,
fun changeItemAtPositionWith(isEnabled: Boolean, position: Int) {
val copiedList = adapter.currentList.toMutableList()
copiedList[position].isEnabled = !copiedList[position].isEnabled
adapter.submitList(copiedList)
}
It doesn't work because content of both lists (copied and non-copied) refer to the same items and while comparing ListAdapter doesn't find any change.
I don't think creating deep copy for every change is a good idea at all.
The only way I could solve it was to copy the object which has to be updated as shown below:
fun updateItemAtPosition(cheeseType, position) {
val newPizza = pizzas[position].copy(cheese = cheeseType)
pizzas[position] = newPizza
adapter.submitAndUpdateList(pizzas)
}
And here is what submitAndUpdateList does
fun submitAndUpdateList(list: List<Pizza>) {
submitList(list.toMutableList())
}
Here is the link to GitHub project, you can try running the app
https://github.com/ndhabrde11/ListAdapter-Sample/blob/master/app/src/main/java/com/example/listadaptertest/PizzaAdapter.kt
I'm working on a simple calorie counter app using two fragments and a ViewModel. I'm a beginner and this is a modification of an app I just created for a course (this app is not a homework assignment). It uses ViewModel and has a fragment that collects user input and a fragment that displays the input as a MutableList of MutableLiveData. I would like for the list screen to initially be empty except for a TextView with instructions, and I'd like the instructions to disappear once an entry has been added to the list. My class instructor told me to use an if-else statement in the fragment with the list to achieve this, but it's not working. He didn't tell me exactly where to put it. I tried a bunch of different spots but none of them worked. I don't get errors - just no change to the visibility of the TextView.
Here is the code for the ViewModel with the list:
val entryList: MutableLiveData<MutableList<Entry>>
get() = _entryList
init {
_entry = MutableLiveData<Entry>()
_entryList.value = mutableListOf()
}
fun addEntry(entryInfo: Entry){
_entry.value = entryInfo
_entryList.value?.add(_entry.value!!)
}
}
And this is the code for the observer in the list fragment:
Observer { entryList ->
val entryListView: View = inflater.inflate(R.layout.fragment_entry_list, null, false)
if (entryList.isNullOrEmpty()) {
entryListView.instructions_text_view.visibility = View.VISIBLE
} else {
entryListView.instructions_text_view.visibility = View.GONE
}
entryList.forEach {entry ->
val view: View = inflater.inflate(R.layout.entry_list_item, null, false)
view.date_entry_text_view.text = String.format(getString(R.string.date), entry.date)
view.calories_entry_text_view.text =
view.line_divider
binding.entryList.addView(view)
}
Thanks for any help.
I guess you are expecting your observer to get notified of the event when you are adding entryInfo to your event list (_entryList.value?.add(_entry.value!!).
But this won't happen as you are just adding an element to the same mutable list, and as the list reference hasn't changed, live data won't emit any update.
To solve this, you have two options.
Create a new boolean live data which controls when to show and hide the info text. Set its initial value to false, and update it to true in addEntry() function.
Instead of updating the same mutable list, create of copy of it, add the element and set the entryList.value equal to this new list. This way your observer will be notified of the new list.
Additionally, its generally not a good practice to expose mutable data unless there is no alternative. Here you are exposing a mutable list of Entry and that too in the form of a mutable live data. Ideally, your should be exposing LiveData<List<Entry>>.
This is one possible implementation of all the points that I mentioned:
private val _entryList = MutableLiveData(listOf<Entry>()) // Create a private mutable live data holding an empty entry list, to avoid the initial null value.
val entryList: LiveData<List<Entry>> = _entryList // Expose an immutable version of _entryList
fun addEntry(entryInfo: Entry) {
_entryList.value = entryList.value!! + entryInfo
}
I haven't used the _entry live data here, but you can implement it the same way.
set your viewModel to observe on entry added.
I think you have gotten your visibility toggle in the your if else blocks wrong.
if (entryList.isNullOrEmpty()) {
entryListView.instructions_text_view.visibility = View.GONE // OR View.INVISIBLE
} else {
entryListView.instructions_text_view.visibility = View.VISIBLE
}
Your Observer should get notified of changes to entryList when _entryList has changed. Make sure you are calling addEntry() function to trigger the notification.
I am trying to display several download progress bars at once via a list of data objects containing the download ID and the progress value. The values of this list of objects is being updated fine (shown via logging) but the UI components WILL NOT update after their initial value change from null to the first progress value. Please help!
I see there are similar questions to this, but their solutions are not working for me, including attaching an observer.
class DownLoadViewModel() : ViewModel() {
...
private var _progressList = MutableLiveData<MutableList<DownloadObject>>()
val progressList = _progressList // Exposed to the UI.
...
//Update download progress values during download, this is called
// every time the progress updates.
val temp = _progressList.value
temp?.forEach { item ->
if (item.id.equals(download.id)) item.progress = download.progress
}
_progressList.postValue(temp)
...
}
UI Component
#Composable
fun ExampleComposable(downloadViewModel: DownloadViewModel) {
val progressList by courseViewModel.progressList.observeAsState()
val currentProgress = progressList.find { item -> item.id == local.id }
...
LinearProgressIndicator(
progress = currentProgress.progress
)
...
}
I searched a lot of text to solve the problem that List in ViewModel does not update Composable. I tried three ways to no avail, such as: LiveData, MutableLiveData, mutableStateListOf, MutableStateFlow
According to the test, I found that the value has changed, but the interface is not updated. The document says that the page will only be updated when the value of State changes. The fundamental problem is the data problem. If it is not updated, it means that State has not monitored the data update.
The above methods are effective for adding and deleting, but the alone update does not work, because I update the element in T, but the object has not changed.
The solution is to deep copy.
fun agreeGreet(greet: Greet) {
val g = greet.copy(agree = true) // This way is invalid
favourites[0] = g
}
fun agreeGreet(greet: Greet) {
val g = greet.copy() // This way works
g.agree = true
favourites[0] = g
}
Very weird, wasted a lot of time, I hope it will be helpful to those who need to update.
As far as possible, consider using mutableStateOf(...) in JC instead of LiveData and Flow. So, inside your viewmodel,
class DownLoadViewModel() : ViewModel() {
...
private var progressList by mutableStateOf(listOf<DownloadObject>()) //Using an immutable list is recommended
...
//Update download progress values during download, this is called
// every time the progress updates.
val temp = progress.value
temp?.forEach { item ->
if (item.id.equals(download.id)) item.progress = download.progress
}
progress.postValue(temp)
...
}
Now, if you wish to add an element to the progressList, you could do something like:-
progressList = progressList + listOf(/*item*/)
In your activity,
#Composable
fun ExampleComposable(downloadViewModel: DownloadViewModel) {
val progressList by courseViewModel.progressList
val currentProgress = progressList.find { item -> item.id == local.id }
...
LinearProgressIndicator(
progress = currentProgress.progress
)
...
}
EDIT,
For the specific use case, you can also use mutableStateListOf(...)instead of mutableStateOf(...). This allows for easy modification and addition of items to the list. It means you can just use it like a regular List and it will work just fine, triggering recompositions upon modification, for the Composables reading it.
It is completely fine to work with LiveData/Flow together with Jetpack Compose. In fact, they are explicitly named in the docs.
Those same docs also describe your error a few lines below in the red box:
Caution: Using mutable objects such as ArrayList or mutableListOf() as state in Compose will cause your users to see incorrect or stale data in your app.
Mutable objects that are not observable, such as ArrayList or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.
Instead of using non-observable mutable objects, we recommend you use an observable data holder such as State<List> and the immutable listOf().
So the solution is very simple:
make your progressList immutable
while updating create a new list, which is a copy of the old, but with your new progress values
My database query operations can take a long time, so I want to display a ProgressBar while the query is in progress. This is especially a problem when the user changes the sorting options, because it displays the old list for a while until the new list comes in and the RecyclerView is updated. I just don't know where to capture the Loading and Success states for a query like this.
Here's my method for getting the PagedList from the database:
fun getGameList(): LiveData<PagedList<Game>> {
// Builds a SimpleSQLiteQuery to be used with #RawQuery
val query = buildGameListQuery()
val dataSourceFactory: DataSource.Factory<Int, Game> = database.gameDao.getGameList(query)
val data: LiveData<PagedList<Game>> = LivePagedListBuilder(dataSourceFactory, DATABASE_PAGE_SIZE)
.build()
return data
}
And I update my list by observing this:
val games = Transformations.switchMap(gameRepository.sortOptions) {
gameRepository.getGameList()
}
Do I need a custom DataSource and DataSource.Factory? If so, I have no idea where to even begin with that. I believe it would be a PositionalDataSource, but I can't find any examples online for implementing a custom one.
I also tried adapter.registerAdapterDataObserver() on my RecyclerView adapter. This fires various callbacks when the new list data is being displayed, but I can't discern from the callbacks when loading has started and stopped.
I was ultimately able to fix this by observing the games LiveData. However, it wasn't exactly straightforward.
Here's my DatabaseState class:
sealed class DatabaseState {
object Success : DatabaseState()
object LoadingSortChange: DatabaseState()
object Loading: DatabaseState()
}
Capturing the Loading state was easy. Whenever the user updates the sort options, I call a method like this:
fun updateSortOptions(newSortOptions: SortOptions) {
_databaseState.value = DatabaseState.LoadingSortChange
_sortOptions.value = newSortOptions
}
The Success state was the tricky one. Since my sorting options are contained in a separate Fragment from the RecyclerView, the games LiveData observer fires twice upon saving new sort options (once when ListFragment resumes, and then again a bit later once the database query is completed). So I had to account for this like so:
/**
* The observer that triggers this method fires once under normal circumstances, but fires
* twice if the sort options change. When sort options change, the "success" state doesn't occur
* until the second firing. So in this case, DatabaseState transitions from LoadingSortChange to
* Loading, and finally to Success.
*/
fun updateDatabaseState() {
when (databaseState.value) {
Database.LoadingSortChange -> gameRepository.updateDatabaseState(DatabaseState.Loading)
DatabaseState.Loading -> gameRepository.updateDatabaseState(DatabaseState.Success)
}
}
Finally, I needed to make some changes to my BindingAdapter to smooth out some remaining issues:
#BindingAdapter("gameListData", "databaseState")
fun RecyclerView.bindListRecyclerView(gameList: PagedList<Game>?, databaseState: DatabaseState) {
val adapter = adapter as GameGridAdapter
/**
* We need to null out the old list or else the old games will briefly appear on screen
* after the ProgressBar disappears.
*/
adapter.submitList(null)
adapter.submitList(gameList) {
// This Runnable moves the list back to the top when changing sort options
if (databaseState == DatabaseState.Loading) {
scrollToPosition(0)
}
}
}