背景
Standard MIDI Fileを再生するアプリを作ろうと思っています。
そのためには、まずファイルブラウザが必要になります。
iOSデバイスに標準でインストールされているファイルアプリと同じような機能を、自作のアプリに取り込む作戦です。
普通にブラウズするにはそれほど苦労しません。
Qiitaやネットの情報で十分実現できます。
今回実現したいのは、ルートからファイルを巡っていくだけでなく、指定(前回再生)したファイルからファイルブラウザをスタートさせたいので、これは一工夫必要です。
また、Navigation Controllerの仕様も知っておく必要があります。
iOSアプリのファイル保存場所
NSSearchPathForDirectoriesInDomains
を使って取得できます。
私はFileManagerのextensionで取得できるようにしました。
extension FileManager {
public var documentPath: String? {
return NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first
}
}
Macのシミュレータを使用する場合、これで取得できたパスがiOSシミュレーションデバイスのファイル保存場所です。
print()などでテキストとして表示させて、Finderの移動>フォルダへ移動...
の入力窓に得られたパスを入力します。
ここにテスト用のフォルダ、ファイルを入れておきます。
iOSデバイス実機の場合は、iOSデバイスをMacにUSBで接続し、Finderでファイルを転送すればOKです。
Xcodeプロジェクトの設定
iOSデバイスでファイルを扱うには、XcodeプロジェクトのInfoに
* Supports opening documents in place
* Asslication supports iTunes file sharing
ファイル構成
作曲者名フォルダの下にStandard MIDI Fileを配置します。
曲のカテゴリ(エチュード、バラード、ピアノ・ソナタとか)も利用してみます。
このファイルをフォルダ階層を維持した状態で、そのままiOSシミュレーションデバイス、iOSデバイスに転送しておきます。
Storyboard
Main
MainのStoaryboardにファイルブラウザを表示するボタンを配置します。
ファイルブラウザGUIは別にStoryboardを作るので、MainのStoryboardではStoryboard Referenceでボタンと繋いでおきます。
Storyboard名はMIDIFilebrowseViewにしました。
ファイルブラウザ
MIDIFilebrowseViewという名称のStoryboardを作ります。
TableViewを持ったViewController
を、Navigation Controllerに入れ込みます。
TableViewの中にはTable View Cell
を配置しておきます。
Navigation Controller内のViewControllerのクラス名はLMIDIFilebrowserとし、別途LMIDIFilebrowseView.swiftファイルを作ります。
LMIDIFilebrowseViewクラスはUIViewControllerを継承し、Navigation、TableViewの制御も行います。
クラス定義はこんな感じになります。
class LMIDIFilebrowseView: UIViewController, UINavigationControllerDelegate, UITableViewDelegate, UITableViewDataSource
ファイル情報の取得
FileManagerを使います。
ファイルブラウザを実現する上で、表示に必要な情報は下記の感じですね。
- フォルダ内のフォルダ・ファイルの数
- ファイル・フォルダ名
- ファイル OR フォルダ
TableViewを使うにあたり1.と2.が必要です。
3.はタップされた時に奥の階層に行くか行かないかの判断に必要です。
そのほか、ファイルやフォルダのアイコンを追加したり、奥の階層の有無をマークで示したりする時にも使えます。
フォルダ内のフォルダ・ファイルの数
FileManagerで取得できます。
open func contentsOfDirectory(atPath path: String) throws -> [String]
ただ、この中には不可視ファイルも含まれているので、得られた結果から適宜選択・削除してください。
ちなみに、私はこのメソッドを利用して下記のようなメソッドを作りました。
Standard MIDI Fileとフォルダだけをリストアップし、さらにソートもしてくれます。
extension FileManager {
public func MIDIContents(ofDirectory: String, sort: NSSortDescriptor? = NSSortDescriptor(key: "", ascending: true), isPlaceFoldersOnTop: Bool = true) -> [String]
}
下記のTableViewのデリゲートメソッドでは、上記[String]のカウントを返せばいいですね。
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
ファイル・フォルダ名
上記で得られた[String]の中身を表示すればOKです。
ファイルとフォルダを区別して表示すると、ファイルブラウザとして便利になります。
ひとまず簡単に表示するなら、こんな感じのソース、結果になります。
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell(style: UITableViewCell.CellStyle.default, reuseIdentifier: "cell")
let item = FileManager.default.MIDIContents(ofDirectory: currentDirectory)[indexPath.row]
cell.textLabel?.text = item.lastPathComponent
if item.isDirectory { //ディレクトリなら
cell.accessoryType = .disclosureIndicator //奥の階層がある目印をつける
}
return cell
}
タップして奥の階層へ〜Navigationの利用
デリゲートメソッドfunc tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
でタップされたものの属性を確認します。
タップされたものがフォルダであれば、新しいviewControllerを作ってNavigation Controllerを使ってプッシュします。
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let selectedRow = indexPath.row
let item = FileManager.default.MIDIContents(ofDirectory: currentDirectory)[selectedRow]
if item.isDirectory {
let vc = storyboard?.instantiateViewController(withIdentifier: STORYBOARD_IDENTIFY) as! MIDIFilebrowseView //次の階層のview controllerを作る
vc.currentDirectory = item //表示するディレクトリ
vc.title = item.lastPathComponent //上部中央に表示するタイトル(フォルダ名)
navigationController?.pushViewController(vc, animated: true) //次の階層へ遷移する
}
}
これで、ルートフォルダから始まって、自由にディレクトリ内を行き来できるようになりました。
MIDIFilebrowseViewに機能を追加
ブラウザとしては、こんな機能も欲しいです
- ホームに戻る
- キャンセル(何もせずに閉じる)
- 選択を決定する
1.はNavigation Controllerの機能として用意されています。
2.は何もせずdismissすればいいです。
3.は選択されたファイルをNotificationで通知します。
これらを実現するため、viewControllerのツールバーにボタンを追加します。
これはソースコードで実装します。
doneButton
はUIButtonのインスタンスを保持しておくため、プロパティを定義しておきます。
その理由は、doneButton
はファイルを選択したときはenableに、そうでないときはdisableにしておきたいからです。
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil)
let homeButton = UIBarButtonItem(image: UIImage(systemName: "house"), style: UIBarButtonItem.Style.plain, target: self, action: #selector(homeButton(_:)))
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButton(_:)))
navigationController?.isToolbarHidden = false
doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButton(_:)))
doneButton.isEnabled = false
toolbarItems = [cancelButton, flexibleSpace, homeButton, flexibleSpace, folderButton, flexibleSpace, doneButton]
それぞれのボタンのactionも追記します。
@objc func doneButton(_ sender: Any) {
if let rowIndex = fileListTableView.indexPathForSelectedRow {
let selectedFile = FileManager.default.MIDIContents(ofDirectory: currentDirectory)[rowIndex.row]
NotificationCenter.default.post(name: MIDIFilebrowseView.fileSelectFinishNotification, object: self, userInfo: [MIDIFilebrowseView.selectedFileKey: selectedFile])
}
dismiss(animated: true)
}
@objc func cancelButton(_ sender: Any) {
dismiss(animated: true)
}
@objc func homeButton(_ sender: Any) {
navigationController?.popToRootViewController(animated: true)
}
これでファイルブラウザらしくなりました!
前回選択したファイルからブラウザをスタートする
ここまではMIDIFilebrowseView.swiftで機能を実装しましたが、ここからはViewController.swiftにも機能を実装していきます。
選択したファイルを覚えておく
まずは、MIDIFilebrowseViewで選択したファイルを記憶します。
MIDIFilebrowseViewからはNotificationでファイルパスを通知するので、ViewControllerで受け取ったファイルパスをUserDefaultを使って保存します。
ViewController.swiftでNotificationのobserver登録、selectorを設定します。
NotificationCenter.default.addObserver(self, selector: #selector(fileSelected(_:)), name: MIDIFilebrowseView.fileSelectFinishNotification, object: nil)
@objc func fileSelected(_ notification: Notification) {
selectedFile = notification.userInfo?[MIDIFilebrowseView.selectedFileKey] as! String
UserDefaults.standard.set(selectedFile, forKey: SELECTED_FILE_SAVE_KEY)
UserDefaults.standard.synchronize()
}
Navigationで表示されるviewControllerにスタートするパスを教える
今まではルートからスタートしていました。
MIDIFilebrowsViewにcurrentDirectory
というプロパティを持たせていました。
private var currentDirectory = FileManager.default.documentPath!
初期値がルートになっていて、ここからスタートするようになっていました。
まずは、ViewControllerからMIDIFilebrowseViewへ保存していたファイルパスを渡します。
MIDIFilebrowseViewにはselectedFile
というプロパティを追加しておきます。
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let nextCtrl = segue.destination as? UINavigationController {
nextCtrl.popoverPresentationController?.delegate = self
segue.destination.preferredContentSize = UIScreen.main.bounds.size
(nextCtrl.viewControllers.first as! MIDIFilebrowseView).selectedFile = selectedFile //表示されるポップオーバーにselectedFileを渡す
}
super.prepare(for: segue, sender: sender)
}
selectedFileを受け取ったとき、有効なファイルならそのファイルの位置からファイルブラウザをスタートします。
有効なファイルでなければ、ルートからスタートすることにします。
MIDIFilebrowseViewでselectedFileを解析する
まずは有効なファイルかどうかを確認します。
public var selectedFile = "" {
didSet {
print(String(format: "received file path : %@", selectedFile))
selectedFile = FileManager.default.replacementDevicePath(filePath: selectedFile, deviceDocumentPath: FileManager.default.documentPath!)
if FileManager.default.fileExists(atPath: selectedFile) {
print(String(format: "OK : %@", selectedFile))
} else {
print(String(format: "NG : %@", selectedFile))
}
}
}
実はここが面倒でした...
シミュレータを使った場合、Documentフォルダより前のパスの一部がアプリ起動のたびに変わります。
/Users/hiroshi/Library/Developer/CoreSimulator/Devices/D3078991-6098-45D7-91BD-B1A290E1D3A5/data/Containers/Data/Application/F638980E-CC62-4FD7-99FA-466697733A1F/Documents/WA_OMAKE.MID
/Users/hiroshi/Library/Developer/CoreSimulator/Devices/D3078991-6098-45D7-91BD-B1A290E1D3A5/data/Containers/Data/Application/EA09C0DD-51FE-4298-AA32-BC9E4EB2186D/Documents/WA_OMAKE.MID
そのため、Documentより前を無視するか、あるいは起動したアプリのdocumentPathで置き換えるか...という方法が考えられます。
選択したファイルを利用するアプリなので、フルパスを使ってアクセスすることが多いです。
なので、起動したアプリのdocumentPathで置き換えるのがベターです。
そのため、FileManagerにextensionでpublic func replacementApplicationPath(filePath: String, deviceDocumentPath: String) -> String
というメソッドを追加しました。
これで、MIDIFilebrowseViewでDocumentパスの中を快適に扱えるようになります。
有効でないファイルだった場合
何もせず、ルートからスタートします。
有効なファイルだった場合
MIDIFilebrowseViewに渡されたとき、そのファイルがDocumentフォルダからの階層を知る必要があります。
"/Users/hiroshi/Library/Developer/CoreSimulator/Devices/D3078991-6098-45D7-91BD-B1A290E1D3A5/data/Containers/Data/Application/EE3AFF3A-55B5-43D6-AA48-062D2A0DC9DC/Documents/ショパン/バラード/バラード第4番ヘ短調Op.52.mid"
Documentsから/ショパン/バラード/
と二つのフォルダ階層の下にあるバラード第4番ヘ短調Op.52.mid
が選ばれていることがわかります。
Navigation Controllerとしては
- ホーム
- ショパン
- バラード
というviewControllerが必要で、これを手動で生成します。
UINavigationControllerのviewControllerの仕様を確認します。
NavigationControllerのviewController管理方法
遷移するviewControllerはnavigationController.viewControllers
に格納されています。
表示されているviewControllerはnavigationController.viewControllers.last
です。
ファイルブラウザの場合、奥の階層へ行くたびにnavigationController.viewControllers
の中身が増えていきます。
戻るときは一つずつnavigationController.viewControllers
を戻っていき、不要になったnavigationController.viewControllers.last
が削除されます。
ルート以降のディレクトリから開始するなら...
以上のことを踏まえて、ルートディレクトリ以降から開始する場合は、
- 表示したいディレクトリのviewControllerはNavigationControllerが作ってくれる
- ルートを含めた表示したいディレクトリより前のviewControllerを手動で作る
- 作ったviewControllerを
navigationController.viewControllers
に挿入
という方法がよさそうです。
selectedFileが渡されたとき、階層を解析してこれらの処理をしていきます。
MIDIFilebrowseViewにselectedFileを渡した時の処理
public var selectedFile = "" {
didSet {
selectedFile = FileManager.default.replacementApplicationPath(filePath: selectedFile, deviceDocumentPath: FileManager.default.documentPath!)
if FileManager.default.fileExists(atPath: selectedFile) {
if selectedFile.isMIDIFile {
currentDirectory = selectedFile.deletingLastPathComponent
let relPath = currentDirectory.deletingDocumentDirectory //Documentsフォルダ以降の階層を含んだパス
var dirs = relPath.split(separator: "/") //分割されたフォルダ
if dirs.count == 0 { //ルートフォルダ上のファイルが選択された
} else {
/*------------------------------------------------------
append home view controller
-----------------------------------------------------*/
let homeVC = storyboard?.instantiateViewController(identifier: STORYBOARD_IDENTIFY) as! MIDIFilebrowseView
homeVC.title = "Files".localized
navigationController?.viewControllers.insert(homeVC, at: 0) //viewControllersに挿入
/*------------------------------------------------------
home以降のディレクトリview controllerを生成
-----------------------------------------------------*/
var insertIndex = 1
var path = FileManager.default.documentPath!
dirs.removeLast() //最後のディレクトリはNavigation Controllerですでに生成されている
for dir in dirs {
let vc = storyboard?.instantiateViewController(withIdentifier: STORYBOARD_IDENTIFY) as! MIDIFilebrowseView
path += "/" + String(dir)
vc.currentDirectory = path //作ったview controllerがファイルを表示するパス
vc.title = String(dir) //view controller上部中央のタイトルはディレクトリ名
navigationController?.viewControllers.insert(vc, at: insertIndex) //viewControllersに挿入
insertIndex += 1
}
}
}
}
}
}
これで、ルートから表示されるところまでのviewControllerをNavigation Contorollerが持っているので、選択したファイルのあるディレクトリから、遷移して戻ることができます。
渡されたファイルを選択状態にする
渡されたファイルのディレクトリ内にあるファイルの一覧は表示されますが、どのファイルが渡されたかが画面上でわかりません。
そこで、viewControllerが表示される前に選択状態にしておきます。
override func viewWillAppear(_ animated: Bool) {
let displayFiles = FileManager.default.MIDIContents(ofDirectory: currentDirectory)
var row = 0
for file in displayFiles {
if file.lastPathComponent == selectedFile.lastPathComponent {
fileListTableView.selectRow(at: IndexPath(row: row, section: 0), animated: false, scrollPosition: .middle)
doneButton.isEnabled = true
}
row += 1
}
}
最後に
これでXcodeプロジェクトのViewControllerに選択したファイルの情報を渡すことができ、ファイルブラウザでの表示・選択が実現できました。
これを使ってStandard MIDI Fileの再生アプリを作っていきます。
XcodeプロジェクトはGitHubからダウンロードできます。
参考になれば幸いです。