Fragmentで自身のPager内での表示状態を監視する

この記事は [リクルートライフスタイル Advent Calendar 2016](http://qiita.com/advent-calendar/2016/recruitlifestyle) の4日目の記事です。

ビューティー開発Tの@sakuna63です。
Androidエンジニアなら愛してやまない(?)Fragmentについて書きます。

## 問題

ViewPager内でFragmentを利用する際、Fragmentと対応するページが選択されたタイミングで処理を実行したくなることがあります。例えば、動画やアニGIFの再生を自動で開始するといった場合です。

onViewCreatedやonResumeなどのライフサイクルに合わせて実行すればいいように思えますが、ViewPagerを利用する場合そうはいきません。ViewPagerは`setOffscreenPageLimit`で渡される値に応じて、選択されたページから左右数ページ(default:1)のインスタンスを事前に生成しようとするためです。これによって、選択状態でなくともFragmentのライフサイクルが始まってしまいます。

そのため、対応するページが選択されたことをFragmentに通知する必要があります。単純に考えると、`OnPageChangeListener#onPageSelected`のタイミングで、Fragmentのインスタンスを取得[^1]し、通知するという方法が考えられます。ただし、この方法はどうもスマートではありません。。。 :thinking: 

## `Fragment#setUserVisibleHint`を使う

そこで登場するのが`Fragment#setUserVisibleHint`です。以下のように、Fragment内でオーバーライドすることで、ユーザに対し可視状態になっているかどうかを監視できます。簡単ですね[^2]。

```java
class MyFragment extends Fragment {
    //...
    @Override
    public void setUserVisibleHint(boolean isVisibleToUser) {
        if (isVisibleToUser) {
            // 表示状態になったときの処理
        } else {
            // 非表示状態になったときの処理
        }
    }
    // ...
}
```
1つ注意点として、このメソッドはonCreateViewなどのライフサイクルメソッドよりも先に呼び出されることがあります[^3][^4]。ドキュメントにも以下のような記載があります。
> Note: This method may be called outside of the fragment lifecycle. and thus has no ordering guarantees with regard to fragment lifecycle method calls.



これで問題解決!!:tada::tada::tada:やったね!!:clap::clap::clap:...では面白くありません。
もう少し掘り下げて、このメソッドがフレームワーク内でどのように呼び出されているのかを見ていきましょう。

SupportLibraryのミラーリポジトリを検索してみたところ、このメソッドは`FragmentCompat`, `FragmentPagerAdapter`, `FragmentStatePagerManager`の3つから呼び出されていることがわかります。つまり実質的に、`Fragment#setUserVisibleHint`はViewPagerを使う際にしか呼び出されないということになります。
https://github.com/android/platform_frameworks_support/search?utf8=%E2%9C%93&q=setuservisiblehint

以下は`Fragment#setUserVisibleHint`の呼び出し部分の抜粋です。

```java:FragmentPagerAdapter.java
public abstract class FragmentPagerAdapter extends PagerAdapter {
    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        //...
        if (fragment != mCurrentPrimaryItem) {
            fragment.setMenuVisibility(false);
            fragment.setUserVisibleHint(false);
        }

        return fragment;
    }

    @Override
    public void setPrimaryItem(ViewGroup container, int position, Object object) {
        Fragment fragment = (Fragment)object;
        if (fragment != mCurrentPrimaryItem) {
            if (mCurrentPrimaryItem != null) {
                mCurrentPrimaryItem.setMenuVisibility(false);
                mCurrentPrimaryItem.setUserVisibleHint(false);
            }
            if (fragment != null) {
                fragment.setMenuVisibility(true);
                fragment.setUserVisibleHint(true);
            }
            mCurrentPrimaryItem = fragment;
        }
    }
}
```

```java:FragmentStatePagerAdapter.java
public abstract class FragmentStatePagerAdapter extends PagerAdapter {
    //...
    @Override
    public Object instantiateItem(ViewGroup container, int position) {
        //...
        fragment.setMenuVisibility(false);
        fragment.setUserVisibleHint(false);
        return fragment;
    }

    @Override
    public void setPrimaryItem(ViewGroup container, int position, Object object) {
        Fragment fragment = (Fragment)object;
        if (fragment != mCurrentPrimaryItem) {
            if (mCurrentPrimaryItem != null) {
                mCurrentPrimaryItem.setMenuVisibility(false);
                mCurrentPrimaryItem.setUserVisibleHint(false);
            }
            if (fragment != null) {
                fragment.setMenuVisibility(true);
                fragment.setUserVisibleHint(true);
            }
            mCurrentPrimaryItem = fragment;
        }
    }
}
```

上記のコードから

- Fragment生成時(`instantiateItem`)
- Fragmentがプライマリになる(= 選択状態になる)時(`setPrimaryItem`)

の2つのタイミングでのみ呼び出されることがわかります。

## `Fragment#onHiddenChanged`ではダメなのか？

名前がそれらしいので勘違いしてしまいますが、このメソッドは `FragmentTransaction#show`, `FragmentTransaction#hide`と対応して呼び出されるメソッドになります。なので、今回のユースケースには合致しません。

## おまけ - `PagerAdapter#getPageWidth`との併用

`PagerAdapter#getPageWidth`はViewPagerの幅に対するページ幅の比率を`float`で返すメソッドです。例えば、`0.5f`を返却することで、1画面中に2ページを同時に表示させることができます。

```java
class MyPagerAdapter extends FragmentPagerAdapter {
    //...
    @Override
    public float getPageWidth(int position) {
        return 0.5f;
    }
}
```

### 問題
上記のアダプタを使用する場合、同時に表示される2つのページに対して`setUserVisibleHint(true)`が呼び出されることが理想です。しかし、現実はそう簡単ではありません。。。:cry:
上記で引用したコードからもわかるように、`FragmentPagerAdapter`, `FragmentStatePagerAdapter`はどちらもプライマリのページ(≒ 選択されているページ)は1つであるという前提で実装されてしまってます。そのため、以下2つの問題が生じてしまいます。

- 表示されているページのうち、片方、具体的にはpositionの小さいページに対してのみ`setUserVisibleHint(true)`が呼びさ出される
- 表示されているページの内、positionの大きいページが非表示になった際に`setUserVisibleHint(false)`が呼び出されない
    - 1つ前のプライマリページに対してのみ`setUserVisibleHint(false)`を呼び出そうとするため(引用コード参照)

### 対応案

上記の問題を解決したコードが以下になります。対策はシンプルで、以下2つの処理を行っています。

- 画面内に表示されている非プライマリなページに対しても`setUserVisibleHint(true)`を呼び出す
- 画面外に行ってしまった、非元プライマリなページに対して `setUserVisibleHint(false)`



```java
class MyPagerAdapter extends FragmentPagerAdapter {
    @Override
    public void setPrimaryItem(ViewGroup container, int position, Object object) {
        super.setPrimaryItem(container, position, object);
        float pageWidth = getPageWidth(position);
        // 画面内に表示されているFragment全てに対してsetUserVisibleHint(true)を呼び出す
        while (pageWidth < 1.0f) {
            position++;
            setFragmentVisibility(position);
            pageWidth += getPageWidth(position);
        }
        // 画面外に行ってしまってFragmentに対してsetUserVisibleHint(false)を呼び出す
        setFragmentVisibility(position + 1);
    }

    private void setFragmentVisibility(int position) {
        Fragment fragment = fm.findFragmentByTag("android:switcher:" + R.id.pager + ":" + position);
        if (fragment != null) {
            fragment.setUserVisibleHint(true);
        }
    }
}
```

結局、最初に述べたスマートじゃない方法[^1]を利用してしまっていますが、ひとまずこの対応案に落ち着きました。。
何かいい対応案を知っている方がいましたら、是非教えていただきたいです！

## 次回

次回(5日目)は同期の @tacumai が書いてくれます。



[^1]: PagerAdapterからFragmentを取得する方法は[こちら](http://qiita.com/chooblarin/items/88b4accac0cbb6944d4b)が参考になります。
[^2]: http://stackoverflow.com/questions/10024739/how-to-determine-when-fragment-becomes-visible-in-viewpager
[^3]: https://developer.android.com/reference/android/app/Fragment.html#setUserVisibleHint(boolean)
[^4]: http://stackoverflow.com/questions/24161160/setuservisiblehint-called-before-oncreateview-in-fragment