FlexibleAdapter
FlexibleAdapter copied to clipboard
ClassCastException after updateDataSet with StickyHeaders
I'm having an issue when I update the adapter's data set (via updateDataSet()
), but only when sticky headers are enabled, AND a new header is introduced as part of the update, AND a scroll occurs, such that previously sticky header's position changes. I'm not using expandable headers. In addition, it's very hard to reproduce. It can take hours, but eventually it will occur. Here's the stack:
java.lang.ClassCastException: com.myapp.MyHeaderItem$ViewHolder cannot be cast to com.myapp.MySectionedItem$ViewHolder
at com.myapp.MySectionedItem.unbindViewHolder(AbstractSegmentItem.java:61)
at eu.davidea.flexibleadapter.FlexibleAdapter.onViewRecycled(FlexibleAdapter.java:1816)
at eu.davidea.flexibleadapter.helpers.StickyHeaderHelper.swapHeader(StickyHeaderHelper.java:245)
at eu.davidea.flexibleadapter.helpers.StickyHeaderHelper.updateHeader(StickyHeaderHelper.java:162)
at eu.davidea.flexibleadapter.helpers.StickyHeaderHelper.updateOrClearHeader(StickyHeaderHelper.java:138)
at eu.davidea.flexibleadapter.helpers.StickyHeaderHelper.onScrolled(StickyHeaderHelper.java:64)
at androidx.recyclerview.widget.RecyclerView.dispatchOnScrolled(RecyclerView.java:4961)
at androidx.recyclerview.widget.RecyclerView.dispatchLayoutStep3(RecyclerView.java:4021)
at androidx.recyclerview.widget.RecyclerView.dispatchLayout(RecyclerView.java:3652)
at androidx.recyclerview.widget.RecyclerView.consumePendingUpdateOperations(RecyclerView.java:1888)
at androidx.recyclerview.widget.RecyclerView$1.run(RecyclerView.java:407)
...
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:767)
at android.view.Choreographer.doCallbacks(Choreographer.java:580)
at android.view.Choreographer.doFrame(Choreographer.java:549)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:753)
at android.os.Handler.handleCallback(Handler.java:739)
at android.os.Handler.dispatchMessage(Handler.java:95)
at android.os.Looper.loop(Looper.java:135)
at android.app.ActivityThread.main(ActivityThread.java:5343)
at java.lang.reflect.Method.invoke(Native Method)
at java.lang.reflect.Method.invoke(Method.java:372)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:905)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:700)
As you can see, when the StickyHeaderHelper
manually recycles its sticky header (normally via its scroll handler, as it scrolls out of the view), it thinks its item is a MySectionedItem
instance (i.e. a subclass of AbstractSectionableItem
, not AbstractHeaderItem
. So the ViewHolder
passed to AbstractSectionableItem#unbindViewHolder()
is that of the header.
As far as I can tell (I'm in no way sure), this is because the StickyHeaderHelper
keeps a reference to the ViewHolder
for the stuck item, so when the data set is updated, and the time comes to swap out them item (i.e. recycle it), and it requests its position in the adapter (via FlexibleAdapter#onViewRecycled()
's call to RecyclerView.ViewHolder#getAdapterPosition()
) it receives the position it was at before the update, which is still a valid index, but no longer referencing the header's actual position in the new data set.
I should also add that my data set is unusual in that its updates tend to be at the head of the list, so new headers tend to be created at index 0, thus offsetting all items after it. If headers were to be created at the end of the list it would be less likely to occur.
As I say, I'm not 100% sure my explanation is correct, but it's the best I can think of. It might also be related to FlexibleAdapter.AdapterDataObserver#updateStickyHeader()
's delayed call to StickyHeaderHelper#updateOrClearHeader()
, which might explain why it doesn't happen every time.
I've checked that my item layout ids are unique, and that Object#equals
works OK. I think it might be related to issues #568 and #575. Any help (or just a workaround) would be greatly appreciated.
@technicalflaw, I don't have time to analyse all this. Can you debug it?
Are you scrolling down I guess, so the sticky header will disappear at top, because the view is indeed recycled manually.
Please check if the position at line 1814
in FlexibleAdapter.onViewRecycled()
is a valid one and belongs to the expected Item and ViewHolder. Which is the position of the sticky header and which the position found?
Sorry for the delay. I've had another look, and whilst I've 'fixed' it I don't know if it's fixing a problem I caused, or something else. Anyway, the main issue seems to be FlexibleAdapter.onViewRecycled()
referencing the adapter position of items that have moved, or been deleted, depending on when it is called. If it's called from StickyHeaderHelper
when it manually recycles its ViewHolder
it's very likely to be wrong, because that class doesn't listen for data change events, but I've also had it happen via delayed recycling after a layout (see RecyclerView#mUpdateChildViewsRunnable
).
That said, here's how I've worked around it.
- Change
FlexibleAdapter.onViewRecycled()
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
super.onViewRecycled(holder);
if (areHeadersSticky()) {
// #297 - Empty (Invisible) Header Item when Using Sticky Headers
holder.itemView.setVisibility(View.VISIBLE);
}
int position = holder.getAdapterPosition();
// the only public way to check for private 'FLAG_REMOVED' set on holder
if (position != RecyclerView.NO_POSITION) {
// not sure of the logic for this check, but it seems to work
if (holder.getOldPosition() != RecyclerView.NO_POSITION) {
position = holder.getLayoutPosition();
}
T item = getItem(position);
if (item != null) {
item.unbindViewHolder(this, holder, position);
}
}
}
- Remove view recycling from
StickyHeaderHelper.swapHeader()
. Unless the position is fixed up in themStickyHeaderViewHolder
when an update occurs it will always point to the old item. Personally I don't useunbindViewHolder()
for headers, so it doesn't matter for me.
// if (mHeaderPosition > oldHeaderPosition) {
// mAdapter.onViewRecycled(mStickyHeaderViewHolder);
// }
- Make
FlexibleAdapter.AdapterDataObserver.updateStickyHeader()
update after layout, rather than an arbitrary delay (plus fix for fixed size translation).
private void updateStickyHeader(int positionStart) {
int stickyPosition = getStickyPosition();
// #499 - Bulk operation properly updates the same sticky header once, while each
// independent operation (multiple events) updates the sticky header multiple times.
if (stickyPosition >= 0 && stickyPosition == positionStart) {
log.d("updateStickyHeader position=%s", stickyPosition);
// this is needed to translate initial sticky header correctly, when state restoration scrolls the list
if (mRecyclerView.hasFixedSize() && !mRecyclerView.isLayoutRequested()) {
mRecyclerView.requestLayout();
}
// #320 - To include adapter changes just notified we need a new layout pass:
// We must give time to LayoutManager otherwise the findFirstVisibleItemPosition()
// will return wrong position!
if (!mIsLayoutChangeListenerAdded) {
mRecyclerView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
v.removeOnLayoutChangeListener(this);
mIsLayoutChangeListenerAdded = false;
if (areHeadersSticky()) {
mStickyHeaderHelper.updateOrClearHeader(true);
}
}
});
mIsLayoutChangeListenerAdded = true;
}
}
}
This will need a lot of testing (a previous solution seemed to work for a couple of days, but ultimately failed), and I'm still not sure if it's right. So far, so good, however.
@technicalflaw thank you. I should have a look, but I do not know when: I have important nicely events in my life now :-D
PS. The ideal is to get over of this solution and develop a custom LayoutManager (solution more complicated but correct).