I've made a list which gets items from a Room-database using LiveData. This liveData<List> is then bound to a recyclerView, using a BindingAdapter.
The lists adapter is listAdapter, not `RecyclerView.Adapter.
I need help holding onto the scroll-state or somehow returning to the scroll-index I was at before the recyclerView reloaded:
//In ViewModel
val movieList = moviesRepository.movies
..
//in Repo
val movies: LiveData<List<Movie>> =
Transformations.map(database.movieDao.getMovies()) {
it.asDomainModel()
}
Every time the DB updates, the recyclerView shoots back up to the top.
And here's the bindingAdapter for the RecyclerView and the list.
#BindingAdapter("listData")
fun bindRecyclerView(recyclerView: RecyclerView, data: List<Movie>?) {
val adapter = recyclerView.adapter as MovieListAdapter
//Log.d("listData binding", "${data}")
adapter.submitList(data)
}
I think I need to use something like recyclerView.smoothScrollToPosition(la.getItemCount());
after the update has occured, but I don't know how to automatically call it when the update has occured
Project Repo
First of all don't use ListAdapter RecyclerView has a more optimized adapter here
in your adapter provide a function that overrides the item list and there is where you notify the data change
Use smoothScrollToPosition(lastVisiblePosition) to scroll to the last visible position where lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
Update lastVisiblePosition before you push new items to the adapter notifyDatasetChanged()
Step 2
fun updateList(newItems:List<Movie>) {
moviesList.addAll(newItems)
lastVisiblePosition = layoutManager.findFirstVisibleItemPosition()
notifyDataSetChanged()
}
from your view when you call adapter.updateList(newItems) just call recyclerView.smoothScrollToPosition(adapter.lastVisiblePosition)
figured it out.
All of the examples I could find were using Activities, not fragments. So all I had to do was pop this into the onCreateView() function in the relevant fragment.kt file.
binding.movieList.layoutManager =
object : LinearLayoutManager(getActivity(), VERTICAL, false) {
override fun onLayoutCompleted(state: RecyclerView.State) {
super.onLayoutCompleted(state)
val lastVisibleItemPosition = findLastVisibleItemPosition()
val count = (binding.movieList.adapter as MovieListAdapter).itemCount
//speed the scroll up a bit, but make it look smooth at the end
binding.movieList.scrollToPosition(count - 5)
binding.movieList.smoothScrollToPosition(count)
}
}
here, the binding.movieList is referring to this xml element
<androidx.recyclerview.widget.RecyclerView
android:id="#+id/movie_list"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="#+id/load_more_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:listData="#{viewModel.movieList}"
tools:listitem="#layout/movie_list_item" />
Related
I have a ListView and trying to use two-way data binding to set the selectedItemPosition in a ViewModel using Two-Way Attributes
But the problem is it doesn't work, the selected item doesn't set in the Value of the liveData, I tried to observe it and the value never changes when i select an item in the listView
data binding in XML:
<ListView
android:id="#+id/listView"
android:layout_width="match_parent"
android:layout_height="#dimen/_150sdp"
android:nestedScrollingEnabled="true"
tools:listheader="#tools:sample/lorem"
tools:visibility="visible"
android:choiceMode="singleChoice"
android:selectedItemPosition="#={viewModel.chosenPosition}" />
in the ViewModel:
val chosenPosition = MutableLiveData<Int>()
in the Fragment:
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
binding.teamsListView.adapter = ArrayAdapter(
context,
R.layout.list_item_choice, teamsNames
)
viewModel.chosenPosition.observe(viewLifecycleOwner) {
Timber.d("chosen position = $it") //never triggers when I select an item in the ListView
}
Problem:
android:selectedItemPosition is triggered whenever an item is selected (this doesn't implicitly include that the item is clicked/checked.
Using android:selectedItemPosition as a two-way data binding in a ListView doesn't actually automatically triggered when an item is selected, and therefore the LiveData doesn't get triggered.
You can see that when you create a normal ListView without any data binding; when you click an item, this won't trigger the selection, notice the below when an item is clicked (nothing get highlighted with a different color):
Solution:
In order to solve that for the sake of data binding, you need to explicitly do select the that item whenever it is clicked by registering OnItemClickListener to the ListView:
binding.listView.setOnItemClickListener { _, _, position, _ ->
if (position >= 0) { // avoid -1 position calls
binding.listview.requestFocusFromTouch()
binding.listview.setItemChecked(position, true)
binding.listview.setSelection(position)
}
}
This way the live data will be set to the current selected position:
Notice when an item is selected, it's now highlighted with a light grey that is because the selection is enabled:
Make sure that you have not forgetten to set your binding lifecycleOwner in your fragment
binding.lifecycleOwner = this
I have a button. When the button is clicked, the button and a textView are animated. The question is: how to get multiple views on the binding adapter? Is the way I did it correct?
<variable
name="variableTextViewDescription"
type="androidx.appcompat.widget.AppCompatTextView" />
fun bind(task: Task, viewModel: ToDoListViewModel) {
binding.task = task
binding.viewModel = viewModel
binding.variableTextViewDescription = binding.textViewDescription
binding.executePendingBindings()
}
#BindingAdapter(value = ["task", "textViewDescription"], requireAll = true)
fun ImageButton.setOnClickButtonMore(task: Task, textViewDescription: AppCompatTextView) {
if (task.isExpanded) {
toggleArrow(this, false, textViewDescription)
} else {
toggleArrow(this, true, textViewDescription)
}
this.setOnClickListener {
task.isExpanded = toggleArrow(it, task.isExpanded, textViewDescription)
}
}
<ImageButton
android:id="#+id/buttonMore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:task="#{task}"
app:textViewDescription="#{variableTextViewDescription}"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="#drawable/ic_baseline_keyboard_arrow_down_24"
tools:ignore="ContentDescription" />
I can propose a solution for you, that maybe different from adding multiple Views to the same Binding Adapter.
You can add a MutableLiveData when changed by Button click, it starts the animation.
So, we will have a single MutableLiveData added to 2 Binding Adapters (the button binding adapter and the ImageView binding adapter).
when the value of the MutableLiveData changed, both binding adapters will fire and in both adapters load your animation.
I have a RecyclerView which allows swipe-to-delete functionality. After deliting, a Snackbar shows to confirm deletion with an action that allows users to "undo" the delete.
Everything works fine until I delete the item at position 0 then hit undo. The item will be reinserted back into the list but users will need to scroll up to bring it back into view.
What I have tried
Setting recyclerView.isNestedScrollingEnabled" on the RecyclerView
Using layoutCoordinator.scrollTo(0, 0) on the Coordinator Layout
Using recyclerView.smoothScrollToPosition(0) on the RecyclerView
Using recyclerView.scrollToPosition(0) on the RecyclerView
Using recyclerView.scrollTo(0, 0) on the RecyclerView
Using itemAdapter.notifiyItemInserted(0) on the Adapter
Using itemAdapter.notifiyItemchanged(0) on the Adapter
Using itemAdapter.notifyDataSetChanged() on the Adapter
Using layoutManager.scrollToPositionWithOffset(0, 0) on the
LayoutManager
Creating a custom LayoutManager and overriding
smoothScrollToPosotion() with my own implementation.
None of the above have offered a solution.
Below are the workings.
ItemsFragment
Inside onCreateView - here is setting up the itemAdapter and recyclerView:
val itemAdapter = object : ItemRecyclerViewAdapter() {
override fun onItemClicked(item: Item) {
// todo
}
}
val layoutManager = LinearLayoutManager(context)
recyclerView.layoutManager = layoutManager
recyclerView.adapter = itemAdapter
Here is my swipeToDeleteCallback with Snackbar action to "undo" the delete:
val swipeToDeleteCallback = object : SwipeToDeleteCallback() {
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val item = itemAdapter.currentList[position]
viewModel.deleteItem(item)
val snackBar = Snackbar.make(
recyclerView,
getString(R.string.snackbar_msg_deleted_card),
Snackbar.LENGTH_LONG
)
snackBar.setAction(R.string.snack_bar_undo) {
viewModel.restoreItem(item)
recyclerView.smoothScrollToPosition(position)
}
snackBar.show()
}
}
val itemTouchHelper = ItemTouchHelper(swipeToDeleteCallback)
itemTouchHelper.attachToRecyclerView(recyclerView)
Setting the items which are LiveData observed from the ViewModel. Changes are immediately passed to the adapter:
val items = viewModel.items.await()
items.observe(viewLifecycleOwner, Observer {
it?.let {
itemAdapter.setItems(it)
}
})
ItemsAdapter
Submitting the list with DiffUtilCallback:
fun setItems(list: List<Item>?) {
adapterScope.launch {
val itemsList = when(list) {
null -> emptyList()
else -> list.sortedByDescending {
it.itemId
}
}
withContext(Dispatchers.Main) {
submitList(itemsList)
}
}
}
Temporary fix
So far the only thing that has worked is this hacky solution inside my Snackbar action:
snackBar.setAction(R.string.snack_bar_undo) {
viewModel.restoreItem(item)
if (position == 0) {
itemsAdapter.notifyItemInserted(0)
recyclerView.smoothScrollToPosition(0)
}
}
Here I'm checking if the item position is 0. If so then telling the adapter that there's a new item inserted at position 0 - then initiating scrolling. Otherwise, don't do anything because items at any position other than 0 will insert and animate fine beceause of the DiffUtilCallback.
This temporary fix works but the scrolling is "snappy" and produces an error in the logs:
RecyclerView: Passed over target position while smooth scrolling
Also, this solution does not work 100% of the time. It's more like 60% of the time.
Does anybody know of a better solution/something I am missing and a way to resolve the error above?
Alrighty, So I have an existing custom recycler view adapter that populates a recycler view using provided items, and sets attributes in a layout i have as follows, ill try to remove irrelevant items from code
class TransactionAdapter(val context: Context, var transactions: List<Transaction>) :
androidx.recyclerview.widget.RecyclerView.Adapter<TransactionAdapter.CustomViewHolder>() {
override fun onCreateViewHolder(p0: ViewGroup, p1: Int): CustomViewHolder {
val inflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
val view = inflater.inflate(R.layout.transaction_list_inner_view, p0, false)
return CustomViewHolder(view)
}
override fun onBindViewHolder(p0: CustomViewHolder, p1: Int) {
p0.transactionNameTextView?.text = transactions[p1].title
p0.transactionAmountTextView?.text = transactions[p1].amount
if (!transactions[p1].location.isNullOrEmpty()) {
p0.transactionLocationTextView?.text = transactions[p1].location
} else {
p0.transactionLocationTextView?.text = "N/A"
}
p0.transactionTimeTextView?.text = transactions[p1].createdAt
p0.transactionDeleteButton?.setOnClickListener { println("working delete") }
}
class CustomViewHolder(v: View) : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {
val transactionNameTextView: TextView? = v.findViewById(R.id.transactionNameTextView) as TextView?
val transactionAmountTextView: TextView? = v.findViewById(R.id.transactionAmountTextView) as TextView?
val transactionLocationTextView: TextView? = v.findViewById(R.id.transactionLocationTextView) as TextView?
val transactionTimeTextView: TextView? = v.findViewById(R.id.transactionTimeTextView) as TextView?
val transactionDeleteButton: AppCompatButton? = v.findViewById(R.id.transactionDeleteButton) as AppCompatButton?
}
}
Now, I want to implement this library to use instead expandable card views so that I can only show for example the name and amount of transactions, and have the rest be expanded, this library can be found here https://github.com/AleSpero/ExpandableCardView , the library asks me to make a new layout with an inner_view attribute, I have done so, my layout is called transaction_list_expandable_view.xml and it looks like this
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:ignore="ExtraText">
<com.alespero.expandablecardview.ExpandableCardView
android:id="#+id/transaction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:title="testt"
app:inner_view="#layout/transaction_list_inner_view"
app:expandOnClick="true"
app:animationDuration="300"
app:startExpanded="false"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
Now comes the problem, my custom TransactionAdapter only handles one layout, which is the one I called transaction_list_inner_view, how can I have this adapter instead handle both inner and expandable views and get the desired result? (a list of cards with relevant titles that expands to reveal the rest of the details belonging to them)
Sorry for the long question and code, thanks in advance for any help.
After checking the code of the library you're using, i think that you shouldn't be inflating the inner view manually (in your adapter) as that's the responsibility of the ExpandableCardView you should inflate the transaction_list_expandable_view.xml in your adapter.
It would look like :
val view = inflater.inflate(R.layout.transaction_list_expandable_view, p0, false)
For populating your inner view, i'm not sure whether the inner view is already inflated in the expandable one at the time of instantiating the CustomViewHolder.
if it's the case just the switching of inner => expandable views above should do the trick.
Background
In case your RecyclerView gets new items, it is best to use notifyItemRangeInserted, together with unique, stable id for each item, so that it will animate nicely, and without changing what you see too much:
As you can see, the item "0", which is the first on the list, stays on the same spot when I add more items before of it, as if nothing has changed.
The problem
This is a great solution, which will fit for other cases too, when you insert items anywhere else.
However, it doesn't fit all cases. Sometimes, all I get from outside, is : "here's a new list of items, some are new, some are the same, some have updated/removed" .
Because of this, I can't use notifyItemRangeInserted anymore, because I don't have the knowledge of how many were added.
Problem is, if I use notifyDataSetChanged, the scrolling changes, because the amount of items before the current one have changed.
This means that the items that you look at currently will be visually shifted aside:
As you can see now, when I add more items before the first one, they push it down.
I want that the currently viewable items will stay as much as they can, with priority of the one at the top ("0" in this case).
To the user, he won't notice anything above the current items, except for some possible end cases (removed current items and those after, or updated current ones in some way). It would look as if I used notifyItemRangeInserted.
What I've tried
I tried to save the current scroll state or position, and restore it afterward, as shown here, but none of the solutions there had fixed this.
Here's the POC project I've made to try it all:
class MainActivity : AppCompatActivity() {
val listItems = ArrayList<ListItemData>()
var idGenerator = 0L
var dataGenerator = 0
class ListItemData(val data: Int, val id: Long)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val inflater = LayoutInflater.from(this#MainActivity)
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder {
return object : RecyclerView.ViewHolder(inflater.inflate(android.R.layout.simple_list_item_1, parent, false)) {}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) {
val textView = holder!!.itemView as TextView
val item = listItems[position]
textView.text = "item: ${item.data}"
}
override fun getItemId(position: Int): Long = listItems[position].id
override fun getItemCount(): Int = listItems.size
}
adapter.setHasStableIds(true)
recyclerView.adapter = adapter
for (i in 1..30)
listItems.add(ListItemData(dataGenerator++, idGenerator++))
addItemsFromTopButton.setOnClickListener {
for (i in 1..5) {
listItems.add(0, ListItemData(dataGenerator++, idGenerator++))
}
//this is a good insertion, when we know how many items were added
adapter.notifyItemRangeInserted(0, 5)
//this is a bad insertion, when we don't know how many items were added
// adapter.notifyDataSetChanged()
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.user.recyclerviewadditionwithoutscrollingtest.MainActivity">
<Button
android:id="#+id/addItemsFromTopButton" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" android:layout_marginRight="8dp"
android:text="add items to top" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="#+id/recyclerView"/>
<android.support.v7.widget.RecyclerView
android:id="#+id/recyclerView" android:layout_width="0dp" android:layout_height="0dp"
android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" android:layout_marginStart="8dp"
android:layout_marginTop="8dp" android:orientation="vertical"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"/>
</android.support.constraint.ConstraintLayout>
The question
Is it possible to notify the adapter of various changes, yet let it stay on the exact same place?
Items that are viewed currently would stay if they can, or removed/updated as needed.
Of course, the items' ids will stay unique and stable, but sadly the cells size might be different from one another.
EDIT: I've found a partial solution. It works by getting which view is at the top, get its item (saved it inside the viewHolder) and tries to scroll to it. There are multiple issues with this though:
If the item was removed, I will have to somehow scroll to the next one, and so on. I think in the real app, I can manage to do it. Wonder if there is a better way though.
Currently it goes over the list to get the item, but maybe in the real app I can optimize it.
Since it just scrolls to the item, if puts it at the top edge of the RecyclerView, so if you've scrolled a bit to show it partially, it will move a bit:
Here's the new code :
class MainActivity : AppCompatActivity() {
val listItems = ArrayList<ListItemData>()
var idGenerator = 0L
var dataGenerator = 0
class ListItemData(val data: Int, val id: Long)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val adapter = object : RecyclerView.Adapter<ViewHolder>() {
val inflater = LayoutInflater.from(this#MainActivity)
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
return ViewHolder(inflater.inflate(android.R.layout.simple_list_item_1, parent, false))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val textView = holder.itemView as TextView
val item = listItems[position]
textView.text = "item: ${item.data}"
holder.listItem = item
}
override fun getItemId(position: Int): Long = listItems[position].id
override fun getItemCount(): Int = listItems.size
}
adapter.setHasStableIds(true)
recyclerView.adapter = adapter
for (i in 1..30)
listItems.add(ListItemData(dataGenerator++, idGenerator++))
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
addItemsFromTopButton.setOnClickListener {
for (i in 1..5) {
listItems.add(0, ListItemData(dataGenerator++, idGenerator++))
}
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
val holder = recyclerView.findViewHolderForAdapterPosition(firstVisibleItemPosition) as ViewHolder
adapter.notifyDataSetChanged()
val listItemToGoTo = holder.listItem
for (i in 0..listItems.size) {
val cur = listItems[i]
if (listItemToGoTo === cur) {
layoutManager.scrollToPositionWithOffset(i, 0)
break
}
}
//TODO think what to do if the item wasn't found
}
}
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
var listItem: ListItemData? = null
}
}
I would solve this problem using the DiffUtil api. DiffUtil is meant to take in a "before" and "after" list (that can be as similar or as different as you want) and will compute for you the various insertions, removals, etc that you would need to notify the adapter of.
The biggest, and nearly only, challenge in using DiffUtil is in defining your DiffUtil.Callback to use. For your proof-of-concept app, I think things will be quite easy. Please excuse the Java code; I know you posted originally in Kotlin but I'm not nearly as comfortable with Kotlin as I am with Java.
Here's a callback that I think works with your app:
private static class MyCallback extends DiffUtil.Callback {
private List<ListItemData> oldItems;
private List<ListItemData> newItems;
#Override
public int getOldListSize() {
return oldItems.size();
}
#Override
public int getNewListSize() {
return newItems.size();
}
#Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
return oldItems.get(oldItemPosition).id == newItems.get(newItemPosition).id;
}
#Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
return oldItems.get(oldItemPosition).data == newItems.get(newItemPosition).data;
}
}
And here's how you'd use it in your app (in java/kotlin pseudocode):
addItemsFromTopButton.setOnClickListener {
MyCallback callback = new MyCallback();
callback.oldItems = new ArrayList<>(listItems);
// modify listItems however you want... add, delete, shuffle, etc
callback.newItems = new ArrayList<>(listItems);
DiffUtil.calculateDiff(callback).dispatchUpdatesTo(adapter);
}
I made my own little app to test this out: each button press would add 20 items, shuffle the list, and then delete 10 items. Here's what I observed:
When the first visible item in the "before" list also existed in the "after" list...
When there were enough items after it to fill the screen, it stayed in place.
When there were not, the RecyclerView scrolled to the bottom
When the first visible item in the "before" list did not also exist int he "after" list, the RecyclerView would try to keep whichever item that did exist in both "before" + "after" and was closest to the first visible position in the "before" list in the same position, following the same rules as above.