AndroidのWebViewでダークモード対応する方法

アプリのダークモード（ダークテーマ）対応にて、WebViewの中身についてはCSSの prefers-color-scheme メディア特性を使って対応しておくだけでOKだろうと思っていました。しかし、簡単にはいかず、調べてもなかなか解決方法が見つからなかったので書きます。

結論だけ先に書くと androidx.webkit を使い、WebコンテンツのCSSを使うダークモードのON/OFFを設定します。これでAndroid 5(API Level 21）以上でダークモード対応を行うことができます。

※Android的にはダークテーマ・ライトテーマですね。書いた後に気づきました。m(__)m
https://developer.android.com/guide/topics/ui/look-and-feel/darktheme

# Webコンテンツのダークモード対応はCSSで行う

Webコンテンツのダークモード対応はCSSの [prefers-color-scheme](https://developer.mozilla.org/ja/docs/Web/CSS/@media/prefers-color-scheme) というメディア特性を使って対応するのが正攻法のようですね。

```css
@media (prefers-color-scheme: dark) {
  // ダークモード用のCSS
}
@media (prefers-color-scheme: light) {
  // ライトモード用のCSS
}
```

https://developer.mozilla.org/ja/docs/Web/CSS/@media/prefers-color-scheme
こちらによると、AndroidのWebViewは76以降で対応しているようなのでWebViewやChromeをアップデートしていれば大体のシステムで使えそうです。
しかし、CSSでダークモード対応したHTMLをWebViewに読み込ませて見ても、何もしないとシステム設定やアプリ設定に連動して変わってはくれません。

# 検証アプリ

まずは検証アプリを作りましょう。以下のようなテーマ切替がわかりやすいHTMLを作ります。

```html:dark.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<style>
@media (prefers-color-scheme: dark) {
  body { background:black; }
}
@media (prefers-color-scheme: light) {
  body { background:white; }
}
</style>
</head>
<body>
<h1 style="color:white">dark theme</h1>
<h1 style="color:black">light theme</h1>
</body>
</html>
```

ライトモードとダークモードで以下のように表示が変わることを期待しています。

|light|dark|
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/e88c4f16-b900-8768-6ca4-7192ad2e0660.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/20efc228-c27f-ff06-ade0-bfb4eabf5776.png)|

アプリの方はダークモードの切替ができるように作っておきます。

```xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <WebView
        android:id="@+id/web_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/radio_group"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <RadioGroup
        android:id="@+id/radio_group"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        >

        <RadioButton
            android:id="@+id/radio_system"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:text="System"
            />
        <RadioButton
            android:id="@+id/radio_light"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:text="Light"
            />
        <RadioButton
            android:id="@+id/radio_dark"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:text="Dark"
            />
    </RadioGroup>
</androidx.constraintlayout.widget.ConstraintLayout>
```

アプリのダークモードは [AppCompatDelegate#setDefaultNightMode](https://developer.android.com/reference/androidx/appcompat/app/AppCompatDelegate#setDefaultNightMode(int)) で切り替えます。

```kotlin
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        when (AppCompatDelegate.getDefaultNightMode()) {
            AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ->
                binding.radioSystem.isChecked = true
            AppCompatDelegate.MODE_NIGHT_NO ->
                binding.radioLight.isChecked = true
            AppCompatDelegate.MODE_NIGHT_YES ->
                binding.radioDark.isChecked = true
            else -> {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
                binding.radioSystem.isChecked = true
            }
        }
        binding.radioGroup.setOnCheckedChangeListener { _, checkedId ->
            when (checkedId) {
                R.id.radio_system ->
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
                R.id.radio_light ->
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
                R.id.radio_dark ->
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
            }
        }
        binding.webView.loadUrl("file:///android_asset/dark.html")
    }
}
```

これで切り替えてみても、ネイティブ部分はテーマが切り替わりますが、WebViewの中身は変わりません。
ただし、prefers-color-scheme が解釈されていない訳ではなく、どちらもlightテーマとして振る舞っているようです。

|light|dark|
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/ce54dda1-d240-f09a-3554-aefb060c23d9.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/eaec06de-c34a-8ce6-5d0f-74264d68861e.png)|

# アプリのテーマをWebViewに反映し、Webコンテンツのダーク設定を使わせる

WebViewのコンテンツのテーマ切替を行うには、アプリネイティブ部分のテーマ設定と、それをどのようにコンテンツに反映するかをWebViewに伝えてあげる必要があります。ただし、通常のWebViewやWebSettingsのインターフェースではかなり制約があるので、androidx.webkitを利用します。
Android 5以上であればWebView（Chrome）はアップデート可能なので、機能が追加されていきますが、WebViewのインターフェースはシステム組み込みなので古いSDKバージョンでは新機能が使えません。androidx.webkit を使うと、この部分を補ってくれるため、古いSDKバージョンでも最新のWebViewの機能が使えるようになります。ダークモード対応もAndroid5(API Level 21)以上で利用可能です。

```kotlin
implementation("androidx.webkit:webkit:1.3.0")
```

`WebSettingsCompat`の`setForceDark`と `setForceDarkStrategy` を利用します。
ForceDarkというと、ライトモードの色を元にいい感じに反転してくれるダークモード、のイメージがあります。そのため、このメソッドじゃないなと思ってしまいがちですが、**CSSの設定を使うダークモードもForceDarkを使って設定します。**ちょっとわかりにくいですね。

ダークモードのON/OFFを`setForceDark`で設定し、どのようにダークモードを反映するかを `setForceDarkStrategy` で設定します。
`WebSettingsCompat` で設定を行う前に `WebViewFeature.isFeatureSupported` でその機能が使えるかを調べてから実行します。
使えない環境でコールするとExceptionが発生してしまうので注意しましょう。
WebViewやChromeのアップデートを行っていない環境では使えません。なので使えない環境についても考慮は必要です。

`setForceDark` の設定値は以下のようになっています。`FORCE_DARK_AUTO`を設定すれば良さそう（というかデフォルト値ですが）、どうもうまく動いてくれなかったので、直接ON/OFFを設定しています。

|値|意味|
|---|---|
|FORCE_DARK_AUTO|親のViewに連動してダークモードのON/OFFを切り替えます|
|FORCE_DARK_OFF|ダークモードを無効にします、ネイティブUIに関係なく非ダークモードでの表示になります|
|FORCE_DARK_ON|ダークモードを有効にします、ネイティブUIに関係なくダークモードでの表示になります|

`setForceDarkStrategy` の設定値は以下のようになっています。

|値|意味|
|---|---|
|DARK_STRATEGY_WEB_THEME_DARKENING_ONLY|Webコンテンツがダークモードに対応していれば、それを使用してダークモード表示を行います。Webコンテンツが対応していなければ何もしません|
|DARK_STRATEGY_USER_AGENT_DARKENING_ONLY|Webコンテンツのダークモード設定を無視して強制ダークモード表示を行います|
|DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING|Webコンテンツが対応していればそれを利用し、対応していなければ強制ダークモード表示を行います|

強制ダークモードもそれなりにいい感じのダークモード表示をしてくれますが、コンテンツによってはおかしな表示になってしまったりするので、通常は`DARK_STRATEGY_WEB_THEME_DARKENING_ONLY` を指定することになると思います。なお、なにも設定しないで `getForceDarkStrategy` で調べると `DARK_STRATEGY_WEB_THEME_DARKENING_ONLY` になっていますが、`setForceDark`で`FORCE_DARK_ON`などを設定すると、`DARK_STRATEGY_USER_AGENT_DARKENING_ONLY` に変わりますので、やはりどちらも設定が必要です。

現在のUIの設定は `resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK` で調べることができますので、それを元にWebViewの設定を行います。

```kotlin
val webSettings = binding.webView.settings
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) {
    val nightMode = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
    val forceDarkMode = if (nightMode) WebSettingsCompat.FORCE_DARK_ON else WebSettingsCompat.FORCE_DARK_OFF
    WebSettingsCompat.setForceDark(webSettings, forceDarkMode)
}
if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK_STRATEGY)) {
    WebSettingsCompat.setForceDarkStrategy(
        webSettings, WebSettingsCompat.DARK_STRATEGY_WEB_THEME_DARKENING_ONLY
    )
}
```

これで以下のように、WebViewにUIのテーマが伝わり、CSSのprefers-color-schemeに基づいてダークモードとライトモードが切り替わるようになります。

|light|dark|
|:-:|:-:|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/70dc4b50-34e7-c490-8629-5529c224c25d.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/4807277c-6feb-6f3f-d576-63e96a3ef8b2.png)|

以上です。