この記事は リクルートライフスタイル Advent Calendar 2016 の4日目の記事です。
ビューティー開発Tの@sakuna63です。
Androidエンジニアなら愛してやまない(?)Fragmentについて書きます。
問題
ViewPager内でFragmentを利用する際、Fragmentと対応するページが選択されたタイミングで処理を実行したくなることがあります。例えば、動画やアニGIFの再生を自動で開始するといった場合です。
onViewCreatedやonResumeなどのライフサイクルに合わせて実行すればいいように思えますが、ViewPagerを利用する場合そうはいきません。ViewPagerはsetOffscreenPageLimit
で渡される値に応じて、選択されたページから左右数ページ(default:1)のインスタンスを事前に生成しようとするためです。これによって、選択状態でなくともFragmentのライフサイクルが始まってしまいます。
そのため、対応するページが選択されたことをFragmentに通知する必要があります。単純に考えると、OnPageChangeListener#onPageSelected
のタイミングで、Fragmentのインスタンスを取得1し、通知するという方法が考えられます。ただし、この方法はどうもスマートではありません。。。
Fragment#setUserVisibleHint
を使う
そこで登場するのがFragment#setUserVisibleHint
です。以下のように、Fragment内でオーバーライドすることで、ユーザに対し可視状態になっているかどうかを監視できます。簡単ですね2。
class MyFragment extends Fragment {
//...
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
if (isVisibleToUser) {
// 表示状態になったときの処理
} else {
// 非表示状態になったときの処理
}
}
// ...
}
1つ注意点として、このメソッドはonCreateViewなどのライフサイクルメソッドよりも先に呼び出されることがあります34。ドキュメントにも以下のような記載があります。
Note: This method may be called outside of the fragment lifecycle. and thus has no ordering guarantees with regard to fragment lifecycle method calls.
これで問題解決!!やったね!!...では面白くありません。
もう少し掘り下げて、このメソッドがフレームワーク内でどのように呼び出されているのかを見ていきましょう。
SupportLibraryのミラーリポジトリを検索してみたところ、このメソッドはFragmentCompat
, FragmentPagerAdapter
, FragmentStatePagerManager
の3つから呼び出されていることがわかります。つまり実質的に、Fragment#setUserVisibleHint
はViewPagerを使う際にしか呼び出されないということになります。
https://github.com/android/platform_frameworks_support/search?utf8=%E2%9C%93&q=setuservisiblehint
以下はFragment#setUserVisibleHint
の呼び出し部分の抜粋です。
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;
}
}
}
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ページを同時に表示させることができます。
class MyPagerAdapter extends FragmentPagerAdapter {
//...
@Override
public float getPageWidth(int position) {
return 0.5f;
}
}
問題
上記のアダプタを使用する場合、同時に表示される2つのページに対してsetUserVisibleHint(true)
が呼び出されることが理想です。しかし、現実はそう簡単ではありません。。。
上記で引用したコードからもわかるように、FragmentPagerAdapter
, FragmentStatePagerAdapter
はどちらもプライマリのページ(≒ 選択されているページ)は1つであるという前提で実装されてしまってます。そのため、以下2つの問題が生じてしまいます。
- 表示されているページのうち、片方、具体的にはpositionの小さいページに対してのみ
setUserVisibleHint(true)
が呼びさ出される - 表示されているページの内、positionの大きいページが非表示になった際に
setUserVisibleHint(false)
が呼び出されない- 1つ前のプライマリページに対してのみ
setUserVisibleHint(false)
を呼び出そうとするため(引用コード参照)
- 1つ前のプライマリページに対してのみ
対応案
上記の問題を解決したコードが以下になります。対策はシンプルで、以下2つの処理を行っています。
- 画面内に表示されている非プライマリなページに対しても
setUserVisibleHint(true)
を呼び出す - 画面外に行ってしまった、非元プライマリなページに対して
setUserVisibleHint(false)
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 が書いてくれます。
-
http://stackoverflow.com/questions/10024739/how-to-determine-when-fragment-becomes-visible-in-viewpager ↩
-
https://developer.android.com/reference/android/app/Fragment.html#setUserVisibleHint(boolean) ↩
-
http://stackoverflow.com/questions/24161160/setuservisiblehint-called-before-oncreateview-in-fragment ↩