I have a RecyclerView whose adapter holds a SortedList of objects I can not modify.
My cards inside the RecyclerView should expand/collapse on click showing additional information when expanded. The problem is that there seems to be no way to store the information whether a card is expanded or collapsed since the ViewHolder gets recycled.
To make things more difficult, new items are added at the beginning and at the end of my adapter, so the positions change all the time. Even though new cards should be shown collapsed, they are sometimes shown as expanded when a ViewHolder of an expanded card gets recycled for the new card. This is when I tried to save the expand/collapse information in the ViewHolder directly.
The correct solution seems to be using a wrapper object with two properties: the original (unmodifiable) object, and a boolean indicating whether the item is expanded or collapsed.
Then in the onBindViewHolder() method of the RecyclerView's adapter, you have to set your views to expanded or collapsed depending on the state of the wrapper object.
#Override
public void onBindViewHolder(final ViewHolder ui, int position) {
if(wrapper_objects.get(position).expanded) {
expandView(ui);
} else {
collapseView(ui);
}
// expand/collapse on click
ui.expand.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View view) {
if(wrapper_objects.get(position).expanded) {
collapseView(ui);
wrapper_objects.get(position).expanded = false;
} else {
expandView(ui);
wrapper_objects.get(position).expanded = true;
}
}
});
}
An additional problem, but rare problem might be that an API sometimes returns the same objects that were already in the SortedList. In this case, they then overwrite the expand/collapse state in the wrapper object. A hackish solution I found for this is doing this:
private SortedList<WrapperObject> wrapper_objects = new SortedList<>(WrapperObject.class, new SortedList.Callback<WrapperObject>(){
#Override
public boolean areContentsTheSame(WrapperObject old, WrapperObject new) {
// keep state in new wrapper object
new.expanded = old.expanded;
return old.object.equals(new.object);
}
}
Related
I got a RecyclerView and want to change the appearance of any clicked row. For that I have a callbackFunction in my Activity which I pass to the Adapter, which then is called inside the Adapter, as soon as I click on any row in the RecyclerView.
The clicked row is then changed, but it happens, that not only the clicked rows are changed but also other rows, that weren't clicked and were never clicked before. I checked the ArrayList that contains the data, but everything is fine there. Only the clicked elements contain the trigger to change the appearance of the row.
What is causing the other rows to change, although they have not been clicked?
Interface inside activity for callback
public interface onHeaderClickListener{
void onHeaderClicked(int index);
}
Inside RecyclerView Adapter
#Override
public void onBindViewHolder(#NonNull RecyclerView.ViewHolder holder, final int position) {
if (holder instanceof ViewHolderHeader){
((ViewHolderHeader)holder).dateHeaderTextView.setText( Integer.toString(((objClass_offerDateHeader) arrayList.get(position)).getDate()));
if(((objClass_offerDateHeader) arrayList.get(position)).isSelected()){
((ViewHolderHeader)holder).dateHeaderTextView.setBackgroundColor(Color.parseColor("#b642f4"));
}
((ViewHolderHeader)holder).dateHeaderTextView.setOnClickListener(new View.OnClickListener() {
#Override
public void onClick(View v) {
onHeaderClickListener.onHeaderClicked(position);
}
});
}
}
Adapter initialisation inside activity
customAdapterRecyclerViewAddOffersTo = new customAdapterRecyclerViewAddOffers(offerArrayList,"dragTo", new onHeaderClickListener() {
#Override
public void onHeaderClicked(int index) {
if (offerArrayList.get(index) instanceof objClass_offerDateHeader){
if(((objClass_offerDateHeader) offerArrayList.get(index)).isSelected()){
((objClass_offerDateHeader) offerArrayList.get(index)).setSelected(false);
}
else {
((objClass_offerDateHeader) offerArrayList.get(index)).setSelected(true);
}
customAdapterRecyclerViewAddOffersTo.notifyDataSetChanged();
}
}
});
In your onBindViewHolder method you have to set the background of the unselected cell, keep in mind the the cells are reused and you only set the background of selected cells so when it is reused the background is not returned to the normal color
So in code you will have to add an else condition
if(((objClass_offerDateHeader) arrayList.get(position)).isSelected()){
((ViewHolderHeader)holder).dateHeaderTextView.setBackgroundColor(Color.parseColor("#b642f4"));
} else {
((ViewHolderHeader)holder).dateHeaderTextView.setBackgroundColor(Color.parseColor("#FFFFFF")); // I assume you need it to be white you can change it to any other color
}
You need to add an else condition here:
if(((objClass_offerDateHeader) arrayList.get(position)).isSelected()){
((ViewHolderHeader)holder).dateHeaderTextView.setBackgroundColor(Color.parseColor("#b642f4"));
}
Viewholders get recycled, so you cannot be sure of the current state when onBindViewHolder is called.
So i have a recycler view loaded with items containing text and image and a checkbox. All the data is loaded just fine initially including the images using picasso. The checkbox is initially hidden, once i tap a button, which is not part of the recycler view. The checkboxes should appear in all the items of recycler view. Everything works fine except for the fact that the images disappear once i do that.
The way i am doing it is that i set the adapter to my recyclerview to null and then initialize a new recycler view adapter which has checkboxes visible and set it to my recycler view.
The same thing happens if i try to search for a particular list item using a search bar on top. Any idea why? Here is the code about how i am setting the adapter to my recycler view.
// set the adapter to null on cards list view.
cards_list_view.setAdapter(null);
// Initialize the adapter again.
deckCardRecycleAdapter = new DeckCardRecycleAdapter(getActivity(), cards_list, show_checkbox);
// Set the adapter again to list view.
cards_list_view.setAdapter(deckCardRecycleAdapter);
And here is my onBindViewHolder function in adapter.
#Override
public void onBindViewHolder(final ViewHolder viewHolder, final int position) {
typeface = Typeface.createFromAsset(context.getAssets(), "fonts/futura-medium.otf");
viewHolder.user_name.setTypeface(typeface);
viewHolder.user_name.setText(cards.get(position).getName().toString());
viewHolder.position.setTypeface(typeface);
viewHolder.position.setText(cards.get(position).getPosition().toString());
viewHolder.dot.setTypeface(typeface);
viewHolder.event_saved.setTypeface(typeface);
if(cards.get(position).getEvent_name().toString().equals("None")) {
viewHolder.event_saved.setVisibility(View.GONE);
viewHolder.dot.setVisibility(View.GONE);
}
else
viewHolder.event_saved.setText(cards.get(position).getEvent_name().toString());
viewHolder.date_saved.setTypeface(typeface);
String timestamp = cards.get(position).getTimestamp().toString();
viewHolder.date_saved.setText(toDate(timestamp));
if(cards.get(position).image_url.toString().equals("None"))
viewHolder.user_image.setImageResource(R.drawable.anonymous);
else {
String url_image = cards.get(position).image_url.toString();
Picasso.with(this.context).cancelRequest(viewHolder.user_image);
Picasso.with(context).load(url_image).into(new Target() {
#Override
public void onBitmapLoaded(Bitmap bitmap, Picasso.LoadedFrom from) {
viewHolder.user_image.setImageBitmap(bitmap);
cards.get(position).setImage_url(getStringImage(bitmap));
}
#Override
public void onBitmapFailed(Drawable errorDrawable) {
}
#Override
public void onPrepareLoad(Drawable placeHolderDrawable) {
}
});
}
Please help. Thanks in advance.
I don't think it's a good idea to reload data each time, it's make a lot of performance instead you can create interface and let the item listening to it, and just with one line of code you can change the visibility
Check this answer
interface Listener {
public void onVisibilityChanged(bool visible);
}
create an interface (set bool parameter for visible/invisible)
Create separate class for manage listener's (like this)
add listener for each item (make checkbox visible or invisible)
remove listener when item removed (avoid NullpointException)
clear listener's if recycler reset (avoid NullpointException)
call the method that have foreach and all the listeners will be triged
I am working where I have checkboxe inside RecyclerView items. I am aware of some of its issues. But, with only 10 items and no scrolling wrong listener is called.
I have something like this:
private class MyAdapter extends RecyclerView.Adapter<MyViewHodler> {
ArrayList<String> items;
ArrayList<String> checkedList = new ArrayList<>();
...
public void onBindViewHolder(final MyViewHolder holder, int position) {
String item = items.get(position);
String check = checkedList.get(item);
if(check != null)
holder.vChecked.setChecked(true);
else
holder.vChecked.setChecked(false);
....
holder.vChecked.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
#Override
public void onCheckedChanged(CompoundButton compoundButton, boolean isChecked) {
if (isChecked) {
checkedList.add(item);
} else {
checkedList.remove(item);
}
}
}
}
Now, strangely this checkedChange calls are made on different views. I have a list of only 10 items and are fit in the recyclerview, no scrolling. I externally change the data (items and checked list), set the new data and calling myAdapter.notifyDatasetChanged()
This call removes one more item from the checked state.
For example, I have 10 items all checked. I uncheck 1 item, and doing some other calculations based on that and calling myAdapter.notifyDatasetChanged(). It also unchecks one more element from the other 9 items.
So even without scrolling why onCheckedChange listener is called for the wrong view?
I have found out the problem. I thought onBindViewHolder is called for every view to change the data. But I was wrong.
notifyDatasetChanged() recycles all the view holders on screen and from the recycled view holder pool randomly viewholders are picked to bind the new data. So when I called the notifyDatasetChanged, the viewholder was used at the different position, and thus changed one more item.
So, I removed the listener to null in onViewRecycled method of the recycler view. Like this:
#Override
public void onViewRecycled(MyViewHolder holder) {
super.onViewRecycled(holder);
holder.vChecked.setOnCheckedChangeListener(null);
}
This, did solved the issue. I was aware of this, but I thought viewholders are reused for the same position if there is no scrolling. But onDatasetChanged recycles all currently used view holders and uses it again from the random pool.
This line: String item = items.get(item);
It bothers me. Shouldn't it be String item = items.get(position);
Edit: If you change one position of the dataset, call notifyItemChanged. Don't notify the whole dataset unless it has changed entirely. Could be this.
Edit 2: Well then the problem is not that onCheckedChanged is being called on the wrong views. It is called in all of the views because you always change the checkbox state at onBindViewHolder
I have a list with 13 items (although items may be added or removed), positions 0-12. When the fragment containing the RecyclerView is first shown, only positions 0 through 7 are visible to the user (position 7 being only half visible). In my adapter I Log every time a view holder is binded/bound (idk if grammar applies here) and record its position.
Adapter
#Override
public void onBindViewHolder(final ViewHolder holder, final int position) {
Log.d(TAG, "onBindViewHolder() position: " + position);
...
}
From my Log I see that positions 0-7 are bound:
I have a selectAll() method that gets each ViewHolder by adapter position. If the returned holder is NOT null I use the returned holder to update the view to show it's selected. If the returned holder IS null I call selectOnBind() a method that flags the view at that position update to show it's selected when it's binded rather than in real time since it's not currently shown:
public void selectAll() {
for (int i = 0; i < numberOfItemsInList; i++) {
MyAdapter.ViewHolder holder = (MyAdapter.ViewHolder)
mRecyclerView.findViewHolderForAdapterPosition(i);
Log.d(TAG, "holder at position " + i + " is " + holder);
if (holder != null) {
select(holder);
} else {
selectOnBind(i);
}
}
}
In this method I Log the holder along with its position:
So up to this point everything seems normal. We have positions 0-7 showing, and according to the Log these are the positions bound. When I hit selectAll() without changing the visible views (scrolling) I see that positions 0-7 are defined and 8-12 are null. So far so good.
Here's where it gets interesting. If after calling selectAll() I scroll further down the list positions 8 and 9 do not show they are selected.
When checking the Log I see that it's because they are never bound even though they were reported to be null:
Even more confusing is that this does not happen every time. If I first launch the app and test this it may work. But it seems to happen without fail afterwards. I'm guessing it has something to do with the views being recycled, but even so wouldn't they have to be bound?
EDIT (6-29-16)
After an AndroidStudio update I cannot seem to reproduce the bug. It works as I expected it to, binding the null views. If this problem should resurface, I will return to this post.
This is happening because:
The views are not added to the recyclerview (getChildAt will not work and will return null for that position)
They are cached also (onBind will not be called)
Calling recyclerView.setItemViewCacheSize(0) will fix this "problem".
Because the default value is 2 (private static final int DEFAULT_CACHE_SIZE = 2; in RecyclerView.Recycler), you'll always get 2 views that will not call onBind but that aren't added to the recycler
In your case views for positions 8 and 9 are not being recycled, they are being detached from the window and will be attached again. And for these detached view onBindViewHolder is not called, only onViewAttachedToWindow is called. If you override these function in your adapter, you can see what I am talking.
#Override
public void onViewRecycled(ViewHolder vh){
Log.wtf(TAG,"onViewRecycled "+vh);
}
#Override
public void onViewDetachedFromWindow(ViewHolder viewHolder){
Log.wtf(TAG,"onViewDetachedFromWindow "+viewHolder);
}
Now in order to solve your problem you need to keep track of the views which were supposed to recycled but get detached and then do your section process on
#Override
public void onViewAttachedToWindow(ViewHolder viewHolder){
Log.wtf(TAG,"onViewAttachedToWindow "+viewHolder);
}
The answers by Pedro Oliveira and Zartha are great for understanding the problem, but I don't see any solutions I'm happy with.
I believe you have 2 good options depending on what you're doing:
Option 1
If you want onBindViewHolder() to get called for an off-screen view regardless if it's cached/detached or not, then you can do:
RecyclerView.ViewHolder view_holder = recycler_view.findViewHolderForAdapterPosition( some_position );
if ( view_holder != null )
{
//manipulate the attached view
}
else //view is either non-existant or detached waiting to be reattached
notifyItemChanged( some_position );
The idea is that if the view is cached/detached, then notifyItemChanged() will tell the adapter that view is invalid, which will result in onBindViewHolder() getting called.
Option 2
If you only want to execute a partial change (and not everything inside onBindViewHolder()), then inside of onBindViewHolder( ViewHolder view_holder, int position ), you need to store the position in the view_holder, and execute the change you want in onViewAttachedToWindow( ViewHolder view_holder ).
I recommend option 1 for simplicity unless your onBindViewHolder() is doing something intensive like messing with Bitmaps.
When you have large number of items in the list you have passed to recyclerview adapter you will not encounter the issue of onBindViewHolder() not executing while scrolling.
But if the list has less items(I have checked on list size 5) you may encounter this issue.
Better solution is to check list size.
Please find sample code below.
private void setupAdapter(){
if (list.size() <= 10){
recycler.setItemViewCacheSize(0);
}
recycler.setAdapter(adapter);
recycler.setLayoutManager(linearLayoutManager);
}
I think playing with view is not a good idea in recyclerview. The approach I always use to follow to just introduce a flag to the model using for RecyclerView. Let assume your model is like -
class MyModel{
String name;
int age;
}
If you are tracking the view is selected or not then introduce one boolean to the model. Now it will look like -
class MyModel{
String name;
int age;
boolean isSelected;
}
Now your check box will be selected/un-selected on the basis of the new flag isSelected (in onBindViewHolder() ). On every selection on view will change the value of corresponding model selected value to true, and on unselected change it to false. In your case just run a loop to change all model's isSelected value to true and then call notifyDataSetChanged().
For Example, let assume your list is
ArrayList<MyModel> recyclerList;
private void selectAll(){
for(MyModel myModel:recyclerList)
myModel.isSelected = true;
notifyDataSetChanged();
}
My suggestion, while using recyclerView or ListView to less try to play with views.
So in your case -
#Override
public void onBindViewHolder(final ViewHolder holder, final int position) {
holder.clickableView.setTag(position);
holder.selectableView.setTag(position);
holder.checkedView.setChecked(recyclerList.get(position).isSelected);
Log.d(TAG, "onBindViewHolder() position: " + position);
...
}
#Override
public void onClick(View view){
int position = (int)view.getTag();
recyclerList.get(position).isSelected = !recyclerList.get(position).isSelected;
}
#Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
int position = (int)buttonView.getTag();
recyclerList.get(position).isSelected = isChecked;
}
Hope it will help you, Please let me know if you need any further explanation :)
So I think you question is answered below by #Pedro Oliveira. The main sense of RecycleView, that he using special algorithms for caching ViewHolder in any time. So next onBindViewHolder(...) may not work, for ex. if view is static, or something else.
And about your question you think to use RecycleView for dynamic changed Views. DON'T DO IT! Because RecycleView invalidates views and has caching system, so you will have a lot of problems.
Use LinkedListView for this task!
I followed this example here: https://developer.android.com/training/material/lists-cards.html
What's Happening:
The list (RecyclerView) is mixing up the data when I scroll. I.e. when I scroll back up after scrolling down, some of the list items are repeated, not displaying proper content.
Here is my onBindViewHolder method:
// cardsData is a List of MyCardItem (MyCardItem is just a holder of information,
// such as an id, String title, String summary, and other info to display on a card
#Override
public void onBindViewHolder(ViewHolder viewHolder, int position)
{
// Update view here
if (!cardsData.isEmpty())
{
viewHolder.updateDisplay(cardsData.get(position), activity, position);
}
}
Here is my updateDisplay method found inside the ViewHolder class:
// Set ID
if (item.getID() != null)
{
mIDView.setText(item.getID());
}
// Set Title
if (item.getTitle() != null)
{
mTitleView.setText(item.getTitle());
}
// Set Summary
if (item.getSummary() != null)
{
mSummaryView.setText(Html.fromHtml(item.getSummary()));
}
// Image
if (item.hasImage())
{
// Display image here
}
I believe it has something to do with the fact that sometimes the data is null, so I choose not to display it. Not all items (cards) will have an image to display, so this is why I check for if the item is supposed to have an image. If it does, then I do some fancy code to show an image (and making the other views GONE so I can only show that image, etc.).
While debugging, I forced all cards to display their id and position in the title. I can confirm that all ids and positions are in their proper places. When I force (removing the if check for id, title, and summary) the id, title, and summary to display, even if null, that content becomes unaffected. However, this does not solve my image situation (won't always have an image). I'll then have some images taking up the place for a certain id/position, where it should not (i.e. instead of displaying the summary text, it shows an image that isn't supposed to be there).
Any input as to why this is happening in my RecyclerView?
RecyclerView, just the same as ListView and GridView, reuses views whenever possible. That means while you scroll, views that fall off the top are used for the views on the bottom, etc. This greatly improves the memory efficiency and scroll performance. This means if you don't reset all of the state on a new item, you'll see state left over from the last time this view was used.
Instead, you should always reset all the of the state:
if (item.getID() != null) {
mIDView.setText(item.getID());
} else {
mIDView.setText("");
}
// etc...
Also, there shouldn't be any situations where you need to check for cardsData.isEmpty() - instead, whenever you update cardsData, you should be calling the appropriate notifyXXX method (such as notifyItemInserted() or in the worse case notifyDataSetChanged()) which will automatically refresh the list appropriately.
Implement
public long getItemId(int position) {}
otherwise, you'll see repeated items in RecyclerView.
Add these two and you are good to go !
#Override
public long getItemId(int position) {
return position;
}
#Override
public int getItemViewType(int position) {
return position;
}
Also in addition to previous answer you can check view on emptiness, for example:
if (TextUtils.isEmpty(viewHolder.item.getText())) {
viewHolder.item.setText(object.getItem());
}
To elaborate on ViliusK's answer. This implementation will work:
#Override
public long getItemId(int position) {
Object obj = getItemAtPosition(position);
return obj.hashCode();
}