概要
- macOSアプリを作っている中で、環境設定ウインドウどうやって作ろうかなあ…と思っていた矢先、タイムラインに天啓が舞い降りました。
手前味噌ですが、環境設定のデザインについては私の過去の発表資料が参考になるかと思います。https://t.co/F8F7OBQXIs
— 1024 𓆏 (@1024jp) January 21, 2020
-
@1024jp さんがご丁寧にGitHubにサンプルを公開してくださっています、これを参考に私も実装してみました。
- Swiftの書き方を含めて非常に参考になるのでおすすめです
- 一度作ってしまえば他のプロジェクトでも流用できるので、辛いのは最初だけですね。
-
Generalでメッセージの変更、Advancedでフォントの外観を変更できるようになっています。
- フォントパネルによるフォントの変更取得も、実装例がなかなか見つからず苦労したのでよければ参考にまで。
GitHub
- 記事にコード全ては載せきれないので、以下を参照ください。
- https://github.com/pommdau/Qiita_PreferenceSample/releases/tag/2020-02-11
参考
環境設定ウインドウの作成
-
環境設定の作法 / Manners of Preferences Window on macOS - Speaker Deck
- 主に参考にしました。
- macOSにおける環境設定ウインドウのお作法を紹介しています。
-
1024jp/Preferences-Demo
- 主に参考にしました。
- 上記のスライドで紹介してされているサンプルコードです。
-
Preferencesみたいなツールバータブウインドウを作る
- ざっとしか見ていないけど、概ね同じ方向性かと。
-
OS Xアプリケーションにおける環境設定ウインドウの作り方
-
Toolbar Buttonを使った古めのサンプルです
-
UserDefaults(環境設定値の保存に使用)
FontPanel
-
NSFontManager
-
NSFontManagerのPropertyにFontPanel関連が書かれています
-
-
target
-
The target for the action associated with the sendAction() property.とあります
-
-
action
- フォントパネルを閉じた場合のアクションが指定できます
- 今回はデフォルトの
changeFont(_:)の名前で定義します
-
NSFontPanel changeFont not called
-
targetやactionの指定方法を参考にしました
-
- NSFont
-
fluffyes/NSFontPanelDemo
Change font using NSFontPanel? : swift- フォントパネル実装のサンプル
-
NSFontChangingプロトコルの追加とchangeFontのoverrideは不要みたいです
-
- フォントパネル実装のサンプル
-
changeFont(_:)
- (公式の情報が無さすぎる問題)
ざっくりコード解説
Main.storyboard
-
Main.storyboardからPreferences.storyboardに接続するため、Storyboard Referenceを作成します
-
Preferences...にバインドします
-
Storyboardの名前を指定します
Preferences.storyboard
WindowController
-
WindowControllerを配置して、デフォルトでついてくるViewControllerは削除します - 後で作る
PreferencesTabViewControllerをコンテンツにします - 下記にチェック
PreferencesWindow
-
WindowController内のwindowには、カスタムクラスPreferencesWindow.swiftを指定します -
NSPanelなのでEscキーで閉じることが可能です
- 最小化とリサイズを無効にします
- 設定画面は画面中央に表示します
PreferencesTabViewController
- 下記を配置します
- 設定画面が複数出ないように、下記を設定します。
-
PreferencesTabViewControllerクラスとします
- 下記を設定
- (設定パネルがうまくリサイズしてくれないことが結構ありました…。IBが怪しい動きをしている?ので、項目へのバインドを再度貼り直すなどしました。)
- タブ項目をそれぞれ設定
設定項目の画面
- 実際に設定画面として表示する画面はここ
- カスタムクラスを設定して、コード内にそれぞれの画面の処理を書きます
- 変更内容は今回は
Notificationを使用しています - 今回のような1対1のものならCocoa Bindingを使用すればグルーコードが減って便利だと思います。
- Opacity(0~100%)には
Number Formatterを使用しています。 - 後ろに
%を勝手につけてくれたり、少数への変換も不要で便利ですね。
ViewController.swift
let advancedPreferences = AdvancedPreferences()
let generalPreferences = GeneralPreferences()
- 設定ウインドウで変更した値は
UserDefaultsを介して取得しています - この作りだったら変数ではなくてシングルトンにしたほうが良いのかな?
// 設定ウインドウからの通知を受け取る設定
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で監視します。
outputTextField.wantsLayer = true // for changing opacity
(...)
outputTextField.layer?.opacity = 0.1
-
NSTextFieldの透明度の設定をする部分 -
NSColorのalphaを変更する手もありますが、絵文字が反映されない問題があります。 - なので
NSTextFieldのlayerのopacityに設定しています
PreferencesWindow.swift
selectedTabViewItemIndex
- これは
NSTabViewControllerがデフォルトでもつプロパティですね。 - selectedTabViewItemIndex
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
switch menuItem.action {
case #selector(toggleToolbarShown(_:))?:
return false
default:
return super.validateMenuItem(menuItem)
}
}
- デフォルトの場合、下記の用にツールバーが隠すことができてしまいます
- そこで上記のコードにより、
Hide toolbarを選択できないようにしています - コードの動きとしては...
-
PrefecencesWindowのメニューアイテムを表示する際に呼ばれて、 - メニューアイテムのアクションが
toggleToolbarShownの場合に - そのアイテムを
disableにする
-
- (その他はコードとコードコメントを参照ください…)
参考
-
validateMenuItem:
-
Implemented to override the default action of enabling or disabling a specific menu item.
-
PreferencesTabViewController.swift
private func switchPane(to tabViewItem: NSTabViewItem) {
- この関数はウインドウをリサイズするために定義されています
newFrame.origin.y += window.frame.height - newFrame.height // タイトルバーの位置を変えないようにするための処理
- 例えば新しい項目に切り替えることでサイズが小さくなる場合、通常ではヘッダー(:=タイトルバー + ツールバー)が下がる形になります。
- macOSでは左下を原点としているためですね
- そこで下図の通り計算をして、原点のY座標を上に移動させることで、ヘッダーの位置をそのままにリサイズを行っています
GeneralPreferences.swift
-
General項目の設定値を扱うための、データクラスです
enum UserDefaultsKey: String
case message
}
-
UserDefault用のキーはenumで宣言しています
GeneralPreferencesViewController.swift
-
General項目のビューを制御するクラスです
NotificationCenter.default.post(name: Notification.Name(rawValue: "GeneralPreferencesChanged"), object: nil)
- 上記の通り、
Notificationによりメインの画面に対して、設定値の変更トリガを引いています。
AdvancedPreferences.swift
-
GeneralPreferences.swiftと同様、Advanced項目の設定値を扱うための、データクラスです
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項目のビューを制御するクラスです
設定画面を閉じる際にフォントパネルも閉じる
override func viewDidDisappear() {
let panel = NSFontManager.shared.fontPanel(true)
panel?.close()
}
- フォントパネルが残ったまま設定ウインドウを閉じると困るので、一緒に閉じるように設定します
フォントパネルの表示と変更内容の受け取り
@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?) {です。
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に習いました。
カラーパネルによる色の変更取得
@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がなければうまくいくので、なぜかなとハマりました。
解決方法
-
NSFontManagerのtargetに自身を指定します
let fontManager = NSFontManager.shared
fontManager.target = self
詳細
-
NSTextFieldが無い場合は、Responder chainでchangeFontが呼べていた。 - しかし
NSTextFieldがある場合、NSViewでResponder chainが止まってしまい、NSViewControllerまで届かない。- 下記メソッドで
Responder chainを確認してみました。
- 下記メソッドで
func displayResponderChain(_ sender: NSResponder?) {
guard let res = sender else {
return
}
print("\(String(describing: res.nextResponder))")
}
print("\(String(describing: sender))") // フォーカスしているNSTextField
displayResponderChain(strokeWidthTextField)
<NSTextField: 0x105014a90>
Optional(<NSView: 0x608000121400>)
- そこで明示的に
NSFontManager.targetにより、メソッドを呼び出すクラスを指定すると、動作するようになりました。
参考
-
[iOS] Responder Chain と UIViewController
- Responder Chainを確認する関数を参考にしました




















