概要
- 今回はSwiftUIのNavigationSplitViewを使って、macOSアプリの設定画面をサイドバーの形式で作ってみます。
- 設定画面


- 設定の値を表示するView

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

- macOS 13以降

GitHub
-
https://github.com/pommdau/navigationsplitview-settings-demo
- よかったらスターをしていただけると嬉しいです!
実装
設定ウインドウの初期設定
-
@main
は以下の通りです。 - 今回
General
とAdvanced
という2つの設定項目を実装します。 -
GeneralSettings
とAdvancedSettings
は設定値を扱うモデルクラスです。- 両方
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
で定義しています。 - また設定値は
didSet
でUserDefaults
に保存するようにしています。
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で定義をしておくと便利です。
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列なので
Sidebar
とDetail
を実装します。
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.
- また設定のウインドウの取得は、Appの前ウインドウを調べて、ウインドウのidentifierが
com_apple_SwiftUI_Settings_window
のものを探して取得しています。- SwiftUI: macOSのSettingsウインドウでTabViewを指定して開く
-
Native-like app settings for macOS
-
NSWindow
のidentifierを見るのが良さそう
-
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の設定画面で編集させます。 - 設定の項目は
Form
とSection
を使って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)
}
}

設定の値を表示する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)
}
}
...
}

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

参考
- NavigationViewの実装
- サイドバーのボタンの非表示
- macOS SwiftUI ウィンドウタイトル名を変更する
-
【SwiftUI】設定アプリのUIを作成する
- Labelの表示の調整がとても参考になりました。
-
NavigationSplitViewVisibility
- 最初の表示を制御は可能。
- 表示を固定するのは無理そう?
- 【SwiftUI】@Environmentの使い方を徹底解説
- [SwiftUI]@EnvironmentObjectの使用方法とハマりどころ
-
EnvironmentObject
を@AppStorage
で保存しようとしたときの参考(結局は不採用) -
macOSアプリ用の環境設定ウインドウの作成方法
- CocoaのTabViewスタイルの実装
- UserDefaults関連
- enumをUserDefaultsに保存したい!
- UserDefaultsにSwiftUIのColorを保存したい
-
How do I use UserDefaults with SwiftUI?
-
ObservableObject
の値をdidSet
でUserDefaults
に保存するアイディアを採用。
-
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") }
}
}