Help us understand the problem. What is going on with this article?

FragmentPagerAdapter を使って動的にいろいろやってみた話

More than 3 years have passed since last update.

はじめに

仕事で必要になったので、ものすごーーーく今更ながら FragmentViewPager を触ってみました。

正直 WebView ほど苦戦しなかっt(ゲフンゲフン な感じでしたが、
「リストビューのアダプターみたいなもんだと思っていたイメージとちょっと違ったなー」とか、
「クリア処理がググって調べた方法よりシンプルに書けたなー」とかいろいろあったので、
メモとして残しておこうという試みですたい。

やりたいこと

  • 動的にページを追加したい
  • 動的にページを挿入したい
  • 動的にページを削除したい
  • 動的にページをクリアしたい(全削除したい)
  • ていうか、アダプターなんだし「リストビューのアダプター」と同じような感覚で使いたい

立ちふさがる3つの壁

壁1:アイテム(ぺージ)が返さなければいけない値がいろいろある

リストビューのアダプターだと、アダプター内にアイテムデータのリストを持って、
getItem だろうが getCount だろうが全て保持しているアイテムのリストの値をもとに返す!
というのは基本形になると思うのですが、FragmentPagerAdapter でそれをやろうとすると、
第1の壁(大したことない)が立ちはだかります。

FragmentPagerAdapter でそういったアイテム取得系のメソッドで
最低限オーバーライドすべきものは、以下の通りです。

  • Fragment getItem (int position)
  • int getCount()
  • CharSequence getPageTitle(int position)

最初は、フラグメントのリストがあればいいよねーぐらいに思っていたのですが、
ページタイトルが…ページタイトルが!

フラグメント側にページタイトル取得用のメソッドを持たせてもいいかなと思ったのですが、
そうするとページの数分フラグメントが増える事態になるので、できず…。

この時点で、すでに「なんか思ってたんと違う…!」となりました。(早

とはいえ、「分かれているなら、一緒にしちゃえばいいじゃない!」精神で、
1つのクラスにページのフラグメントとページのタイトルを持たせることにしたのでした。まる。

壁2:アイテムの ID は不変でユニークであるべきであるらしい

なんのかんのと問題を解決して、
まーとりあえずの見かけ上は動的に操作できるっぽくなった状態で立ちはだかった第2の壁がこれでした。

現象としては、削除したページと同じインデックス番号のページを表示させると落ちる

発生した例外は、こんな感じ。

FATAL EXCEPTION: main
Process: com.sample.tablayoutsample, PID: 25259
java.lang.IllegalStateException: Can't change tag of fragment SimpleTextFragment{c5245f3 #2 id=0x7f0c006a android:switcher:2131492970:2}: was android:switcher:2131492970:2 now android:switcher:2131492970:3
   at android.support.v4.app.BackStackRecord.doAddOp(BackStackRecord.java:422)
   at android.support.v4.app.BackStackRecord.add(BackStackRecord.java:413)
   at android.support.v4.app.FragmentPagerAdapter.instantiateItem(FragmentPagerAdapter.java:99)
   at android.support.v4.view.ViewPager.addNewItem(ViewPager.java:943)
   at android.support.v4.view.ViewPager.populate(ViewPager.java:1157)
   at android.support.v4.view.ViewPager.populate(ViewPager.java:1025)
   at android.support.v4.view.ViewPager$3.run(ViewPager.java:254)
   at android.view.Choreographer$CallbackRecord.run(Choreographer.java:767)
   at android.view.Choreographer.doCallbacks(Choreographer.java:580)
   at android.view.Choreographer.doFrame(Choreographer.java:549)
   at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:753)
   at android.os.Handler.handleCallback(Handler.java:739)
   at android.os.Handler.dispatchMessage(Handler.java:95)
   at android.os.Looper.loop(Looper.java:135)
   at android.app.ActivityThread.main(ActivityThread.java:5221)
   at java.lang.reflect.Method.invoke(Native Method)
   at java.lang.reflect.Method.invoke(Method.java:372)
   at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:899)
   at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:694)

うん、全然わかんない!

でも、なんとなく
「フラグメントのタグを変えようとしたけど、そんなんできねーよ!」
ってことっぽいというのは理解したので、
元の FragmentPagerAdapter のアイテム生成処理を見てみました。(一部抜粋)

@Override
public Object instantiateItem(ViewGroup container, int position) {
    if (mCurTransaction == null) {
        mCurTransaction = mFragmentManager.beginTransaction();
    }

    final long itemId = getItemId(position);

    // Do we already have this fragment?
    String name = makeFragmentName(container.getId(), itemId);
    Fragment fragment = mFragmentManager.findFragmentByTag(name);
    if (fragment != null) {
        if (DEBUG) Log.v(TAG, "Attaching item #" + itemId + ": f=" + fragment);
        mCurTransaction.attach(fragment);
    } else {
        fragment = getItem(position);
        if (DEBUG) Log.v(TAG, "Adding item #" + itemId + ": f=" + fragment);
        mCurTransaction.add(container.getId(), fragment,
                makeFragmentName(container.getId(), itemId));
    }
    if (fragment != mCurrentPrimaryItem) {
        fragment.setMenuVisibility(false);
        fragment.setUserVisibleHint(false);
    }

    return fragment;
}

/**
 * Return a unique identifier for the item at the given position.
 *
 * <p>The default implementation returns the given position.
 * Subclasses should override this method if the positions of items can change.</p>
 *
 * @param position Position within this adapter
 * @return Unique identifier for the item at position
 */
public long getItemId(int position) {
    return position;
}

private static String makeFragmentName(int viewId, long id) {
    return "android:switcher:" + viewId + ":" + id;
}

ふんふん、なるほど…。

もともとはぺージ位置をもとにフラグメントのタグを生成して add していたけど、
動的に削除とかしちゃったせいでぺージ位置が初期設定の値から変わってしまい、
正しいタグを生成できなくなった結果、フラグメントが取得できなくなってしまったことが原因のようです。

こいつーぅ(#^∇^)σ)゚ー゚)

確かに、ページ位置はユニークだけどさ…orz

基本的に
「ページなんて動的に操作しないだろう?HAHAHA」
みたいに思われて作られたのかなぁ、腑に落ちないけど。

ということで、先の壁の時に作ったページのコンテンツとかをまとめているクラスに ID を追加して、
この ID を生成する口を用意することにしましたとさ!

壁3:削除してもフラグメントが残る

そんなこんなで、いろいろわかってきたあたりで出会った第3の壁。

「保持していたページのリストをクリアしても(※つまり、getCount メソッドが 0 を返す状態のとき)
 最後に表示していたぺージが残る」

なんでやねーん!!( ' ^'c彡 ))Д´) パーン

最初は、「フラグメントを add したっきり、remove してない」からかと思って、
remove な処理を入れてみたら、一応対応できた。

なんだけども、諸悪の根源はこれじゃなかった。

一旦解決して、やれやれ状態になっている時に、
今度は「削除対象のぺージがアタッチされた後に内部データを消してもぺージが残る」って現象を発見した。

これの場合は remove しても remove したぺージが表示され続ける事態に…なんでだっ。

ググったら大体同じことで悩んでいる人がいっぱいいて、以下のように書けばいいよってことだった。

@Override
public int getItemPosition(Object object) {
    return POSITION_NONE;
}

これは、「ページ位置のキャッシュを無効にする」というオプションらしい。

なんかふわっとした理解ではあるんだけども、
「ページが表示され続ける=キャッシュが残っている」という状態だったようだ。

なるほど、わからん\(^o^)/

本当はキャッシュを消す時と消さない時に分けて処理を書ければよかったんだけど、
そのあたりまでは理解が及ばず…。

とりあえず、キャッシュを一切しない方向で進めることにしました。
いやはや、まだまだっすなぁ;

基底のアダプターを作ってみた

ということを踏まえて、自分で作成した基底のアダプタークラスは、こんな感じになりました。

コード

import android.content.Context;
import android.os.Bundle;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
import android.view.ViewGroup;

import java.util.ArrayList;
import java.util.List;

public abstract class BaseFragmentPagerAdapter<T extends Fragment> extends FragmentPagerAdapter {

    private static class Page<T extends Fragment> {

        private long mId;
        private String mTitle;
        private T mContents;

        /**
         * コンストラクタ
         *
         * @param id           ページの ID
         * @param pageTitle    ページのタイトル
         * @param pageContents ページのコンテンツ
         */
        public Page(long id, String pageTitle, T pageContents) {
            mId = id;
            mTitle = pageTitle;
            mContents = pageContents;
        }

        /**
         * ページの ID を取得
         *
         * @return ページの ID
         */
        public long getId() {
            return mId;
        }

        /**
         * ページのタイトルを取得
         *
         * @return ページのタイトル
         */
        public String getTitle() {
            return mTitle;
        }

        /**
         * ページのコンテンツを取得
         *
         * @return ページのコンテンツを取得
         */
        public T getContents() {
            return mContents;
        }

        @Override
        public String toString() {
            return "Page{" +
                    "mId=" + mId +
                    ", mTitle='" + mTitle + '\'' +
                    ", mContents=" + mContents +
                    '}';
        }

    }

    private Context mContext;
    private FragmentManager mFragmentManager;

    private List<Page<T>> mPageList;
    private List<Page<T>> mRemoveTargetPageList;

    /**
     * コンストラクタ(自動生成)
     */
    public BaseFragmentPagerAdapter(Context context, FragmentManager fm) {
        super(fm);

        mContext = context;
        mFragmentManager = fm;

        mPageList = new ArrayList<>();
        mRemoveTargetPageList = new ArrayList<>();
    }

    @Override
    public int getItemPosition(Object object) {
        // 動的に更新するため、ページ位置のキャッシュは行わない
        // (この設定によって、データ追加/削除時にビューが更新される)
        return POSITION_NONE;
    }

    @Override
    public long getItemId(int position) {
        // 動的に更新することで ID が変わってしまうのを防ぐために、用意した ID を返却する
        return mPageList.get(position).getId();
    }

    @Override
    public Fragment getItem(int position) {
        return mPageList.get(position).getContents();
    }

    @Override
    public CharSequence getPageTitle(int position) {
        return mPageList.get(position).getTitle();
    }

    @Override
    public int getCount() {
        return mPageList.size();
    }

    @Override
    public void finishUpdate(ViewGroup container) {
        super.finishUpdate(container);

        // 削除対象のページがある場合は、まとめて削除
        if (mRemoveTargetPageList.size() != 0) {
            final List<Fragment> targetFragmentList = new ArrayList<>();
            for (Page removeTargetPage : mRemoveTargetPageList) {
                targetFragmentList.add(removeTargetPage.getContents());
            }

            // フラグメントを削除        
            final FragmentTransaction transaction = fragmentManager.beginTransaction();     
            for (Fragment targetFragment : targetFragmentList) {        
                transaction.remove(targetFragment);     
            }       
            transaction.commitAllowingStateLoss();      
            fragmentManager.executePendingTransactions();

            mRemoveTargetPageList.clear();
        }
    }

    /**
     * ページの追加
     *
     * @param title         ページのタイトル
     * @param contentsClass ページのコンテンツクラス
     * @param arguments     アーギュメント
     */
    public void add(final String title, final Class<T> contentsClass, final Bundle arguments) {
        // ページを生成して追加
        final Page<T> newPage = createPage(title, contentsClass, arguments);
        mPageList.add(newPage);

        notifyDataSetChanged();
    }

    /**
     * ページの挿入
     *
     * @param index         挿入する位置(インデックス)
     * @param title         ページのタイトル
     * @param contentsClass ページのコンテンツクラス
     * @param arguments     アーギュメント
     */
    public void insert(final int index, final String title, final Class<T> contentsClass,
                       final Bundle arguments) {
        // ページを生成して挿入
        final Page<T> newPage = createPage(title, contentsClass, arguments);
        mPageList.add(index, newPage);

        notifyDataSetChanged();
    }

    /**
     * 指定ページの削除
     *
     * @param index ページのインデックス
     */
    public void remove(int index) {
        // 更新後にフラグメントを削除するために、削除対象を保持してから削除
        mRemoveTargetPageList.add(mPageList.get(index));
        mPageList.remove(index);

        notifyDataSetChanged();
    }

    /**
     * ページのクリア
     */
    public void clear() {
        // 更新後にフラグメントを全削除するために、削除対象を保持してからクリア
        mRemoveTargetPageList.addAll(mPageList);
        mPageList.clear();

        notifyDataSetChanged();
    }

    /**
     * コンテキストの取得
     *
     * @return コンテキスト
     */
    protected Context getContext() {
        return mContext;
    }

    /**
     * ページの ID を生成
     *
     * @return ページの ID
     */
    protected abstract long createPageId();

    /**
     * ページの生成
     *
     * @param title         ページのタイトル
     * @param contentsClass ページのコンテンツクラス
     * @param arguments     アーギュメント
     * @return ページ
     */
    private Page<T> createPage(final String title, final Class<T> contentsClass,
                               final Bundle arguments) {
        // ページの ID が変わると、動的に挿入や削除した際に
        // 正常なページ遷移ができないため、自前でページの ID を用意する
        final long pageId = createPageId();

        // フラグメントは必ず新規作成
        final T contents = FragmentUtil.create(getContext(), contentsClass, arguments);

        // ページ生成
        return new Page<>(pageId, title, contents);
    }

}

解説

まず、Page クラスが第1の壁の突破に必要だった
「コンテンツとかタイトルとかをひとまとめにしているクラス」です。

このクラスは、正直このアダプターを継承するクラスで認識する必要がないもの
(むしろ、認識しちゃうと突然の独自クラス登場によって学習コストが上がりそう…)なので、
プライベートな定義に止めて、基底クラス内でしか使えないようにしてあります。

また、ページャーのフラグメントのインスタンスを使い回すと、
「java.lang.IllegalStateException: Can't change tag of fragment」が発生する仕様になっているので、
追加時に必ず新しいインスタンスを作成するように強制しています。

正確には、アタッチ中のフラグメントを使うといけないだけなんだけど、
そこまで厳密にこだわらなくてもいいかなと思って、新規作成オンリーな仕様になっています。
(そこまで複雑な仕様は、むしろ見直したほうがいいんじゃないか説)

フラグメント内の値を変えたい場合は、
getItem メソッドで取得したインスタンスに対してゴニョゴニョすればできるはず。

第2の壁の突破に必要だったページの ID は、Page クラス内で保持しつつも、
生成処理は絶対に継承先のクラスが実装してちょ!仕様に。

最初は累計ページ数をカウントして、それを ID にしていたのですが、
そうすると ID 指定に自由度がなくなってしまうので、その辺までフォローするのはやめました。

第3の壁は、ちゃんと突破したとは若干言い難いですが、とりあえずキャッシュは OFF モード指定。

これで一応解決してるっちゃしてるけども、
やっぱり FragmentPagerAdapter 内の処理で add しっぱなしになっているフラグメントが気になる。

確かにこのアダプターを管理しているフラグメントがお亡くなりになれば、
ページのフラグメントも同じようにお亡くなりにはなるんだろうけど、
正直、remove したり clear したりした不要なページは、個人的にはそのまま残っていてほしくないところです。

ということで、キャッシュの諸々とは別に、
削除対象のページをフラグメントマネージャーから削除する処理も追加しています。

このフラグメントの remove 処理部分は、ネットで調べた限りの方法だと、
わりと複雑な処理をやっているところが多かったので悩んだのですが、
「要はデータの更新後(内部のデータクリア処理完了後)に削除しちゃえばいいんだよな」
と思った結果の方法です。

finishUpdate メソッド 自体は、その名前の通り「データを更新した後の後処理メソッド」なので、
内部的にはクリアされた状態=このタイミングでやれば内部データとフラグメントの不整合は起きないはず…。

使い方

継承する

public class SimpleTextFragmentPagerAdapter extends BaseFragmentPagerAdapter<SimpleTextFragment> {

    private long mTotalPageCount;

    /**
     * コンストラクタ(自動生成)
     */
    public SimpleTextFragmentPagerAdapter(Context context, FragmentManager fm) {
        super(context, fm);

        mTotalPageCount = 0;
    }

    @Override
    protected long createPageId() {
        // 累計ページ数をカウントし、その値を ID として利用する
        return mTotalPageCount++;
    }

}

設定する

public class MainActivity extends MobileActivity {

    private TabLayout mTabLayout;
    private SimpleTextFragmentPagerAdapter mFragmentPagerAdapter;

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

        mTabLayout = (TabLayout) findViewById(R.id.tabs);
        ViewPager viewPager = (ViewPager) findViewById(R.id.fragment_view_pager);
        mFragmentPagerAdapter = new SimpleTextFragmentPagerAdapter(this,
                getSupportFragmentManager());

        // デフォルトで3つくらい追加しておく
        add(mFragmentPagerAdapter);
        add(mFragmentPagerAdapter);
        add(mFragmentPagerAdapter);

        viewPager.setAdapter(mFragmentPagerAdapter);
        mTabLayout.setupWithViewPager(viewPager);

        // 動的に追加してみる
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mFragmentPagerAdapter == null) {
                    return;
                }

                add(mFragmentPagerAdapter);
            }
        }, 3000);

        // 動的に削除してみる
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mFragmentPagerAdapter == null) {
                    return;
                }

                mFragmentPagerAdapter.remove(2);
            }
        }, 6000);

        // 動的に挿入してみる
        postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mFragmentPagerAdapter == null) {
                    return;
                }

                insert(mFragmentPagerAdapter, 1);
            }
        }, 9000);

        // 動的にクリアしてみる
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mFragmentPagerAdapter == null) {
                    return;
                }

                mFragmentPagerAdapter.clear();
            }
        }, 12000);
    }

    /**
     * ページ追加
     *
     * @param adapter SimpleTextFragmentPagerAdapter
     */
    private void add(final SimpleTextFragmentPagerAdapter adapter) {
        if (adapter == null) {
            return;
        }

        final int pageCount = adapter.getCount() + 1;

        final Bundle arguments = new Bundle();
        arguments.putInt(SimpleTextFragment.ARGUMENTS_KEY_PAGE_COUNT, pageCount);

        // 追加
        adapter.add("PAGE " + String.valueOf(pageCount), SimpleTextFragment.class, arguments);
    }

    /**
     * ページ挿入
     *
     * @param adapter SimpleTextFragmentPagerAdapter
     * @param index   挿入位置
     */
    private void insert(final SimpleTextFragmentPagerAdapter adapter, final int index) {
        if (adapter == null) {
            return;
        }

        // 挿入確認用に適当に設定
        final int pageCount = 9;

        final Bundle arguments = new Bundle();
        arguments.putInt(SimpleTextFragment.ARGUMENTS_KEY_PAGE_COUNT, pageCount);

        // 挿入
        adapter.insert(index, getString(R.string.text_page, pageCount), SimpleTextFragment.class, arguments);
    }

}

以上!!

※ ちなみに SimpleTextFragment はこんな感じのフラグメントです。

public class SimpleTextFragment extends Fragment {

    public static final String ARGUMENTS_KEY_PAGE_COUNT = "arguments_key_page_count";

    /**
     * コンストラクタ
     */
    public SimpleTextFragment() {
        setRetainInstance(true);
    }

    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        final TextView textView = new TextView(getContext());
        textView.setGravity(Gravity.CENTER);

        final Bundle arguments = getArguments();
        if (arguments != null) {
            int pageCount = arguments.getInt(ARGUMENTS_KEY_PAGE_COUNT, -1);
            if (0 < pageCount) {
                textView.setText("Page " + pageCount));
            }
        }

        return textView;
    }

}

申し訳程度のライセンス

MIT License

Copyright (c) 2016 akitaika_

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

まとめ

  • FragmentPagerAdapter クセありすぎぃ!
  • 一通り作った後で、FragmentStatePagerAdapter の存在に気づく…まぁ、ほぼほぼ同じようなものだよね!
  • getItemPosition メソッドの返却値は、もうちょっとちゃんと理解したい所存です、まる

参考

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした