[Android] RecyclerViewでViewPagerっぽいのを作った時のTips

  • 17
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

TL;DR

https://github.com/lsjwzh/RecyclerViewPager をお使いください。(僕は作者ではありませんが、多分ViewPagerっぽいRecyclerViewを作ると大体このライブラリっぽくなると思います。なので使ってしまった方が楽なはず)

以降の記事は上記のライブラリがあることを実装の途中に知ったものの、実装面での落とし穴的なものに対して疑心暗鬼になりすぎた筆者が自力とコピペでRecyclerViewをViewPagerっぽくしたてあげた成れの果ての記録です。

Disclaimer

  • :warning: 筆者はAndroid実装初心者ですので、クソ実装をしている可能性があります。クソだと思った部分は絶対に真似しないでください。
    • もし指摘していただけるのであれば、涙を流して喜びます。
  • :warning: 2015/12時点の記事です。

環境

  • com.android.support:recyclerview-v7:23.1.1
  • Android4.0以降

必要な機能と事前調査と実装

必要な機能

  • ViewPagerっぽいページングスクロール
    • 1ページ1ページ、ヒュイッヒュイッってスワイプでスクロールさせたい
  • 中に出す子ビューの幅は可変
    • いろんな幅のビューを表示させたい
  • 表示スタイルはカバーフローっぽいタイプ
    • 中央にメインの子ビューが出ていて、左右にチラチラっと子ビューが見えてる
  • スクロールはきっちり中心から中心へ
    • 左右の子ビューの幅が違っててもね!
  • 無限スクロール

上記の「子ビューの幅は可変」と「カバーフローっぽい表示」をViewPagerでは両立させられず、結果別のViewで頑張ろうということになり、すべては始まったのです。

事前調査

実装に入る前に、どのビューなら出来そうかというのをちょこっと調べた記録を載せます。ちょこちょこ入っているコメントは調べている時に書いたメモです。

色々あるんだなぁ〜と悩んだわけですが、最終的にRecyclerViewで頑張れるんじゃないか(ググると色々引っかかるし)という結論に至り、それでやることにしました。

実装

とりあえず調べたことを繋ぎ合わせればそれっぽいのが出来そうだと思ったので、実装を進めました。

スクロール時の子ビューのセンタリング

とりあえず子ビューのセンタリングからかなと考え、先ほどの https://github.com/humblerookie/centerlockrecyclerview/ を参考に実装しました。
基本的な流れは、スクロールの状態がSCROLL_STATE_IDLEになった→一番中央に近い子ビューのズレをスクロールさせよう、という感じです。

public class HogeRecyclerViewPager extends RecyclerView {
...
    // インナークラスで実装してる想定
    static class HogeRecyclerViewCenterLockListener extends RecyclerView.OnScrollListener {

        // スクロールの再帰を防ぐ変数
        private boolean mAutoSet = true;

        @Override
        public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) {

            super.onScrollStateChanged(recyclerView, newState);

            new Thread(new Runnable() {

                @Override
                public void run() {

                    final LinearLayoutManager layoutManager    = (LinearLayoutManager) recyclerView.getLayoutManager();
                    final HogeRecyclerView    hogeRecyclerView = (HogeRecyclerView) recyclerView;

                    final int                 centerPivot      = hogeRecyclerView.getCenterPivot();
                    int                       _centerPosition  = hogeRecyclerView.getCenterPosition();

                    if (newState == SCROLL_STATE_IDLE) {
                        _centerPosition = hogeRecyclerView.getCenterPosition(true);
                        hogeRecyclerView.setScrollingBusy(false);
                        hogeRecyclerView.setNotFlinging(true);
                    }

                    final int centerPosition = _centerPosition;

                    if (!mAutoSet && newState == SCROLL_STATE_IDLE) {
                        recyclerView.post(new Runnable() {

                            @Override
                            public void run() {

                                final View view         = ViewUtil.findCenterViewByPosition(layoutManager, centerPosition);
                                final int  viewCenter   = ViewUtil.getCenter(view, centerPivot);
                                final int  scrollNeeded = viewCenter - centerPivot;

                                if (scrollNeeded != 0) {
                                    recyclerView.smoothScrollBy(scrollNeeded, 0);
                                }

                                mAutoSet = true;
                                hogeRecyclerView.setScrollingBusy(true);
                            }
                        });
                        // (*)
                    } else {
                        mAutoSet = false;
                        // (**)
                    }
                }
            }).start();
        }

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {

            super.onScrolled(recyclerView, dx, dy);
        }
...
}

1ページずつスワイプ

次にスワイプどうしようと考えようとしたタイミングで、https://github.com/lsjwzh/RecyclerViewPager の存在に気付きウボァとなったわけですが、ありがたく参考にさせていただくことにしました。
基本的な流れは、フリックイベントきた→閾値超えるぐらい強い→x方向の速度見てページングスクロール、という感じです。

public class HogeRecyclerViewPager extends RecyclerView {
...
    @Override
    public boolean fling(int velocityX, int velocityY) {

        boolean flinging = mNotFlinging && super.fling((int) (velocityX * mFlingFactor), (int) (velocityY * mFlingFactor));

        if (flinging) {
            mNotFlinging = false;
            boolean canScrollHorizontally = getLayoutManager().canScrollHorizontally();
            if (canScrollHorizontally && mPageAdjustLowThreshold < velocityX) {
                adjustNextPage();
            } else if (canScrollHorizontally && velocityX < -mPageAdjustLowThreshold) {
                adjustPreviousPage();
            }
        }

        return flinging;
    }
...
    private void adjustNextPage() {

        final int centerPosition = mCenterPosition;

        post(new Runnable() {
            @Override
            public void run() {

                final LinearLayoutManager layoutManager = (LinearLayoutManager) getLayoutManager();
                final int                 centerPivot   = getCenterPivot();
                final int                 scrollDx      = ViewUtil.getRightPageScrollDx(layoutManager, centerPosition, centerPivot);
                smoothScrollBy(scrollDx, 0);
            }
        });
    }
...
    private void adjustPreviousPage() {

        final int centerPosition = mCenterPosition;

        post(new Runnable() {
            @Override
            public void run() {

                final LinearLayoutManager layoutManager = (LinearLayoutManager) getLayoutManager();
                final int                 centerPivot   = getCenterPivot();
                final int                 scrollDx      = ViewUtil.getLeftPageScrollDx(layoutManager, centerPosition, centerPivot);
                smoothScrollBy(scrollDx, 0);
            }
        });
    }
...
    // スクロールしてる最中に中央位置のインデックスを更新しないと、どうも二枚飛ばしのスクロールが発生しやすい気がします
    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {

        super.onScrollChanged(l, t, oldl, oldt);

        if (!mScrollingBusy && Math.abs(l - oldl) < 2) {
            updateCenterPosition();
        }
    }
...
}

アダプター

アダプターでやる特別な実装はほぼないと思うのですが、無限スクロールのためにRecylerView.Adapter::getItemCountがInteger.MAX_VALUEを返す必要があります。またその為に表示用の無限スクロール位置と、アダプター内部で保持しているリストの位置を気をつけて扱うことが重要になると思います。出来るだけ分かりやすくコメントで残しておくと良いような気がします。

public class HogeRecyclerViewAdapter extends RecyclerView.Adapter<HogeRecyclerViewAdapter.ViewHolder> {
...
    /**
     * 無限スクロール位置からアダプターの内部位置を取得する
     *
     * @param position 無限スクロール位置
     *
     * @return 実際の表示アイテムを得るための位置
     */
    public int getItemWindowPosition(int position) {

        int itemSize = getItemSize();

        if (position < 0) {
            return 0;
        } else if (itemSize > 0 && itemSize <= position) {
            return position % itemSize;
        } else {
            return position;
        }

    }
...
    @Override
    public int getItemCount() {

        return getCount();
    }

    // }}} RecycerView.Adapter overriding methods

    // Private APIs {{{

    /**
     * アイテムのカウントを返す。正確な値は返さないので、getItemSizeで取得すること
     *
     * @return アイテムがあればInteger.MAX_VALUE、何もなければ0
     */
    private int getCount() {

        return (mItems.isEmpty()) ? 0 : Integer.MAX_VALUE;
    }
...
}

発生した問題と解決方法

とりあえず上記の実装をすれば最低限動くビューにはなるかなと思います。
以降は僕の実装で発生した問題点と、その対処についてです。

ページングスクロールで二枚飛ばしが頻発した

スワイプした時に1ページだけスクロールするのではなく、2ページスクロールしてしまう現象が最初多発しました。
二枚飛ばしを完全に防ぐことは多分難しいと僕は思っていて、それでも多少頻度を下げるために先ほどのHogeRecyclerView::onScrollChangedの中の処理を入れました。デバッグしていて感じたのですが、勢いをつけてフリックしまくるとRecyclerViewの子ビューの中央インデックスのインクリメント処理で1上がってその直後に1上がるため結果2上がったみたいな現象が発生するようでした。ようはフリックし終わった後にまだ勢いがある状態で中央インデックスが更新された結果、もう1上がってしまう、という感じです。

なので子ビューの中央インデックスの更新を頻繁に行うことで1上がったらそこで終わりを出来るだけ徹底させるようにしました。

初期表示時のセンタリングのズレ

android:gravitycenterを指定しているのですが、なぜか起動直後だけは僅かなセンタリングのズレが発生しました。色々やった結果onCreateViewした後scrollToで無理やりセンタリングさせることで回避しました。オフセットはきっちり指定します。

スクロールが、ヌルヌルッのヌルヌルヌルヌルヌルにならない

ページングスクロールをヌルヌルにしたい。僕は生半可なヌルヌルでは到底満足できませんでした。そもそも最低限の実装だとカクツキがひどい状態でした。

とにかくカックカク

だいたいRecyclerView.Adapter::onBindViewHolderの中で色々な処理をやろうとしていたのが原因でした。中の処理をnew Thread(new Runnable() {...})で切り出すと、それなりにマシになりました。

妙な引っかかるようなカクツキがある

しかしそれでも妙に引っかかるようなカクツキがあります。ゆっくりフリックするとRecyclerView.Adapter::onBindViewHolderの処理が走る部分で明らかにカクッと引っかかっていました。
こりゃ`RecyclerView.Adapter::onBindViewHolderの内部を少しでも軽くしないとダメぽ、と思ったので、次のようにしました。

  • スクロール中はRecyclerView.Adapter::onBindViewHolderの処理は実行させない
    • その間の処理はThreadPoolExecutorに貯めます
  • ページングスクロールが完了し、SCROLL_STATE_IDLEになったタイミングで処理を一気に実行させる

ThreadPoolExecutorは公式に書いてある実装(pause/resume付き)を使用し、スクロール中はひたすらpause状態で待たせます。スクロールが終わったら(コード的にはHogeRecyclerViewCenterLockListener::onScrollStateChanged(*)の部分です、)resume状態にします。これで引っかかりはほぼ無くなりました。

とはいえここまで僕が色々やる羽目になったのは、`RecyclerView.Adapter::onBindViewHolder`の中で色々やっていたせいだと思います。アプリによっては特に気にする必要がない部分かもしれません。

まとめ

疑似コードオンリーで申し訳ないのですが、

  • RecyclerView::flingでページングスクロールを開始する
  • 中央へビューを吸い寄せるようなのはRecyclerView.OnScrollListenerでスクロール状態の変化をキャッチして吸い付かせる
  • RecyclerViewAdapter::onBindViewHolderの中で思い処理は絶対にやらない
    • 特にフリックの最中やスクロールの最中にRecyclerView.Adapter::onBindViewHolderが呼ばれると確実にカクツクと思います
  • 各々のアプリで求めるべきもの(スムーズなスクロールなのか、きっちりページングさせるスクロールなのか)があると思うので、それに合わせて細かく調整する
    • 特に二枚飛ばしするようなページングスクロールでもいい場合はもっと楽な実装にできると思います

ということを言いたい感じでした。

読んでいただきありがとうございました。

参考コード