概要
- 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を確認する関数を参考にしました