IndexOutOfBoundsException when adding items to specific index through LiveData observer - java

I'm working on a ViewPager fragment that uses FragmentStatePagerAdapter as the adapter. The Adapter's data is a List object that comes from the database, and I am observing the query with MediatorLiveData.
MediatorLiveData merges six different lists that I have for one day and turns them into one list that is used by the Adapter.
I want to add an item to the List to a specific index and update the UI dynamically. The observer notifies the adapter when an item is added and the update works fine, however when I try to do it on a specific index, calling notifyDataSetChanged() causes an IndexOutOfBoundsError.
I initialize the adapter with a list as follows:
public MealDetailsPagerAdapter(FragmentManager fm, List<Long> list, long date, MealViewModel vm) {
super(fm);
this.mealIdList = list;
this.date = date;
this.model = vm;
}
Where MealViewModel is not relevant to this question, it is used on the fragment that the adapter is creating. The list changes are done by another viewmodel.
Here's a code that works correctly:
public void changeItems(List<Long> list, long date) {
if(this.date != date){
this.mealIdList = list;
notifyDataSetChanged();
return;
}
mealIdList.addAll(list);
mealIdList = removeDuplicates(mealIdList);
System.out.println(Arrays.toString(mealIdList.toArray()));
notifyDataSetChanged();
}
And the observer that calls it:
#Override
public void onActivityCreated(#Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mViewModel.getMealIdsInDay().observe(getViewLifecycleOwner(), longs -> {
myAdapter.changeItems(longs, mCurrentIndexDay);
if(isResumed){
mViewPager.setCurrentItem(myAdapter.getPageIndexForMealId(mViewModel.getMealId()));
isResumed=false;
}
updateIndicator(mViewPager);
});
}
Where isResumed is false by default, however if the user adds a new Meal isResumed is changed to true and the viewPager's current position gets changed to the created Meal's position.
However, with the working code, the created Meal's position will always be at the end of the adapter's List because of addAll(). I want to add the meal to a specific position, but if I get the index with mViewPager.getCurrentItem() and send it to the method as follows:
mealIdList.addAll(index, list);
the addAll itself works, but notifyDataSetChanged() causes an IndexOutOfBoundsError.
Here's the complete stack trace:
E/AndroidRuntime: FATAL EXCEPTION: main
Process: fi.seehowyoueat.shye.debug, PID: 14148
java.lang.IndexOutOfBoundsException: Index: 2, Size: 2
at java.util.ArrayList.set(ArrayList.java:453)
at androidx.fragment.app.FragmentStatePagerAdapter.destroyItem(FragmentStatePagerAdapter.java:147)
at androidx.viewpager.widget.ViewPager.populate(ViewPager.java:1212)
at androidx.viewpager.widget.ViewPager.setCurrentItemInternal(ViewPager.java:669)
at androidx.viewpager.widget.ViewPager.setCurrentItemInternal(ViewPager.java:631)
at androidx.viewpager.widget.ViewPager.dataSetChanged(ViewPager.java:1086)
at androidx.viewpager.widget.ViewPager$PagerObserver.onChanged(ViewPager.java:3097)
at androidx.viewpager.widget.PagerAdapter.notifyDataSetChanged(PagerAdapter.java:291)
at fi.octo3.shye.view.viewpagers.MealDetailsPagerAdapter.changeTheItems(MealDetailsPagerAdapter.java:87)
at fi.octo3.shye.fragments.MealDetailsFragment.lambda$onActivityCreated$0(MealDetailsFragment.java:223)
at fi.octo3.shye.fragments.-$$Lambda$MealDetailsFragment$XB4Svnx84FE6kVa5Gzle01e8F3o.onChanged(Unknown Source:4)
at androidx.lifecycle.LiveData.considerNotify(LiveData.java:131)
at androidx.lifecycle.LiveData.dispatchingValue(LiveData.java:149)
at androidx.lifecycle.LiveData.setValue(LiveData.java:307)
at androidx.lifecycle.MutableLiveData.setValue(MutableLiveData.java:50)
at fi.octo3.shye.models.viewmodel.-$$Lambda$2CouvY7DQv4NA0nk6EMoH6jUavw.onChanged(Unknown Source:4)
at androidx.lifecycle.MediatorLiveData$Source.onChanged(MediatorLiveData.java:152)
at androidx.lifecycle.LiveData.considerNotify(LiveData.java:131)
at androidx.lifecycle.LiveData.dispatchingValue(LiveData.java:149)
at androidx.lifecycle.LiveData.setValue(LiveData.java:307)
at androidx.lifecycle.LiveData$1.run(LiveData.java:91)
at android.os.Handler.handleCallback(Handler.java:873)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:280)
at android.app.ActivityThread.main(ActivityThread.java:6748)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)
Looking at it, it seems that the problem is somehow caused by the LiveData, but I'm not sure how to fix it. If you want to look at the method that updates the MediatorLiveData that I have it's here:
private void refresh(long key){
if(groupsAndMealsMap.get(key) != null){
//remove source and then value from map
liveDataMerger.removeSource(groupsAndMealsMap.get(key));
groupsAndMealsMap.remove(key);
//add new value to map and add it as a source
groupsAndMealsMap.append(key, mealIdsInGroup);
liveDataMerger.addSource(groupsAndMealsMap.get(key), liveDataMerger::setValue);
}
}
refresh() is called by addItem() that gets an updated List of Meals from the database (list being mealIdsInGroup) and liveDataMerger consists of six LiveData> objects.
Any help will be appreciated. Thank you!
EDIT
Here's the addItem() method, as you can see the service executor waits for the operation to be done before moving on to the refresh() method.
public void addItem(Meal meal, long mealGroupId){
ExecutorService service = Executors.newCachedThreadPool();
service.execute(() -> {
setMealId(db.mealDao().insertMealIntoGroup(meal, mealGroupId));
mealIdsInGroup = db.mealDao().loadMealsWithinGroup(mealGroupId);
});
service.shutdown();
try {
service.awaitTermination(Long.MAX_VALUE, TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
refresh(mealGroupId);
}

I suspect that the issue occurs because the insert operation is not over yet.
Use a Handler to put a delay
new Handler(getMainLooper()).postDelayed(new Runnable() {
#Override
public void run() {
notifyDataSetChanged();
}
}, 500);
500 ms just for testing.

Does that issue occurs no matter of what position you're in?
Because if you are in position 0 of the adapter, the adapter didn't created the fragment that's in 3rd position yet.
Remember that the one of the main differences between ViewPagerAdapter and FragmentStatePagerAdapter is that the latter only creates 3 fragments at a time, and if you are in the first position or in the last, the adapter will only contain 2 instances of fragments.
Let me know if this solution helped you!

Related

How can I avoid "Inconsistency detected. Invalid item position" when updating Realm from separate thread?

UPDATE:
I see the same error ("Inconsistency detected. Invalid view holder adapter position") in another situation - this time when bulk adding.
The situation is I am implementing a nested recyclerview, each of which uses a RealmRecyclerViewAdapter and each has an OrderedRealmCollection as its basis. The result I'm going after is this:
I have implemented this at the first level by a query for distinct items in my realm keyed off of year and month:
OrderedRealmCollection<Media> monthMedias = InTouchDataMgr.get().getDistinctMedias(null,new String[]{Media.MEDIA_SELECT_YEAR,Media.MEDIA_SELECT_MONTH});
This gives me one entry for July, one for August, of 2019 in this example.
Then for each ViewHolder in that list, during the bind phase I make another query to determine how many Media items are in each month for that year:
void bindItem(Media media) {
this.media = media;
// Get all the images associated with the year in that date, set adapter in recyclerview
OrderedRealmCollection<Media> medias = InTouchDataMgr.get().getAllMediasForYearAndMonth(null, media.getYear(), media.getMonth());
// This adapter loads the CardView's recyclerView with a StaggeredGridLayoutManager
int minSize = Math.min(MAX_THUMBNAILS_PER_MONTH_CARDVIEW, medias.size());
imageRecyclerView.setLayoutManager(new StaggeredGridLayoutManager(minSize >= 3 ? 3 : Math.max(minSize, 1), LinearLayoutManager.VERTICAL));
imageRecyclerView.setAdapter(new RealmCardViewMediaAdapter(medias, MAX_THUMBNAILS_PER_MONTH_CARDVIEW));
}
At this point I have the single month which is bound to the first ViewHolder, and now I have the count of media for that month, and I want to cause this ViewHolder to display a sampling of those items (maximum of MAX_THUMBNAILS_PER_MONTH_CARDVIEW which is initialized as 5) with the full count shown in the header.
So I pass the full OrderedRealmCollection of that media to the "second level" adapter that handles the list for this CardView.
That adapter looks like this:
private class RealmCardViewMediaAdapter extends RealmRecyclerViewAdapter<Media, CardViewMediaHolder> {
int forcedCount = NO_FORCED_COUNT;
RealmCardViewMediaAdapter(OrderedRealmCollection<Media> data, int forcedCount) {
super(data, true);
this.forcedCount = forcedCount;
}
#NonNull
#Override
public CardViewMediaHolder onCreateViewHolder(#NonNull ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(InTouch.getInstance().getApplicationContext());
View view = layoutInflater.inflate(R.layout.timeline_recycler_row_content, parent, false);
return new CardViewMediaHolder(view);
}
#Override
public void onBindViewHolder(#NonNull CardViewMediaHolder holder, int position) {
// Let Glide load the thumbnail
GlideApp.with(InTouch.getInstance().getApplicationContext())
.load(Objects.requireNonNull(getData()).get(position).getUriPathToMedia())
.thumbnail(0.05f)
.placeholder(InTouchUtils.getProgressDrawable())
.error(R.drawable.ic_image_error)
.into(holder.mMediaImageView);
}
#Override
public int getItemCount() {
//TODO - the below attempts to keep the item count at forced count when so specified, but this is causing
// "Inconsistency detected. Invalid view holder adapter position" exceptions when adding a bulk number of images
return (forcedCount == NO_FORCED_COUNT ? getData().size() : Math.min(forcedCount,getData().size()));
//return getData().size();
}
}
So what this is attempting to do is limit the number of items reported by the adapter to the smaller set of thumbnails to show in the first level CardView to a max of 5, spread around using that StaggeredGridLayout.
All this works perfectly until I do a bulk add from another thread. The use case is the user has selected the FAB to add images, and they have selected a bunch (my test was ~250). Then the Uri for all of this is passed to a thread, which does a callback into the method below:
public void handleMediaCreateRequest(ArrayList<Uri> mediaUris, String listId) {
if ( handlingAutoAddRequest) {
// This will only be done a single time when in autoAdd mode, so clear it here
// then add to it below
autoAddedIDs.clear();
}
// This method called from a thread, so different realm needed.
Realm threadedRealm = InTouchDataMgr.get().getRealm();
try {
// For each mediaPath, create a new Media and add it to the Realm
int x = 0;
for ( Uri uri: mediaUris) {
try {
Media media = new Media();
InTouchUtils.populateMediaFromUri(this, media, uri);
InTouchDataMgr.get().addMedia(media, STATUS_UNKNOWN, threadedRealm);
autoAddedIDs.add(media.getId());
if ( x > 2) {
// Let user see what is going on
runOnUiThread(this::updateUI);
x = 0;
}
x++;
} catch (Exception e) {
Timber.e("Error creating new media in a batch, uri was %s, error was %s", uri.getPath(),e.getMessage());
}
}
} finally {
InTouchDataMgr.get().closeRealmSafely(threadedRealm);
runOnUiThread(this::updateUI);
}
}
This method is operating against the realm, which is then making its normal callback into the OrderedCollection which is the base of the list in the recyclerview(s).
The addMedia() method is standard Realm activity, and works fine everywhere else.
updateUI() essentially causes an adapter.notifyDataSetChanged() call, in this case to the RealmCardViewMediaAdapter.
If I either don't use a separate thread, or I don't attempt to limit the number of items the adapter returns to a max of 5 items, then this all works perfectly.
If I leave the limit of 5 in as the return value from getItemCount() and don't refresh the UI until all has been added, then this also works, even from a different thread.
So it seems there is something about notifyDataSetChanged() being called as the Realm based list of managed objects is being updated in real time that is generating this error. But I don't know why or how to fix?
UPDATE END
I am using Realm Java DB 6.0.2 and realm:android-adapters:3.1.0
I created a class that extends RealmRecyclerViewAdapter class for my RecyclerView:
class ItemViewAdapter extends RealmRecyclerViewAdapter<RealmObject, BindableViewHolder> implements Filterable {
ItemViewAdapter(OrderedRealmCollection data) {
super(data, true);
}
I am initializing this adapter using the standard pattern of passing an OrderedRealmCollection to the adapter:
ItemViewAdapter createItemAdapter() {
return new ItemViewAdapter(realm.where(Contact.class).sort("displayName"));
}
"realm" has been previously initialized in the class creating the adapter.
I allow the user to identify one or more rows in this recyclerView that they want to delete, then I execute an AsyncTask which calls the method handling the delete:
public static class DoHandleMultiDeleteFromAlertTask extends AsyncTask {
private final WeakReference<ListActivity> listActivity;
private final ActionMode mode;
DoHandleMultiDeleteFromAlertTask(ListActivity listActivity, ActionMode mode) {
this.listActivity = new WeakReference<>(listActivity);
this.mode = mode;
}
#Override
protected void onPreExecute() {
listActivity.get().mProgressBar.setVisibility(View.VISIBLE);
}
#Override
protected void onPostExecute(Object o) {
// Cause multi-select to end and selected map to clear
mode.finish();
listActivity.get().mProgressBar.setVisibility(View.GONE);
listActivity.get().updateUI(); // Calls a notifyDataSetChanged() call on the adapter
}
#Override
protected Object doInBackground(Object[] objects) {
// Cause deletion to happen.
listActivity.get().handleMultiItemDeleteFromAlert();
return null;
}
}
Inside handleMultiItemDeleteFromAlert(), since we are being called from a different thread I create and close a Realm instance to do the delete work:
void handleMultiItemDeleteFromAlert() {
Realm handleDeleteRealm = InTouchDataMgr.get().getRealm();
try {
String contactId;
ArrayList<String> contactIds = new ArrayList<>();
for (String key : mSelectedPositions.keySet()) {
// The key finds the Contact ID to delete
contactId = mSelectedPositions.getString(key);
if (contactId != null) {
contactIds.add(contactId);
}
}
// Since we are running this from the non-UI thread, I pass a runnable that will
// Update the UI every 3rd delete to give the use some sense of activity happening.
InTouchDataMgr.get().deleteContact(contactIds, handleDeleteRealm, () -> runOnUiThread(ContactListActivity.this::updateUI));
} finally {
InTouchDataMgr.get().closeRealmSafely(handleDeleteRealm);
}
}
And the deleteContact() method looks like this:
public void deleteContact(ArrayList<String> contactIds, Realm realm, Runnable UIRefreshRunnable) {
boolean success = false;
try {
realm.beginTransaction();
int x = 0;
for ( String contactId : contactIds ) {
Contact c = getContact(contactId, realm);
if (c == null) {
continue;
}
// Delete from the realm
c.deleteFromRealm();
if ( UIRefreshRunnable != null && x > 2 ) {
try {
UIRefreshRunnable.run();
} catch (Exception e) {
//No-op
}
x = 0;
}
x++;
}
success = true;
} catch (Exception e) {
Timber.d("Exception deleting contact from realm: %s", e.getMessage());
} finally {
if (success) {
realm.commitTransaction();
} else {
realm.cancelTransaction();
}
}
Now my problem - when I was doing this work entirely from the UI thread I had no errors. But now when the transaction is committed I am getting:
Inconsistency detected. Invalid item position 1(offset:-1).state:5 androidx.recyclerview.widget.RecyclerView{f6a65cc VFED..... .F....ID 0,0-1440,2240 #7f090158 app:id/list_recycler_view}, adapter:com.reddragon.intouch.ui.ListActivity$ItemViewAdapter#5d77178,
<a bunch of other lines here>
I thought that RealmRecyclerViewAdapter already registered listeners, kept everything straight, etc. What more do I need to do?
The reason I am using a separate thread here is that if a user identifies a few dozen (or perhaps hundreds) items in the list to delete, it can take several seconds to perform the delete (depending on which list we are talking about - there are various checks and other updates that have to happen to preferences, etc.), and I didn't want the UI locked during this process.
How is the adapter getting "inconsistent"?
I solved this by adjusting the architecture slightly. It seems there is some issue with StaggeredGridLayoutManager when mixed with:
The dynamic ability of RealmRecyclerView to update itself automatically during bulk adds or deletes.
An adapter that attempts to limit what is shown by returning a count from getItemCount() that is not equal to the current list count.
I suspect this has to do with how ViewHolder instances get created and positioned by the layout manager, since this is where the error is pointing to.
So what I did instead is rather than have the adapter return a value that can be less than the actual count of the list being managed at any point in time, I changed the realm query to use the .limit() capability. Even when the query returns less than the limit initially, it has the nice side effect of capping itself at the limit requested as the list dynamically grows from the bulk add. And it has the benefit of allowing getItemCount() to return whatever the current size of that list is (which always works).
To recap - when in "Month View" (where I want the user to only see a max of 5 images like the screen shot above), the first step is to populate the adapter of the top level RealmRecyclerView with the result of a DISTINCT type query which results in an OrderedRealmCollection of Media objects which correspond to each month from each year in my media Library.
Then in the "bind" flow of that adapter, the MonthViewHolder performs the second realm query, this time with a limit() clause:
OrderedRealmCollection<Media> medias = InTouchDataMgr.get().getMediasForYearAndMonthWithLimit(null,
media.getYear(),
media.getMonth(),
MAX_THUMBNAILS_PER_MONTH_CARDVIEW); // Limit the results to our max per month
Then the adapter for the RealmRecyclerView associated with this specific month uses the results of this query as its list to manage.
Now it can return getData().size() as the result of the getItemCount() call whether I am in the Month view (capped by limit() ) or in my week view which returns all media items for that week and year.

Android Java - Race Condition in OnCreate with two Observers and making lists

sorry if this is a convoluted question. Working on creating an app for a college course and I'm running into (what appears to be) a race condition in my OnCreate method.
TL;DR - sometimes my spinner populates and I can get an index from it. Sometimes it's not populated yet when trying to get a specific index. Details and code below.
The app is a "course scheduler" for a college student.
I'm creating an Activity that displays existing course information and allows you to edit it. In the OnCreate method for this Activity, I am filling a spinner for "Mentors" for the course and a spinner for which "Term" the course belongs in. This information is being pulled from a Room DB.
I have a seperate activity for a new course and for editing a course. For the "new course" activity, everything works fine. I getAllMentors() or getAllTerms() successfully and fill the spinner list.
For the "Edit Course" Activity, there's an extra step involved and it seems to be causing me some issues.
When editing a course, I pass the intent from the originating Activity with all the necessary EXTRAS. This is successful.
In OnCreate for EditCourseActivity, I do the following:
I get the mentorID from the EXTRA that's passed in from the originating Activity.
I access my MentorViewModel and call my getAllMentors() method which returns LiveData> of all mentors in the db.
because it returns LiveData, I use an observer and loop through the LiveData adding the Name of each mentor to a List and the
entire mentor to a List.
I populate my spinner with the information in List full of mentor names.
then I do a for loop, looping through List looking for one that has the same id as what I grabbed form the EXTRA in step 1.
If I find a match in that list, I call a getMentorName() method to snag their name as a string.
I have a methond getIndex(spinner, string) that will loop through the provided spinner, trying to find a match for the string that's
passed in (mentors name) that I grabbed that should match the ID of
the mentor assigned to the course. This method returns index location
of the matched string in the spinner.
I set the spinner selection to the index found.
I do basically the same process for term.
Me being a new developer, I'm not used to OnCreate running the code synchronously.
Because of this, it appears that I have a race condition somewhere between populating the List of mentor names that populates the spinner, and calling my getIndex() method.
Sometimes the spinner is populated and getIndex works properly and sets the correct mentor. Sometimes the spinner is empty and my getIndex() returns -1 (which it should do in a no-find situation) that populates the spinner with the first item in the list (once it's populated).
protected void onCreate(Bundle savedInstanceState) {
//////////////////////////Handling Mentor spinner menu/////////////////////////////////////////////////
int mentorId = courseData.getIntExtra(EXTRA_COURSE_MENTOR_ID, -1);
final ArrayAdapter<String> sp_CourseMentorAdapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, mentorNameList);
sp_CourseMentorAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
sp_CourseMentor.setAdapter(sp_CourseMentorAdapter);
final MentorViewModel mentorViewModel = ViewModelProviders.of(this).get(MentorViewModel.class);
//Mentor test = mentorViewModel.getMentorById(mentorId);
mentorViewModel.getAllMentors().observe(this, new Observer<List<Mentor>>() {
#Override
public void onChanged(#Nullable List<Mentor> mentorList) {
if (mentorList != null) {
for (Mentor m : mentorList) {
mentorNameList.add(m.getMentor_name());
mentorListMentor.add(m);
}
}
sp_CourseMentorAdapter.notifyDataSetChanged();
}
});
for(Mentor m: mentorListMentor){
if (m.getMentor_id()==mentorId){
String test = m.getMentor_name();
int spinnerSelectionM2 = getIndexM(sp_CourseMentor, test);
sp_CourseMentor.setSelection(spinnerSelectionM2);
}
}
Is there a way to get them to run asynchronously? Somehow to get the observer doing my getAllMentors() to complete first and populate the spinner, THEN have the for loop run?
Or a better way to handle this?
Thanks in advance.
Room always runs the code on a separated thread, not the Main/UI thread. You can change that behavior with
allowMainThreadQueries()
after initializating your database. This will make the query run first, populate your list and then run your for-loop code. I do not recommend this approach, since it is a bad practice to make queries on the UI thread.
You have two options:
Change your foor loop to a function and call it after adding the values from the observer:
mentorViewModel.getAllMentors().observe(this, new Observer<List<Mentor>>() {
#Override
public void onChanged(#Nullable List<Mentor> mentorList) {
if (mentorList != null) {
for (Mentor m : mentorList) {
mentorNameList.add(m.getMentor_name());
mentorListMentor.add(m);
}
lookForMentor();
}
}
});
private void lookForMentor() {
for(Mentor m: mentorListMentor){
if (m.getMentor_id()==mentorId){
String test = m.getMentor_name();
int spinnerSelectionM2 = getIndexM(sp_CourseMentor, test);
sp_CourseMentor.setSelection(spinnerSelectionM2);
}
}
}
Put the for inside the observer, change the Room DAO to return a List and use LiveData on your own viewmodel:
MentorViewModel.java:
MentorViewModel extends ViewModel {
private MutableLiveData<List<Mentor>> _mentorsLiveData = new MutableLiveData<List<Mentor>>();
public LiveData<List<Mentor>> mentorsLiveData = (LiveData) _mentorsLiveData;
void getAllMentors(){
//room db query
_mentorsLiveData.postValue(mentorsList);
}
}
EditActivity.java:
mentorsViewModel.getAllMentors();
mentorViewModel.mentorsLiveData.observe(this, new Observer<List<Mentor>>() {
#Override
public void onChanged(#Nullable List<Mentor> mentorList) {
mentorsListMentor.addAll(mentorList);
sp_CourseMentorAdapter.notifyDataSetChanged();
for(Mentor m: mentorListMentor){
if (m.getMentor_id()==mentorId){
String test = m.getMentor_name();
int spinnerSelectionM2 = getIndexM(sp_CourseMentor, test);
sp_CourseMentor.setSelection(spinnerSelectionM2);
}
}
}
}
});

Renew StableIdKeyProvider cache and RecyclerView/SelectionTracker crash on new selection after item removed

Preparation:
RecyclerView with RecyclerView.Adapter binded to SQLite Cursor (via ContentProvider && Loader). RecyclerView and RecyclerView.Adapter linked with SelectionTracker as design suggests.
SelectionTracker builded with StableIdKeyProvider.
On first step - delete an item:
Select RecyclerViews's an item with a long press (cheers to SelectionTracker's SelectionObserver), draw Action Bar Context Menu, fire
the delete action, do the SQL deletion task.
After SQL deletion ends, do the Cursor Loader renewal with
restartLoader call.
onLoadFinished fired, new Cursor obtained, on
RecyclerView.Adapter method notifyDataSetChanged called.
RecyclerView.Adapter redraw RecyclerView content, and all is looks
good.
On second step - do the selection of some other item. Crash:
java.lang.IllegalArgumentException
at androidx.core.util.Preconditions.checkArgument(Preconditions.java:38)
at androidx.recyclerview.selection.DefaultSelectionTracker.anchorRange(DefaultSelectionTracker.java:269)
at androidx.recyclerview.selection.MotionInputHandler.selectItem(MotionInputHandler.java:60)
at androidx.recyclerview.selection.TouchInputHandler.onLongPress(TouchInputHandler.java:132)
at androidx.recyclerview.selection.GestureRouter.onLongPress(GestureRouter.java:96)
at android.view.GestureDetector.dispatchLongPress(GestureDetector.java:779)
at android.view.GestureDetector.access$200(GestureDetector.java:40)
at android.view.GestureDetector$GestureHandler.handleMessage(GestureDetector.java:293)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:193)
at android.app.ActivityThread.main(ActivityThread.java:6669)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)
What I see on first step while deletion item in progress.
While StableIdKeyProvider do internal job with onDetached ViewHolder item, it don't see previously assigned ViewHolder's position within an Adapter:
void onDetached(#NonNull View view) {
RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view);
int position = holder.getAdapterPosition();
long id = holder.getItemId();
if (position != RecyclerView.NO_POSITION && id != RecyclerView.NO_ID) {
int position here is RecyclerView.NO_POSITION
Thats why the RecyclerView crashes after - StableIdKeyProvider's cache contains old snapshot of ID's without deletion affected.
The question is - WHY? and HOW to renew the cache of StableIdKeyProvider?
Another note:
While I read the RecyclerView code, I see this comment:
* Note that if you've called {#link RecyclerView.Adapter#notifyDataSetChanged()}, until the
* next layout pass, the return value of this method will be {#NO_POSITION}.
I am not understood what exactly mean this words. Perhaps I faced with described situation - notifyDataSetChanged called in not appropriate time? Or I need to call it twice?
PS.
Sorry for about literary description, there is a lot of complexity code
I am ended up to play with StableIdKeyProvider and switch to plain my own implementation of ItemKeyProvider:
new ItemKeyProvider<Long>(ItemKeyProvider.SCOPE_MAPPED) {
#Override
public Long getKey(int position) {
return adapter.getItemId(position);
}
#Override
public int getPosition(#NonNull Long key) {
RecyclerView.ViewHolder viewHolder = recyclerList.findViewHolderForItemId(key);
return viewHolder == null ? RecyclerView.NO_POSITION : viewHolder.getLayoutPosition();
}
}
Crash is gone, RecyclerView's navigation/selection/modification looks OK.
What about StableIdKeyProvider ?.. Hmm, may be it is not designed to work with mutable content of RecyclerView.
Update 2021-12-03
Last week I got a new round with fighting on RecycleView.
As mentioned on the question - exactly problem is the CACHE of StableIdKeyProvider. And switch to ItemKeyProvider is the workaround.
As code of StableIdKeyProvider explains, chache tied to the window's evens: attach and detach. So, and the comment which I am quoted above - is the exactly pointed to the problem: when new Cursor arrives - reattaching Cursor to the Adapter and notifying - requires to fire at right time. "Right time" - is enqueue this job in layout-message-thread. In this way RecyclerView and underlying "toolbox" can correct perform an update itself. For doing so, just wrap providing a new Cursor inside the post runnable method. The code:
#Override
public void onLoadFinished(#NonNull Loader<Cursor> loader, Cursor data) {
recycler.post(new Runnable() {
#Override
public void run() {
adapter.swapCursor(data);
}
});
...
My problem was solved by setting setHasStableIds(true) in Recycle view adapter and overriding getItemId, It seems that Tracker require both setHasStableIds(true) and overrindinggetItemId in adapter I got this error after setting stable Ids true without overriding getItemId
init {
setHasStableIds(true)
}
override fun getItemId(position: Int) = position.toLong()
override fun getItemViewType(position: Int) = position
Ran into the same problem and after a long search I have found the answer:
You just need to override the method in your Recycler Adapter view.
override fun getItemId(position: Int): Long = position.toLong()
I encountered the same issue with the StableIdKeyProvider. Writing a custom implementation of ItemKeyProvider seems to do the trick. Here's a basic Kotlin implementation you can use when building a selection tracker for a RecyclerView:
class RecyclerViewIdKeyProvider(private val recyclerView: RecyclerView)
: ItemKeyProvider<Long>(ItemKeyProvider.SCOPE_MAPPED) {
override fun getKey(position: Int): Long? {
return recyclerView.adapter?.getItemId(position)
?: throw IllegalStateException("RecyclerView adapter is not set!")
}
override fun getPosition(key: Long): Int {
val viewHolder = recyclerView.findViewHolderForItemId(key)
return viewHolder?.layoutPosition ?: RecyclerView.NO_POSITION
}
}
In my case, the problem is related to the initialization of ItemDetailsLookup.ItemDetails in ViewHolder. As it turned out, getAdapterPosition() may return the wrong position during ViewHolder binding. The solution is to call getAdapterPosition() at the time call getItemDetails() in ItemDetailsLookup.
I got it resolved by keeping the initialization and setting selectiontracker to the adapter at the very end.
capturedThumbnailListAdapter = new CapturedThumbnailListAdapter(this);
capturedThumbnailListAdapter.setCapturedThumbnailList(capturedThumbnailList);
viewBinding.capturedImagesRv.setLayoutManager(new LinearLayoutManager(this,
LinearLayoutManager.HORIZONTAL, true));
viewBinding.capturedImagesRv.setAdapter(capturedThumbnailListAdapter);
SelectionTracker<Long> tracker = new SelectionTracker.Builder<Long>("thumb_selection",
viewBinding.capturedImagesRv, new StableIdKeyProvider(viewBinding.capturedImagesRv),
new CapturedThumbnailListAdapter.ItemLookUp(viewBinding.capturedImagesRv),
StorageStrategy.createLongStorage())
.withSelectionPredicate(SelectionPredicates.createSelectAnything())
.build();
capturedThumbnailListAdapter.setSelectionTracker(tracker);

Insert new Fragment next in already running Android ViewPager

Trying to dynamically add Fragments to different pages in an Android ViewPager.
Dynamically adding a Fragment to the end of the list of Fragments works fine, but attempting to add to the next slot (the unseen page after the current viewable page). I've tried adding to the arraylist of fragments and using notifyDataSetChanged and trying to use set on each of the fragments after to set each to the previous item.
Edit: updated code
class MainPagerAdapter extends FragmentPagerAdapter {
public MainPagerAdapter(FragmentManager fm) {
super(fm);
}
#Override
public PageFragment getItem(int position) {
return PageFragment.newInstance(Datamart.getInstance().getURLs().get(position));
}
#Override
public int getCount() {
return Datamart.getInstance().getURLs().size();
}
#Override
public int getItemPosition(Object object) {
PageFragment pageFragment = (PageFragment) object;
for ( int i = 0; i < getCount(); i++ ) {
if ( pageFragment.getURL().equals( Datamart.getInstance().getURLs().get(i) ) ) {
return i;
}
}
return POSITION_NONE;
}
}
Within the Datamart:
public class Datamart {
static Datamart instance;
private ArrayList<String> URLs;
private MainPagerAdapter mainPagerAdapter;
private ViewPager viewPager;
public Datamart() {
URLs = new ArrayList<>();
}
public static Datamart getInstance() {
if (instance == null) {
instance = new Datamart();
}
return instance;
}
public MainPagerAdapter getMainPagerAdapter() {
return mainPagerAdapter;
}
public void setMainPagerAdapter(MainPagerAdapter mainPagerAdapter) {
this.mainPagerAdapter = mainPagerAdapter;
}
public ViewPager getViewPager() {
return viewPager;
}
public void setViewPager(ViewPager viewPager) {
this.viewPager = viewPager;
}
public void addToEnd( String URL ) {
URLs.add(URL);
mainPagerAdapter.notifyDataSetChanged();
}
public void addNext( String URL ) {
URLs.add(viewPager.getCurrentItem() + 1, URL);
mainPagerAdapter.notifyDataSetChanged();
}
public ArrayList<String> getURLs() {
return URLs;
}
public void setURLs(ArrayList<String> URLs) {
this.URLs = URLs;
}
}
ViewPager is doing its darndest to try and keep track of where all the fragments go, and you got it confused because you inserted a fragment in the middle without telling it.
Let's do an example. Say that you have two pages so that you are displaying the first page and the second page is the offscreen page to the right. You have fragment0 for the first page and fragment1 for the second page.
First, keep in mind that since ViewPager is managing the offscreen pages to the immediate left and right, it already thinks it knows what the second page fragment is, which is fragment1.
When you insert a fragment between those existing page fragments, ViewPager is not going to ask you right away which fragment is at the second position, it's going to ask you if the fragment at the second position has changed.
This is where getItemPosition() comes in. You didn't implement getItemPosition() in your FragmentPagerAdapter, so the default behavior of getItemPosition() is to return POSITION_UNCHANGED. (As you'll see, this is why adding a fragment at the end doesn't cause an error.)
So when you called notifyDataSetChanged(), the ViewPager called getCount() and saw that instead of two pages, you now have three. It then called getItemPosition() for page 0 and page 1 (the pages it knew about) and your default implementation told it that nothing had changed.
But something did change, you inserted a page/fragment in the middle of the two existing pages. ViewPager asked you if the second page changed, and your getItemPosition() should have returned 2 instead of POSITION_UNCHANGED.
Now because your getCount() returned 3 instead of 2, ViewPager knows that there is a new page at the end that it doesn't know about, so it calls getItem() to get the new fragment.
Here is where the error occurs. Your getItem() method returned fragment1 for page 3, and ViewPager choked because you handed it the fragment that it already knew about. Specifically, it uses the fragment's tag for some housekeeping. It creates a tag out the the page's container id and the item position (page number). So at any given time it expects that either (a) the tag is null, or (b) the tag will be the same value that it already calculated from the container id and the item position. Neither of those conditions were true, so that's why IllegalStateException was thrown.
So how do you fix this?
You implement a getItemPosition() that does the right thing. When your getItemPosition() is called, you scan through your list of fragments, and when you find a matching fragment, you return the index of the fragment. If you removed a fragment such that there's no matching fragment in your list, you return POSITION_NONE to tell the ViewPager that the page should be removed. ViewPager will then call your getItem() method to get the new page. In this way, ViewPager can always stay in sync with your adapter.
When I write a FragmentPagerAdapter, I don't even use the fragments in the model. If I were writing your adapter, I would just have a list of URLs, since it looks like each page/fragment has it's own unique URL.
I would also implement a getUrl() method on the fragment class to get the URL that the fragment was created with.
So for getItemPosition(), I would call getUrl() on the fragment passed in, and search my adapter's list for that URL. Then I would return the index of the URL in the list, or POSITION_NONE if it's not there any more.
Then in getItem(), I would instantiate a new fragment using the URL at the list position that was passed in. My rule is that I never instantiate a fragment until the ViewPager specifically requests it by calling getItem().
This way, only the ViewPager is accessing the fragments, and I don't run into these types of issues.
Also, don't make any changes to the adapter model between the time when ViewPager calls startUpdate() to when it calls finishUpdate(). This is how ViewPager tells your adapter that it's in a "critical section" of figuring out where all the page fragments go.
PagerAdapters can be a bear to work with. Put some debug logging in your getCount(), getItemPosition(), and getItem() methods so that you can get a feel for how ViewPager uses your adapter to manage its pages.

For loop being missed out in Android

I have an activity in Android that takes the result of a web service and puts it into an adapter to display a list of items. I've created a listener interface for this called OnLoadCompleteListener and my listener for this activity looks like
mListener = new OnLoadCompleteListener() {
#Override
public void onPostExecute(RootWebService service) {
if(service instanceof WebService) {
//gets the number of items
int total = ((WebService) service).getCount();
for(int i=0; i<total; ++i) {
//Log.d("ITEM", "inserting " + i);
Item item = ((WebService) service).get(i);
//our adapter class automatically receives items
mAdapter.addItem(item);
}
}
}
};
Now this is the bizarre bit: the listener is getting hit, total is being set to 12 (the number of items I asked for) and then the for loop is being bypassed altogether. The adapter remains empty and the screen displays pretty much nothing.
However, if the commented out Log.d(...) call is made active, the loop works.
Why? The callback is being run from the UI thread, the getCount() method is returning the correct value, and this adapter has worked before. What on earth is happening?
By the way, mAdapter is an instance of a subclass of Adapter I wrote for the purpose, and the addItem(Item) method looks like this:
public synchronized void addItem(Item item) {
mItems.add(item);
notifyDataSetChanged();
}
mItems is exactly what you would expect.
I believe you need to do something like invalidate or something along those lines which pretty much just refreshes the data in the adapter.
I think its this.
myListView.getAdapter().notifyDataSetChanged();
If that doesn't work it could be...
myListView.invalidate();

Categories