iOS13のダークモード対応について

# はじめに
iOS 13でダークモードがサポートされるようになりました。iOSアプリの対応方法もネット上の情報が大分充実してきましたが、それでも対応していく中であまり情報を見かけなかった部分を中心に情報を整理しておきたいと思います。

## 前提環境(調査時点)
- iOS 13
- Xcode 11
- Swift 5.1

## やりたいこと
- iOSアプリをダークモード対応する
- iOS 10以降を引き続きサポートする

# 対応した内容
## 基本的な対応方針
### `UIColor`に追加された色の使用方法
動的色生成に対応した[UI Element Colors](https://developer.apple.com/documentation/uikit/uicolor/ui_element_colors)と[Standard Colors](https://developer.apple.com/documentation/uikit/uicolor/standard_colors)(の一部)を使うことで、基本的な対応はできるのですが、残念ながらこれらのAPIはiOS13以上限定なので、それ以前のiOSを引き続きサポートするためには工夫が必要です。

幸い、[Backwards compatibility for iOS 13 system colors](https://noahgilmore.com/blog/dark-mode-uicolor-compatibility/)の記事に`ColorCompatibility`というenumを作成して両対応を実現する方法が書いてありましたので、この方法を使いました。

色の選択にあたってはどの名前の色がライトモードとダークモードのそれぞれでどんな色なのかをパッと見たかったので、[対比表](https://lavox.github.io/color.html
)を作成しましたのでご活用ください。

### 独自の動的色生成方法
基本色だけでは足りなかったので、独自に動的にライトモードとダークモードに対応する色を作成する必要がありました。この方法についても、[iOS 13からのダークモード対応のコツ](https://qiita.com/gonsee/items/c04b73787730c0e831df)によくまとまっていて、この記事の`dynamicColor(light: UIColor, dark: UIColor)`相当の関数を作成して使用しました。

### ストーリーボード(Interface Builder)上の色の扱い
ストーリーボードでViewやLabelを定義した際のテキスト色や背景色に、UIColorに新規追加されたUI Element ColorsやStandard Colorsが使えるようになりました。心配だったのは新規追加された色を使ってしまうとiOS 12以前では使えなくなるのではないかということでしたが、ストーリーボード上で使う分には特に問題なく、iOS 12以前だと期待通りライトモードの色で表示されました。

なお、ソースコード中でこれらの色を書くと過去iOSに対応していないと怒られてしまうので、上の方で説明した`ColorCompatibility`のような方法を使う必要があります。

## CGColor対応
例えば`UILabel`に枠をつける時に、`label.layer.borderColor`で枠の色を設定することがありますが、この時に使うのはCGColorです。

```swift:
label.layer.borderColor = ColorCompatibility.label.cgColor
```

などとするわけですが、この行が実行された瞬間に動的な色の評価がされた上でCGColorに変換されてしまい、以降同じ画面が表示された状態でライトモード〜ダークモードの変更が行われても色が変更されません。

この回避方法は[iOS 13からのダークモード対応のコツ](https://qiita.com/gonsee/items/c04b73787730c0e831df)に書かれていて、基本的には`traitCollectionDidChange(_:)`の中で色を設定し直すようにしました。ただこの関数はライトモード〜ダークモード変更時だけでなく、デバイスの縦置き・横置きが変わった時等にも呼び出されるので、以下のように色が変わった時だけ再設定するようにすると無駄な処理をしなくて済みます。

```swift:
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    guard let pt = previousTraitCollection else { return }
    if #available(iOS 13.0, *) {
        if pt.hasDifferentColorAppearance(comparedTo: self.traitCollection) {
            // ここで色の再設定処理をする
        }
    }
}
```

## SpriteKit対応
私が開発しているアプリはSpriteKitも使用しています。SpriteKit関連のソースコードでは、ノードの`strokeColor`や`fillColor`に`UIColor`を設定しているので、一見問題なさそうに見えますが、CGColor等と同じくモード切替時に追従できません。

CGColor同様、上位のView等の`traitCollectionDidChange(_:)`を使用して色を再設定する必要があります。

## `UIActivityIndicatorView`対応
地味ですが、`UIActivityIndicatorView`(処理待ち時間にクルクル回るアレです)のstyle定義にiOS 13で変更がありました。[UIActivityIndicatorView.Style](https://developer.apple.com/documentation/uikit/uiactivityindicatorview/style)を確認すると、iOS 12まで使っていた`.whiteLarge`, `.white`, `.gray`はdeprecatedになり、`large`と`medium`が新設されています。ただ新設された値はiOS 13以降でしか使えないため、仕方がないのでストーリーボード上では従来の値、ソースコード上でiOS 13以降であれば新規の値を設定するようにしました。

## 色の決め方
私は色の専門家でも何でもないので、悩んだのはダークモードでの色をどうやって決めるかです。なるべくはApple標準定義の色を使うようにするとしても、ライトモードの段階でいろいろと独自に作ってしまった色のダークモード版をどうするかという問題がつきまといました。

特に問題だったのは背景色で、これをうかつにダークモードに持ち込もうとするとどうしても変に目立ってしまいます。目立つのを回避するために暗めの色にするというのを試しましたが暗めの色だとどうしても視認性が悪くなります。単なる背景色であればそれでも良いのですが、背景色に意味がある場合（例えば、何かの状態によって背景の色が変わるなど）、パッと見で判断できないのは困ります。

試行錯誤の結果、以下の方針で対応することにしました。

- 背景色は基本的にApple標準の黒〜グレー系の色を使う。元々背景色に意味があったとしても極力そうする。
- 意味のあった背景色は、ダークモードでは背景で表現するのは諦めて、前景色(文字色とか)で表現する。例：ピンク背景に黒文字(ライトモード) → グレー背景に赤文字(ダークモード)
- 場合によっては背景色で表現していたものを、枠線で表現することも考える

また、独自色については[Contrast Checker](https://contrastchecker.com)も参考にさせていただきました。全部のチェック項目をOKにすることは難しいですが、多少でもOKがあるとそれなりに見られる色になるようです。

あとダークモードで使う色は暗めの色にすることばかりを考えていたのですが、面積が広い部分に使うのではなければ淡めの明るい色も見栄えが良かったりすることを知り合いの方に教えていただきました。これも参考になりました。

## 最難関：graphics context描画対応
最後に一番ハマったのがこれです。私のアプリでは、少し加工したスクリーンショットを作成するために、専用のViewをxibファイルで定義しておき、必要時に`UIGraphicsBeginImageContextWithOptions()`〜`drawHierarchy()`〜`UIGraphicsEndImageContext()`を使って画像を生成するということをしていました。以下のような感じです。

```swift:
func createImage() -> UIImage {
    let rect = CGRect(x: 0, y: 0, width: 375, height: 677)
    // スクリーンショット用のView
    let ssView = ShareScreenshotView(frame: rect) // 独自定義したView
    
    // ここで、ssViewのコンポーネントに対して色や文字を諸々設定(※)

    let imgRect = CGRect(適切なサイズ定義)
    
    // context処理開始
    UIGraphicsBeginImageContextWithOptions(imgRect.size, false, 2.0)
    
    ssView.setNeedsLayout()
    ssView.drawHierarchy(in: rect, afterScreenUpdates: true)

    let img = UIGraphicsGetImageFromCurrentImageContext()
    // context処理終了
    UIGraphicsEndImageContext()
    
    // この後、img (CGImage)をUIImageに変換して返す
    return 変換したUIImage
}

```

これについてもライト/ダークモードに対応した画像を生成したかったのですが、大分ハマりました。
基本的には普通の画面と同じく、`UIColor`で設定している部分には動的に色が設定され、`CGColor`の部分については配慮が必要なのですが、その配慮の仕方に悩まされました。

モード変更後にスクリーンショットを作成しようとすると、※印の箇所で`CGColor`を作成した時点ではどういう基準で評価されているのか不明ですが異なるモードの色になってしまうことがありました。しかも、
普通の画面では`traitCollectionDidChange(_:)`で`CGColor`を再設定するようにすればそれで良かったのですが、このパターンでは`traitCollectionDidChange(_:)`は呼ばれないようでした。

結論としては、描画時には`setNeedsLayout()`によって各Viewの`layoutSubviews()`が呼ばれるので、そこで色を再設定するとうまくモードに応じた色で表示（というか画像作成）されました。

そう言えば、[iOS 13からのダークモード対応のコツ](https://qiita.com/gonsee/items/c04b73787730c0e831df)には`layoutSubviews()`についても書かれていました。こちらに書かなければならないケースもあるようです。


以上、私がダークモード対応するにあたって行った内容を書いてみました。何かのお役に立つと幸いです。