Sunday, December 21, 2014

RecyclerView: Grid with header

GridView shares a lot of features with ListView, with one noticeable difference: no headers or footers. Now they are unified under RecyclerView, and I want to see how I can add a header to a grid.

GridLayoutManager

I created a RecylcerView using a GridLayoutManager with a spanCount of 2.

RecyclerView recyclerView = (RecyclerView) findViewById(
    R.id.recycler_view);
recyclerView.addItemDecoration(new MarginDecoration(this));
recyclerView.setHasFixedSize(true);
recyclerView.setLayoutManager(new GridLayoutManager(this, 2));
recyclerView.setAdapter(new NumberedAdapter(30));

NumberedAdapter shows the position of the item as string, and toasts when clicked.

Variable span size

In the basic setup I have a spanCount of 2, each item with a span size of 1. A header will need a span size of 2 instead. Before I try to add a header, I want to see how I can change the span size. Turns out to be fairly easy.

GridLayoutManager manager = new GridLayoutManager(this, 3);
manager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
  @Override
  public int getSpanSize(int position) {
    return (3 - position % 3);
  }
});
recyclerView.setLayoutManager(manager);

setSpanSizeLookup lets you change the span size according to the position. This formula gives me span sizes 3, 2, 1, 3, 2, 1...

Header

Now let's add a header! We will need an adapter that providers two view types, one the header and one for the items. Take a look at HeaderNumberedAdapter, which takes a View as the header in the constructor and stash it away in a member variable.

public boolean isHeader(int position) {
  return position == 0;
}

@Override
public TextViewHolder onCreateViewHolder(
    ViewGroup parent, int viewType) {
  if (viewType == ITEM_VIEW_TYPE_HEADER) {
    return new TextViewHolder(header);
  }
  View view = LayoutInflater.from(parent.getContext())
    .inflate(R.layout.item, parent, false);
  return new TextViewHolder(view);
}

@Override
public void onBindViewHolder(
    final TextViewHolder holder, final int position) {
  if (isHeader(position)) {
    return;
  }

  // Subtract 1 for header
  final String label = labels.get(position - 1);

  holder.textView.setText(label);
  holder.textView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
      Toast.makeText(holder.textView.getContext(), 
          label, Toast.LENGTH_SHORT).show();
    }
  });
}

@Override
public int getItemViewType(int position) {
  return isHeader(position) ? 
    ITEM_VIEW_TYPE_HEADER : ITEM_VIEW_TYPE_ITEM;
}

@Override
public int getItemCount() {
  return labels.size() + 1;
}

When the RecyclerView creates a view, if we are at the header position we wrap the stashed header with the view holder. On bind does not need to do anything for the header since the logic is done in the activity. However, we need to subtract the position by 1 when we bind the remaining items.

Back to the activity. We need to initialize the HeaderNumberedAdapter with a header, and also override the setSpanSizeLookup to have the header span all the columns.

final GridLayoutManager manager = new GridLayoutManager(this, 2);
recyclerView.setLayoutManager(manager);

View header = LayoutInflater.from(this).inflate(
    R.layout.header, recyclerView, false);
header.setOnClickListener(new View.OnClickListener() {
  @Override
  public void onClick(View v) {
    Toast.makeText(v.getContext(), R.string.grid_layout_header, 
        Toast.LENGTH_SHORT).show();
  }
});
final HeaderNumberedAdapter adapter 
    = new HeaderNumberedAdapter(header, 30);
recyclerView.setAdapter(adapter);

manager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
  @Override
  public int getSpanSize(int position) {
    return adapter.isHeader(position) ? manager.getSpanCount() : 1;
  }
});

We inflate the header, define its click behavior, and use it to construct the adapter. Then, in setSpanSizeLookup, we return the span count as the span size if we are at the header position.

Summary

To create a grid with header using RecyclerView:

  1. Define an adapter with two view types, one for the header and one of the items.
  2. Inflate a header and pass it to the adapter.
  3. Override setSpanSizeLookup in GridLayoutManager to return the span count as the span size for the header.

Source: https://github.com/chiuki/android-recyclerview

P.S.: I got a comment on my Google+ post that you can also copy HeaderGridView.java out of AOSP if you don't need other RecyclerView functionalities such as animation, reordering and staggering.

24 comments:

Inline coding questions will not be answsered. Instead, ask on StackOverflow and put the link in the comment.

  1. Thanks, I was able to add a Viewpager as the header to a recyclerView using your post as a starting point.

    ReplyDelete
  2. The best solution. I used the code to make a footer. Thanks a lot.

    ReplyDelete
  3. Thank you very much. Took me a minute to get the span aspect of it but finally got it.

    ReplyDelete
  4. Thank you , I couldn't imagine it was so easy to implement

    ReplyDelete
  5. Thanks! Very useful and quite easy to implement!

    ReplyDelete
  6. Thank you so much Chiuki! You are the best! Love you!

    ReplyDelete
  7. Spend a lot of time to find this. Thank you!

    ReplyDelete
  8. Instead of setting a new click listener each time in onBindViewHolder(), I suggest you set it once in onCreateViewHolder(), then you fetch the label using getAdapterPosition() in onClick() (or you retrieve it from the TextView).

    ReplyDelete
  9. The documentation states that if you override getSpanSize() without overriding getSpanIndex() in your own SpanSizeLookup like you do here, you should enable the span index cache using setSpanIndexCacheEnabled(true).

    ReplyDelete