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
Related
I've created an adapter (extending ListAdapter with DiffUtil.ItemCallback) for my RecyclerView. It's an ordinary adapter with several itemViewTypes, but it should be smth like cyclic, if API sends flag and dataset size is > 1 (made by overriding getItemCount() to return 1000 when conditions == true).
When I change app locale through app settings, my fragment recreates, data loads asynchronously (reactively, several times in a row, from different requests, depending on several rx fields, which causes data set to be a combination of data on different languages just after locale is changed (in the end all dataset is correctly translated btw) (make it more like synchronous is not possible because of feature specifics)), posting its values to LiveData, which triggers updates of recycler view, the problem appears:
After last data set update some of the views (nearest to currently displayed and currently displayed) appear not to be translated.
Final data set, which is posted to LiveData is translated correctly, it even has correct locale tag in its id. Also after views are recycled and we return back to them - they are also correct.
DiffUtil is computed correctly also (I've tried to return only false in item callbacks and recycler view still didn't update its view holders correctly).
When itemCount == list.size everything works fine.
When adapter is pretending to be cyclic and itemCount == 1000 - no.
Can somebody explain this behaviour and help to figure out how to solve this?
Adapter Code Sample:
private const val TYPE_0 = 0
private const val TYPE_1 = 1
class CyclicAdapter(
val onClickedCallback: (id: String) -> Unit,
val onCloseClickedCallback: (id: String) -> Unit,
) : ListAdapter<IViewData, RecyclerView.ViewHolder>(DataDiffCallback()) {
var isCyclic: Boolean = false
set(value) {
if (field != value) {
field = value
}
}
override fun getItemCount(): Int {
return if (isCyclic) {
AdapterUtils.MAX_ITEMS // 1000
} else {
currentList.size
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
TYPE_0 -> Type0.from(parent)
TYPE_1 -> Type1.from(parent)
else -> throw ClassCastException("View Holder for ${viewType} is not specified")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is Type0 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type0
holder.setData(item, onClickedCallback)
}
is Type1 -> {
val item = getItem(
AdapterUtils.actualPosition(
position,
currentList.size
)
) as ViewData.Type1
holder.setData(item, onClickedCallback, onCloseClickedCallback)
}
}
}
override fun getItemViewType(position: Int): Int {
return when (val item = getItem(AdapterUtils.actualPosition(position, currentList.size))) {
is ViewData.Type0 -> TYPE_0
is ViewData.Type1 -> TYPE_1
else -> throw ClassCastException("View Type for ${item.javaClass} is not specified")
}
}
class Type0 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type0,
onClickedCallback: (id: String) -> Unit
) {
(itemView as Type0View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id,)
}
}
}
companion object {
fun from(parent: ViewGroup): Type0 {
val view = Type0View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type0(view)
}
}
}
class Type1 private constructor(itemView: View) :
RecyclerView.ViewHolder(itemView) {
fun setData(
viewData: ViewData.Type1,
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
(itemView as Type1View).apply {
acceptData(viewData)
setOnClickedCallback { url ->
onClickedCallback(viewData.id)
}
setOnCloseClickedCallback(onCloseClickedCallback)
}
}
companion object {
fun from(parent: ViewGroup): Type1 {
val view = Type1View(parent.context).apply {
layoutParams =
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
}
return Type1(view)
}
}
}
}
ViewPager Code Sample:
class CyclicViewPager #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr),
ICyclicViewPager {
private val cyclicViewPager: ViewPager2
private lateinit var onClickedCallback: (id: String) -> Unit
private lateinit var onCloseClickedCallback: (id: String) -> Unit
private lateinit var adapter: CyclicAdapter
init {
LayoutInflater
.from(context)
.inflate(R.layout.v_cyclic_view_pager, this, true)
cyclicViewPager = findViewById(R.id.cyclic_view_pager)
(cyclicViewPager.getChildAt(0) as RecyclerView).apply {
addItemDecoration(SpacingDecorator().apply {
dpBetweenItems = 12
})
clipToPadding = false
clipChildren = false
overScrollMode = RecyclerView.OVER_SCROLL_NEVER
}
cyclicViewPager.offscreenPageLimit = 3
}
override fun initialize(
onClickedCallback: (id: String) -> Unit,
onCloseClickedCallback: (id: String) -> Unit
) {
this.onClickedCallback = onClickedCallback
this.onCloseClickedCallback = onCloseClickedCallback
adapter = CyclicAdapter(
onClickedCallback,
onCloseClickedCallback,
).apply {
stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
cyclicViewPager.adapter = adapter
}
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
cyclicViewPager.post {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
is CyclicViewPagerState.CyclicityState.Disabled -> {
if (viewPagerState.pages.size == 1 && adapter.isCyclic) {
cyclicViewPager.setCurrentItem(0, false)
adapter.isCyclic = false
}
adapter.submitList(viewPagerState.pages)
}
}
}
}
Adapter Utils Code:
object AdapterUtils {
const val MAX_ITEMS = 1000
fun actualPosition(position: Int, listSize: Int): Int {
return if (listSize == 0) {
0
} else {
(position + listSize) % listSize
}
}
fun getCyclicInitialPosition(listSize: Int): Int {
return if (listSize > 0) {
MAX_ITEMS / 2 - ((MAX_ITEMS / 2) % listSize)
} else {
0
}
}
}
Have tried not to use default itemView variable of RecyclerView (became even worse).
Tried to make diff utils always return false, to check if it calculates diff correctly (yes, correctly)
Tried to add locale tags to ids of data set items (didn't help to solve)
Tried to post empty dataset on locale change before setting new data to it (shame on me, shouldn't even think about it)
Tried do add debounce to rx to make it wait a bit before update (didn't help)
UPD: When I call adapter.notifyDatasetChanged() manually, which is not the preferred way, everything works fine, so the question is why ListAdapter doesn't dispatch notify callbacks properly in my case?
The issue with ListAdapter is that it doesn't clearly state that you need to supply a new list for it to function.
In other words, the documentation says: (and I quote the source code):
/**
* Submits a new list to be diffed, and displayed.
* <p>
* If a list is already being displayed, a diff will be computed on a background thread, which
* will dispatch Adapter.notifyItem events on the main thread.
*
* #param list The new list to be displayed.
*/
public void submitList(#Nullable List<T> list) {
mDiffer.submitList(list);
}
The key word being new list.
However, as you can see there, all the adapter does is defer to the DiffUtil and calls submitList there.
So when you look at the actual source code of the AsyncListDiffer you will notice it does, at the beginning of its code block:
if (newList == mList) {
// nothing to do (Note - still had to inc generation, since may have ongoing work)
if (commitCallback != null) {
commitCallback.run();
}
return;
}
In other words, if the new list (reference) is the same as the old one, regardless of their contents, don't do anything.
This may sound cool but it means that if you have this code, the adapter will not really update:
(pseudo...)
var list1 = mutableListOf(...)
adapter.submitList(list1)
list1.add(...)
adapter.submitList(list1)
The reason is list1 is the same reference your adapter has, so the differ exits prematurely, and doesn't dispatch any changes to the adapter.
Quite obscure, I know.
The solution, as pointed in many SO answers is to create a copy of the list itself.
Most users do
var list1 = mutableListOf(...)
adapter.submitList(list1)
var list2 = list1.toMutableList()
list2.add(...)
adapter.submitList(list2)
The call to toMutableList() creates a new list containing the items of list1 and so the comparison above if (newList == mList) { should now be false and the normal code should execute.
UPDATE
Keep in mind that a lot of developers make the mistake of...
var list = mutableListOf...
adapter.submitList(list)
list.add(xxx)
adapter.submitList(list.toList())
This doesn't work, because the new list you create, is referencing the same objects the adapter has. This means that both lists list and list.toList() are pointing to the same things despite being two instances of an ArrayList.
But the side-effect is that DiffUtil compares the items and they are the same, so no diff is dispatched to the adapter either.
The correct sequence is...
val list = mutableListOf(...)
adapter.submitList(list.toList())
// Make a copy first, so we can alter it as we please without the *current list held by the adapter* from being affected.
var modified = list.toMutableList()
modified.add(...)
adapter.submitList(modified)
After taking a look at your sample in GitHub, I was able to reproduce the issue. With only about 30-40 minutes of playing with it, I can say that I'm not 100% sure what component is not updating.
Things I've noticed.
The onBindViewHolder method is not called when you change the locale (except maybe the 1st time?).
I do not understand why the need to post to the adapter after you've submitted the list in the callback:
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
Why ? This means the user loses their current position.
Why not keep the existing?
I noticed you do cyclicViewPager.offscreenPageLimit = 3 this effectively disables the RecyclerView "logic" for handling changes, and uses instead the usual ViewPager state adapter logic of "prefetching/keeping" N (3 in your case) pages in "advance".
At first I thought this was causing issues, but removing it (which sets it to -1 which is the default and the "use RecyclerView" value, didn't make a big change (though I did notice some changes here and there, as in it would sometimes update the current one -but not the next ones within 2~3 pages).
The documentation says:
Set the number of pages that should be retained to either side of the currently visible page(s). Pages beyond this limit will be recreated from the adapter when needed. Set this to OFFSCREEN_PAGE_LIMIT_DEFAULT to use RecyclerView's caching strategy.
So I would have imagined that the default value would be aided by the ListAdapter and its DiffUtil. Doesn't seem to be the case.
What I did try (among a few other things) was to see if the issue was in the actual adapter (or at least the viewPager dependency on its adapter). I ran out of time (work!) but I noticed that if you do:
override fun setState(viewPagerState: CyclicViewPagerState) {
when (viewPagerState.cyclicityState) {
is CyclicViewPagerState.CyclicityState.Enabled -> {
// call initialize again, to recreate the adapter
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
// Setting vp item to ... (code omitted for brevity)
}
This works. It's theoretically less efficient as you're recreating the whole adapter, but in your example you're effectively creating an ENTIRE new set of data changing every ID, so in terms of performance, I'd argue this is more efficient as there's no need to recalculate changes and dispatch them, since to the eyes of the Diff Util, all the rows are different. By recreating the adapter, well... the VP has to reinit anyway.
I noticed this worked fine in your example.
I went ahead and added two more things, because the "silly" adapter cannot reliably tell you which position is the current... you can naively save it:
In CyclicViewPager:
var currentPos: Int = 0
init {
...
this.cyclicViewPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int)
currentPos = position
}
})
}
And then
is CyclicViewPagerState.CyclicityState.Enabled -> {
initialize(this.onClickedCallback, this.onCloseClickedCallback)
adapter.submitList(viewPagerState.pages) {
adapter.isCyclic = true
if (adapter.currentList.size <= currentPos) {
cyclicViewPager.setCurrentItem(currentPos, false)
} else {
cyclicViewPager.setCurrentItem(
// Setting view pager item to +- 500
AdapterUtils.getCyclicInitialPosition(
adapter.currentList.size
), false
)
}
}
}
This does work, but of course, you're recreating the entire VP adapter again, so it may not be desired.
At this point, I'd either need to spend much more time trying to figure out which part of VP, RV, or its dependencies is not "dispatching" the correct data. My guess would be somewhere around some silly ViewPager optimization combined with Android terribly unreliable View system, not picking a message in the queue; but I may be also terribly wrong ;)
I hope someone smarter and/or with more coffee in their system can find out a simpler solution.
(all in all, I found the sample project relatively easy to navigate, but the design of your data a bit convoluted, but... as it was a sample, it's hard to tell what "real-life" data structures you really have).
I try to create a notification overlay inside my application that can show notifications about certain application wise important events.
I decided to use a RecyclerView which will be drown directly on WindowManager.
This works fine for showing initial items, however the the items don't get updated.
Below is my implementation. So when I call start the not1 and not2 are shown, but when removeNotification function get called, the notification is actually being removed and a correct list is being submitted to the adapter, but the view on screen does not update.
However if I add windowManager.updateViewLayout(recyclerView, layoutParams) after submitList inside removeNotification, everything seems to work as expected, but this time I am loosing RV animations..
As this is the first time I work with WindowManager directly, I am quite confused. Can someone help me to figure out what's going on and how can I achieve what I want to, if only that's possible.
class NotificationOverlay(private val context: Context) {
private val windowManager: WindowManager =
context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private val layoutParams = WindowManager.LayoutParams().apply {
gravity = android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL
width = WindowManager.LayoutParams.MATCH_PARENT
height = WindowManager.LayoutParams.WRAP_CONTENT
format = PixelFormat.TRANSLUCENT
dimAmount = 0.5f
flags = WindowManager.LayoutParams.FLAG_DIM_BEHIND
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
}
private val notifications = mutableListOf<NotificationItem>().also {
it.addAll(listOf(
NotificationItem(title = "not 1", message = "first notification"),
NotificationItem(title = "not 2", message = "second notification")
))
}
private val notificationsAdapter = NotificationAdapter(::removeNotification)
private val recyclerView = RecyclerView(context).apply {
layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
adapter = notificationsAdapter
}
private fun removeNotification(notification: NotificationItem){
notifications.remove(notification)
notificationsAdapter.submitList(notifications)
if(notificationsAdapter.currentList.isEmpty()){
windowManager.removeView(recyclerView)
}
}
fun show(){
windowManager.addView(recyclerView, layoutParams)
notificationsAdapter.submitList(notifications)
}
}
Edited
Well, I found out that the issue is not in WindowManager but rather in DiffUtils, but cannot understand what's wrong with it, as it is very simple one, and I implemented such DiffUtils a lot of times, anyways, I'll post the code here if you could have any idea on why this does not work:
class NotificationAdapter(private val onCloseClicked: (NotificationItem) -> Unit):
ListAdapter<NotificationItem, NotificationAdapter.NotificationViewHolder>(DiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationViewHolder {
val binding = ItemNotificationOverlayBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return NotificationViewHolder(binding, onCloseClicked)
}
override fun onBindViewHolder(holder: NotificationViewHolder, position: Int) {
holder.bind(currentList[position])
}
class NotificationViewHolder(private val itemBinding: ItemNotificationOverlayBinding, private val onCloseClicked: (NotificationItem) -> Unit): RecyclerView.ViewHolder(itemBinding.root) {
fun bind(item: NotificationItem) {
itemBinding.title.text = item.title
itemBinding.message.text = item.message
itemBinding.close.setOnClickListener {
onCloseClicked.invoke(item)
}
}
}
class DiffCallback : DiffUtil.ItemCallback<NotificationItem>() {
override fun areItemsTheSame(oldItem: NotificationItem, newItem: NotificationItem) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: NotificationItem, newItem: NotificationItem) =
oldItem == newItem
}
}
What can every be wrong in such a simple construction? I am going crazy already and want to throw away this DiffUtils and implement the old school notifyItemRemoved
Edited 2
So the answer offered by #IamFr0ssT fixed the issue. I dig a bit deeper to see why this happens and the reason is in androidx.recyclerview.widget.AsyncListDiffer class in main submitList function. It is doing the following check there:
if (newList == mList) {
// nothing to do (Note - still had to inc generation, since may have ongoing work)
if (commitCallback != null) {
commitCallback.run();
}
return;
}
so my diff was never being even tried to be calculated as I was submitting the same reference of the list.
what the additional toList() function suggested by #IamFr0ssT did, is created a new instance of the list thus making the differ to calculate my diff. If you go deeper inside toList() function, it eventually creates a new instance of an ArrayList based on provided list ...return ArrayList(this)
So well, this issue had nothing to do with WindowManager just the DiffUtil
You are passing the MutableList notifications to the adapter, and the adapter is not making a copy of the list, it is just using the same list.
When you edit the list in your removeNotification callback, you are editing the list that the adapter is using.
Because of that, when the diff is being calculated, it is comparing the list that it thinks is currently displayed, but is not, to itself. Thus no diff and no notifyItemRemoved or other events.
What you can do to fix it, I think, is just call .toList() on the mutable list when you call submitList():
class NotificationOverlay(private val context: Context) {
...
private fun removeNotification(notification: NotificationItem){
notifications.remove(notification)
notificationsAdapter.submitList(notifications.toList())
if(notificationsAdapter.currentList.isEmpty()){
windowManager.removeView(recyclerView)
}
}
fun show(){
windowManager.addView(recyclerView, layoutParams)
notificationsAdapter.submitList(notifications.toList())
}
}
Also, how do you get NotificationItem.id? It should be different for each entry.
Um.. I think you should try to manually notify your RecyclerView to redraw with notifyDataSetChanged()
If it doesn't work ListAdapter you are using as adapter does diff computation and dispatches the result to the RecyclerView. Maybe diff is not correct and the adapter does not see a difference between the old list and the new one - in this case it won't update UI. You may try to check diff behaviour and maybe comparing behavior of your data to change it.
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 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?
The code above is the RecyclerViewAdapter, which changes color only when it is the first item, as shown below.
class TestAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val textColor1 = Color.BLACK
private val textColor2 = Color.YELLOW
private val items = ArrayList<String>()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val textColor = if(position==0) textColor1 else textColor2
holder.itemView.textView.setTextColor(textColor)
holder.itemView.textView.text = items[position]
}
fun move(from:Int,to:Int){
val item = items[from]
items.remove(item)
items.add(to,item)
notifyItemMoved(from,to)
}
}
In this state I would like to move Value 3 to the first position using the move function. The results I want are shown below.
But in fact, it shows the following results
When using notifyDataSetChanged, I can not see the animation transition effect,
Running the onBindViewHolder manually using findViewHolderForAdapterPosition results in what I wanted, but it is very unstable. (Causing other parts of the error that I did not fix)
fun move(from:Int,to:Int){
val item = items[from]
val originTopHolder = recyclerView.findViewHolderForAdapterPosition(0)
val afterTopHolder = recyclerView.findViewHolderForAdapterPosition(from)
items.remove(item)
items.add(to,item)
notifyItemMoved(from,to)
if(to==0){
onBindViewHolder(originTopHolder,1)
onBindViewHolder(afterTopHolder,0)
}
}
Is there any other way to solve this?
Using the various notifyItemFoo() methods, like moved/inserted/removed, doesn't re-bind views. This is by design. You could call
if (from == 0 || to == 0) {
notifyItemChanged(from, Boolean.FALSE);
notifyItemChanged(to, Boolean.FALSE);
}
in order to re-bind the views that moved.
notifyItemMoved will not update it. According to documentation:
https://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter
This is a structural change event. Representations of other existing items in the data set are still considered up to date and will not be rebound, though their positions may be altered.
What you're seeing is expected.
Might want to look into using notifyItemChanged, or dig through the documentation and see what works best for you.