RecyclerView not scrolling top top when new item inserted - android

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?

Related

Why is this recyclerview only shows 5 items?

So this recyclerview somehow only shows 5 items. When I delete the 5th item, the 6th item will comeout, so it seems it can only shows 5 items. Why?
For further information, this recyclerview is using Groupie library. I have used the same code in other activity but this one, each row is much bigger in terms of height because it has more information, does it have to do with the reason why the 6th item cannot be shown? The other recyclerview using groupie only shows name in each row, so it can show up to 7 items so far, only this one, despite using the same code, only shows 5 items maximum.
Following is the recyclerview code:
private fun fetchProducts() {
databaseReferenceProducts = FirebaseDatabase.getInstance().getReference("produk")
databaseReferenceProducts.child(uid).addListenerForSingleValueEvent(object: ValueEventListener {
override fun onDataChange(snapshot: DataSnapshot) {
if (snapshot.exists()) {
binding.kedaiConstraintLayout.setPadding(0,0,0,0)
val adapter = GroupAdapter<GroupieViewHolder>()
snapshot.children.forEach {
val user = it.getValue(DataProduk::class.java)
if (user != null) {
binding.daftarProdukRecyclerView.adapter = adapter
adapter.add(productItems(user))
}
}
binding.daftarProdukRecyclerView.addItemDecoration(
DividerItemDecoration(
context,
DividerItemDecoration.VERTICAL
)
)
}
override fun onCancelled(snapshot: DatabaseError) {
}
})
}
For whoever facing this kind of issue, this is because the RecyclerView is overlapped with ScrollView. Replace it with NestedScrollView instead.

Restoring Scroll Position in Paging Library 3

I am using Paging Library 3 with a RemoteMediator which includes loading data from the network and the local Room database. Every time I scroll to a certain position in the RecyclerView, navigate away to another Fragment, and then navigate back to the Fragment with the list, the scroll state is not preserved and the RecyclerView displays the list from the very first item instead of the position I was at before I navigated away.
I have tried using StateRestorationPolicy with no luck and can't seem to figure out a way to obtain the scroll position of the PagingDataAdapter and restore it to that same exact position when navigating back to the Fragment.
In my ViewModel, I have a Flow that collects data from the RemoteMediator:
val flow = Pager(config = PagingConfig(5), remoteMediator = remoteMediator) {
dao?.getListAsPagingSource()!!
}.flow.cachedIn(viewModelScope)
and I am submitting that data to the adapter within my Fragment:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
adapter?.submitData(pagingData)
}
}
At the top of the Fragment, my adapter is listed as:
class MyFragment : Fragment() {
...
private var adapter: FeedAdapter? = null
...
override onViewCreated(...) {
if (adapter == null) {
adapter = FeedAdapter(...)
}
recyclerView.adapter = adapter
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collectLatest { pagingData ->
adapter?.submitData(pagingData)
}
}
}
}
How can we make sure that the adapter shows the list exactly where it was at before the user left the Fragment upon returning instead of starting the list over at the very first position?
Do this in your fragment's onViewCreated:
viewLifecycleOwner.lifecycleScope.launch {
viewModel.flow.collect { pagingData ->
adapter.submitData(viewLifecycleOwner.lifecycle, pagingData)
}
}
After checking many solutions, this is the method that worked for me cachedIn()
fun getAllData() {
viewModelScope.launch {
_response.value = repository.getPagingData().cachedIn(viewModelScope)
}
}

I want to update my room DB field and also disable swipe in recyclerview item at the same time in Android how to achieve this?

I have a recyclerview where when I swipe left I open a bottom sheet and select one value and then I need to update my room DB table and also disable swipe.
But currently im not able to do both things at the same time, only the disable swipe is working not able to update recyclerview item
swipeController = SwipeController(object:SwipeControllerActions() {
override fun onRightClicked(position:Int) {
val bottomSheetFragment = BottomModalFragment()
bottomSheetFragment.show(parentFragmentManager, bottomSheetFragment.tag)
var homeTaskModel: HomeTaskModel = homeAdapter.getWordAtPosition(position)!!
TASK_STATUS.observe(viewLifecycleOwner, Observer {
homeTaskModel.task_status = it //passing value
homeViewModel.updateSwipeType(1,0) //disable swipe
homeAdapter.notifyItemChanged(position)
})
}
Below is my onbindviewholder code
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val tasks = taskslist[position]
var task__status = tasks.task_status
holder.Taskbinding.taskdata = taskslist[position]
holder.Taskbinding.txtTaskname.text = tasks.taskname
holder.Taskbinding.txtTaskStatus.text = task__status
//hide unhide navigation imageview
if (task__status.equals("Travel To Site"))
holder.Taskbinding.imgNavigation.visibility = View.VISIBLE
else
holder.Taskbinding.imgNavigation.visibility = View.GONE
}
getItemViewType() is called before onBindViewHolder where I am updating my views
override fun getItemViewType(position: Int): Int {
return taskslist.get(position).swipe_type
}
How to acheieve both the scenarios???
create refreshList in adapter class the refresh the list
fun refreshList(list:arrayOf()){
tasklist = list
}
call this function from mainActivity where you want to notify the adapter
homeAdapter.refreshList(pass your updated list here)
thats it!!

RecyclerView sets wrong MotionLayout state for its items

First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.
https://github.com/muetzenflo/SampleRecyclerView
This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.
So the main problem is:
ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.
So here is the problem / What I've found so far:
I am using a RecyclerView.
It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).
This MotionLayout has 2 states, let's call them State big and State small
All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.
But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):
swipe from item 1 to the right to item 2
change from big to small
change back from small to big
swipe from item 2 to the left to item 1
=> item 1 is now in the small state, but should be in the big state
Additional findings:
After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.
Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.
What did I try?
I tried to call notifyDataSetChanged() whenever the transition has completed
I tried keeping a local Set of created ViewHolders to call the transition on them directly
I tried to use data-binding to set the motionProgress to the MotionLayout
I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
I searched this great in-depth article about RVs for hint what to try next
Anyone had this problem and found a good solution?
Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.
class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {
private val viewHolders = ArrayList<RecyclerView.ViewHolder>()
private var direction = Direction.UNDEFINED
fun setMotionProgress(direction: MatchCardViewModel.Direction) {
if (this.direction == direction) return
this.direction = direction
viewHolders.forEach {
updateItemView(it)
}
}
private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
if (viewHolder.adapterPosition >= 0) {
val motionLayout = viewHolder.itemView as MotionLayout
when (direction) {
Direction.TO_END -> motionLayout.transitionToEnd()
Direction.TO_START -> motionLayout.transitionToStart()
Direction.UNDEFINED -> motionLayout.transitionToStart()
}
}
}
override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
val item = getItem(position)
holder.bind(item, clickListener)
val itemView = holder.itemView
if (itemView is MotionLayout) {
if (!viewHolders.contains(holder)) {
viewHolders.add(holder)
}
updateItemView(holder)
}
}
override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
viewHolders.remove(holder)
}
super.onViewRecycled(holder)
}
}
I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.
https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424
Some things that I changed but are not relevant to the solution, but help with finding the problem:
made duration 1sec
more items in recycler view
recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"
Going into details about what made it work better:
all calls to motion layout are done in a post manner
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
Compared actual vs desired properties
val goalProgress =
if (currentState) 1f
else 0f
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
if (motionLayout.currentState != desiredState) {
safeRunBlock {
startTransition(currentState)
}
}
}
This would be the full class of the partial solution
class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
val motionLayout: MotionLayout =
binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
.also {
it.setTransitionDuration(1_000)
it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
}
var lastPosition: Int = -1
fun bind(item: T, position: Int, layoutState: Boolean) {
if (position != lastPosition)
Log.i(
"OnBind",
"Position=$position lastPosition=$lastPosition - $layoutState "
)
lastPosition = position
setMotionLayoutState(layoutState)
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
fun setMotionLayoutState(currentState: Boolean) {
val goalProgress =
if (currentState) 1f
else 0f
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
if (motionLayout.currentState != desiredState) {
Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
safeRunBlock {
startTransition(currentState)
}
}
}
}
fun startTransition(currentState: Boolean) {
if (currentState) {
motionLayout.transitionToStart()
} else {
motionLayout.transitionToEnd()
}
}
}
Edit: added constraint layout version

RecyclerView - Stay on 0 position after notifiyItemInserted(0)

I have a chat in my application and I want my RV stay on position 0 when the new message comes and the first visible item position is 0, but new messages are added above the currently visible one, but the visible items stay in view so user has to scroll to see new message.
My adapter was extended from RecyclerView.Adapter<RecyclerView.ViewHolder> and I found a little hacky solution to get it work:
private suspend fun update(newItems: List<IChatItem>) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
val diffResult = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(Diff(this.currentItems, newItems))
}
diffResult.dispatchUpdatesTo(this)
recyclerViewState?.let {
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
}
The thing is saving RV state and after DiffUtil completes it's work, restore it. It did work, but now I'm trying to implement new adapter and extend my adapter class from ListAdapter because it launches DiffUtil in another thread. It works good, but the problem is I can't implement my previous solution here, it just doesn't work.
I tried following:
override fun submitList(list: MutableList<IChatItem>?) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
super.submitList(list)
recyclerViewState?.let {
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}
}
But this doesn't work anymore. Does anybody have a solution?
PS: I found this question - Inserting RecyclerView items at zero position - always stay scrolled to top, but I don't think this is the best solution to add empty invisible items in adapter's list. Any other ideas?
UPD: as I thought - because this adapter launches DiffUtil in another thread, it restored RV state before DiffUtil completes it's job. This code works:
override fun submitList(list: List<IChatItem>?) {
var recyclerViewState: Parcelable? = null
if ((recyclerView?.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() == 0) {
recyclerViewState = recyclerView?.layoutManager?.onSaveInstanceState()
}
super.submitList(list)
recyclerViewState?.let {
Handler().postDelayed(
{
recyclerView?.layoutManager?.onRestoreInstanceState(recyclerViewState)
}, 1000
)
}
}
But ofc this is not what I want. Is there any way to detect when DiffUtil is done with everything?
You can use RecyclerView.AdapterDataObserver() and handle what you need.
Create your listener class that implement RecyclerView.AdapterDataObserver()
and in your adapter constructor call registerAdapterDataObserver(your listener)
and then handle your callbacks.
See RecyclerView.AdapterDataObserver

Categories

Resources