Pro RecyclerView notes

Notes from Pro RecyclerView

Past

ListView

  • Designed for consistent and repeating design
  • A lot of data, not enough memory
  • Solves the problem by faking – shows the user the views they can see. It creates only views for this. Reuse existing views for new elements.
  • Too complex
  • A lot of one – off features
  • Undefined behavior when APIs are mixed
  • Undefined behavior = undefined API
  • Duplicate features
  • Focused view vs selected view
  • Item click listener vs view click listener
  • setItemsCanFocus – why need it if the view has own focus property?
  • Animations
  • They are invented after ListView was present. Complex implementation
  • It has assumptions about how things work – GridView, StaggerdGridView, Horizontal List View

Best things from ListView

  • Take the ViewHolder pattern and implement it
  • Decouple creation from binding a component – onCreateItem vs onBind
  • Use the framework focus
  • Smart adapters – tell what has changed and the performance can be better

RecyclerView design

RecyclerView contains separate items Layout Manager, Item Animator Adapter

Best practices

View::requestLayout

When a view requests layout and it bubbles to the parent until the root. The parent says OK, on the next layout frame pass I will call you. The frame happens, the measure of all views happens. Then the layout comes and we reposition the view hierarchy.

ImageView vs TextView request layout

ImageView

Loading an image into an ImageView in the onBind method of a RecyclerView looks like this

onBindViewHolder(ViewHolder bh, int position) {
  ...
  imageLoader.loadImage(vh.image, user.profileUrl, R.drawable.placeholder)
}

When this happens the IamgeView says the data has been changed and requests a layout. Next, the item view requests a layout because of the ImageView change. And lastly, the RecyclerView requests layout which makes it reposition all of the hierarchy of the views.

setHasFixedSize makes the RecyclerView not call the request layout method so it won’t call the parent’s method. It will resize it’s children only. ImageView since 2011 has the correct check if it’s width is the same as the bitmap width so it won’t call the request layout method.

Loading an image in a StaggeredGridManager can make it jump or load in different columns. Image jumping while loading can be avoided by having metadata about the image in the api from which the image is downloaded. So you can make a custom AspectRatioImageView which can size itself based on a known image ratio.

TextView

Setting a new string calls requestLayout. You can simply debug it by setting a breakpoint on the requestLayout method in the RecyclerView.

DataBinding

When updating an observable the DataBinding does not update the View until the next frame. If you have DataBinding class in your RecyclerView, it won’t like this approach.
The onBindViewHolder method expects the data to be ready so it can measure it. So you should call binding.executePendingBindings() so everything should be synchornized to the View.

void onBindViewHolder(ViewHolder vh, int pos) {
  vh.binding.setItem(items.get(pos));
  vh.binding.executePendingBindings();
}

Data Updates

Updating adapters

When updating data in a RecyclerView, most of the time we set the new data in the adapter and we call adapter.notifyDatasetChanged(). That is an inefficient operation which calls the onBindViewHolder method on all the current visible views and repositions them. To fix this all you have to do is call setHasStableIds on the RecyclerView. After that implement the method long getItemId(int pos) in the adapter. Thi way the RecyclerView will animate the changes.

Sorted List

Even with stable ids the RecyclerView rebinds all the views visible on the screen. Even thought it knew what position to rebind, but it still has to remeasure and layout views again. To fix the operation use the SortedList class. It knows when to call notifyDatasetChanged();

SortedList<Item> mySortedList = new SortedList<Item>(Item.class,
    new SortedListAdapterCallback<Item>(myAdapter) {
        @Override
        public int compare(Item item1, Item item2) {
            return item1.id - item2.id;
        }
    
        @Override
        public boolean areItemsTheSame(Item item1, Item item2) {
            return item1.id == item2.id;
        }
    
        @Override
        public boolean areContentsTheSame(Item oldItem, Item
newItem) {
        return oldItem.text.equals(newItem.text);
    }
});

Updating an item required you to implement an additional method and keep a map of currently visible items. An implementation may look like this:

// SortedList::updateItemAt
Map<Integer, Item> items; // item id -> Item
void insert(Item item) {
    Item existing = items.put(item.id, item);

    if (existing == null) {
        mySortedList.add(item);
    } else {
        int ind = mySortedList.indexOf(existing);
        mySortedList.updateItemAt(ind, item);
    }
}

DiffUtil

DiffUtils calculates the differences between 2 lists. A sample usage looks like this:

  DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldList, newList));
  myAdapter.setItems(newList);
  result.dispatchUpdatesTo(myAdapter);

How my callback looks like:

class MyCallback extends DiffUtil.Callback {
    @Override
    public int getOldListSize() {
        return mOld.size();
    }

    @Override
    public int getNewListSize() {
        return mNew.size();
    }

    @Override
    public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
        return mOld.get(oldItemPosition).id == mNew.get(newItemPosition).id;
    }

    @Override
    public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
        return mOld.get(oldItemPosition).equals(mNew.get(newItemPosition));
    }
}

A new method used in the API is getChangePayload. It is used to tell which part of your view has exactly changed. It may not be the whole row of the view, but only the votesCount connected with a certain TextView. To implement this API you have to do:

@Override
@Nullable
public Object getChangePayload(int oldItemPosition,
int newItemPosition) {
    Item oldItem = mOldItems.get(oldItemPosition);
    Item newItem = mNewItems.get(newItemPosition);
    if (oldItem.votes != newItem.votes) {
        return VOTES;
    }
    return null;
}

So in the new onBindViewHolder method you can check what the changes are and update only the elements connected with them:

@Override 
public void onBindViewHolder(RecyclerView.ViewHolder holder,
        int position,List<Object> payloads) {

    if (payloads.isEmpty()) {
        onBindViewHolder(holder, position);
    } else {
        if (payloads.contains(VOTES)) {
            holder.voteCount.setText("" + item.votes);
        }
    }
}

Resource Management

A ViewHolder lifecycle: onCreate -> onBindViewHolder -> onViewAttachedToWindow -> (user scrolls down) onViewDetachedFromWindow -> (the same item may get reatttached and call onViewAttachedToWindow) -> onRecycled (release resources)

RecyclerView is Async

on frame -> handle pending changes -> (calling scrollToPostion, notifyDatasetChanged etc. it has implications)

So if you call recyclerView.scrollToPosition(15) and then immediately call int x = layoutManager.getFirstVisibleItemPosition(); x won’t be 15 because all the things happen async. The better approach is:

void onCreate(SavedInstanceState state) {
    ....
    mRecyclerView.scrollToPosition(selectedPosition);
    mRecyclerView.setAdapter(myAdapter);
}

It will work just fine because they are both async and will happen on the next frame.

void onCreate(SavedInstanceState state) {
    ....
    mRecyclerView.scrollToPosition(selectedPosition);
    model.loadItems(items ->
        mRecyclerView.setAdapter(
            new ItemAdapter(items));
    );
}

This will work also because until both adapter and LayoutManager are set the RecyclerView does not create views.

ViewHolder++

Create bind() method in the ViewHolder class. No need to keep the ViewHolder simple;

class ViewHolder {
    ...
    public bindTo(Item item, ImageLoader imageLoader) {
        title.setText(item.getTitle());
        body.setText(item.getBody());
        imageLoader.loadImage(icon, item.IconUrl());
    }
}

void onBindViewHolder(ViewHolder vh, int position) {
    vh.bindTo(items.get(position), mImageLoader);
}

This ViewHolder can be reused easily!

ViewTypes

Pass the layout resource id directly in the getItemViewType method.

@Override
public int getItemViewType(int position) {
    User user = mItems.get(position);
    if (user.isPremium()) {
        return TYPE_PREMIUM;
    }
    return TYPE_BASIC;
}

public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    View view = mLayoutInflater.inflate(viewType, parent, false);
    return new UserViewHolder(view);
}

Click listeners

Why doesn’t RecyclerView has onItemClickListener? If you set an onItemClickListener on a ListView, it prevents children clicks.

ItemClickListener

When creating a ViewHolder in onCreateViewHolder attach the click listener there. You don’t the the click method to rebind in onBindViewHolder. The ViewHolder has a method called getAdapterPosition which returns the selected position of the item. You must check the position agains -1, because the rebind is async so there may be no selected views.

class MyAdapter {
    ItemClickListener itemClickListener;
    public onCreateViewHolder(...) {
        final ViewHolder vh = ....;
        myViewHolder.itemView.setOnClickListener({
            int pos = vh.getAdapterPosition();
            if (pos != NO_POSITION) {
                itemClickListener.onClick(items[pos]);
            }
        });
    }
}

Adapter Position vs Layout Position

Adapter positions are the positions of the data item in the adapter. The layout position is the position of the view behind the data item. So, if you say that a data item order has changed the Adapter position changes immediately, while the layout position changes only after the recalculation of the layout.

 

Bad usages of RecyclerView

Extending RecyclerView

Overriding the onLayout method and wrapping the super call in try-catch block ad eating the exception Do not do this. This means there is a problem!

Old Habits Die Hard

onBindViewHolder(ViewHolder vh, final int position) – the position is not final! Use onCreateVH to create click listeners.

// Bad example
public void onBindViewHolder(ViewHolder vh, final int position) {
    vh.likeButton.setOnClickListener = new OnClickListener() {
        items[position].liked = true;
        notifyItemChanged(position);
    }
}

Create

Always create the ViewHolder. Do not return the same ViewHolder instance! The holder may not be null because the RecyclerView failed to recreate it, but do not return the same ViewHolder.

public void onCreateViewHolder(int type) {
    if (type == HEADER) {
        if (headerVH == null) {
            headerVH = new HeaderViewHolder(...);
        }
        return headerVH;
    }
}

Fooling the RecyclerView

Do not call the adapter.setData method on a separate thread and the adapter.notifyDataSetChanged on the main thread. There is a reason why it is done that way, the layout manager may be doing some work on the main thread while you are updating the items.

void refreshData() {
    new AsyncTask(...) {
        void doInBackground() {
            List<Item> items = webservice.fetch();
            adapter.setData(items);
        }
        void onPostExecute() {
            adapter.notifyDataSetChanged();
        }
    }
}

 

You may also like...