6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-01

概要

  • 今回はSwiftUIのNavigationSplitViewを使って、macOSアプリの設定画面をサイドバーの形式で作ってみます。
  • 設定画面
image image
  • 設定の値を表示するView
image

macOS 13から設定のUIが変更されました

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

GitHub

実装

設定ウインドウの初期設定

  • @mainは以下の通りです。
  • 今回GeneralAdvancedという2つの設定項目を実装します。
  • GeneralSettingsAdvancedSettingsは設定値を扱うモデルクラスです。
    • 両方ObservableObjectを継承したクラスで、これをEnvironmentObjectとして設定画面とメイン画面に渡しています。
    • 設定画面ではこのオブジェクトの値を編集し、メイン画面で編集した値を@Publishedによって変更を検知しViewを更新されるようにしています。
@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が表示されます。
  • またシステム設定の動きに倣って、ウインドウの幅は固定とし、高さは特定の値以上となるようにしています。
Settings {
    SettingsView()
        .frame(minHeight: 470)
        .frame(width: 720)
        .environmentObject(generalSettings)
        .environmentObject(advancedSettings)
}

設定値を扱うモデルクラスの定義

  • 設定値を扱うモデルクラスは以下のように定義しています。
  • 各プロパティが変更された際に子Viewを更新するため、@Publishedで定義しています。
  • また設定値はdidSetUserDefaultsに保存するようにしています。
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)"
        }
    }
}

設定画面の実装

  • まず設定画面のペイン(今回でいうとGeneralAdvancedを指す)を管理するために、enumで定義をしておくと便利です。
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の実装は以下の通りです。
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を実装しています。
  • 今回は2列なのでSidebarDetailを実装します。
struct NavigationSplitView<Sidebar, Content, Detail> where Sidebar : View, Content : View, Detail : View
  • Sidebarには先程のenumのallCasesからサイドバーの項目部分のViewを実装し、detailに各項目の設定画面の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()
    }
}
  • 最後に開いた設定のペインを記録しておき、再度設定を表示したときにそのペインが開かれるのが期待される動作です。(お作法ともいえます)
  • このため下記のように@AppStorageでUserDefaultsに保存しておきます。
@AppStorage("selected-settings-pane") private var selectedSettingsPane: SettingsPaneType = .general
  • またescを押下したときに設定画面を閉じることも期待される動作です。
  • escの押下は下記で検知できます。

onExitCommand(perform:)
The user generates an exit command by pressing the Menu button on tvOS, or the escape key on macOS.

let window = NSApp.windows.first { $0.identifier?.rawValue == "com_apple_SwiftUI_Settings_window" }!
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の設定画面で編集させます。
  • 設定の項目はFormSectionを使ってViewを作成します。
  • また設定ウインドウのタイトルは.navigationTitleで指定します。
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)
    }
}
image

設定の値を表示するViewの実装

  • 前述の通り設定の値の取得はEnvironmentObjectで行います。
  • この値を使ってViewを実装することで、設定画面で値が変更されるとリアルタイムでこちらのViewにも反映されます。
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)      
        }
    }
	...
}
image

課題

NavigationSplitView(columnVisibility: .constant(.all))
image

参考

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") }
    }
}
6
5
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?