PreferenceHeaderFragmentCompatを使って2ペイン対応の設定画面を作る

androidx.preference の 1.2.0 から `PreferenceHeaderFragmentCompat` が使えるようになっています。
2ペイン対応の設定画面を簡単に作ることができます。

https://developer.android.com/jetpack/androidx/releases/preference

画面の幅が一定を超えると2ペインモード、一定以下なら1ペインモードに切り替わります。
Foldableデバイスの開閉や、タブレットの画面回転などに応じて、幅を有効活用できるレイアウトに切り替えることができます。
Deprecatedになってしまった [PreferenceActivity](https://developer.android.com/reference/android/preference/PreferenceActivity) でできていたことですが、これがモダンなライブラリを使って実現できるようになっています。

## 2ペインモード

タブレット

|||
|--|--|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/9c44d47f-4658-cdc3-5f4a-6a6e486b8430.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/807875a3-4564-3708-b503-a60c37a999e3.png)|

Surface Duo の場合は綺麗に2画面を使ってくれます。

|||
|--|--|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/3be481f7-e436-1721-98cf-ceed434d68ab.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/19401e12-2182-9680-f12f-af1c1467e199.png)|

## 1ペインモード

||||
|--|--|--|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/564b058f-c47c-ce0d-26f5-9a0f201273c2.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/1d7863ae-8928-d4af-5286-7f5daeee30aa.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/00133204-50f7-00a0-f80d-8bd779ebcf56.png)|

# 使い方

従来のPreferenceFragmentCompatでも設定画面の階層化だけはできていました。
SettingsActivityをテンプレートから作成するときの、「Split setting hierarchy into sub-screen」にチェックを入れると作成されます。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/b50e4cee-cf5c-b9aa-388e-aafaaa46adeb.png)

このときヘッダーに当たる部分は以下のようなxmlになっていて、`Preference`に`app:fragment`で下階層のFragmentを指定します。

```xml:header_preferences.xml
<PreferenceScreen
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <Preference
        app:fragment="net.mm2d.myapplication.SettingsActivity$MessagesFragment"
        app:icon="@drawable/messages"
        app:key="messages_header"
        app:title="@string/messages_header"
        />

    <Preference
        app:fragment="net.mm2d.myapplication.SettingsActivity$SyncFragment"
        app:icon="@drawable/sync"
        app:key="sync_header"
        app:title="@string/sync_header"
        />
</PreferenceScreen>
```

`PreferenceHeaderFragmentCompat`の最低限の実装では、`onCreatePreferences`を実装し、上記XMLをinflateするPreferenceFragmentCompatを返します。

```kotlin:SettingsActivity.kt
class Header : PreferenceHeaderFragmentCompat() {
    override fun onCreatePreferenceHeader(): PreferenceFragmentCompat =  HeaderFragment()
}

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

あとはこのFragmentを配置するだけで、あとはPreferenceHeaderFragmentCompatが子Fragmentで画面遷移を制御してくれます。

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

# NavigationUp の操作を接続する

最低限の実装をしただけで、メニューのタップとバックキーによる画面遷移が動作します。
ただ、Toolbarの「←」ボタン、いわゆるNavigationUPが動作しません。

PreferenceHeaderFragmentCompatはOnBackPressedCallbackでバックキーを受け取り、UP動作を行っているため、Activityで`onSupportNavigateUp` を override して onBackPressed() を呼ぶことで動作をつなげることができます。

```kotlin:SettingsActivity.kt
override fun onSupportNavigateUp(): Boolean {
    onBackPressed()
    return true
}
```

……まあ、動くといえば、動くんだけど、NavigationUPの実装で、onBackPressedコールするってなんか負けた気がするよね。

PreferenceHeaderFragmentCompat は slidingPaneLayout にアクセスできるようになっているので、`closePane()`をコールすることでHeaderに戻し、戻れない場合は戻り値で判定することができるので、こっちの方がそれっぽいでしょうか？

```kotlin:SettingsActivity.kt
override fun onSupportNavigateUp(): Boolean {
    if ((supportFragmentManager.findFragmentById(R.id.settings) as Header)
        .slidingPaneLayout.closePane()) {
        return true
    }
    return super.onSupportNavigateUp()
}
```

ただまあ、もうちょっとスマートに実装できないものかと思いますが、良い方法が見つかりません。良い方法があれば教えてください。

# Toolbarタイトルを画面遷移に合わせて変更する

「Split setting hierarchy into sub-screen」のテンプレートではOnPreferenceStartFragmentCallbackをActivityに実装して、遷移先に応じてToolbarのタイトルを変更する実装が入っています。これはどのように実装すれば良いでしょうか？

```kotlin:SettingsActivity.kt
override fun onPreferenceStartFragment(
    caller: PreferenceFragmentCompat,
    pref: Preference
): Boolean {
    // Instantiate the new Fragment
    val args = pref.extras
    val fragment = supportFragmentManager.fragmentFactory.instantiate(
        classLoader,
        pref.fragment
    ).apply {
        arguments = args
        setTargetFragment(caller, 0)
    }
    // Replace the existing Fragment with the new Fragment
    supportFragmentManager.beginTransaction()
        .replace(R.id.settings, fragment)
        .addToBackStack(null)
        .commit()
    title = pref.title
    return true
}
```

PreferenceHeaderFragmentCompat はそのchildFragmentとしてPreferenceFragmentCompatを配置しているため、実装はPreferenceHeaderFragmentCompat を継承したクラスで行います。

PreferenceHeaderFragmentCompat は OnPreferenceStartFragmentCallback を実装しているため、onPreferenceStartFragmentをoverrideすることで、同様に画面遷移を知ることができます。
ここでActivityのtitleを変更します。ただし、2ペインの場合は画面遷移を伴わないので、1ペインの場合だけ変更するようにします。

```kotlin:SettingsActivity.kt
override fun onPreferenceStartFragment(
    caller: PreferenceFragmentCompat,
    pref: Preference
): Boolean {
    title = pref.title ?: ""
    if (slidingPaneLayout.isSlideable) {
        requireActivity().title = title
    }
    return super.onPreferenceStartFragment(caller, pref)
}
```

これで、子フラグメントへの遷移での変更は可能になりましたが、Headerに戻った場合に戻してあげたいです。
これはslidingPaneLayoutのPanelSlideListenerで検出します。slidingPaneLayoutは、onViewCreated以降でないとアクセスできないので、onViewCreatedでListenerを設定してみましょう。

```kotlin:SettingsActivity.kt
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    slidingPaneLayout.addPanelSlideListener(object : PanelSlideListener {
        override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
        override fun onPanelOpened(panel: View) = Unit
        override fun onPanelClosed(panel: View) {
            title = getText(R.string.title_activity_settings)
            requireActivity().title = title
        }
    })
}
```

最後に、画面回転やFoldableの開閉などでモードが変わってもタイトルを引き継げるようにしておきます。

```kotlin:SettingsActivity.kt
override fun onViewStateRestored(savedInstanceState: Bundle?) {
    super.onViewStateRestored(savedInstanceState)
    if (savedInstanceState != null) {
        title = savedInstanceState.getCharSequence(TITLE_TAG) ?: ""
        slidingPaneLayout.doOnLayout {
            if (slidingPaneLayout.isSlideable) {
                requireActivity().title = title
            } else {
                requireActivity().title = getText(R.string.title_activity_settings)
            }
        }
    }
}

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    if (title.isEmpty()) {
        title = getText(R.string.title_activity_settings)
    }
    outState.putCharSequence(TITLE_TAG, title)
}
```

SettingsActivityの実装全体としては以下のような感じです。

```kotlin:SettingsActivity.kt
private const val TITLE_TAG = "settingsActivityTitle"

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

    override fun onSupportNavigateUp(): Boolean {
        if ((supportFragmentManager.findFragmentById(R.id.settings) as Header)
            .slidingPaneLayout.closePane()) {
            return true
        }
        return super.onSupportNavigateUp()
    }

    class Header : PreferenceHeaderFragmentCompat() {
        private var title: CharSequence = ""

        override fun onCreatePreferenceHeader(): PreferenceFragmentCompat {
            return HeaderFragment()
        }

        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            slidingPaneLayout.addPanelSlideListener(object : PanelSlideListener {
                override fun onPanelSlide(panel: View, slideOffset: Float) = Unit
                override fun onPanelOpened(panel: View) = Unit
                override fun onPanelClosed(panel: View) {
                    title = getText(R.string.title_activity_settings)
                    requireActivity().title = title
                }
            })
        }

        override fun onViewStateRestored(savedInstanceState: Bundle?) {
            super.onViewStateRestored(savedInstanceState)
            if (savedInstanceState != null) {
                title = savedInstanceState.getCharSequence(TITLE_TAG) ?: ""
                slidingPaneLayout.doOnLayout {
                    if (slidingPaneLayout.isSlideable) {
                        requireActivity().title = title
                    } else {
                        requireActivity().title = getText(R.string.title_activity_settings)
                    }
                }
            }
        }

        override fun onSaveInstanceState(outState: Bundle) {
            super.onSaveInstanceState(outState)
            if (title.isEmpty()) {
                title = getText(R.string.title_activity_settings)
            }
            outState.putCharSequence(TITLE_TAG, title)
        }

        override fun onPreferenceStartFragment(
            caller: PreferenceFragmentCompat,
            pref: Preference
        ): Boolean {
            title = pref.title ?: ""
            if (slidingPaneLayout.isSlideable) {
                requireActivity().title = title
            }
            return super.onPreferenceStartFragment(caller, pref)
        }
    }

    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)
        }
    }
}
```

----

以上、PreferenceHeaderFragmentCompatの使い方でした。
使ってみた感想としては、見落としているだけかもしれませんが、もう少しカスタマイズ性が欲しい気がしますが、簡単に2ペインの設定画面が作れるので、デザイン性とかにそれほどこだわらないのであれば手軽に利用できるのではないでしょうか。

まだできたばかりでドキュメントもあまりなさそうで、コードを読みながら手探りで使い方を調べたので、不適切な説明になっている場所があるかもしれません。よりよい方法などありましたらご指摘いただければと思います。