13
9

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 3 years have passed since last update.

macOSアプリ用の環境設定ウインドウの作成方法

Last updated at Posted at 2020-01-24

概要

  • macOSアプリを作っている中で、環境設定ウインドウどうやって作ろうかなあ…と思っていた矢先、タイムラインに天啓が舞い降りました。
  • @1024jp さんがご丁寧にGitHubにサンプルを公開してくださっています、これを参考に私も実装してみました。
    • Swiftの書き方を含めて非常に参考になるのでおすすめです
  • 一度作ってしまえば他のプロジェクトでも流用できるので、辛いのは最初だけですね。
  • Generalでメッセージの変更、Advancedでフォントの外観を変更できるようになっています。

-w799

-w859

  • フォントパネルによるフォントの変更取得も、実装例がなかなか見つからず苦労したのでよければ参考にまで。

GitHub

参考

環境設定ウインドウの作成

UserDefaults(環境設定値の保存に使用)

FontPanel

ざっくりコード解説

Main.storyboard

  • Main.storyboardからPreferences.storyboardに接続するため、Storyboard Referenceを作成します

-w458

  • Preferences...にバインドします

-w543

  • Storyboardの名前を指定します

-w496

Preferences.storyboard

WindowController

  • WindowControllerを配置して、デフォルトでついてくるViewControllerは削除します
  • 後で作るPreferencesTabViewControllerをコンテンツにします
  • 下記にチェック

-w1306

PreferencesWindow

  • WindowController内のwindowには、カスタムクラスPreferencesWindow.swiftを指定します
  • NSPanelなのでEscキーで閉じることが可能です

-w1309

  • 最小化とリサイズを無効にします

-w1302

  • 設定画面は画面中央に表示します

-w1307

PreferencesTabViewController

  • 下記を配置します

-w447

  • 設定画面が複数出ないように、下記を設定します。

image

  • PreferencesTabViewControllerクラスとします

-w1310

  • 下記を設定
  • (設定パネルがうまくリサイズしてくれないことが結構ありました…。IBが怪しい動きをしている?ので、項目へのバインドを再度貼り直すなどしました。)

-w1320

  • タブ項目をそれぞれ設定

-w1327

-w1310

設定項目の画面

  • 実際に設定画面として表示する画面はここ
  • カスタムクラスを設定して、コード内にそれぞれの画面の処理を書きます
  • 変更内容は今回はNotificationを使用しています
  • 今回のような1対1のものならCocoa Bindingを使用すればグルーコードが減って便利だと思います。

-w1309

  • Opacity(0~100%)にはNumber Formatterを使用しています。
  • 後ろに%を勝手につけてくれたり、少数への変換も不要で便利ですね。

-w1324

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の透明度の設定をする部分
  • NSColoralphaを変更する手もありますが、絵文字が反映されない問題があります。
  • なのでNSTextFieldlayeropacityに設定しています

PreferencesWindow.swift

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にする

-w662

  • (その他はコードとコードコメントを参照ください…)

参考

  • 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に習いました。

-w560

カラーパネルによる色の変更取得


@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がなければうまくいくので、なぜかなとハマりました。

-w948

解決方法

  • NSFontManagertargetに自身を指定します
let fontManager = NSFontManager.shared
fontManager.target = self

詳細

  • NSTextFieldが無い場合は、Responder chainchangeFontが呼べていた。
  • しかし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により、メソッドを呼び出すクラスを指定すると、動作するようになりました。

参考

13
9
0

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
13
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?