LoginSignup
19
16

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-11-02

アプリのダークモード(ダークテーマ)対応にて、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 というメディア特性を使って対応するのが正攻法のようですね。

@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を作ります。

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

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

<?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 で切り替えます。

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

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

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

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

WebSettingsCompatsetForceDarksetForceDarkStrategy を利用します。
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 になっていますが、setForceDarkFORCE_DARK_ONなどを設定すると、DARK_STRATEGY_USER_AGENT_DARKENING_ONLY に変わりますので、やはりどちらも設定が必要です。

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

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

以上です。

19
16
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
19
16