RecyclerViewのGridLayoutManagerでフッターにProgressBarを出す方法

RecyclerViewが発表されて1年半ほど経ちましたが、みなさんRecyclerViewは活用していますか?

これまではListView・GridViewを頑張って使っていたiQONも、直近のリリースから少しずつRecyclerViewに置き換えはじめました。 RecyclerViewはListView・GridViewよりも柔軟になり拡張しやすくなった代わりに、必要なものは自分で実装しないといけなくなりました。 そのため、ListView・GridViewにはあったけどRecyclerViewではなくなった機能が存在します。

今回はRecyclerViewの GridLayoutManager を使う際、データロード中フッターにProgressBarを出す方法を紹介したいと思います。 LinearLayoutManagerに関しては今回触れませんが 『ProgressBarを表示する』 の項を参考にしてもらえれば実装できると思います。

サンプルコード

今回の内容のサンプルコードはこちらになります。 https://github.com/nissiy/GridLayoutSample

参照していただけると理解が深まると思います。 興味がある方はビルドもしてみてください。

f:id:vasilyjp:20160129155446g:plain

実装

ProgressBarを表示する

RecyclerViewには ListView#addFooterView のような仕組みがないためフッターを自分で実装しないといけません。 フッターを作成してそこにProgressBarを表示させるには、以下のことを行う必要があります。

  • データロード前と後でデータセットに細工をする
  • データセットの中身を見て RecyclerView.Adapter#getItemViewType の返す値を変える

データロード前と後でデータセットに細工をする

以下のように、通信処理の前後でデータセットにStubをセットしたり、取り除いたりします。

// MainActivity.java
private void loadData(final int page) {
    // ProgressBarを表示させるためにStubをセット
    if (page > 1) {
        adapter.add(new ProgressStub());
    }

    // postDelayedして通信処理を仮想しています
    handler.postDelayed(new Runnable() {
        @Override
        public void run() {
            // 通信処理が終わったのでセットしたStubを取り除く
            if (page > 1) {
                adapter.remove(adapter.getItemCount() - 1);
            }
            
            // 通信して取得したデータを処理
            ...
        }
    }, 2000);
}

データセットの中身を見て RecyclerView.Adapter#getItemViewType の返す値を変える

RecyclerView.Adapter#getItemViewType(int position) をOverrideして、返す値を変えることで RecyclerView.Adapter#onCreateViewHolder 側で、ViewTypeによってViewHolderを分けることができます。

今回もデータセット内のStubの有無をチェックして、ViewTypeを返し分けて、ViewHolderを分けることでProgressBarを表示させるようにしています。 ヘッダーなどを実装する場合にも同様のアプローチを取ることで実装できます。

// PhotoGridAdapter.java
@Override
public int getItemViewType(int position) {
    Object object = objects.get(position);
    if (object instanceof ProgressStub) {
        // データがProgressStubの場合は通常とは違う値を返す
        return TYPE_PROG;
    } else {
        return TYPE_ITEM;
    }
}
// PhotoGridAdapter.java
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    RecyclerView.ViewHolder viewHolder;
    if (viewType == TYPE_ITEM) {
        FrameLayout view = (FrameLayout) inflater.inflate(R.layout.photo_layout, parent, false);
        AppCompatImageView photoImageView = (AppCompatImageView) view.findViewById(R.id.photo_image_view);
        photoImageView.setLayoutParams(new FrameLayout.LayoutParams(imageSize, imageSize));
        viewHolder = new PhotoLayoutHolder(view);
    } else {
        // ViewTypeがTYPE_PROGの場合はProgressBarのViewHolderを返す
        FrameLayout view = (FrameLayout) inflater.inflate(R.layout.progress_bar_layout, parent, false);
        viewHolder = new ProgressBarLayoutHolder(view);
    }
    return viewHolder;
}

カラム数をpositionごとに変える

GridLayoutManagerを使う際には、SpanCountを 2 や 3 などに設定してカラム数を決めると思います。 GridLayoutManagerは拡張することでカラム数をpositionごとに変更することができるため、ヘッダー・フッターを作りたい時や、グリッドの途中でぶち抜きのコンテンツを出したいときに細工を行います。

今回もフッターに出すProgressBarはキレイに中央寄りになってほしいので、ProgressBarを表示するpositionではカラム数が変わるようにGridLayoutManagerを拡張しました。

public class GridWithProgressLayoutManager extends GridLayoutManager {

    public GridWithProgressLayoutManager(Context context,
                                         final int spanCount,
                                         final RecyclerBaseAdapter adapter) {
        super(context, spanCount);

        setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
            // 今回はここを細工しています
            @Override
            public int getSpanSize(int position) {
                // ProgressBarを表示するpositionではSpanSizeをいっぱいに広げる
                if (adapter != null
                        && adapter.getItemViewType(position) == RecyclerBaseAdapter.TYPE_PROG) {
                    return spanCount;
                }
                // 1を返すと通常通りのSpanSizeになる
                return 1;
            }

            // 今回は触れませんが高速化のためにOverrideしています。詳しくは下記のURLを参照してください。
            // http://developer.android.com/intl/ja/reference/android/support/v7/widget/GridLayoutManager.SpanSizeLookup.html
            @Override
            public int getSpanIndex(int position, int spanCount) {
                if (adapter != null
                        && adapter.getItemViewType(position) == RecyclerBaseAdapter.TYPE_PROG) {
                    return 0;
                }
                return position % spanCount;
            }
        });
    }

}

ItemDecorationを使っている場合は注意が必要

ItemDecorationを使っている場合、処理が複数回呼ばれてProgressBarがカクついてしまいます。 そのため、ProgressBarの場合は処理をスキップしてあげる必要があります。

GridLayoutManager特有の問題のためLinearLayoutManagerに関しては気にしなくて大丈夫です。

// GridSpacingItemDecoration.java
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
    // ProgressBarのViewHolderの場合は処理をスキップする
    RecyclerView.ViewHolder viewHolder = parent.getChildViewHolder(view);
    if (viewHolder instanceof ProgressBarLayoutHolder) {
        return;
    }

    int position = parent.getChildAdapterPosition(view);
    int column = position % spanCount;

    outRect.left = column * spacing / spanCount;
    outRect.right = spacing - (column + 1) * spacing / spanCount;
    outRect.bottom = spacing;
}

まとめ

長年、ListView・GridViewを使い続けているプロジェクトの場合、RecyclerViewへ移行するとなると自分で実装しないといけないものが多くかなりハードであると思います。 iQONの場合も最適化のための独自の仕組みや、広告表示処理などが複雑に絡まっているためRecyclerViewへの移行には時間がかかっています。 ただ、RecyclerViewへ置き換えが完了したページを見ると、もともと巨大で手を入れにくかった処理がモジュールごとに分散できているのでメンテナンスがしやすくなっています。

シンプルなリスト表示・グリッド表示の場合には今まで通りListView・GridViewを使った方が良いと思いますが、positionごとにコンテンツを変えたり、アニメーションを駆使したりしたい場合は、長期的考えてRecyclerViewを使ったほうが良いと思います。

最後に

VASILYでは、一緒にiQONを開発してくれる仲間を募集しています。少しでもご興味のある方は是非こちらからご応募よろしくお願いいたします。