macOSアプリのダークモード対応

# はじめに

macOSでは10.14（2018年リリース）からダークモードが追加されています。

macOS 10.14以降、アプリで独自定義のカラーを使う場合、ライトモード用とダークモード用のカラーを用意しておく必要があります。ライト/ダーク用のカラーはカラーアセットを利用して用意できます。

<img width="1396" alt="スクリーンショット 2019-10-20 19.50.47.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73490/f4ca0bbb-71ef-4aac-f82e-21313903c0a4.png">

カラーアセットから以下のコードで外観モードにあわせたカラーオブジェクトを生成できます。

```swift
let color = NSColor(named: NSColor.Name("MyColor"))
```

しかし、この方法ではモードを切り替え時、**起動中のアプリでは自動的にカラーが変更されません**。macOS 10.15ではNSColorに[init(name:dynamicProvider:)](https://developer.apple.com/documentation/appkit/nscolor/3294481-init)イニシャライザが追加されており、ドキュメントに説明は無いですが、察するにこれを使えば動的に変更できるものと思います。では、macOS 10.14ではどうするか?

# macOS 10.14でダークモードに対応する

macOS 10.14では、独自定義のカラーをViewに設定していた場合、モードの変更を検知し、カラーを設定し直すコードを書く必要があります。

## ダークモードか判定する

ダークモードか判定する`isDarkMode`コンピューテッドプロパティをNSApplicationに生やします。

```swift
extension NSApplication {
    public var isDarkMode: Bool {
        if #available(OSX 10.14, *) {
            let name = effectiveAppearance.name
            return name == .darkAqua
        }
        else {
            return false
        }
    }
}
```

## 外観モードにあわせてカラーオブジェクトを返す

ライト/ダークモード用のカラーオブジェクトを返す適当なメソッドを実装します。

```swift
struct CustomColors {
    static var background: NSColor {
        if NSApplication.shared.isDarkMode {
            return NSColor(red: 34.0 / 255.0, green: 34.0 / 255.0, blue: 34.0 / 255.0, alpha: 1.0)
        }
        return .white    // for Light Mode
    }
}
```

## 外観モードの変更を検知する

外観モードが変更されると、NSViewの`viewDidChangeEffectiveAppearance`メソッドが呼ばれます。このメソッドをオーバライドし、カラーオブジェクトを再設定するようにします。

```swift
class CustomView: NSView {

    @available(OSX 10.14, *)
    override func viewDidChangeEffectiveAppearance() {
        layer?.backgroundColor = CustomColors.background.cgColor
    }
}
```

これで動的にカラーを変更することができます。ただ、このやり方は各ビューで継承クラスを作らないといけないため、使い勝手が悪いときがあります。

そこで僕は以下のようなクラスを作って監視するようにしています。

```swift
class AppearanceMonitor: NSView {

    public static let appearanceChangedNotification = NSNotification.Name("AppearanceChangedNotification")
    public static let shared                        = AppearanceMonitor(frame: .zero)

    public var isMonitoring: Bool {
        superview != nil
    }

    override func viewDidMoveToSuperview() {
        guard let _ = superview else {
            print("[\(self.className)] Monitoring stopped.")
            return
        }
        print("[\(self.className)] Monitoring started.")
    }

    @available(OSX 10.14, *)
    override func viewDidChangeEffectiveAppearance() {
        NotificationCenter.default.post(name: AppearanceMonitor.appearanceChangedNotification, object: nil, userInfo: nil)
    }

    public func start(with viewController: NSViewController) {
        if !isMonitoring {
            viewController.view.addSubview(self)
        }
    }

    public func stop() {
        if isMonitoring {
            removeFromSuperview()
        }
    }
}
```

```swift
class ViewController: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        // 監視用のビューを貼る（frame=.zeroなので見た目には影響がない）
        AppearanceMonitor.shared.start(with: self)
    }

    override func viewWillAppear() {
        super.viewWillAppear()

        NotificationCenter.default.addObserver(self,
                                               selector: #selector(appearanceChanged),
                                               name: AppearanceMonitor.appearanceChangedNotification,
                                               object: nil)
    }

    override func viewWillDisappear() {
        super.viewWillDisappear()

        NotificationCenter.default.removeObserver(self,
                                                  name: AppearanceMonitor.appearanceChangedNotification,
                                                  object: nil)
    }

    @objc func appearanceChanged() {
        // ライト/ダーク・モード切り替え時に呼ばれるのでUIを更新する
    }
}
```

なお、他に`AppleInterfaceThemeChangedNotification`を監視する方法もありますが、公式ドキュメントに記載がない(?)ため、積極的に使っていいものなのか悩ましいです。

# プログラムでライト/ダーク・モードを切り替える

最後に、プログラムでライト/ダーク・モードを切り替える方法を紹介します。

```swift
// ダークモードにする
NSApp.appearance = NSAppearance(named: .darkAqua)
// ライドモードにする
NSApp.appearance = NSAppearance(named: .aqua)
// プログラムでの設定を解除する -> Macの設定に従う
NSApp.appearance = nil
```

# 参考
Supporting Dark Mode in Your Interface 
https://developer.apple.com/documentation/xcode/supporting_dark_mode_in_your_interface