LoginSignup
11
6

More than 3 years have passed since last update.

SettingsActivity再入門 ~PreferenceFragmentCompatで作る設定画面~

Posted at

Androidでは設定画面を作る仕組みがJetpackで提供されています。かつてはPreferenceActivityというものがありましたが、いまではPreferenceFragmentCompatがその役割を提供しています。

PreferenceFragmentCompatでは、Androidらしい設定画面を少ない記述で簡単に実現できます。商用プロダクトではあんまり見かけない気がしますが、どうしても標準的な見た目は嫌だとか、これでは実現できない特殊な画面を作らないといけないなどの理由がなければ、商用プロダクトで使っても全然問題のないクオリティの設定画面が作れると思います。
デバッグ用の設定画面とか身内にしか見られないような場所の設定を作る必要があるなら一択でしょう。

また、デフォルトの挙動から、DefaultSharedPreferencesに対して読み書きにするためにしか使えないと勘違いされがちですが、デフォルト以外のSharedPreferencesやSharedPreferences以外への読み書きも可能です。

読み書きの対象を変更する方法についてはこちらを参照ください
SettingsActivity再入門 ~PreferenceFragmentCompatの読み書き対象を変更する~

事始め

どういうことができるのかはSettingsActivityというテンプレートが用意されているので作ってみましょう。

Split settings hierarchy into separate sub-screensのチェックボックスをONにすると、カテゴリーとその詳細を別画面にする構成で作られます。

Splitなし Splitあり 子画面 子画面

Splitなしの場合のActivityは以下のように非常にシンプルです。XMLの記述だけでほとんど完結しています。

SettingsActivity.kt
class SettingsActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.settings_activity)
        if (savedInstanceState == null) {
            supportFragmentManager
                .beginTransaction()
                .replace(R.id.settings, SettingsFragment())
                .commit()
        }
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }

    class SettingsFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.root_preferences, rootKey)
        }
    }
}

Splitありの場合はちょっと複雑です。Fragmentの遷移とタイトルの変更のためコードが増えています。

SettingsActivity2.kt
private const val TITLE_TAG = "settingsActivityTitle"

class SettingsActivity2 : AppCompatActivity(),
    PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.settings_activity)
        if (savedInstanceState == null) {
            supportFragmentManager
                .beginTransaction()
                .replace(R.id.settings, HeaderFragment())
                .commit()
        } else {
            title = savedInstanceState.getCharSequence(TITLE_TAG)
        }
        supportFragmentManager.addOnBackStackChangedListener {
            if (supportFragmentManager.backStackEntryCount == 0) {
                setTitle(R.string.title_activity_settings2)
            }
        }
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putCharSequence(TITLE_TAG, title)
    }

    override fun onSupportNavigateUp(): Boolean {
        if (supportFragmentManager.popBackStackImmediate()) {
            return true
        }
        return super.onSupportNavigateUp()
    }

    override fun onPreferenceStartFragment(
        caller: PreferenceFragmentCompat,
        pref: Preference
    ): Boolean {
        val args = pref.extras
        val fragment = supportFragmentManager.fragmentFactory.instantiate(
            classLoader,
            pref.fragment
        ).apply {
            arguments = args
            setTargetFragment(caller, 0)
        }
        supportFragmentManager.beginTransaction()
            .replace(R.id.settings, fragment)
            .addToBackStack(null)
            .commit()
        title = pref.title
        return true
    }

    class HeaderFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.header_preferences, rootKey)
        }
    }

    class MessagesFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.messages_preferences, rootKey)
        }
    }

    class SyncFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.sync_preferences, rootKey)
        }
    }
}

Fragmentごとにタイトルの変更をしなくて良いのならSplitなしと同等レベルにシンプルになります。(とはいえタイトルが変化しないと分かりにくいですし、PreferenceFragmentCompat.OnPreferenceStartFragmentCallbackの実装は推奨されています。これはあくまで最低限の動作をさせるための確認です)

SettingsActivity2.kt
class SettingsActivity2 : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.settings_activity)
        if (savedInstanceState == null) {
            supportFragmentManager
                .beginTransaction()
                .replace(R.id.settings, HeaderFragment())
                .commit()
        }
        supportActionBar?.setDisplayHomeAsUpEnabled(true)
    }

    override fun onSupportNavigateUp(): Boolean {
        if (supportFragmentManager.popBackStackImmediate()) {
            return true
        }
        return super.onSupportNavigateUp()
    }

    class HeaderFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.header_preferences, rootKey)
        }
    }

    class MessagesFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.messages_preferences, rootKey)
        }
    }

    class SyncFragment : PreferenceFragmentCompat() {
        override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
            setPreferencesFromResource(R.xml.sync_preferences, rootKey)
        }
    }
}

Prefereceの機能

共通

key

PreferenceFragmentCompatはkey/value型のデータを扱う画面であり、各項目はこのkeyに紐付けされます。SharedPreferencesに書き込まれる設定値のkeyとして使用されるほか、値を持たない項目についても、そのPreferenceに対して何らかの操作をコード上からアクセスする場合はこのkeyを使ってPreferencesにアクセするため、設定しておく必要があります。Viewに置けるidのようなものですね。idはint値ですが、keyはStringです。

<Preference
    app:key="messages"
    />

title/summary

各項目のタイトルとその説明を設定できます。Titleのみやsummaryのみでも問題ありません。片方だけの場合、高さが少し小さくなります。

<Preference
    app:title="title"
    app:summary="summary"
    />

デフォルト値

Preferenceにはデフォルト値を設定しておくことができます、これは値が書き込まれていない状態に読み出されるデフォルト値ではなく、値が書き込まれていない場合に書き込まれるデフォルト値となります。
PreferenceFragmentCompat#setPreferencesFromResourceをコールしたときに、PreferenceManager経由でPreferenceInflaterが呼び出され、この中でデフォルト値の書き込みが行われます。不適切なデフォルト値を設定しているここでExceptionが発生することになります。

<SwitchPreferenceCompat
     app:defaultValue="true"
     />

アイコンスペース

テンプレートの画面を見て左側のマージン広すぎでは?と思いませんか?
これはアイコンを配置するためのエリアを開けてあるためで、各Preferenceのプロパティにapp:icon=でアイコンを指定できます。また、アイコンはつかないのでマージンは不要という場合はapp:iconSpaceReserved="false"を指定することでマージンを消すことができます。

<Preference
    app:icon="@drawable/ic_settings"
    />
<Preference
    app:iconSpaceReserved="false"
    />
default app:icon="@drawable/ic_settings" app:iconSpaceReserved="false"

Preferenceの依存関係と有効無効

別の設定項目が有効になるまでは、その設定項目は意味をなさないといった依存関係を記述することができます。
app:dependency=で依存先となるKeyを指定します。SwitchPreferenceなどの場合はfalseの場合、EditTextPreferenceの場合値が設定されていない(empty)の場合にdisableになります。ListPreferenceの場合はdisable状態を持ちません。

<Preference
    app:dependency="sync"
    />
disabled enabled

また、単純に有効無効を設定する場合は、app:enableを使用します

<Preference
    app:enabled="false"
    />

この有効無効状態を変化させる場合はコード上から設定します。

findPreference<Preference>("hogehoge")?.isEnabled = true

Preferenceを非表示にする

設定項目を必要に応じて表示するような用途の場合、app:isPreferenceVisible="false"で非表示にすることができます。

<Preference
    app:isPreferenceVisible="false"
    />

表示させる条件はXMLでは表現できないので、コード上で設定します。

findPreference<Preference>("hogehoge")?.isVisible = true

設定値をsummaryに反映する

現在の設定値がどのような値になっているかに基づいてsummaryの文字列を変更したい場合、それぞれのPreferenceにデフォルトで用意されている機能もありますが、SummaryProviderを実装し、文字列を自分で組み立てることもできます。

SummaryProviderは値が変化したときにコールされ、引数にそのPreferenceが渡されますので、その値に基づいたsummryに表示したいCharSequenceを返却するようにします。

findPreference<EditTextPreference>("hogehoge")?.setSummaryProvider {
    "設定値は「${(it as EditTextPreference).text}」です"
}

当然、summaryを直接書き換えることもできます。

findPreference<EditTextPreference>("hogehoge")?.summary = "hogehoge"

読み書き処理をさせない

デフォルトではすべてのPreferenceはkey/valueを書き込む動作となりますが、この処理を停止させることもできます。

<Preference
    app:persistent="false"
    />

設定値と紐付けるのではない、情報表示用などとして利用するPreferencesに読み書きを行わせないようにするときに指定します。
なお、app:persistent="false"に設定した場合でも、Switchの状態などが変化しなくなるわけではありません。
そういうことがしたい場合、次の項目を参考にしてください。

設定値の変化をトリガに処理を実行する

設定値が変化したときに、何らかの処理を実行したい場合、PreferenceにOnPreferenceChangeListenerを設定することで変化のトリガーを受け取ることができます。

findPreference<Preference>("sync")?.setOnPreferenceChangeListener { preference, newValue ->
    true
}

また、戻り値としてBooleanを返しますが、このときにfalseを返すと、その値の変更を無効化することができます。

Preferences

summary値をコピー可能にする

summaryに何らかの情報を表示させておいて、それをコピー可能にさせることができます。

<Preference
    app:enableCopying="true"
    />

ロングタップで以下のようなドロップダウンメニューが出てきて、Copyを選択することで、クリップボードにsummaryの内容がコピーされます。

別のfragmentに遷移する

SplitでSettingsActivityを作った場合、header_preferences.xmlで使われています。
app:fragmentでfragmentクラス名を指定すると、タップされた際にそのFragmentに遷移するようになります。

<Preference
    app:fragment="net.mm2d.myapplication.SettingsActivity2$MessagesFragment"
    />

fragmentを指定しておくだけで、PreferenceFragmentCompatがデフォルトの実装を持っているので、最低限のフラグメントの遷移処理が実行されます。しかし、この処理は利用側が適切に実装することが推奨されています。
実際の実装は以下のようになっており、デフォルトの処理が実行されるときはワーニングログが出力されるようになっていることからも、この実装に任せるのはよろしくなさそうですね。

PreferenceFragmentCompat.java
@Override
public boolean onPreferenceTreeClick(Preference preference) {
    if (preference.getFragment() != null) {
        boolean handled = false;
        if (getCallbackFragment() instanceof OnPreferenceStartFragmentCallback) {
            handled = ((OnPreferenceStartFragmentCallback) getCallbackFragment())
                    .onPreferenceStartFragment(this, preference);
        }
        if (!handled && getActivity() instanceof OnPreferenceStartFragmentCallback) {
            handled = ((OnPreferenceStartFragmentCallback) getActivity())
                    .onPreferenceStartFragment(this, preference);
        }
        if (!handled) {
            Log.w(TAG,
                    "onPreferenceStartFragment is not implemented in the parent activity - "
                            + "attempting to use a fallback implementation. You should "
                            + "implement this method so that you can configure the new "
                            + "fragment that will be displayed, and set a transition between "
                            + "the fragments.");
            final FragmentManager fragmentManager = requireActivity()
                    .getSupportFragmentManager();
            final Bundle args = preference.getExtras();
            final Fragment fragment = fragmentManager.getFragmentFactory().instantiate(
                    requireActivity().getClassLoader(), preference.getFragment());
            fragment.setArguments(args);
            fragment.setTargetFragment(this, 0);
            fragmentManager.beginTransaction()
                    // Attempt to replace this fragment in its root view - developers should
                    // implement onPreferenceStartFragment in their activity so that they can
                    // customize this behaviour and handle any transitions between fragments
                    .replace((((View) getView().getParent()).getId()), fragment)
                    .addToBackStack(null)
                    .commit();
        }
        return true;
    }
    return false;
}

上記の処理から分かるように、PreferenceFragmentCompatを配置したActivityにOnPreferenceStartFragmentCallbackを実装しておくと、onPreferenceStartFragmentが実行されます。

PreferenceFragmentCompat.java
public interface OnPreferenceStartFragmentCallback {
    boolean onPreferenceStartFragment(PreferenceFragmentCompat caller, Preference pref);
}

タブレットなどの広い画面をもつ端末の場合は、ヘッダーとメニューを一つの画面にまとめるといった実装できますね。

Intentを投げる

Preferenceタグの中にintentタグを書くことで、そのPreferenceがタップされたときにそのIntentを投げることができます。

以下のように書くと、暗黙的Intentを投げてブラウザを開くことができます。

<Preference>
    <intent
        android:action="android.intent.action.VIEW"
        android:data="https://www.google.com/"
        />
</Preference>

パッケージ名とクラス名を書き、明示的Intentを投げることもできます。またextraタグでextraの設定も可能です。

<Preference>
    <intent
        android:targetClass="net.mm2d.myapplication.SettingsActivity2"
        android:targetPackage="net.mm2d.myapplication"
        >
        <extra
            android:name="HOGE"
            android:value="FUGA"
            />
    </intent>
</Preference>

ActivityNotFoundExceptionが発生した場合、catchすることができないので、受け手が必ず存在するIntent以外はこの方法を使わない方が良いでしょう。

クリックイベントを実装する

前項までのようにクリック時の動作をXMLで指定することもできますが、より複雑な処理を実装したい場合は、コード上からOnPreferenceClickListenerを設定することで実装することができます。
戻り値はbooleanでtrueを返却するとクリックイベントが消費され、他のクリック時の処理が発火しなくなります。

findPreference<Preference>("sync")?.setOnPreferenceClickListener { 
    true
}

処理の優先度としては、OnPreferenceClickListener > fragment > intent という順序になっているようです。

SwitchPreferenceCompat

Boolean値を扱うPreferenceです。同様にBooleanを扱うPreferenceはTwoStatePreferenceのサブクラスとして実装されており、他にSwitchPreferenceとCheckboxPreferenceをほぼ同じように使うことができます。違いは名前の通り、ON/OFFの表現が、SwitchCompatかCheckboxかSwitchかの違いですね。ON/OFFの表現が複数あるのは分かりにくくなりますし、SwitchPreferenceCompatを使うのが良いかと思います。

ON/OFFでsummaryを変更する

TwoStatePreferenceは取り得る値が2つしかないので、その2パターンの文字列をXMLで設定しておくことで、ON/OFFに応じてsummaryを変更してくれます

<SwitchPreferenceCompat
    app:summaryOff="@string/attachment_summary_off"
    app:summaryOn="@string/attachment_summary_on"
    />

EditTextPreference

任意のString値を扱うPreferenceです。タップするとダイアログが開いてユーザーが任意のテキストを入力できます。

設定値をsummryに表示する

設定値はStringなので、一番シンプルなsummaryの表示は、設定値をそのまま表示するものです。
この動作で問題無い場合は、SummaryProviderを実装する必要はありません。
app:useSimpleSummaryProvider="true"を指定することでこの動作となります。

<EditTextPreference
    app:useSimpleSummaryProvider="true"
    />

ダイアログのテキストを変更する

EditTextPreferenceに限りませんが、ダイアログが開くタイプのPreferenceの場合はタイトル、メッセージ、ボタンのテキストを変更できます。

<EditTextPreference
    app:dialogTitle="dialogTitle"
    app:dialogMessage="dialogMessage"
    app:positiveButtonText="positiveButtonText"
    app:negativeButtonText="negativeButtonText"
    />

ListPreference

String値を扱うPreferenceです。タップするとダイアログが開き複数の項目から一つの項目を選択することができます。

設定値と選択肢、デフォルト値

デフォルト値はstringで指定可能です。
ダイアログに表示する選択肢はapp:entriesで、それが選択された際に実際に書き込まれる値をapp:entryValuesで指定します。

<ListPreference
    app:defaultValue="reply"
    app:entries="@array/reply_entries"
    app:entryValues="@array/reply_values"
    />

いずれもstring-arrayのリソースとして定義して使用します。当然のことながら要素数を同じにしておく必要があります。
entriesの方が少ない場合は、選択できない値が出てくるだけですが、逆だとArrayIndexOutOfBoundsExceptionが発生してしまいます。

arrays.xml
<string-array name="reply_entries">
    <item>Reply</item>
    <item>Reply to all</item>
</string-array>

<string-array name="reply_values">
    <item>reply</item>
    <item>reply_all</item>
</string-array>

設定値をsummryに表示する

EditTextPreferenceと同様、設定値はStringなので、一番シンプルなsummaryの表示は、設定値をそのまま表示するものです。
この動作で問題無い場合は、SummaryProviderを実装する必要はありません。
app:useSimpleSummaryProvider="true"を指定することでこの動作となります。

<EditTextPreference
    app:useSimpleSummaryProvider="true"
    />

MultiSelectListPreference

Setを扱うPreferenceです。タップするとダイアログが開き、複数の項目から任意個の項目を選択することができます。

設定値と選択肢、デフォルト値

設定値と選択肢はListPreferenceと同じですが、デフォルト値をstringで指定することはできません、string-arrayで指定可能です。

設定値をsummryに表示する

MultiSelectListPreferenceはapp:useSimpleSummaryProvider="true"を使うことができません。必要であればSummaryProviderを実装する必要があります。(文字列連結で表示する機能があっても良さそうですが)

findPreference<MultiSelectListPreference>("reply")?.summaryProvider = 
    Preference.SummaryProvider<MultiSelectListPreference> {
        it.values.joinToString()
    }

SeekBarPreference

int値を扱うPreferenceです。SeekBarの操作でint値を設定します。

最大値、最小値

最大値と最小値を指定することができますが、ネープスペースプレフィックスが異なります、最小値はapp:minで、最大値はandroid:maxで指定します。

<SeekBarPreference
    app:min="0"
    android:max="50"
    />

現在値の表示

デフォルトでは現在値の表示は行われませんが、app:showSeekBarValue="true"を指定することでシークバー横に現在値が表示されるようになります。

<SeekBarPreference
    app:showSeekBarValue="true"
    />

PreferenceCategory

複数のPreferenceをグループ化するための仕組みです。titleがカテゴリー名として表示され、Categoryの間にはdividerが表示されます。タイトルだけでなく、説明などを追加したい場合はsummaryを指定することもできます。

PreferenceCategoryは入れ子にすることもできますが、あまり意味は無いと思います。

<PreferenceCategory app:title="@string/messages_header">

</PreferenceCategory>

折りたたんだ状態で表示する

PreferenceCategoryにapp:initialExpandedChildrenCountを指定することで、ここで指定した個数以上のPreferenceを折りたたんでおくことができます。タップすることで展開されますが、再度折りたたむことはできません。

<PreferenceCategory app:initialExpandedChildrenCount="0">
展開前 展開後
Screenshot_1618317268.png

PreferenceFragmentComaptでできることをざっと紹介しました。すべてを紹介しきったわけではなく標準的な仕組みだけですが、これだけでも結構いろんなことが簡単にできそうですよね。

設定画面を自前で作ったことのある人なら分かると思いますが、各UIパーツを配置して、設定を反映、タップなどの操作を受け取り~と想像以上にたくさんの実装が必要です。それがXMLを書くだけでほとんどの機能を実装できてしまいますし、Android的でシステムUI等と親和性の高いマテリアルデザインのUIが特に意識することなく実現できますので、実装コストが低く、メンテナンス性も良いと思います。

積極的に使っていきましょう。

引き続き、以下の記事もどうぞ。
SettingsActivity再入門 ~PreferenceFragmentCompatの読み書き対象を変更する~

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6