こんにちは。コミュニケーションアプリ「LINE」のAndroidクライアント開発をしている小川琢也です。
今回は、LINE Android アプリの LIFF ブラウザでダークモード対応を行った際の話を紹介します。
LIFF ブラウザ
LIFF は LINE Front-end Framework
の略で、LINE が提供するウェブアプリのプラットフォームです。 LIFF についての詳細は こちらを参照ください。 このプラットフォーム上で動作するウェブアプリを LIFF アプリと呼び、これら LIFF アプリを LINE アプリの上で表示するためのブラウザが LIFF ブラウザです。
Android での LIFF ブラウザは WebView を利用しており、ダークモードの対応も WebView に沿ったものとなります。
WebView のダークモード対応
Andrid 12 (Api level 32) まで
androidx.webkit
を利用し、 WebSettingsCompat.setForceDark
と WebSettingsCompat.setForceDarkStrategy
を呼び出すことにより、ダークテーマ対応のウェブサイトを WebView 上にダークテーマ表示することができます。
setForceDark
は設定値として以下の値を取ります
value | |
---|---|
FORCE_DARK_AUTO (default) | 親の View の状態に従って force dark を有効にする |
FORCE_DARK_OFF | 親の View の状態に関係なく force dark を無効にする |
FORCE_DARK_ON | 親の View の状態に関係なく force dark を有効にする |
setForceDarkStrategy
の設定値と表示されるウェブサイトに適用される CSS media query prefers-color-scheme
の関係は以下のとおりです。
設定値により適用される prefers-color-scheme
に違いがあります。
value | prefers-color-scheme (dark mode site) | prefers-color-scheme (no dark mode site) | |
---|---|---|---|
DARK_STRATEGY_WEB_THEME_DARKENING_ONLY | ウェブページが提供するダークテーマによりダーク表示されます。ダークテーマが提供されていなければデフォルトのテーマで表示されます | dark | light (default) |
DARK_STRATEGY_USER_AGENT_DARKENING_ONLY | ウェブページがダークテーマを提供していても無視され、強制ダーク表示されます | light | light |
DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING | ウェブページがダークテーマをサポートしていればそちらによりダーク表示され、サポートされてない場合に強制ダーク表示されます | dark | light (default) |
User Agent とはウェブページにアクセスする際にHTTPを解釈するプログラム、ソフトウェアのことであり、ウェブブラウザやウェブクローラなどのことを指し、ここではウェブブラウザに該当します。
以下、setForceDark
と setForceDarkStrategy
に渡す設定値を変えた場合の WebView 上の表示の具体例です。
ダークテーマ非適用サイト
ダークモードに対応していないウェブサイト例です。
ウェブサイトにダークモードの定義がないので、DARK_STRATEGY_WEB_THEME_DARKENING_ONLY
の場合はテーマに従った表示の変更は発生せず、DARK_STRATEGY_USER_AGENT_DARKENING_ONLY
または DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING
の場合は、ブラウザにより薄い背景色がダークな色に書き換えられて、白字は黒字となって表示されることがわかります。
force dark | OFF | ON (DARK_STRATEGY_WEB_THEME_DARKENING_ONLY) | ON (DARK_STRATEGY_USER_AGENT_DARKENING_ONLY) | ON (DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING) |
---|---|---|---|---|
background #ffffff | ![]() |
![]() |
![]() |
![]() |
background #cbf7c7 | ![]() |
![]() |
![]() |
![]() |
background #90ee90 | ![]() |
![]() |
![]() |
![]() |
background #487047 | ![]() |
![]() |
![]() |
![]() |
background #000000 | ![]() |
![]() |
![]() |
![]() |
ダークテーマ適用サイト
先ほどのウェブサイトにおいてダークモードの場合に backgroundColor が Gray となるようにダークテーマ定義を追加した際の挙動です。DARK_STRATEGY_WEB_THEME_DARKENING_ONLY
と DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING
の場合はダークテーマ表示され、DARK_STRATEGY_USER_AGENT_DARKENING_ONLY
の場合は先ほどと同じ ブラウザ による色の書き換えが起きることがわかります。
force dark | OFF | ON (DARK_STRATEGY_WEB_THEME_DARKENING_ONLY) | ON (DARK_STRATEGY_USER_AGENT_DARKENING_ONLY) | ON (DARK_STRATEGY_PREFER_WEB_THEME_OVER_USER_AGENT_DARKENING) |
---|---|---|---|---|
background #ffffff | ![]() |
![]() |
![]() |
![]() |
background #cbf7c7 | ![]() |
![]() |
![]() |
![]() |
background #90ee90 | ![]() |
![]() |
![]() |
![]() |
background #487047 | ![]() |
![]() |
![]() |
![]() |
background #000000 | ![]() |
![]() |
![]() |
![]() |
ブラウザによる表示の変更では期待しない表示となることが発生しうるため、基本的には DARK_STRATEGY_WEB_THEME_DARKENING_ONLY
と setForceDark
の組み合わせで使うこととなるかと思います。
val webContentForceDarkMode = if (isDarkModeEnabled) {
WebSettingsCompat.FORCE_DARK_ON
} else {
WebSettingsCompat.FORCE_DARK_OFF
}
if (WebViewFeature.isFeatureSupported(FORCE_DARK)) {
WebSettingsCompat.setForceDark(webViewSettings, webContentForceDarkMode)
}
if (WebViewFeature.isFeatureSupported(FORCE_DARK_STRATEGY)) {
WebSettingsCompat.setForceDarkStrategy(webViewSettings, DARK_STRATEGY_WEB_THEME_DARKENING_ONLY)
}
Android 13 (Api level 33) 以降
WebSettingsCompat.setForceDarkStrategy
と WebSettingsCompat.setForceDark
はともに Deprecated となり、呼び出しても何も起きなくなりました。
代わりに WebView に適用されている テーマ の isLightTheme
の値に従って prefers-color-scheme
が渡され、ウェブアプリはそれに従ったテーマ表示を行うようになります。 各継承元のテーマと WebView に適用されるテーマの関係は以下のようになり、DayNight
テーマが継承されている場合に、OSのダークモードの設定にしたがった表示が行われることになります。
theme | isLightTheme | prefers-color-scheme |
---|---|---|
Theme.AppCompat.NoActionBar | N/A | light |
Theme.AppCompat.Light.NoActionBar | true | light |
Theme.AppCompat.Night.NoActionBar | false | dark |
Theme.AppCompat.DayNight.NoActionBar | false at light mode/true at dark mode | light at light mode/dark at dark mode |
また、ブラウザによるダークテーマ表示を行うための互換性のための API として WebSettingsCompat.setAlgorithmicDarkeningAllowed
という API が追加されました。
以下、DayNight
テーマを使ったサンプルアプリにて先ほどのウェブサイトを表示した例です。
ダークテーマ非適用サイト
setAlgorithmicDarkeningAllowed
により setForceDarkStrategy
で DARK_STRATEGY_USER_AGENT_DARKENING_ONLY
が設定された時と同じ表示となります。
dark mode | OFF | ON(darkning false) | ON(setAlgorithmicDarkeningAllowed true) |
---|---|---|---|
background #ffffff | ![]() |
![]() |
![]() |
background #cbf7c7 | ![]() |
![]() |
![]() |
background #90ee90 | ![]() |
![]() |
![]() |
background #487047 | ![]() |
![]() |
![]() |
background #000000 | ![]() |
![]() |
![]() |
ダークテーマ適用サイト
ウェブサイトのダークテーマが反映された表示になり、setAlgorithmicDarkeningAllowed
の影響は受けません。
dark mode | OFF | ON(darkning false) | ON(setAlgorithmicDarkeningAllowed true) |
---|---|---|---|
background #ffffff | ![]() |
![]() |
![]() |
background #cbf7c7 | ![]() |
![]() |
![]() |
background #90ee90 | ![]() |
![]() |
![]() |
background #487047 | ![]() |
![]() |
![]() |
background #000000 | ![]() |
![]() |
![]() |
ブラウザによる表示の変更が不必要であれば、DayNight
テーマを適用するだけで、WebView 上の表示はダークモード対応としては十分となります。
強制ダークテーマで使われるアルゴリズム
Android 12 (API 32) での setForceDarkStrategy
に DARK_STRATEGY_USER_AGENT_DARKENING_ONLY
が指定された場合、および Android 13 (API 33)
での setAlgorithmicDarkeningAllowed
が有効になった場合に強制ダークテーマがどう適用されるかについて説明します。
HTML ファイルが描画されるまでの流れは以下のようになっており、 HTML の解析、CSS の解析をしたのち Render Tree を構築し、Layout 作業でHTML の各 node のサイズと位置を計算し、Painting にて結果のレンダリングが行われます。
Android WebView (Chrome) では、WebKit
から分岐して開発が行われている Blink
という レンダリングエンジンが使われています。 Blink
では 強制ダークテーマの適用は Painting フェーズにて以下の処理により行われます。
まず、Brightness の計算が行なわれます。
int DarkModeColorClassifier::CalculateColorBrightness(SkColor color) {
int weighted_red = SkColorGetR(color) * 299;
int weighted_green = SkColorGetG(color) * 587;
int weighted_blue = SkColorGetB(color) * 114;
return (weighted_red + weighted_green + weighted_blue) / 1000;
}
この計算は W3 にて推奨された計算方法で、こちらに記載があります。
計算された Brightness が決められた閾値内にある場合 (Background 要素の場合 205 より大きい場合、Foreground 要素の場合 150 より小さい場合)に次の InvertColor
が呼ばれ色の変換が行われます。
SkColor4f InvertColor(const SkColor4f& color) const override {
SkV3 rgb = {color.fR, color.fG, color.fB};
SkV3 lab = transformer_.SRGBToLAB(rgb);
lab.x = std::min(110.0f - lab.x, 100.0f);
rgb = transformer_.LABToSRGB(lab);
SkColor4f inverted_color{rgb.x, rgb.y, rgb.z, color.fA};
return AdjustGray(inverted_color);
}
sRGB カラーを CIE XYZ カラースペースに変換し、さらに CIE LAB カラースペースへと変換します。 詳細な変換の計算法についてはこちらに記載されています。
CIE LAB カラースペースでは、x
が Lightness (明度) を意味し、人間の明度の知覚に近い 0(Black) から 100(White) の値をとります。 110.0f - lab.x
と 100.0f
のうち小さい値を明度とすることによりダークな明度への変換が行われます。 その後、CIE LAB カラースペースから sRGB に戻し、マテリアルデザインで推奨されるダークグレー色が使われるように調整されたダークテーマ適用後の色となります。
LIFF ブラウザでのダークモード対応
LINE アプリの着せかえ機能の考慮
LINE アプリには「着せかえ」機能があり、「着せかえ」が有効になっている場合は Android 端末のダークモード設定には依存せずにアプリ全体としてライトモードが適用されるようになっています。
また、着せかえ設定として「ダークモードではブラック着せかえを適用」というオプションがあり、こちらが設定されている場合にはライトモードとするかダークモードとするかは Android 端末のダークモード設定に従うようになっています。
LINE アプリでのダークモード設定は、アプリ起動時にローカルデータベースに保存された「着せかえ」の情報と Android 端末のダークモード設定を以下の流れにより決定し、 AppCompatDelegate.setDefaultNightMode
を呼び出すことで設定しています。
setDefaultNightMode
が取る現在有効な設定値は以下の通りです。
value | |
---|---|
MODE_NIGHT_FOLLOW_SYSTEM (default) | Android system のナイトモード設定に従う |
MODE_NIGHT_AUTO_BATTERY | バッテリーセイバー設定が有効な時にナイトモード設定を使う |
MODE_NIGHT_NO | 常にライトモードを使う |
MODE_NIGHT_YES | 常にナイトモードを使う |
この値は getDefaultNightMode
により参照可能です。
LIFF アプリへのダークテーマ設定
LIFF アプリはそれぞれが、ライトテーマのみ利用可能か、ダークテーマとライトテーマの両方を利用可能かの設定値を持っています。
LIFF アプリを LIFF ブラウザ上に表示する際には、アプリとしてダークモードが適用されており、かつ LIFF アプリがダークテーマ利用を許可されている場合に WebView にダークテーマ表示にする必要があります。
前述の通り、Android 12 (API 32) までは setForceDark
を使って、WebView の状態を動的に変更することが可能でしたが、Android 13 (API 33) 以降は WebView へのダークテーマの適用は Android アプリのテーマに従うので、LIFF の設定情報を反映させるには、開かれる LIFF アプリに応じて Android アプリのテーマを切り替えることが必要となります。
setDefaultNightMode
Android アプリのテーマの切り替えは AppCompatDelegate.setDefaultNightMode
を使って行うことができます。
AppCompatActivity
を継承している Activity
では onCreate
の際に AppCompatDelegate
の instance を生成し、delegate.onCreate
にて active な AppCompatDelegate
を sActivityDelegates
という ArraySet に保持するようになっています。
public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
...
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
final AppCompatDelegate delegate = getDelegate();
delegate.installViewFactory();
delegate.onCreate(savedInstanceState);
if (delegate.applyDayNight() && mThemeId != 0) {
// If DayNight has been applied, we need to re-apply the theme for
// the changes to take effect. On API 23+, we should bypass
// setTheme(), which will no-op if the theme ID is identical to the
// current theme ID.
if (Build.VERSION.SDK_INT >= 23) {
onApplyThemeResource(getTheme(), mThemeId, false);
} else {
setTheme(mThemeId);
}
}
super.onCreate(savedInstanceState);
}
public abstract class AppCompatDelegate {
...
@NightMode
private static int sDefaultNightMode = MODE_NIGHT_UNSPECIFIED;
/**
* All AppCompatDelegate instances associated with a "live" Activity, e.g. lifecycle state is
* post-onCreate and pre-onDestroy. These instances are used to instrument night mode's uiMode
* configuration changes.
*/
private static final ArraySet<WeakReference<AppCompatDelegate>> sActivityDelegates =
new ArraySet<>();
それぞれの AppCompatDelegate
はナイトモードに関する設定値を保持しており、setDefaultNightMode
が呼ばれると sActivityDelegates
に保持されている全ての AppCompatDelegate
に対して applyDayNight
を呼び出して NightMode の設定が反映されます。
setDefaultNightMode
の影響範囲はアプリ全体であり、LIFF を閉じた際に元に戻す考慮が必要となるので適していません。
private static void applyDayNightToActiveDelegates() {
synchronized (sActivityDelegatesLock) {
for (WeakReference<AppCompatDelegate> activeDelegate : sActivityDelegates) {
final AppCompatDelegate delegate = activeDelegate.get();
if (delegate != null) {
if (DEBUG) {
Log.d(TAG, "applyDayNightToActiveDelegates. Applying to " + delegate);
}
delegate.applyDayNight();
}
}
}
}
setLocalNightMode
現在の表示されている Activity にのみ設定を反映させる API として setLocalNightMode
が用意されています。
AppCompatDelegate
が持つ setDefaultNightMode
によって設定されたナイトモード設定値を上書きするようになっているため、 LIFF 画面のみのように特定の画面のみに反映させるにはこちらが使えます。
@Override
@RequiresApi(17)
public void setLocalNightMode(@NightMode int mode) {
if (DEBUG) {
Log.d(TAG, String.format("setLocalNightMode. New: %d, Current: %d",
mode, mLocalNightMode));
}
if (mLocalNightMode != mode) {
mLocalNightMode = mode;
if (mBaseContextAttached) {
// If we the base context is attached, we call through to apply the new value.
// Otherwise we just wait for attachBaseContext/onCreate
applyDayNight();
}
}
}
動的な設定の反映
さらに LIFF Browser では、LIFF アプリを WebView に表示している状態から別の LIFF アプリを同じ WebView 上に表示することがあり、この考慮も必要となります。
最初に開かれた LIFF アプリがダークテーマの利用を許可されおり、次に開いた LIFF アプリには許可されていないような場合では、WebView を開いている Activity 上で動的にテーマの変更が必要となるのですが、setLocalNightMode
は Activity の onCreate
でしか設定値の反映させることができない制限があり、その他の場所から呼び出した場合は Activity を recreate
する必要があります。
以下に Android 13 (API 33) の端末上で必要となるシーケンス図を記載します。
ここでは OS のダークモードが有効、LIFF_A がダークテーマ表示可能、LIFF_B はダークテーマ表示不可な場合としています。
WebView を表示する Activity 上で onCreate が呼び出されたタイミング上で適用されるテーマが決定され、WebView に反映されるのがライトテーマになるのか、ダークテーマになるのかが決まります。
recreate
を行った場合は Activity の onDestroy が呼ばれ、その後 onCreate が呼び出されるので、WebView の状態を復元するために saveState と restoreState も実行することも必要となります。
なお、ここまで LIFF ブラウザにダークモードを適用するために必要となる実装について説明しましたが、LIFF ブラウザでは LIFF アプリ向けに提供されているフロントエンドの LIFF SDK の挙動による制約の関係で、現在 saveState/restoreState をまだ利用できていない状況です。 このため、現在は上記の手法は取り入れることはできず、LIFF アプリのダークテーマ設定値に従ってライトテーマ・ダークテーマを表示し分けることは残念ながらできていません。
LIFF アプリのダークテーマの利用可否の設定値が導入された背景がダークテーマ未対応な LIFF アプリがダークテーマ表示された際の意図しない表示の問題を避けるためでした。 昨今はウェブサイトにダークテーマを正しく適用するサイトも増えてきており、またこの設定値によりダークテーマの制御をすることはアクセシビリティの観点はよくなさそうという話となり、これを機に全ての LIFF アプリに対してダークテーマ設定を許可するように設定値について見直すこととなりました。 LIFF ブラウザ上ではアプリ全体のダークモード設定に従うことで十分となり、複雑な考慮は必要なくなりました。
まとめ
今回は LINE の LIFF ブラウザで利用している WebView をダークモード対応するために調査したこと、解決策として考えたことについて紹介しました。
今回紹介した内容が少しでも参考になれば幸いです。