Tuesday, December 23, 2014

RecyclerView: Autofit grid

After I made a RecyclerView grid with a header, I realized that I want to make it auto fit as well. I want to define the size of each item and let the system compute the spanCount automatically.

To determine the number of spans, I need two things: the width of the RecyclerView, and the width of each item.

AutofitRecyclerView

We extend RecyclerView to get access to its width. As for the width of each item, when GridView has android:numColumns="auto_fit", it uses android:columnWidth to compute the number of columns. Let's reuse that attribute.

private void init(Context context, AttributeSet attrs) {
  if (attrs != null) {
    int[] attrsArray = {
        android.R.attr.columnWidth
    };
    TypedArray array = context.obtainStyledAttributes(
      attrs, attrsArray);
    columnWidth = array.getDimensionPixelSize(0, -1);
    array.recycle();
  }

  manager = new GridLayoutManager(getContext(), 1);
  setLayoutManager(manager);
}

In the constructor, we read the value of android:columnWidth and save it in a member variable. Later in onMeasure will will use it to determine the span count.

Even though we will be setting the span count in onMeasure, the app will crash if we wait until then to define a GridLayoutManager, so we create one here as well, with a span count of 1.

In onMeasure, we ask the super class to perform the measurement, then take the value from getMeasuredWidth to compute the span count.

protected void onMeasure(int widthSpec, int heightSpec) {
  super.onMeasure(widthSpec, heightSpec);
  if (columnWidth > 0) {
    int spanCount = Math.max(1, getMeasuredWidth() / columnWidth);
    manager.setSpanCount(spanCount);
  }
}

Notice the Math.max call? This makes sure that we will have at least a span count of 1, even if the column width is defined to be larger than the width of the RecyclerView.

With that, the span count changes with the width of the RecyclerView.

One thing to note: we are using the column width the compute the span count, and once that is given to the RecyclerView it will turn around and compute the width of each item. So for instance if your RecyclerView is 320dp wide and you use a column width of 72dp, a span count of 4 (320dp / 72dp = 4.4444) will be given to the RecyclerView, which will make your items 320dp / 4 = 80dp wide, not 72dp. Make sure your item layout takes that into account.

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

Why bother?

Since GridView already supports auto fit, why bother reimplementing it using RecyclerView? RecyclerView has a lot of other functionalities like built-in animations for inserting and removing items, reordering etc, so you may want to use that instead of GridView.

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.