macOSアプリ用の環境設定ウインドウの作成方法

# 概要

- macOSアプリを作っている中で、環境設定ウインドウどうやって作ろうかなあ…と思っていた矢先、タイムラインに天啓が舞い降りました。

<blockquote class="twitter-tweet" data-conversation="none" data-cards="hidden" data-partner="tweetdeck"><p lang="ja" dir="ltr">手前味噌ですが、環境設定のデザインについては私の過去の発表資料が参考になるかと思います。<a href="https://t.co/F8F7OBQXIs">https://t.co/F8F7OBQXIs</a></p>&mdash; 1024 𓆏 (@1024jp) <a href="https://twitter.com/1024jp/status/1219523437929984001?ref_src=twsrc%5Etfw">January 21, 2020</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

- @1024jp さんがご丁寧に[GitHubにサンプル](https://github.com/1024jp/Preferences-Demo)を公開してくださっています、これを参考に私も実装してみました。
    - Swiftの書き方を含めて非常に参考になるのでおすすめです
- 一度作ってしまえば他のプロジェクトでも流用できるので、辛いのは最初だけですね。
- `General`でメッセージの変更、`Advanced`でフォントの外観を変更できるようになっています。

![-w799](https://i.imgur.com/ovxjsuT.jpg)

![-w859](https://i.imgur.com/CcSEi9h.jpg)

- フォントパネルによるフォントの変更取得も、実装例がなかなか見つからず苦労したのでよければ参考にまで。

# GitHub

- 記事にコード全ては載せきれないので、以下を参照ください。
- https://github.com/pommdau/Qiita_PreferenceSample/releases/tag/2020-02-11

# 参考
## 環境設定ウインドウの作成
- [環境設定の作法 / Manners of Preferences Window on macOS \- Speaker Deck](https://speakerdeck.com/1024jp/manners-of-preferences-window-on-macos)
    - 主に参考にしました。
    - macOSにおける環境設定ウインドウのお作法を紹介しています。
- [1024jp/Preferences\-Demo](https://github.com/1024jp/Preferences-Demo)
    - 主に参考にしました。
    - 上記のスライドで紹介してされているサンプルコードです。
- [Preferencesみたいなツールバータブウインドウを作る](https://qiita.com/hanamiju/items/365818e736f8ef318e81)
    - ざっとしか見ていないけど、概ね同じ方向性かと。
- [OS Xアプリケーションにおける環境設定ウインドウの作り方](https://genjiapp.com/blog/2012/10/25/how-to-develop-a-preferences-window-for-os-x-app.html)
	- `Toolbar Button`を使った古めのサンプルです

## UserDefaults（環境設定値の保存に使用）
- [Swift：UserDefaultsで初期値を設定する方法](https://qiita.com/Kyome/items/2bb24909ec12d5a48e14)
- [Swift 3でUserDefaultsにUIColorを保存する方法](https://www.webprofessional.jp/store-uicolor-with-userdefaults-in-swift-3/)

## FontPanel
- [NSFontManager](https://developer.apple.com/documentation/appkit/nsfontmanager)
    - `NSFontManager`の`Property`に`FontPanel`関連が書かれています
- [target](https://developer.apple.com/documentation/appkit/nsfontmanager/1462380-target)
    - >The target for the action associated with the sendAction() property.とあります
- [action](https://developer.apple.com/documentation/appkit/nsfontmanager/1462349-action)
    - フォントパネルを閉じた場合のアクションが指定できます
    - 今回はデフォルトの`changeFont(_:)`の名前で定義します
- [NSFontPanel changeFont not called](https://stackoverflow.com/questions/27036767/nsfontpanel-changefont-not-called)
    - `target`や`action`の指定方法を参考にしました
- [NSFont](https://developer.apple.com/documentation/appkit/nsfont)
- [fluffyes/NSFontPanelDemo](https://github.com/fluffyes/NSFontPanelDemo) <br> [Change font using NSFontPanel? : swift](https://www.reddit.com/r/swift/comments/cnfhqz/change_font_using_nsfontpanel/ewd39mz/)
  - フォントパネル実装のサンプル
    - `NSFontChanging`プロトコルの追加とchangeFontのoverrideは不要みたいです
- [changeFont\(\_:\)](https://developer.apple.com/documentation/appkit/nsfontchanging/3005180-changefont)
  - （公式の情報が無さすぎる問題）

# ざっくりコード解説
## Main.storyboard
- `Main.storyboard`から`Preferences.storyboard`に接続するため、`Storyboard Reference`を作成します

![-w458](https://i.imgur.com/TUzYP97.jpg)

- `Preferences...`にバインドします

![-w543](https://i.imgur.com/GiTItyi.jpg)

- `Storyboard`の名前を指定します

![-w496](https://i.imgur.com/REkQA9b.jpg)

## Preferences.storyboard
### WindowController

- `WindowController`を配置して、デフォルトでついてくる`ViewController`は削除します
- 後で作る`PreferencesTabViewController`をコンテンツにします
- 下記にチェック

![-w1306](https://i.imgur.com/5dF8dQz.jpg)

### PreferencesWindow
- `WindowController`内の`window`には、カスタムクラス`PreferencesWindow.swift`を指定します
- `NSPanel`なのでEscキーで閉じることが可能です

![-w1309](https://i.imgur.com/a06qRCA.jpg)

- 最小化とリサイズを無効にします

![-w1302](https://i.imgur.com/j1OeKWj.jpg)

- 設定画面は画面中央に表示します

![-w1307](https://i.imgur.com/UN55x55.jpg)

### PreferencesTabViewController

- 下記を配置します

![-w447](https://i.imgur.com/v5Knezu.jpg)

- 設定画面が複数出ないように、下記を設定します。

![image](https://i.imgur.com/D8NNB00.png)

- `PreferencesTabViewController`クラスとします

![-w1310](https://i.imgur.com/5oLJ1To.jpg)

- 下記を設定
- （設定パネルがうまくリサイズしてくれないことが結構ありました…。IBが怪しい動きをしている？ので、項目へのバインドを再度貼り直すなどしました。）

![-w1320](https://i.imgur.com/rj8CKWd.jpg)

- タブ項目をそれぞれ設定

![-w1327](https://i.imgur.com/SWHGG1M.jpg)

![-w1310](https://i.imgur.com/4WloEUM.jpg)

### 設定項目の画面
- 実際に設定画面として表示する画面はここ
- カスタムクラスを設定して、コード内にそれぞれの画面の処理を書きます
- 変更内容は今回は`Notification`を使用しています
- 今回のような1対1のものならCocoa Bindingを使用すればグルーコードが減って便利だと思います。

![-w1309](https://i.imgur.com/Irh5Qps.jpg)

- Opacity（0~100%）には`Number Formatter`を使用しています。
- 後ろに`%`を勝手につけてくれたり、少数への変換も不要で便利ですね。

![-w1324](https://i.imgur.com/SE7LJ76.jpg)

## ViewController.swift

```swift
let advancedPreferences = AdvancedPreferences()
let generalPreferences = GeneralPreferences()
```

- 設定ウインドウで変更した値は`UserDefaults`を介して取得しています
- この作りだったら変数ではなくてシングルトンにしたほうが良いのかな？

```swift
// 設定ウインドウからの通知を受け取る設定
let notificationNames = [Notification.Name(rawValue: "AdvancedPreferencesChanged"),
                         Notification.Name(rawValue: "GeneralPreferencesChanged")]

for notificationName in notificationNames {
    NotificationCenter.default.addObserver(forName: notificationName,
                                           object: nil,
                                           queue: nil) {
                                            (notification) in
                                            self.updateOutputTextField()
    }
}
```

- 設定ウインドウの変更は`Notification`で監視します。

```swift
outputTextField.wantsLayer = true   // for changing opacity
(...)
outputTextField.layer?.opacity = 0.1
```

- `NSTextField`の透明度の設定をする部分
- `NSColor`の`alpha`を変更する手もありますが、絵文字が反映されない問題があります。
- なので`NSTextField`の`layer`の`opacity`に設定しています

## PreferencesWindow.swift

```swift
selectedTabViewItemIndex
```

- これは`NSTabViewController`がデフォルトでもつプロパティですね。
- [selectedTabViewItemIndex](https://developer.apple.com/documentation/appkit/nstabviewcontroller/1428220-selectedtabviewitemindex?language=objc)

```swift
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
    switch menuItem.action {
    case #selector(toggleToolbarShown(_:))?:
        return false
    default:
        return super.validateMenuItem(menuItem)
    }
}
```

- デフォルトの場合、下記の用にツールバーが隠すことができてしまいます
- そこで上記のコードにより、`Hide toolbar`を選択できないようにしています
- コードの動きとしては... 
    -  `PrefecencesWindow`のメニューアイテムを表示する際に呼ばれて、
    -  メニューアイテムのアクションが`toggleToolbarShown`の場合に
    -  そのアイテムを`disable`にする

![-w662](https://i.imgur.com/JtuYUF8.jpg)

- （その他はコードとコードコメントを参照ください…）

### 参考
- [validateMenuItem:](https://developer.apple.com/documentation/appkit/nsmenuitemvalidation/3005191-validatemenuitem?language=objc)
    - > Implemented to override the default action of enabling or disabling a specific menu item. 

## PreferencesTabViewController.swift

```swift
private func switchPane(to tabViewItem: NSTabViewItem) {
```

- この関数はウインドウをリサイズするために定義されています

```swift
newFrame.origin.y += window.frame.height - newFrame.height    // タイトルバーの位置を変えないようにするための処理
```

- 例えば新しい項目に切り替えることでサイズが小さくなる場合、通常ではヘッダー（:=タイトルバー + ツールバー）が下がる形になります。
    - macOSでは左下を原点としているためですね
- そこで下図の通り計算をして、原点のY座標を上に移動させることで、ヘッダーの位置をそのままにリサイズを行っています

![](https://i.imgur.com/N4uMWKe.jpg)

## GeneralPreferences.swift

- `General`項目の設定値を扱うための、データクラスです

```swift
enum UserDefaultsKey: String
    case message
}
```

- `UserDefault`用のキーは`enum`で宣言しています

## GeneralPreferencesViewController.swift
- `General`項目のビューを制御するクラスです

```swift
NotificationCenter.default.post(name: Notification.Name(rawValue: "GeneralPreferencesChanged"), object: nil)
```

- 上記の通り、`Notification`によりメインの画面に対して、設定値の変更トリガを引いています。

## AdvancedPreferences.swift
- `GeneralPreferences.swift`と同様、`Advanced`項目の設定値を扱うための、データクラスです

```swift
var font: NSFont {
    get {
        guard let name = UserDefaults.standard.object(forKey: UserDefaultsKey.fontName.rawValue) as? String else {
            return NSFont.systemFont(ofSize: NSFont.systemFontSize)
        }
        
        let size = CGFloat(UserDefaults.standard.float(forKey: UserDefaultsKey.fontSize.rawValue)) // 登録されていないときは…？
        guard let font = NSFont(name: name, size: size) else {
            return NSFont.systemFont(ofSize: NSFont.systemFontSize)
        }
        return font
    }
    
    set(font) {
        UserDefaults.standard.set(font.fontName, forKey: UserDefaultsKey.fontName.rawValue)
        UserDefaults.standard.set(Float(font.pointSize), forKey: UserDefaultsKey.fontSize.rawValue)
    }
}  
```

- `UserDefaults`に保存する際は、`NSFont`ではなく、フォント名とフォントサイズに分けています

## AdvancedPreferencesViewController.swift

- `Advance`項目のビューを制御するクラスです

### 設定画面を閉じる際にフォントパネルも閉じる

```swift
override func viewDidDisappear() {
    let panel = NSFontManager.shared.fontPanel(true)
    panel?.close()
}
```

- フォントパネルが残ったまま設定ウインドウを閉じると困るので、一緒に閉じるように設定します

### フォントパネルの表示と変更内容の受け取り

```swift
@IBAction func showFontPanel(_ sender: Any) {
    let fontManager = NSFontManager.shared
    fontManager.target = self
    let panel = fontManager.fontPanel(true)
    panel?.orderFront(self)
    panel?.isEnabled = true // trueをセットすると使用可能になります（今回は無くても良い？）
}
```

- フォントパネルで変更があった際に`action`を送る相手を、`target`で自身に設定します
- `action`のデフォルト名は`func changeFont(_ sender: NSFontManager?) {`です。

```swift
extension AdvancedPreferencesViewController : NSFontChanging {
    func changeFont(_ sender: NSFontManager?) {
        guard let fontManager = sender else {
            return
        }
        let newFont = fontManager.convert(advancedPreferences.font)
        advancedPreferences.font = newFont
        fontNameTextField.stringValue = String(format: "%@ %d", advancedPreferences.font.fontName, Int(advancedPreferences.font.pointSize))
        advancedPreferencesChanged()
    }
}
```

- 上記の通り定義します。
- 表示のフォーマットは`CotEditor`に習いました。

![-w560](https://i.imgur.com/ELgx2PX.jpg)

### カラーパネルによる色の変更取得

```swift

@IBAction func changeFontColor(_ sender: Any) {
    guard let colorWell = sender as? NSColorWell else {
        return
    }
    if (colorWell.identifier!.rawValue == "FontColorWell") {
        advancedPreferences.fontColor = colorWell.color
    } else if (colorWell.identifier!.rawValue == "StrokeColorWell") {
        advancedPreferences.strokeColor = colorWell.color
    }
    advancedPreferencesChanged()
}
```

- `FontPanel`とは打って変わって、簡単に`IBAction`で取得できます。
- 処理が同じなので、同じアクションに紐づけて`identifier`で分岐させています。

# つまづきポイント
## フォントパネルの変更後にchangeFontが呼ばれない
### 概要
- `NSTextField`がなければうまくいくので、なぜかなとハマりました。

![-w948](https://i.imgur.com/hts7VKv.jpg)

### 解決方法

- `NSFontManager`の`target`に自身を指定します

```swift
let fontManager = NSFontManager.shared
fontManager.target = self
```
    
### 詳細
    
- `NSTextField`が無い場合は、`Responder chain`で`changeFont`が呼べていた。
- しかし`NSTextField`がある場合、`NSView`でResponder chainが止まってしまい、`NSViewController`まで届かない。
    - 下記メソッドで`Responder chain`を確認してみました。

```swift
func displayResponderChain(_ sender: NSResponder?) {
    guard let res = sender else {
        return
    }
    print("\(String(describing: res.nextResponder))")
}
```

```swift
print("\(String(describing: sender))")  // フォーカスしているNSTextField
displayResponderChain(strokeWidthTextField)
        
<NSTextField: 0x105014a90>
Optional(<NSView: 0x608000121400>)
```

- そこで明示的に`NSFontManager.target`により、メソッドを呼び出すクラスを指定すると、動作するようになりました。

### 参考
- [\[iOS\] Responder Chain と UIViewController](http://cocoadays.blogspot.com/2011/06/ios-responder-chain-uiviewcontroller.html)
    - Responder Chainを確認する関数を参考にしました