読者です 読者をやめる 読者になる 読者になる

Support LibraryのPreferenceFragmentCompatでPreferenceScreenによる遷移を有効にする

TL;DR

  • AppCompatActivityはもちろん、PreferenceActivityに載せても動かない
  • 自前でハンドリングする必要があり、以下のいずれかの手法を取る必要がある。
  • ActivityにOnPreferenceStartScreenCallback や OnPreferenceStartFragmentCallback を実装させる (Activity起動 or View使い回し)
  • PreferenceFragmentCompat#onNavigateToScreen(PreferenceScreen) をoverrideする (上記をPreferenceFragmentCompat上で処理する用)
  • boolean PreferenceFragmentCompat#onPreferenceTreeClick(Preference) をoverrideする (Fragment起動用)

PreferenceFragmentCompat

Support Library v23から導入された android.support.v4.Fragment をベースにしたPreferenceFragment。
3rd party ライブラリを使って凌いでいた人も多かったはず。
そして僕が気付いたのはv24.2.0なので今更感がある。

developer.android.com

PreferenceScreenによる遷移が自動では処理されない問題

今までのPreferenceと同様、xmlによる構築が可能。
xml内にルートでないPreferenceScreenがある場合、そのPreferenceScreenをクリックすると新しい画面が開かれる仕様となっている。

公式リファレンスを見るとxmlにPreferenceScreenが書かれており、その子要素の注釈として、Next screenで表示されるものを記述できるぞ!と書いてある。

f:id:jmatsu:20160925175244p:plain

ということで、PreferenceFragmentCompatでもやってみたが、画面が開かれない・・・
でもIntentを記述するとちゃんと起動するので、click処理は諸々走っている様子。

A PreferenceScreen object should be at the top of the preference hierarchy. 
Furthermore, subsequent PreferenceScreen in the hierarchy denote a screen break -- that is 
the preferences contained within subsequent PreferenceScreen should be shown on another screen. 
The preference framework handles this by calling onNavigateToScreen(PreferenceScreen).

こうも書いてあるけど、これで「自分でハンドリングして」と読むのはちょっと厳しくない・・・?

とりあえず onNavigateToScreen はいつ呼ばれるのかというと、PreferenceScreen#onClick() で処理されていた。

    @Override
    protected void onClick() {
        if (getIntent() != null || getFragment() != null || getPreferenceCount() == 0) {
            return;
        }
        final PreferenceManager.OnNavigateToScreenListener listener =
                getPreferenceManager().getOnNavigateToScreenListener();
        if (listener != null) {
            listener.onNavigateToScreen(this);
        }
    }

ちなみに PreferenceManager#getOnNavigateToScreenListener() はPreferenceFragmentCompat自身を返し、それは OnNavigateToScreenListener を実装している。
うーん、いやしかし、これを見ると中のListenerを呼ぶ条件は満たしている。
とりあえず、Intentは別の部分で処理されていると考えて良さそうで、分けて考えて良さそう。
一個一個自分でclick listenerをbindしてもいいけど・・・それは根本解決ではないし、今後困ってしまうので探してみる。

まずPreferenceScreenをクリックしたときに呼ばれる上述のlistenerの中身は以下のようになっている.(v24.2.0)

// in PreferenceFragmentCompat.java

 /**
     * Called by
     * {@link android.support.v7.preference.PreferenceScreen#onClick()} in order to navigate to a
     * new screen of preferences. Calls
     * {@link PreferenceFragmentCompat.OnPreferenceStartScreenCallback#onPreferenceStartScreen}
     * if the target fragment or containing activity implements
     * {@link PreferenceFragmentCompat.OnPreferenceStartScreenCallback}.
     * @param preferenceScreen The {@link android.support.v7.preference.PreferenceScreen} to
     *                         navigate to.
     */
    @Override
    public void onNavigateToScreen(PreferenceScreen preferenceScreen) {
        boolean handled = false;
        if (getCallbackFragment() instanceof OnPreferenceStartScreenCallback) {
            handled = ((OnPreferenceStartScreenCallback) getCallbackFragment())
                    .onPreferenceStartScreen(this, preferenceScreen);
        }
        if (!handled && getActivity() instanceof OnPreferenceStartScreenCallback) {
            ((OnPreferenceStartScreenCallback) getActivity())
                    .onPreferenceStartScreen(this, preferenceScreen);
        }
    }

つまり以下の条件のうち一方を満たす必要がある。

  • PreferenceFragmentCompat#callbackFragment is an instance of OnPreferenceStartScreenCallback
  • PreferenceFragmentCompat#activity is an instance of OnPreferenceStartScreenCallback

で、じゃあそこらへんどうなってるんだっけ・・・っていうと

  • PreferenceFragmentCompat#getCallbackFragment() は常にnullを返す
  • Preference用のCompatActivityはないのでAppCompatActivityを使っていたが、当然実装してない

何もしてないやんけ。自前でハンドリングが必要だなぁ・・・。

とここまでやったところで記述を見つける。

f:id:jmatsu:20160925180541p:plain

・・・うぇい!

調べたら onPreferenceTreeClick と OnPreferenceStartFragmentCallback 周りも同じ感じだった。

実装方法の検討

じゃあどれが一番いいんだろう、と。
このdocによると、Activityに実装させる方法が正攻法っぽい。
他のやり方でもできそうなので色々調べてみる。

PreferenceFragmentCompat#getCallbackFragment()Javadocを見てみると

/**
     * Basically a wrapper for getParentFragment which is v17+. Used by the leanback preference lib.
     * @return Fragment to possibly use as a callback
     * @hide
     */

あー・・・なんか用途が違う。実現可能ではあるけれど、提供された目的と違う方法はあまり好ましくない。
なのでやるとしたら、まあ以下の通りになりそう。

  • ActivityにOnPreferenceStartScreenCallback や OnPreferenceStartFragmentCallback を実装させる
  • onNavigateToScreen(PreferenceScreen) をoverrideする
  • boolean onPreferenceTreeClick(Preference) をoverrideする
  • getCallbackFragment() をoverrideして、OnPreferenceStartScreenCallback や OnPreferenceStartFragmentCallback を実装したFragmentを返すようにする (本来の用途と違うのでやめた方がいい)

実装にあたって

気をつけないといけないことはいくつかあるが、PreferenceScreen はParcelableでもなんでもないということを念頭に置く必要がある。
つまりそのPreferenceScreenをそのまま新しく作成するPreferenceFragmentCompatのインスタンスに渡して云々・・・は出来ない。

備忘録も兼ねて、いくつか思いついた実装を書いておく。

OnPreferenceStartScreenCallback

  • Fragment, Intentが設定されておらず、そのPreferenceScreenは1個以上の子要素を持つことが保証されている。

xmlに書いたPreferenceScreen(子孫)に表示したい全ての要素が記述してあるとき

PreferenceFragmentCompatを再利用する
boolean onPreferenceStartScreen(caller: PreferenceFragmentCompat, pref: PreferenceScreen) {
    if (pref.getKey() == null) {
        return false;
    }

    caller.setPreferencesFromResource(R.xml.setting, pref.key);
    return true;    
}

どこからか、利用されているxmlのidを拾ってくる必要はあるが、PreferenceFragmentCompatの使い回しができる。
けどback処理が面倒くさそう。

sample: https://gist.github.com/jmatsu/f7a7f0a8384c84e50ea5211e1207bd8c#file-reuse_preferencefragmentcompat-kt

PreferenceFragmentCompatを新しく作る
class HogeFragment extends PreferenceFragmentCompat {
    static PreferenceFragmentCompat newInstance(String rootKey) {
        PreferenceFragmentCompat fragment = new PreferenceFragmentCompat();
        Bundle bundle = new Bundle();
        bundle.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, rootKey);
        fragment.setArguments(bundle);
        return fragment;
    }

    void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
        setPreferencesFromResource(R.xml.setting, rootKey)
    }
}

// ↓ Activityで実装する

boolean onPreferenceStartScreen(caller: PreferenceFragmentCompat, pref: PreferenceScreen) {
    if (pref.getKey() == null) {
        return false;
    }

    getSupportFragmentManager().beginTransaction().replace(R.id.container, HogeFragment.newInstance(pref.key)).addToBackstack(null).commit();
    return true;    
}

どんなFragmentで扱うかを知っている必要がある。Fragment attributeを設定してしまうと、ここが呼ばれないことを留意する必要がある。

sample: https://gist.github.com/jmatsu/f7a7f0a8384c84e50ea5211e1207bd8c#file-renew_preferencefragmentcompat-kt

PreferenceScreenのkeyとxmlのid名を対応させる

boolean onPreferenceStartScreen(caller: PreferenceFragmentCompat, pref: PreferenceScreen) {
    if (pref.getKey() == null) {
        return false;
    }

    int id = getResource().getIdentifier(pref.getKey(), "xml", getPackageName());
    caller.addPreferencesFromResource(id); // lint抑制をする
    return true;    
}

ゴリ押し。PreferenceFragmentCompatの使い回しができる。けどやっぱりback処理が面倒くさそう。

試してないけど。

OnPreferenceStartFragmentCallback

  • Fragmentが設定されていることが保証されている。

Fragment名から愚直に作成する

boolean onPreferenceStartFragment(caller: PreferenceFragmentCompat, pref: Preference) {
    if (!(pref instanceof ScreenPreference)) {
        return false;
    }

    String fragmentName = pref.getFragment();
    // name に合わせてFragmentを生成して色々する
    return true;    
}

普通っぽい。