[AppKit] 選択できる NSSavePanel の作り方

複数の保存フォーマットをサポートする NSSavePanel の作り方を説明します

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Big Sur 11.2
  • Xcode 12.4

概要

AppKit が提供する NSSavePanel は、そのままでも フォルダ作成等の機能が使えますが、複数フォーマットをサポートしようとすると追加実装が必要になります。

Apple のドキュメントは、こちら

具体的には以下の点について 不足を感じると思います。

  • 保存フォーマットの選択
  • 保存ファイル名へのファイル拡張子の設定

NSSavePanel で上記の対応を行うための追加実装を説明します。

保存フォーマットの選択

デフォルトの NSSavePanel には、保存フォーマットを選択するような機能はついていません。
ユーザーの入力したファイル名の拡張子から判断するという手もありますが、あまりスマートではありません。

NSSavePanel に 保存フォーマットを選択できるようなプルダウンメニューを追加するのが一般的です。

accessoryView

NSSavePanel の accessoryView に ビューを設定すると 以下の場所に追加でビューを表示させることができます。

AccessoryViewLocation
AccessoryViewLocation

このアクセサリビューを使って、フォーマット選択を行えるようにします。

accessoryView の実装

画像を保存するという設定で、JPG ファイル/ PNG ファイルを選択できるようにしてみます。

NSSavePanel#accessoryView

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/02/04
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @StateObject private var appModel: MyAppModel = MyAppModel()
    
    var body: some View {
        VStack {
            Text("Hello, world!")
            Button(action: {
                self.appModel.savePanel()
            }, label: {
                Text("SavePanel")
            })
        }
                .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

class MyAppModel: ObservableObject{
    var nsSavePanel: NSSavePanel? = nil
    
    func savePanel() {
        let label = NSTextField(frame: CGRect(x: 20, y: 15, width: 80, height: 18))
        label.stringValue = "type: "
        label.alignment = .right
        label.isBordered = false
        label.isSelectable = false
        label.isEditable = false
        label.backgroundColor = .clear

        let popup = NSPopUpButton(frame: CGRect(x: 100, y: 10, width: 80, height: 25))
        popup.addItems(withTitles: ["JPG", "PNG"])

        let accessoryView = NSView(frame: CGRect(x: 0, y: 0, width: 200, height: 50))
        accessoryView.addSubview(popup)
        accessoryView.addSubview(label)
        
        self.nsSavePanel = NSSavePanel()
        guard let nsSavePanel = self.nsSavePanel else { return }
        nsSavePanel.canCreateDirectories = true
        nsSavePanel.showsTagField = false
        nsSavePanel.isExtensionHidden = false
        nsSavePanel.allowedFileTypes = ["jpg", "png"]
        nsSavePanel.nameFieldStringValue = "FileNameBase"
        nsSavePanel.accessoryView = accessoryView
        nsSavePanel.level = NSWindow.Level.modalPanel
        nsSavePanel.beginSheetModal(for: NSApp.mainWindow!) { (result) in
            if result == .OK {
                guard let saveFileURL = nsSavePanel.url else { return }
                let selected = popup.selectedItem?.title ?? "JPG"
                if selected == "JPG" {
                    print("you tried to save file: \(saveFileURL)")
                } else if selected == "PNG" {
                    print("you tried to save file: \(saveFileURL)")
                }
            }
        }
    }
}

上記アプリを起動すると以下のような動作になります。

保存ファイル名へのファイル拡張子の設定

NSSavePanel を開いたときに、保存ファイル名を設定しておくことができます。表示された後に、ユーザーが自分で変更することもできますが、ある程度自動で設定してくれると便利です。

複数フォーマットを選択できるようにしているときには、フォーマットに応じた拡張子を自動でつけてくれると便利です。

保存ファイル名の設定

NSSavePanel の nameFieldStringValue を設定することで、NSSavePanel が開かれたときに入力されている値を設定することができます。

先の例では、nameFieldStringValue に設定している "FileNameBase" という値と、allowedFileTypes で最初に設定されている jpg が組み合わされて、"FileNameBase.jpg" という値が表示されています。

選択されたフォーマットの検知

ユーザーが保存ファイルフォーマットを変更したことは、NSPopupButton の Target/Action を通じて取得することができます。

Target/Action を用いて、以下のような形になります。

NSPopUpButton に target/action 設定(1)

        popup.target = self
        popup.action = #selector(saveTypeIsChanged)

    // target/action で呼ばれる関数
    @objc
    func saveTypeIsChanged(_ any:Any) {
        guard let popupButton = any as? NSPopUpButton else { return }
        guard let selected = popupButton.selectedItem?.title else { return }
        ...
     }

上記コードで何を選択されたかという情報を取得することはできるのですが、それを NSSavePanel に反映しようとすると問題が発生します。

具体的には、nameFieldStringValue を更新しても、すでに開かれている NSSavePanel で表示されているファイル名は、更新されません。

選択されたフォーマットのファイル名への反映

allowedFileTypes をうまく使うことで、保存ファイル名の拡張子をアップデートすることができました。

allowedFileType を更新すると NSSavePanel は更新されます
allowedFileType を変更すると、表示されているファイル名の拡張子も更新されるようです。
”ようです”と書いているのはこの辺りの動作についてはドキュメントには記載されておらず、実際の動作から推測しているためです。

先ほどの target/action で呼ばれる関数を以下のようにすると期待の動作になります。

NSPopUpButton に target/action 設定(2)

    @objc
    func saveTypeIsChanged(_ any:Any) {
        guard let popupButton = any as? NSPopUpButton else { return }
        guard let selected = popupButton.selectedItem?.title else { return }
        if selected == "JPG" {
            self.nsSavePanel?.allowedFileTypes = ["jpg"]
        } else if selected == "PNG" {
            self.nsSavePanel?.allowedFileTypes = ["png"]
        }
    }

以下のような動作になります。

全コード

NSSavePanel example

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2021/02/04
//  © 2021  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @StateObject private var appModel: MyAppModel = MyAppModel()
    
    var body: some View {
        VStack {
            Text("Hello, world!")
            Button(action: {
                self.appModel.savePanel()
            }, label: {
                Text("SavePanel")
            })
        }
                .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

class MyAppModel: ObservableObject{
    var nsSavePanel: NSSavePanel? = nil
    
    func savePanel() {
        let label = NSTextField(frame: CGRect(x: 20, y: 15, width: 80, height: 18))
        label.stringValue = "type: "
        label.alignment = .right
        label.isBordered = false
        label.isSelectable = false
        label.isEditable = false
        label.backgroundColor = .clear

        let popup = NSPopUpButton(frame: CGRect(x: 100, y: 10, width: 80, height: 25))
        popup.addItems(withTitles: ["JPG", "PNG"])

        let accessoryView = NSView(frame: CGRect(x: 0, y: 0, width: 200, height: 50))
        accessoryView.addSubview(popup)
        accessoryView.addSubview(label)
        
        popup.target = self
        popup.action = #selector(saveTypeIsChanged)

        self.nsSavePanel = NSSavePanel()
        guard let nsSavePanel = self.nsSavePanel else { return }
        nsSavePanel.canCreateDirectories = true
        nsSavePanel.showsTagField = false
        nsSavePanel.isExtensionHidden = false
        nsSavePanel.allowedFileTypes = ["jpg"]
        nsSavePanel.nameFieldStringValue = "FileNameBase"
        nsSavePanel.accessoryView = accessoryView
        nsSavePanel.level = NSWindow.Level.modalPanel
        nsSavePanel.beginSheetModal(for: NSApp.mainWindow!) { (result) in
            if result == .OK {
                guard let saveFileURL = nsSavePanel.url else { return }
                let selected = popup.selectedItem?.title ?? "JPG"
                if selected == "JPG" {
                    print("you tried to save file: \(saveFileURL)")
                } else if selected == "PNG" {
                    print("you tried to save file: \(saveFileURL)")
                }
            }
        }
    }
    @objc
    func saveTypeIsChanged(_ any:Any) {
        guard let popupButton = any as? NSPopUpButton else { return }
        guard let selected = popupButton.selectedItem?.title else { return }
        if selected == "JPG" {
            self.nsSavePanel?.allowedFileTypes = ["jpg"]
        } else if selected == "PNG" {
            self.nsSavePanel?.allowedFileTypes = ["png"]
        }
    }
}

まとめ:NSSavePanel でフォーマット選択できるようにする拡張

NSSavePanel の拡張
  • accessoryView を使うと、追加 UI を表示できる
  • allowedFileTypes を使うと、表示されているファイル名の拡張子も更新される

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です