Monaca(Cordova)とOnsen UIのダークモード対応

Monaca(Cordova)とOnsen UIの構成でダークモードに対応するに当たって、色々と検討した結果を書き記します。

## 答えはここに書いてた
https://ionicframework.com/docs/theming/dark-mode
以下要約。

- メディアクエリー（prefers-color-scheme）だと最新のブラウザしか動作しないため、クラスでフォールバックすると良い（JSで制御できるようにもなるしね）
- メディアクエリー変更の検知は`window.matchMedia` 使えば良い

以下のコードで現在のメディアクエリーの取得と変更検知のイベントリスナーを登録しています。ダークモードの場合は、bodyタグにdarkクラスを付与します。

```js
// prefersDark.matches=trueの場合、ダークモード
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');

toggleDarkTheme(prefersDark.matches);

// モードの変更を検知
prefersDark.addListener((mediaQuery) => toggleDarkTheme(mediaQuery.matches));

// ダークモードの場合、bodyに"dark"クラスを付与
function toggleDarkTheme(shouldAdd) {
  document.body.classList.toggle('dark', shouldAdd);
}
```

`:root`擬似クラスに通常時(ライトモード)の変数を定義して、`body.dark`にダークモード時の変数を定義しています。
各スタイルは変数を参照するだけでよく、ダークモードを意識する必要がありません。コードもスッキリします。

```css
:root {
  /* ここに通常時(ライトモード)の変数を定義 */
  --some-class-text-color: black;
}
body.dark {
  /* ここにダークモード時の変数を定義 */
  --some-class-text-color: white;
}

.some-class {
  /** 色は変数を参照する */
  color: var(--some-class-text-color);
}
```

ちなみに、CSSの変数（CSS Variables）はiOS 9.3以降、Chrome 49以降で使用できるので、Monacaアプリであれば特に問題なく使えそうです。
https://caniuse.com/#feat=css-variables

## Onsen UIのスタイルを切り替える
### PostCSSの問題
Onsen UIはPostCSSを使用しており、各スタイルの色は変数を参照しています。
PostCSS([postcss-custom-properties](https://github.com/postcss/postcss-custom-properties))によってCSS Variablesが実際の色コードに変換されているので、postcss-custom-propertiesを無効にしてCSS Variablesを残せば良いと考えました。

しかし、Onsen UIは[postcss-color-function](https://github.com/postcss/postcss-color-function)も使用しており、CSS Variablesを残してしまうとビルド時にエラーになってしまいます…
例えば、以下のように90%で透過する関数を使用すると、ビルド後は透過済みの値に変換されるのですが、`red`をCSS変数にするとビルド時に色が決定されていないため変換することができません。

```css
.some-style {
  background: color(red alpha(90%)) /* => rgba(255, 0, 0, 0.9) */
  background: color(var(--some-color) alpha(90%)) /* NG */
}
```

また、color関数はmod-color関数に置き換えられており、さらにそのmod-color関数もCSS5に延期とのことで、現状のブラウザでの動作は望むべくもありません。
https://github.com/w3c/csswg-drafts/issues/3187#issuecomment-499206254

### スタイルシートの切り替え
そこで、少し無理やりですが、スタイルシートを丸ごと切り替える方法を試してみます。
Onsen UIは標準テーマがonsen-css-components.css、ダークテーマがdark-onsen-css-components.cssに定義されているため、これを外部スタイルシートとして読み込みます。
後でDOMを参照するために、id属性も付与します。

```html
<link rel="stylesheet" type="text/css" href="onsen-css-components.css" id="theme-css">
```

Webpackを使って１ファイルにバンドルしている場合はバンドルするのをやめて、外部ファイルとして扱います。Monacaで作成したプロジェクトの場合は、www以下におけばOKです。

※webpack-serveはメモリ上に資産を保持するため、静的資産を参照するにはcontentオプションにディレクトリを指定する必要があります。(webpack-serve v2.xの場合)

```webpack.config.js
webpackConfig.serve = {
  //...
  content: 'www/',
  //...
};
```

以下はVue.jsの例です。`dark`クラスをbodyに付与する代わりに、Onsen UIのテーマCSSファイルを切り替えています。

```App.vue
<script>
  export default{
    mounted() {
      this.$ons.ready(() => {
        // prefersDark.matches=trueの場合、ダークモード
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
        // 現在のモードに変更
        this.changeTheme(prefersDark.matches)
        // モードの変更を検知
        prefersDark.addListener((mediaQuery) => this.changeTheme(mediaQuery.matches))
      })
    },
    methods: {
      changeTheme(dark) {
        // モードに応じて、CSSファイルを変更
        if (dark) {
          document.getElementById('theme-css').setAttribute('href', 'dark-onsen-css-components.css')
        } else {
          document.getElementById('theme-css').setAttribute('href', 'onsen-css-components.css')
        }
      }
    }
  }
</script>
```

実際に動かしてみると、モード変更時にリアルタイムにスタイルが切り替わります。
![blkwj-df0n2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51815/a2014cc2-8809-61c2-8457-ad737acca710.gif)

## アプリ本体のスタイルを切り替える
アプリのスタイルを変更する方法は、前述のIonicの記事と同じ方法が良いかなと思います。

なお、PostCSSを使っている場合は、postcss-custom-propertiesを無効にしておく必要があります。無効化は以下のように設定すればOKです。

```postcss.config.js
module.exports = ({ file, options, env }) => ({
  plugins: {
    'postcss-cssnext': {
      features: {
        customProperties: false
      }
    }
  }
});
```

## ステータスバーとWebViewの背景色を切り替える
config.xmlのStatusBarOverlaysWebViewに`false`を設定している（加えてiPhone X系なら、`meta[name=viewport]`に`viewport-fit=cover`も指定していない）場合、ステータスバーやSafe Area外はWebView外になるため、別途ダークモードの対応が必要です。

ステータスバーの色変更は[cordova-plugin-statusbar](https://github.com/apache/cordova-plugin-statusbar)を使用すれば良さそうです。
当初、ステータスバーのテキスト色は、標準モードは`StatusBar.styleDefault()`、ダークモードは`StatusBar.styleLightContent()`で変えれば良いと考えていましたが、iOSだとダークモード時の`StatusBar.styleDefault()`は背景は黒に、テキストは白になるため使用できません。

iOS13から`UIStatusBarStyleDarkContent`が追加されているので、標準モードの場合はこれを設定すれば良さそうです。
すでに、Issue化されていて対応は時間の問題ですが、とりあえずプラグインを修正して対応します。
https://github.com/apache/cordova-plugin-statusbar/issues/148

```objective-c:CDVStatusBar.m
// 以下を追記
- (void) styleDarkContent:(CDVInvokedUrlCommand*)command
{
    if (@available(iOS 13.0, *)) {
        [self setStyleForStatusBar:UIStatusBarStyleDarkContent];
    }
}
```

```statusbar.js
// 以下を追記
styleDarkContent: function () {
  // dark text ( to be used on a light background )
  if (cordova.platformId == 'ios') {
      exec(null, null, "StatusBar", "styleDarkContent", []);
  } else {
      this.styleDefault();
  }
},
```

あとは、モード変更時にステータスバーの設定を変更します。
Safe Area外の背景色（画面下部分）はWebViewの背景色を変更します。実装は[cordova-plugin-webviewcolor](https://github.com/EddyVerbruggen/cordova-plugin-webviewcolor)を使用します。

```App.vue
<script>
  export default{
    mounted() {
      //...
    },
    methods: {
      changeTheme(dark) {
        if (dark) {
          // ダークモードの場合
          document.getElementById('theme-css').setAttribute('href', 'dark-onsen-css-components.css')
          window.StatusBar.styleLightContent()
          window.StatusBar.backgroundColorByHexString('#181818')
          window.plugins.webviewcolor.change('#0d0d0d')
        } else {
          // 標準モードの場合
          document.getElementById('theme-css').setAttribute('href', 'onsen-css-components.css')
          window.StatusBar.styleDarkContent()
          window.StatusBar.backgroundColorByHexString('#fafafa')
          window.plugins.webviewcolor.change('#efeff4')
        }
      }
    }
  }
</script>
```

## AndroidのWebViewで動作しない問題
AndroidのWebViewだと、メディアクエリー（prefers-color-scheme）が動作しないのか、テーマの切り替えがされませんでした。(´・ω・`)
APIバージョンを29にしてもダメです。

[WebSettings#setForceDark](https://developer.android.com/reference/android/webkit/WebSettings.html#setForceDark(int))という設定項目が追加されており、CordovaのWebView(SystemWebView)に対して[FORCE_DARK_AUTO](https://developer.android.com/reference/android/webkit/WebSettings.html#FORCE_DARK_AUTO)を設定しましたが、テーマは切り替わらず…
FORCE_DARK_AUTOの説明には、親ビューがダークテーマの場合はWebViewも暗くすると説明がありますが、親ビューがダークモードに追従していないのかもしれません。

※なお、本問題はIssue化されていますが、未解決のままクローズされています…
https://github.com/apache/cordova-android/issues/747

仕方ないため、ネイティブのAPIを使用してテーマ(ライトorダーク)の取得と、テーマの変更時の検知を実装します。
取得は[Configuration#uiMode](https://developer.android.com/reference/android/content/res/Configuration#uiMode)から、Configuration#uiModeの変更検知は[Activity#onConfigurationChanged](https://developer.android.com/reference/android/app/Activity.html#onConfigurationChanged(android.content.res.Configuration))で行うことができます。以下、Cordovaプラグイン化したソースコードのサンプルです。

```DarkMode.java
package jp.co.pscsrv.cordova;

import android.content.res.Configuration;

import org.apache.cordova.CallbackContext;
import org.apache.cordova.CordovaPlugin;
import org.apache.cordova.PluginResult;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.util.ArrayList;

public class DarkMode extends CordovaPlugin {

    private static final String TAG = "DarkMode";

    private CallbackContext callbackContext;

    @Override
    public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
        if (action.equals("init")) {
            this.callbackContext = callbackContext;
        } else if (action.equals("getTheme")) {
            getTheme(args, callbackContext);
        }

        return true;
    }

    private void getTheme(JSONArray args, CallbackContext callbackContext) throws JSONException {
        String theme = getTheme(this.cordova.getActivity().getResources().getConfiguration());
        PluginResult pluginResult = new  PluginResult(PluginResult.Status.OK, theme);
        callbackContext.sendPluginResult(pluginResult);
    }

    @Override
    public void onConfigurationChanged(Configuration newConfig) {
        // テーマが変わると呼ばれる
        if (callbackContext != null) {
            String theme = getTheme(newConfig);
            if (theme != null) {
                PluginResult pluginResult = new PluginResult(PluginResult.Status.OK, theme);
                pluginResult.setKeepCallback(true);
                callbackContext.sendPluginResult(pluginResult);
            }
        }
    }

    private String getTheme(Configuration config) {
        int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK;
        switch (currentNightMode) {
            case Configuration.UI_MODE_NIGHT_NO:
                return "light";
            case Configuration.UI_MODE_NIGHT_YES:
                return "dark";
            default:
                return null;
        }
    }
}
```

```dark-mode.js
var exec = require('cordova/exec');

var DarkMode = function () {};

DarkMode.prototype.init = function () {
  var that = this
  var success = function (theme) {
    if (that.onThemeChanged) {
      that.onThemeChanged(theme)
    }
  }
  var fail = function () {}
  cordova.exec(
      success,
      fail,
      'DarkMode',
      'init',
      []
  )
}

DarkMode.prototype.getTheme = function () {
  return new Promise(function(resolve, reject) {
    cordova.exec(
        resolve,
        reject,
        'DarkMode',
        'getTheme',
        []
    )
  })
}

DarkMode.prototype.setOnThemeChanged = function (onThemeChanged) {
  this.onThemeChanged = onThemeChanged
}

module.exports = new DarkMode()
```

Androidの場合、作成したCordovaプラグインを参照するように、コードを書き換えます。

```App.vue
<script>
  export default{
    //...
    mounted() {
      this.$ons.ready(() => {
        if (this.$ons.platform.isWebView() && this.$ons.platform.isAndroid()) {
          // Androidの場合、ネイティブAPIよりテーマの取得と変更検知の登録を行う
          window.DarkMode.init()
          window.DarkMode.getTheme().then(theme => {
            this.changeTheme(theme === 'dark')
          })
          window.DarkMode.setOnThemeChanged(theme => {
            this.changeTheme(theme === 'dark')
          })
        } else {
          // Use matchMedia to check the user preference
          const prefersDark = window.matchMedia('(prefers-color-scheme: dark)')
          this.changeTheme(prefersDark.matches)
          // Listen for changes to the prefers-color-scheme media query
          prefersDark.addListener((mediaQuery) => this.changeTheme(mediaQuery.matches))
        }
      })
    }
    //...
  }
</script>
```


![4v1ye-m8e76.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51815/9002702b-92ac-e25b-56c6-210b34742eb5.gif)

これで、AndroidのWebViewもダークモードに追従するようになりました。
図らずも、クラスのフォールバックを活かせました。
※他に良い方法があれば、教えてください！

## Monacaは未対応
ここまで書いておいて今更ですが…

ダークモードに対応するには、Xcode11でビルドする必要があります。
MonacaはXcodeのバージョンがまだ10.xのため非対応です。(2019/10/10時点)
Androidも同様です。

XcodeとGradleがバージョンアップされるのを待ちます…
とりあえず試したい人は、ローカル（Cordova）でビルドしましょう。

## サンプル
https://github.com/kishino/dark-mode-sample

## まとめ
UIライブラリの仕組みに制限されて素直に実装できなかったり、WebViewが対応できていなかったりと、少し時期尚早感はありますが、一応ダークモードの対応ができました。
実際にiOS13でダークモード（日照時間で自動切り替え）を設定すると、思ったより良いUXでした。余裕があればMonaca(Cordova)アプリもダークモードに対応していきたいと思います！