Androidでダークテーマ機能を実装する
はじめに
このようなFragmentの画面を用意しました.
実装方法
Step1 設定画面のレイアウトの表示
実はこれは普通のFragmentではなくてPreferenceScreenというコンポーネントを使って設定画面専用の画面みたいな感じで作成しています.
ざっくりと説明すると,いままで XMLをres/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.xml
やcolors.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