こんにちは、Androidエンジニアの @nakamuuu です。
フリマアプリのようなショッピングアプリやニュースアプリなどを使っていると、タブを無限に横スワイプできる画面構成をよく見かけることと思います。フリルのタイムライン画面でも同様の画面構成を採用し、カテゴリやユーザーが保存した条件ごとのタイムラインを切り替えて閲覧することができます。
Android版フリルではそれぞれのタイムラインを表示するタブをFragmentとして分割し、ViewPagerとFragmentStatePagerAdapterを組み合わせて画面を構築していました。しかし、この実装にはSavedStateに関わるいくつかの問題がありました。最近のリリースでこの「擬似無限ViewPager」の実装を改善したため、その際に得た知見を簡単に紹介していきたいと思います。
(この記事は Fablic Advent Calender 2017 の11日目の記事です。)
擬似無限ViewPagerのシンプルな実装
周回的にタブを切り替えられる無限ViewPagerを実装するのに簡単な方法は PagerAdapter#getCount()
で十分に大きな値を返すことでしょう。「無限」というと少し語弊があるかもしれませんが、一般ユーザーが使用する範囲内ではほぼ問題はないことと思います。FragmentStatePagerAdapterを継承し、簡単に擬似無限ViewPager用のPagerAdapterを作成すると以下のようになります。
class MyPagerAdapter(fragmentManager: FragmentManager, val items: ArrayList<Item>) : FragmentStatePagerAdapter(fragmentManager) { // アイテムが存在すれば無限にタブが存在するものとして振る舞う override fun getCount() = if (items.isEmpty()) 0 else Integer.MAX_VALUE override fun getItem(position: Int): Fragment { val internalItemPosition = getInternalItemPosition(position) // TODO: internalItemPositionを基にFragmentを生成して返す } // この値で ViewPager#setCurrentItem(int, boolean) を呼び出して、初期位置がちょうど真ん中になるようにする fun getInitialPosition() = (getCount().toDouble() / 2 / items.size).toInt() * items.size private fun getInternalItemPosition(position: Int) = if (items.isEmpty()) 0 else position % items.size }
冒頭でも述べましたが、この実装にはSavedStateに関わるいくつかの問題があります。この問題は主にScrollViewやRecyclerViewなどのスクロール可能なViewを各タブの中で使用していると顕著に表面化することと思います。
1つは「タブを一周するとスクロール位置が保持されていない」という問題です。例えば「新着」のタイムラインでスクロールを行った後、順にタブを切り替えていき、再度「新着」のタイムラインが表示されたとき、どのような表示になっているでしょうか?おそらく、スクロール位置が先頭に戻っていることでしょう。細かなところではありますが、少し不親切な挙動であると思います。
また、Android版フリルのタイムライン画面の場合はタブが動的に変更されるケースがあります。何らかのタイムラインでスクロールを行った後、タブが追加されたとします。この実装では、「スクロールしたタブのスクロール位置が先頭に戻る」「スクロールしていないタブが何故かスクロールした位置になっている」といった事象が発生しうるはずです。
FragmentStatePagerAdapterの実装を読み解く
先ほどの例のように、一般にViewPagerで多数のFragmentを扱う際にはSupport Libraryの FragmentStatePagerAdapter を使用することと思います。このFragmentStatePagerAdapterは、同じくSupport Libraryに存在する FragmentPagerAdapter とは異なり、画面外に移動した不要なFragmentを破棄するため、メモリ効率の面で有利です。
さて、FragmentStatePagerAdapterを用いて愚直に実装した擬似無限ViewPagerでは「タブを一周するとスクロール位置が保持されていない」「アイテムを動的に変更した時にスクロール位置が狂う」という2つの問題がありました。FragmentStatePagerAdapterの実装から、この問題の原因を探ってみます。
FragmentStatePagerAdapterでは破棄されたFragmentのSavedStateをArrayListとして保持しています。Fragmentの破棄が行われる destroyItem(ViewGroup, int, Object)
を見てみると、以下のようにArrayListの position
の位置にSavedStateを保存する実装となっています。
private ArrayList<Fragment.SavedState> mSavedState = new ArrayList<Fragment.SavedState>(); @Override public void destroyItem(ViewGroup container, int position, Object object) { // -------- 省略 -------- while (mSavedState.size() <= position) { mSavedState.add(null); } mSavedState.set(position, fragment.isAdded() ? mFragmentManager.saveFragmentInstanceState(fragment) : null); // -------- 省略 -------- }
そして、Fragmentの作成・復元が行われる instantiateItem(ViewGroup, int)
では、ArrayListの position
の位置にあるSavedStateを引っ張ってきて Fragment#setInitialSavedState(SavedState)
でセットしています。
@Override public Object instantiateItem(ViewGroup container, int position) { // -------- 省略(Fragmentが破棄されていなければそれを使い回す処理等) -------- Fragment fragment = getItem(position); if (mSavedState.size() > position) { Fragment.SavedState fss = mSavedState.get(position); if (fss != null) { fragment.setInitialSavedState(fss); } } // -------- 省略 -------- return fragment; }
これが先に挙げた2つの問題の原因です。タブを一周すれば position
は別の値となっているため、保持されているスクロール位置を含むViewの状態を含むSavedStateは適切に復元されません。 また、アイテムの動的な変更を行えば変更によってズレた分だけ異なる位置のFragmentにSavedStateが復元されてしまいます。*1
これを回避するにはFragmentStatePagerAdapterを基に、タブのポジションとは別のキーでSavedStateを保持するPagerAdapterを自前で実装する必要があります。*2
SavedStateを考慮して擬似無限ViewPager用のPagerAdapterを実装する
幸いなことに、FragmentStatePagerAdapterはそこまで複雑な処理をしていないため、PagerAdapterを継承して自前で同等の処理を記述することはそう難しくありません。自前で実装したAdapterの全体像は以下のGistをご覧ください。
InfiniteFragmentStatePagerAdapter · GitHub
この InfiniteFragmentStatePagerAdapter ではアイテム数 / Fragment / タイトルを取得する抽象関数の他に getItemIdentifier(position: Int)
という関数を定義しています。この関数で取得された文字列をSavedStateを保持するキーとして用いることで、誤ったFragmentにSavedStateが復元されることを防いでいます。
abstract class InfiniteFragmentStatePagerAdapter(private val fragmentManager: FragmentManager) : PagerAdapter() { abstract fun getItemCount(): Int // アイテム数を返す abstract fun getItem(position: Int): Fragment // 指定された位置に表示するFragmentを返す abstract fun getItemIdentifier(position: Int): String // SavedStateを保持するキーとして使用する識別子を返す abstract fun getItemTitle(position: Int): String // 指定された位置のタイトルを返す // -------- 省略 -------- }
フリルではInfiniteFragmentStatePagerAdapterを継承した以下のようなAdapterを作成し、タイムライン画面で使用しています。タブを一周したりアイテムを変更した際にも、
getItemIdentifier(position: Int)
から取得された識別子を基にSavedStateが適切なFragmentに復元されます。
class TimelineContentPagerAdapter(fragmentManager: FragmentManager) : InfiniteFragmentStatePagerAdapter(fragmentManager) { // Contentは各タブのタイトル / 識別子として使用する文字列 / Fragmentにargumentsとして渡すBundleを内包するオブジェクト var contents: ArrayList<Content> = ArrayList() set(value) { field = value notifyDataSetChanged() } override fun getItemCount() = contents.size override fun getItem(position: Int) = TimelineContentFragment().apply { arguments = contents[position].arguments } override fun getItemIdentifier(position: Int) = contents[position].identifier override fun getItemTitle(position: Int) = contents[position].title }
(※コードは実際のものから一部改変)
まとめ
FragmentStatePagerAdapterの実装を読み解くことで、アイテムの動的な変更にも対応した擬似無限ViewPagerを作成することができました。InfiniteFragmentStatePagerAdapterの継承先では周回を考慮して位置を計算する必要もなくなったため、コード的にもかなり簡潔になったように思います。
さて、来年の2月8日 / 9日に開催されるAndroidカンファレンス DroidKaigi 2018 には、弊社から黒川(@hydrakecat)、中村(@nakamuuu)の2名が登壇させていただきます。Fablic, inc. はスポンサーとしても協賛させていただいており、登壇・スポンサーの両面でイベントを盛り上げていければと思います。
@nakamuuu からは「ウィンドウサイズの変更に強い堅牢な画面の構築」というタイトルで、今回の記事にも関連する「状態の保持」周りの話も含めつつ、ウィンドウサイズの変更に強い画面の構築を通して得た知見、Tipsを共有させていただく予定です。
FablicのAndroidアプリ開発では、プラットフォームへの深い理解を基にしたユーザビリティの向上にも強く力を入れています。このような環境で共にプロダクトを作り上げていきたいエンジニアのご応募をお待ちしております!
*1:PagerAdapter#getItemPosition(Object) を適切にオーバーライドしていても、FragmentStatePagerAdapterで保持されているSavedStateの復元は位置の変更を考慮しません。
*2:アイテムの動的な変更を伴わない場合は antonyt/InfiniteViewPager のようにAdapterをラップする形での実装も可能です。