Developer's Blog

拡張性は無限大!Android の新コンポーネント RecyclerView の真価を探る

Fenrir Advent Calendar 2014

こんにちは。共同開発部の北川です。

Fenrir Advent Calendar 2014 20日目です。 今年、もっともグッときた出来事といえば Android 5.0 Lollipop のリリースですよね! Lollipop では Material Design が採用され、色と重なりでアプリの世界観を表すようになりました。これまでの Android とは異なり、アニメーションやインタラクションを重視するデザインへと進化しています。

見た目の変化もさることながら、Lollipop では機能面も数多く強化されています。 その中の一つ、RecyclerView は非常に柔軟で表現力の高い UI コンポーネントです。 RecyclerView を使った新しい表現に挑戦してみました。

RecyclerView

RecyclerView とは

Android ではデータコレクション表現のために ListView と Adapter が用意されています。 Adapter がデータソースを管理し、ListView は View の表示と、View の再利用を管理します。

RecyclerView はこの ListView と Adapter で実現していたものを、さらに細分化することで表示をカスタマイズできるようになっています。

RecyclerView は次のクラスと連携します。

RecyclerView

ViewGroup として、画面に表示される View の親となります。 ただし、レイアウトや View の再利用は別のクラスに任せており、 RecyclerView はそれらのクラスを保持し、処理を移譲しています。

RecyclerView.Adapter

Adapter クラスです。データソースを管理します。 View の生成とデータの反映も担当します。

RecyclerView.Recycler

Recycler クラスは、View の再利用性を管理します。 RecyclerView から削除された View は Recycler が再利用のために保持します。

RecyclerView.ViewHolder

ViewHolder クラスは、再利用される View を内包します。 View の再利用時の無駄な計算を省いて処理を高速化するために使われます。

RecyclerView.LayoutManager

LayoutManager クラスは、RecyclerView のレイアウトを決定します。 View の初期配置と、スクロール時の View の移動は LayoutManager が担当しています。

RecyclerView.ItemDecoration

アイテムに対する装飾を担当します。 View のまわりに線を引いたり、枠を付けるような処理を独立して実装できるようになります。

RecyclerView.SmoothScroller

スムーズスクロール処理を実装します。 レイアウトを独自実装していると、スクロール量の算出も独自に実装が必要となるため、 必要に応じて SmoothScroller を実装します。

これらのクラスはそれぞれがほとんど独立しています。 つまり、同じデータを保持 (Adapter) していながら、レイアウト (LayoutManager) だけを変更することもできますし、 アイテムの表示をカスタマイズ (ItemDecoration) することもできます。

ListView では View そのものがリスト表示処理を担っていたため、レイアウトもスクロール処理もカスタマイズすることができませんでした。RecyclerView では自由に実装することが可能となります。

どこで使えるか

RecyclerView は Android 5 (Lollipop) から提供され、Android 5 以上で利用できます。

さらに、Android Support Library (v7 RecyclerView Library) が提供されており、 Android 5 未満の Android でも利用できるようになっています! 明日からのアプリ開発では RecyclerView を検討しましょう!

挑戦してみた

RecyclerView は非常に柔軟です。本当に使えるのか?どこまで柔軟にレイアウトを実装できるのか?ということを探るため、 次のようなサンプルアプリを作成しました。

RecyclerViewサンプル

アイテムが重なって隠れているので使い物にならない、という現実的な指摘は真摯に受け止めたいと思います。

横スクロールをベースに、アイテムが重なるようなレイアウトとしています。 また、スクロールに合わせてアイテムのサイズが変化します。

サンプルは v7 Support Library を使用しており、Android 4.4 上での動作を確認しています。

サンプル実装の解説

サンプルコードの重要な部分だけ解説します。 サンプルの実装にあたっては、AOSP の LinearLayoutManager 実装をおおいに参考としました。

レイアウト作成

xml で定義します。

アイテムの View は v7 Support Library CardView を使用していますが、そのレイアウトは割愛します。

...
<android.support.v7.widget.RecyclerView
    android:id="@+id/recycler_view"
    android:scrollbars="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_centerVertical="true"
    />
...

View の初期化

RecyclerView へ、LayoutManager と Adapter を設定します。 この二つが設定されていれば、とりあえずは動作します。 LinearLayoutManager を設定することで従来の ListView と同様の表示となるため、 気軽に RecyclerView を使うことができますね。

HorizontalCardLayoutManager と DroidCardAdapter は独自実装のクラスです。

...
@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    mRecyclerView = (RecyclerView)findViewById(R.id.recycler_view);
    HorizontalCardLayoutManager manager =
        new HorizontalCardLayoutManager(getApplicationContext());
    DroidCardAdapter adapter = new DroidCardAdapter(getApplicationContext());
    mRecyclerView.setLayoutManager(manager);
    mRecyclerView.setAdapter(adapter);
    mRecyclerView.setHasFixedSize(false);
}
...

Adapter の実装

DroidCardAdapter は20個のアイテムを返すようにしました。 非常にシンプルですが、ViewHolder を使用することで onBindViewHolder 処理が軽量化されます。

public class DroidCardAdapter extends RecyclerView.Adapter {
    private LayoutInflater mInflator;

    public DroidCardAdapter(Context context) {
        mInflator = LayoutInflater.from(context);
    }

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
        // View を生成
        return new DroidViewHolder(
            mInflator.inflate(R.layout.droid_card, viewGroup, false));
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int i) {
        // View にデータを設定
        ((DroidViewHolder)viewHolder).mTextView.setText(String.valueOf(i));
    }

    @Override
    public int getItemCount() {
        return 20;
    }

    private static class DroidViewHolder extends RecyclerView.ViewHolder {
        public TextView mTextView;
        public DroidViewHolder(View itemView) {
            super(itemView);
            mTextView = (TextView)itemView.findViewById(R.id.text);
        }
    }
}

LayoutManager の実装

レイアウトを決定するもっとも複雑な部分です。

LayoutManager では、onLayoutChildrenscrollHorizontallyBy を実装します。 onLayoutChildren は View 生成時やデータセットが変更されたときなど、初回のレイアウト決定時のみ実行されます。scrollHorizontallyBy はスクロール時の View の動作を決定します。

この二つの実装により、自由なレイアウトとスクロールが可能となります。初期レイアウトとスクロールの挙動を定義することで自由なレイアウトを実現するという、数学的帰納法のような考え方の面白い仕組みです。

今回は実装を簡易化するため LinearLayoutManager を継承していますが、 これでは LinearLayoutManager の内部実装に依存してしまいます。 ゼロから LayoutManager を実装することが理想です。

/**
 * アイテムをリサイズしながら水平方向にスクロールするLayoutManager
 * LinearLayoutManagerをベースに作成する
 */
public class HorizontalCardLayoutManager extends LinearLayoutManager {
    private float mScale = 0.7f;

    public HorizontalCardLayoutManager(Context context) {
        super(context);
        this.setOrientation(LinearLayoutManager.HORIZONTAL);
    }

    /**
     * 座標からアイテムの拡大率の計算
     */
    private float getScale(int left, int parentWidth) {
        return 1.0f - ((1.0f - mScale) * left / parentWidth);
    }

    @Override
    public View findViewByPosition(int position) {
        // Viewの重なり順を逆転しているため
        // LinearLayoutManager.findViewByPositionの動作を修正
        final int childCount = getChildCount();
        if (childCount == 0) {
            return null;
        }
        final int firstIndex = childCount - 1;
        final int firstPosition = getPosition(getChildAt(firstIndex));
        final int viewIndex = firstIndex - (position - firstPosition);
        if (0 <= viewIndex && viewIndex < childCount) {
            return getChildAt(viewIndex);
        }
        return null;
    }
...

次は、onLayoutChildren による初期レイアウトの計算です。

地道に計算して、ひたすら View を生成して配置していきます。 アイテムをすべて配置し終えるか、画面外へはみ出すまで配置していきます。onLayoutChildren は初期レイアウト時のみに実行されるため、多少重い処理を行っても構いません。

本来であれば、onResume などを考慮し View 状態を復帰するような実装が必要です。

/**
 * 初回の View レイアウト
 */
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    // LinearLayoutManagerの処理を通しておく
    super.onLayoutChildren(recycler, state);
    // LinearLayoutManager によって配置されている View はすべて捨てる
    detachAndScrapAttachedViews(recycler);

    int count = getItemCount();
    int parentTop = getPaddingTop();
    int parentLeft = getPaddingLeft();
    int parentRight = getWidth() - getPaddingRight();
    int parentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
    int parentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
    int left = parentLeft, top = 0, bottom = 0, right = 0,
        height = 0, width = 0, middle = 0;
    float scale = 1.0f;
    for (int i = 0; (i < count) && (left < parentRight); i++, left = middle) {
        View view = recycler.getViewForPosition(i);
        addView(view, 0);
        measureChildWithMargins(view, 0, 0);
        scale = getScale(left, parentWidth);
        width = getDecoratedMeasuredWidth(view);
        height = getDecoratedMeasuredHeight(view);
        top = parentTop + (parentHeight - height) / 2;
        bottom = top + height;
        right = left + width;
        middle = left + width / 2;
        view.setScaleX(scale);
        view.setScaleY(scale);
        layoutDecorated(view, left, top, right, bottom);
    }
}

最後にもっとも複雑な部分、scrollHorizontallyBy によるスクロールの計算です。 スクロール計算もひたすら地道に計算していきます。

スクロールしたい方向と距離が引数で渡されます。 スクロール処理が終了したら、実際に移動した距離を返します。 scrollHorizontallyBy が 0 を返すと、それ以上スクロールができないことを意味します。

このサンプルにおけるスクロール計算はかなり雑であり、実際に動作させると挙動不審です… サンプルコードは参考に留めるようにしてください。

大きな流れは以下の通りです。

  1. 右または左方向にスクロールさせる
    • 必要に応じて新しい View を生成しながら、少量ずつスクロールする
  2. 画面からはみ出した View を削除する
  3. 移動した View のリサイズ処理

scrollHorizontallyBy は連続して大量に呼び出されるため、軽量実装を心掛けます。 たとえば、scrollHorizontallyBy 内でインスタンスの生成といった重い処理を挟むと おそらくパフォーマンスの問題が出るでしょう。LayoutManager クラスのメソッドと座標計算だけで 処理を完結させます。

offsetChildrenHorizontal によりすべての View を一括で水平移動することが可能です。 これを使わずにすべての View を個別に移動するようなループ処理を実装しても構いません。

/**
 * スクロール時のView移動やサイズ変更を行う
 */
@Override
public int scrollHorizontallyBy(int dx,
          RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getChildCount() <= 0) {
        return 0;
    }

    int count = getItemCount();
    int parentTop = getPaddingTop();
    int parentLeft = getPaddingLeft();
    int parentRight = getWidth() - getPaddingRight();
    int parentHeight = getHeight() - getPaddingTop() - getPaddingBottom();
    int parentWidth = getWidth() - getPaddingLeft() - getPaddingRight();
    int firstPosition = findLastVisibleItemPosition();
    View firstView = findViewByPosition(firstPosition);
    int lastPosition = findFirstVisibleItemPosition();
    View lastView = findViewByPosition(lastPosition);
    int firstIndex = 0;
    int lastIndex = getChildCount() - 1;
    int left = parentLeft, top = 0, bottom = 0, right = 0,
        height = 0, width = 0, middle = 0;
    float scale = 1.0f;
    int scrolled = 0;
    if (dx < 0) {
        // 右方向スワイプスクロール
        while (dx < scrolled) {
            left = getDecoratedLeft(firstView);
            middle = left + getDecoratedMeasuredWidth(firstView);
            // loop一回で移動できる距離を算出
            int scrollable = (0 < firstPosition)
                    ? Math.max(parentLeft - middle, 0)
                    : Math.max(parentLeft - left, 0);
            int scrollBy = Math.min(scrolled - dx, scrollable);
            scrolled -= scrollBy;
            // すべてのViewを水平方向に移動する
            offsetChildrenHorizontal(scrollBy);
            for (int i = 0; i < getChildCount(); i++) {
                View view = getChildAt(i);
                if (parentRight <= getDecoratedLeft(view)) {
                    // 画面からのはみ出しを検出
                    firstIndex++;
                    lastPosition--;
                }
            }
            if (dx < scrolled) {
                if (0 < firstPosition) {
                    // 新しいViewの生成
                    firstView = recycler.getViewForPosition(firstPosition - 1);
                    addView(firstView, getChildCount());
                    firstPosition--;
                    lastIndex++;
                    measureChildWithMargins(firstView, 0, 0);
                    right = middle;
                    left = right - getDecoratedMeasuredWidth(firstView);
                    height = getDecoratedMeasuredHeight(firstView);
                    top = parentTop + (parentHeight - height) / 2;
                    layoutDecorated(firstView, left, top, right, top + height);
                } else {
                    break;
                }
            }
        }
    } else if (0 < dx) {
        // 左方向スワイプスクロール
        while (scrolled < dx) {
            left = getDecoratedLeft(lastView);
            middle = left + getDecoratedMeasuredWidth(lastView)/2;
            // loop一回で移動できる距離を算出
            int scrollable = (lastPosition + 1 < count)
                            ? Math.max(middle - parentRight, 0)
                            : Math.max(left - parentRight, 0);
            int scrollBy = Math.min(dx - scrolled, scrollable);
            scrolled += scrollBy;
            // すべてのViewを水平方向に移動する
            offsetChildrenHorizontal(-scrollBy);
            for (int i = getChildCount() - 1; 0 <= i; i--) {
                View view = getChildAt(i);
                if (getDecoratedRight(view) <= parentLeft) {
                    // 画面からのはみ出しを検出
                    lastIndex--;
                    firstPosition++;
                }
            }
            if (scrolled < dx) {
                if (lastPosition + 1 < count) {
                    // 新しいViewの生成
                    lastView = recycler.getViewForPosition(lastPosition + 1);
                    addView(lastView, 0);
                    lastPosition++;
                    lastIndex++;
                    measureChildWithMargins(lastView, 0, 0);
                    left = middle;
                    height = getDecoratedMeasuredHeight(lastView);
                    top = parentTop + (parentHeight - height) / 2;
                    layoutDecorated(lastView, left, top,
                        left + getDecoratedMeasuredWidth(lastView),
                        top + getDecoratedMeasuredHeight(lastView));
                } else {
                    break;
                }
            }
        }
    }
    // 画面からはみ出たViewを削除
    for (int i = getChildCount() - 1; lastIndex < i; i--) {
        removeAndRecycleViewAt(i, recycler);
    }
    for (int i = firstIndex - 1; 0 <= i; i--) {
        removeAndRecycleViewAt(i, recycler);
    }
    for (int i = 0; i < getChildCount(); i++) {
        // 位置に応じてViewのリサイズ
        View view = getChildAt(i);
        left = getDecoratedLeft(view);
        scale = getScale(left, parentWidth);
        view.setScaleX(scale);
        view.setScaleY(scale);
    }
    return scrolled;
}

複雑でしたが、これでスクロール可能なレイアウトが完成しました。

まとめ

RecyclerView は非常に強力であることが分かりました。 独自のスクロール可能なレイアウトを作成するとき、View の再利用処理がネックとなりますが、 RecyclerView を使用することで適切に再利用対応をしてくれます。

ただし、レイアウト計算は緻密であり、座標計算に慣れていなければ実装は難しいかもしれません。 複雑な実装を避けることはできませんが、それでもこれまでに無かったような新しい UI を実現したいときには、RecyclerView に頼ってみてはいかがでしょうか。

フェンリルのオフィシャル Twitter アカウントでは、フェンリルプロダクトの最新情報などをつぶやいています。よろしければフォローしてください!

Copyright © 2019 Fenrir Inc. All rights reserved.