NavigationSplitViewを使ったmacOSアプリの設定の作成

## 概要
- 今回はSwiftUIの[NavigationSplitView](https://developer.apple.com/documentation/swiftui/navigationsplitview)を使って、macOSアプリの設定画面をサイドバーの形式で作ってみます。
- 設定画面

<img width="400" alt="image" src="https://i.imgur.com/txVjxxO.jpg">

<img width="400" alt="image" src="https://i.imgur.com/gEqGYzh.jpg">

- 設定の値を表示するView

<img width="400" alt="image" src="https://i.imgur.com/uRvHtn7.jpg">

## macOS 13から設定のUIが変更されました
- macOS 13よりOSの設定画面が大きく変更されました。参考: [(WWDC2022) What's new in AppKit](https://developer.apple.com/videos/play/wwdc2022/10074/)
- また以前は`環境設定(Preferences)`と呼ばれていましたが、これからは`設定(Settings)`という呼び方に変更されました。
    - WWDCの動画では自動で書き換わる部分もあるということですが、ローカライズや各自のドキュメント類の中で使用している文言を置き換えるように気をつけてね、と言われていました。
- macOS 12以前

<img width="400" alt="image" src="https://i.imgur.com/EApcyBl.jpg">

- macOS 13以降

<img width="400" alt="image" src="https://i.imgur.com/XgwoRic.jpg">

## GitHub
- https://github.com/pommdau/navigationsplitview-settings-demo
    - よかったらスターをしていただけると嬉しいです！

## 実装
### 設定ウインドウの初期設定
- `@main`は以下の通りです。
- 今回`General`と`Advanced`という２つの設定項目を実装します。
- `GeneralSettings`と`AdvancedSettings`は設定値を扱うモデルクラスです。
    - 両方`ObservableObject`を継承したクラスで、これを`EnvironmentObject`として設定画面とメイン画面に渡しています。
    - 設定画面ではこのオブジェクトの値を編集し、メイン画面で編集した値を`@Published`によって変更を検知しViewを更新されるようにしています。

```swift
@main
struct NavigationSplitViewSettingsDemoApp: App {
    
    private var generalSettings = GeneralSettings()
    private var advancedSettings = AdvancedSettings()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .frame(width: 600, height: 400)
                .environmentObject(generalSettings)
                .environmentObject(advancedSettings)
        }
        
        Settings {
            SettingsView()
                .frame(minHeight: 470)
                .frame(width: 720)
                .environmentObject(generalSettings)
                .environmentObject(advancedSettings)
        }
    }
}
```

- 設定画面は`Settings`の中で指定したViewが表示されます。
- また`システム設定`の動きに倣って、ウインドウの幅は固定とし、高さは特定の値以上となるようにしています。

```swift
Settings {
    SettingsView()
        .frame(minHeight: 470)
        .frame(width: 720)
        .environmentObject(generalSettings)
        .environmentObject(advancedSettings)
}
```

### 設定値を扱うモデルクラスの定義
- 設定値を扱うモデルクラスは以下のように定義しています。
- 各プロパティが変更された際に子Viewを更新するため、`@Published`で定義しています。
- また設定値は`didSet`で`UserDefaults`に保存するようにしています。

```swift
class GeneralSettings: ObservableObject {
    
    let settingsPaneType: SettingsPaneType = .general

    @Published var shapeColor: Color = UserDefaults.standard.color(forKey: UserDefaultsKey.shapeColor.key) ?? .green {
        didSet {
            UserDefaults.standard.set(shapeColor, forKey: UserDefaultsKey.shapeColor.key)
        }
    }

    @Published var shapeSize: Float = UserDefaults.standard.float(forKey: UserDefaultsKey.shapeSize.key) {
        didSet {
            UserDefaults.standard.set(shapeSize, forKey: UserDefaultsKey.shapeSize.key)
        }
    }
    
    @Published var needsShapeShadow: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKey.needsShapeShadow.key) {
        didSet {
            UserDefaults.standard.set(needsShapeShadow, forKey: UserDefaultsKey.needsShapeShadow.key)
        }
    }
}

// MARK: - UserDefaults

extension GeneralSettings {
    
    private static var className: String {
        String(describing: self)
    }
    
    enum UserDefaultsKey: String, CaseIterable {
        case shapeColor
        case shapeSize
        case needsShapeShadow
        
        // e.g. "GeneralSettings-shapeSize"
        var key: String {
            "\(className)-\(rawValue)"
        }
    }
}
```

### 設定画面の実装

- まず設定画面のペイン(今回でいうと`General`や`Advanced`を指す)を管理するために、enumで定義をしておくと便利です。

```swift
enum SettingsPaneType: Int, CaseIterable {
    case general
    case advanced
    
    var title: String {
        switch self {
        case .general:
            return "General"
        case .advanced:
            return "Advanced"
        }
    }
    
    var icon: Image {
        ...
    }
}
```

- 設定画面となる`SettingsView.swift`の実装は以下の通りです。

```swift
struct SettingsView: View {
    
    // 前回開いた設定を覚えておき再度それを開く
    @AppStorage("selected-settings-pane") private var selectedSettingsPane: SettingsPaneType = .general
    
    private var window: NSWindow? {
        NSApp.windows.first(where: { window in
            window.identifier?.rawValue == "com_apple_SwiftUI_Settings_window"
        })
    }
    
    var body: some View {
        NavigationSplitView(columnVisibility: .constant(.all)) {
            List(SettingsPaneType.allCases, id: \.self, selection: $selectedSettingsPane) { settingsPane in
                NavigationLink(value: settingsPane) {
                    settingsLabel(settingsPane)
                }
            }
            .toolbar(.hidden, for: .windowToolbar)  // サイドバーの表示/非表示ボタンを隠す
        } detail: {
            switch selectedSettingsPane {
            case .general:
                GeneralSettingsView()
            case .advanced:
                AdvancedSettingsView()
            }
        }
        .onExitCommand {  // esc押下時に設定ウインドウを閉じる
            window?.close()
        }
    }
    
    @ViewBuilder
    private func settingsLabel(_ settingsPane: SettingsPaneType) -> some View {
        ...
    }
}
```

- 先程のenumを使用して、[NavigationSplitView](https://developer.apple.com/documentation/swiftui/navigationsplitview)を実装しています。
- 今回は2列なので`Sidebar`と`Detail`を実装します。

```swift
struct NavigationSplitView<Sidebar, Content, Detail> where Sidebar : View, Content : View, Detail : View
```

- `Sidebar`には先程のenumの`allCases`からサイドバーの項目部分のViewを実装し、detailに各項目の設定画面のViewを実装します。

```swift
NavigationSplitView(columnVisibility: .constant(.all)) {
    List(SettingsPaneType.allCases, id: \.self, selection: $selectedSettingsPane) { settingsPane in
        NavigationLink(value: settingsPane) {
            settingsLabel(settingsPane)
        }
    }
    .toolbar(.hidden, for: .windowToolbar)  // サイドバーの表示/非表示ボタンを隠す
} detail: {
    switch selectedSettingsPane {
    case .general:
        GeneralSettingsView()
    case .advanced:
        AdvancedSettingsView()
    }
}
```

- 最後に開いた設定のペインを記録しておき、再度設定を表示したときにそのペインが開かれるのが期待される動作です。(お作法ともいえます)
- このため下記のように`@AppStorage`でUserDefaultsに保存しておきます。

```swift
@AppStorage("selected-settings-pane") private var selectedSettingsPane: SettingsPaneType = .general
```

- また`esc`を押下したときに設定画面を閉じることも期待される動作です。
- `esc`の押下は下記で検知できます。

> [onExitCommand\(perform:\)](https://developer.apple.com/documentation/swiftui/view/onexitcommand(perform:))
> The user generates an exit command by pressing the Menu button on tvOS, or the escape key on macOS.

- また設定のウインドウの取得は、Appの前ウインドウを調べて、ウインドウのidentifierが`com_apple_SwiftUI_Settings_window`のものを探して取得しています。
    - [SwiftUI: macOSのSettingsウインドウでTabViewを指定して開く](https://zenn.dev/kyome/articles/ed44135f7a2093)
    - [Native\-like app settings for macOS](https://www.reddit.com/r/SwiftUI/comments/11ud8al/nativelike_app_settings_for_macos/)
        - `NSWindow`のidentifierを見るのが良さそう

> ```swift
> let window = NSApp.windows.first { $0.identifier?.rawValue == "com_apple_SwiftUI_Settings_window" }!
> ```

```swift
struct SettingsView: View {

    private var window: NSWindow? {
        NSApp.windows.first(where: { window in
            window.identifier?.rawValue == "com_apple_SwiftUI_Settings_window"
        })
    }
    
    var body: some View {
        NavigationSplitView(columnVisibility: .constant(.all)) {
        	...
        } detail: {
            ...
        }
        .onExitCommand {  // esc押下時に設定ウインドウを閉じる
            window?.close()
        }
    }
}
```

### 設定画面 - General / Advanced
- `General`の設定画面の実装は以下の通りです。(`Advanced`は同様なので割愛）
- 設定の値は`@EnvironmentObject`で受け取り、この値をGenralの設定画面で編集させます。
- 設定の項目は`Form`と`Section`を使ってViewを作成します。
- また設定ウインドウのタイトルは`.navigationTitle`で指定します。

```swift
struct GeneralSettingsView: View {
    
    @EnvironmentObject private var generalSettings: GeneralSettings
    
    var body: some View {
        VStack {
            Form {
                Section {
                    ColorPicker(selection: $generalSettings.shapeColor.animation()) {
                        Text("Color")
                    }
				...
                } header: {
                    Text("Appearance")
                }
            }
            .formStyle(.grouped)
        }
        .navigationTitle(generalSettings.settingsPaneType.title)
    }
}
```

<img width="400" alt="image" src="https://i.imgur.com/ht3ySuy.jpg">

### 設定の値を表示するViewの実装
- 前述の通り設定の値の取得は`EnvironmentObject`で行います。
- この値を使ってViewを実装することで、設定画面で値が変更されるとリアルタイムでこちらのViewにも反映されます。

```swift
struct ContentView: View {
    
    @EnvironmentObject private var generalSettings: GeneralSettings
    @EnvironmentObject private var advancedSettings: AdvancedSettings
    
    var body: some View {
        VStack {
            ZStack {
				...
                shape()
                    .foregroundColor(generalSettings.shapeColor)
                    .frame(width: CGFloat(generalSettings.shapeSize),
                           height: CGFloat(generalSettings.shapeSize))
                    .shadow(color: generalSettings.needsShapeShadow ? .black : .clear, radius: 4, x: 0, y: 8)
            }

            Text(advancedSettings.message)
                .frame(width: 200)      
        }
    }
	...
}
```

<img width="400" alt="image" src="https://i.imgur.com/wDLgas7.png">

## 課題
- (追記 2023-12-18) [macOS 14 Sonomaでは折りたたんでしまったサイドバーをマウスホバーで表示することが可能に。
](https://applech2.com/archives/20231218-macos-14-2-sonoma-sidebar-mouse-hover.html)
- 現状解決できていない問題として、設定のサイドバーを閉じることができてしまう点があります。
    - 調べたのですがいい方法が見つからずじまいでした…
- 暫定的な対策として、再度設定を表示したときにサイドバーが開くよう下記の`columnVisibility`をして救済できるようにしています。

```swift
NavigationSplitView(columnVisibility: .constant(.all))
```

<img width="400" alt="image" src="https://i.imgur.com/2FdA5Iz.jpg">

## 参考
- NavigationViewの実装
    - [Migrating to new navigation types](https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types)
-  サイドバーのボタンの非表示
    - [NavigationSplitView hide sidebar toggle button](https://developer.apple.com/forums/thread/727968)
    - [toolbar\(\_:for:\)](https://developer.apple.com/documentation/swiftui/view/toolbar%28_:for:%29)
- [macOS SwiftUI ウィンドウタイトル名を変更する](https://mjeld.com/macos-swiftui-window-title/)
- [【SwiftUI】設定アプリのUIを作成する](https://qiita.com/SNQ-2001/items/1d0e394d05722eb1e72e)    
    - Labelの表示の調整がとても参考になりました。
- [NavigationSplitViewVisibility](https://developer.apple.com/documentation/swiftui/navigationsplitviewvisibility)
    - 最初の表示を制御は可能。
    - 表示を固定するのは無理そう？
- [【SwiftUI】@Environmentの使い方を徹底解説](https://ios-docs.dev/swiftui-environment/#i-2)
- [\[SwiftUI\]@EnvironmentObjectの使用方法とハマりどころ](https://zenn.dev/oka_yuuji/articles/731ed584bff706)
- `EnvironmentObject`を`@AppStorage`で保存しようとしたときの参考(結局は不採用)
    - [Save custom Codable types in AppStorage or SceneStorage](https://nilcoalescing.com/blog/SaveCustomCodableTypesInAppStorageOrSceneStorage/)
    - [ExampleAppStorageWithCustomType\.swif](https://github.com/NilCoalescing/SwiftUI-Code-Examples/blob/main/Custom-Types-in-SceneStorage-and-AppStorage/ExampleAppStorageWithCustomType.swift)
- [macOSアプリ用の環境設定ウインドウの作成方法](https://qiita.com/IKEH/items/e11911d01a417b577353)
    - CocoaのTabViewスタイルの実装
- UserDefaults関連
    - [enumをUserDefaultsに保存したい！](https://qiita.com/matsuji/items/a8f63d36c9b75ed7d0d2)
    - [UserDefaultsにSwiftUIのColorを保存したい](https://qiita.com/MaskerDog/items/4e4bdeabff15dde9782a)
    - [How do I use UserDefaults with SwiftUI?](https://stackoverflow.com/questions/56822195/how-do-i-use-userdefaults-with-swiftui)
        - `ObservableObject`の値を`didSet`で`UserDefaults`に保存するアイディアを採用。

```swift
class UserDefaultsManager: ObservableObject {
    @Published var firstToggle: Bool = UserDefaults.standard.bool(forKey: "firstToggle") {
        didSet { UserDefaults.standard.set(self.firstToggle, forKey: "firstToggle") }
    }

    @Published var secondToggle: Bool = UserDefaults.standard.bool(forKey: "secondToggle") {
        didSet { UserDefaults.standard.set(self.secondToggle, forKey: "secondToggle") }
    }
}
```