batchのブログ

知見の備忘録

Androidでダークテーマ機能を実装する

はじめに

このようなFragmentの画面を用意しました. f:id:batch08:20200407153519p:plain

実装方法

Step1 設定画面のレイアウトの表示

実はこれは普通のFragmentではなくてPreferenceScreenというコンポーネントを使って設定画面専用の画面みたいな感じで作成しています. ざっくりと説明すると,いままで XMLres/layout/fragment_preferences.xml と作っていたものを res/xml/preferences.xml みたいに作って中身をこんな感じにすると先程の画像のような画面になります.

<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <PreferenceCategory
        android:key="categoryTheme"
        android:title="@string/theme_label">
        <SwitchPreferenceCompat
            android:key="darkTheme"
            android:title="@string/dark_theme_label" />
    </PreferenceCategory>
    <PreferenceCategory
        android:key="categoryLanguage"
        android:title="@string/language_label">
        <ListPreference
            android:entries="@array/language_labels"
            android:key="language"
            android:title="@string/language_label"
            app:entryValues="@array/language_values"
            app:useSimpleSummaryProvider="true" />
    </PreferenceCategory>
</PreferenceScreen>

ちなみに,今回あまり関係ないですがあまり見慣れない @array ですが,res/values/arrays.xml のようなファイルを作ってその中に

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <array name="language_labels">
        <item>日本語</item>
        <item>English</item>
    </array>
    <array name="language_values">
        <item>JP</item>
        <item>EN</item>
    </array>
</resources>

というような内容のものを作りました.

Fragmenのクラス内では PreferenceFragmentCompat()というのを継承してあげて普段のFragmentの onCreateViewでしているように

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences, rootKey)
    }

というのを書いてあげます.

Step2 ダークテーマ用のリソースファイルの作成

デフォルトである res/valuesがあると思いますが,res/values-nightというのを作ってstrings.xmlcolors.xmlなどを置いてあげるとダークテーマに切り替えたときのリソースファイルとして定義することができます.

今回は,res/values-night/themes.xmlを作りました.

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="Theme.Todone.DayNight" parent="Theme.MaterialComponents.DayNight">
        <item name="windowActionBar">false</item>
        <item name="windowNoTitle">true</item>

        <item name="colorPrimary">@android:color/black</item>
        <item name="colorPrimaryDark">@android:color/black</item>
        <item name="colorAccent">@android:color/black</item>
    </style>
</resources>

ここで Theme.MaterialComponents.DayNight っていうのがダークテーマにも対応したテーマを提供してくれてるやつなのでこれをparentに設定してあげます.

普通の res/values/themes.xmlにいるstyleのThemeのparentも同じようにして colorPrimaryなどの色だけライトテーマとダークテーマ用に分けて書きます. AndroidManifest.xmlのとこも忘れずに先ほどつくったThemeを当てるように書き換えます. android:theme="@style/Theme.Todone.DayNight">

Step3 ダークテーマを端末の状態によって設定するようにする

Android 10からAndroid OSレベルでダークテーマを設定できるようになりました.

今回ではアプリを動かした端末がAndroid 10であった場合にその端末がダークテーマを設定した場合アプリもダークテーマにするやり方を紹介します.

こんな感じでApplicationを継承したAppクラスを作って以下のように実装します.

class App : Application() {

    override fun onCreate() {
        super.onCreate()
        setupNightMode()
    }
    private fun setupNightMode() {
        val nightMode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
        } else {
            AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY
        }
        AppCompatDelegate.setDefaultNightMode(nightMode)
    }
}

これで端末自体のダークテーマの設定に応じてアプリの方も自動でダークテーマに切り替わるようになります.

Step4 スイッチでダークテーマを切り替える

では実際にStep1で作ったレイアウト上にあるDark Themeスイッチを切り替えてダークモードとライトモードを切り替えていきます. 結果的にこのような感じで実装しました

class SettingsFragment : PreferenceFragmentCompat() {

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        setPreferencesFromResource(R.xml.preferences, rootKey)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        preferenceManager.findPreference<SwitchPreferenceCompat>(DARK_THEME_KEY).also {
            it?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue ->
                if (newValue as Boolean) {
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
                } else {
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
                }
                return@OnPreferenceChangeListener true
            }
        }
    }

    companion object {
        private const val DARK_THEME_KEY = "darkTheme"
    }
}

onPreferenceChangeListener の中が実際にスイッチが切り替えた際に呼ばれます.

その中で, AppCompatDelegate.setDefaultNightMode() を使って AppCompatDelegate.MODE_NIGHT_YESを設定してあげることで自動的にダークモード(res/values-night/themes.xml)に切り替わります. AppCompatDelegate.MODE_NIGHT_NOでライトテーマ(res/values/themes.xml)に設定します.

その他の詳細な知見

おそらく,一番簡単にダークテーマを実装するのは上記の方法だと思います.

最初,DroidKaigi2020のconference-app( https://github.com/DroidKaigi/conference-app-2020) を参考に実装していたのですが思わぬ挙動に遭遇して少し難しくて同じように実装できませんでした.その内容などをこのセクションで書きたいと思います.

今回ダークテーマを設定するFragmentではViewModelなどは使わずFragmentクラス内のみで実装をしました.

DroidKaigiの場合ざっくり説明するとViewModelを採用し,DarkThemeのスイッチが切り替えられたタイミングでViewModelに定義したメソッドを呼び出してそのメソッド内でViewModelのLiveDataを更新するようにしてその変更をFragmentでobserveして実際にAppCompatDelegate.setDefaultNightMode() を動かしていました.

雰囲気で自分もそのように実装したのですが,スイッチを一回切り替えたときにViewModel側のLiveDataの値の更新も一度しかしていないのにobserverが2回呼ばれたような挙動になってテーマ切替時にAppCompatDelegate.setDefaultNightMode() も2回呼ばれViewがカクカクする現象に陥りました.

これは,AppCompatDelegate.setDefaultNightMode() が動いたときにActivityごと再生成される仕様であるのが原因でした.

1回目のobserverが動くのは純粋にスイッチを一回切り替えてLiveDataの値を更新したときの通知を受け取った分で2回目のobserverが動くのはAppCompatDelegate.setDefaultNightMode() が動いてMainActivityが再生成された際に設定画面のFragmentも再生成され,その時にViewModelに置いているLiveDataが前回の値をFragmentが再生成されたときに値を通知している分であると考えられます.

これをDroidKaigiではきっといい感じにやっているんだと思いますが自分のレベルでは読み解けませんでした…

さいごに

現在学生を主体として7名で技術学習用にTODONEというTodoアプリをAndroidチーム,バックエンドチーム,デザイナーの3つのチームで開発中です. ここはもっとこういう感じに実装できるよなどアドバイスなどいただけたら嬉しいです. github.com