UINavigationを使ってファイルブラウザを作る

## 背景
Standard MIDI Fileを再生するアプリを作ろうと思っています。
そのためには、まずファイルブラウザが必要になります。
iOSデバイスに標準でインストールされている**ファイル**アプリと同じような機能を、自作のアプリに取り込む作戦です。

普通にブラウズするにはそれほど苦労しません。
Qiitaやネットの情報で十分実現できます。
今回実現したいのは、ルートからファイルを巡っていくだけでなく、**指定(前回再生)したファイルからファイルブラウザをスタートさせたい**ので、これは一工夫必要です。
また、Navigation Controllerの仕様も知っておく必要があります。

![Simulator Screen Recording - iPhone 16e - 2025-04-18 at 08.43.04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/72db9e2d-0d70-4e1d-9ff6-4c0be2a5b3be.gif)


## iOSアプリのファイル保存場所
```NSSearchPathForDirectoriesInDomains```を使って取得できます。
私はFileManagerのextensionで取得できるようにしました。
```swift:ドキュメント保存場所の取得
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

を追加しておきます。
![スクリーンショット 2025-04-18 9.07.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/12fbe531-0e01-43a9-9263-d34bba2d990d.png)

## ファイル構成
作曲者名フォルダの下にStandard MIDI Fileを配置します。
曲のカテゴリ(エチュード、バラード、ピアノ・ソナタとか)も利用してみます。

このファイルをフォルダ階層を維持した状態で、そのままiOSシミュレーションデバイス、iOSデバイスに転送しておきます。

![スクリーンショット 2025-04-18 7.56.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/7ab2bbb1-acc5-45f4-8e7c-09ccb2be820c.png)

## Storyboard
### Main
MainのStoaryboardにファイルブラウザを表示するボタンを配置します。
ファイルブラウザGUIは別にStoryboardを作るので、MainのStoryboardではStoryboard Referenceでボタンと繋いでおきます。
Storyboard名は**MIDIFilebrowseView**にしました。

![スクリーンショット 2025-04-18 9.29.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/c02a73e2-1c94-4066-93c1-e11f37bc0da2.png)

### ファイルブラウザ
**MIDIFilebrowseView**という名称のStoryboardを作ります。
TableViewを持った```ViewController```を、Navigation Controllerに入れ込みます。
TableViewの中には```Table View Cell```を配置しておきます。

![スクリーンショット 2025-04-18 8.21.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/1c091f66-a72a-42e4-a88d-0ec945c8e688.png)

Navigation Controller内のViewControllerのクラス名はLMIDIFilebrowserとし、別途LMIDIFilebrowseView.swiftファイルを作ります。

LMIDIFilebrowseViewクラスはUIViewControllerを継承し、Navigation、TableViewの制御も行います。
クラス定義はこんな感じになります。

```swift
class LMIDIFilebrowseView: UIViewController, UINavigationControllerDelegate, UITableViewDelegate, UITableViewDataSource
```

## ファイル情報の取得
FileManagerを使います。
ファイルブラウザを実現する上で、表示に必要な情報は下記の感じですね。
1. フォルダ内のフォルダ・ファイルの数
2. ファイル・フォルダ名
3. ファイル OR フォルダ

TableViewを使うにあたり1.と2.が必要です。

3.はタップされた時に奥の階層に行くか行かないかの判断に必要です。
そのほか、ファイルやフォルダのアイコンを追加したり、奥の階層の有無をマークで示したりする時にも使えます。

### フォルダ内のフォルダ・ファイルの数
FileManagerで取得できます。
```swift:FileManager.default
open func contentsOfDirectory(atPath path: String) throws -> [String]

```
ただ、この中には不可視ファイルも含まれているので、得られた結果から適宜選択・削除してください。

ちなみに、私はこのメソッドを利用して下記のようなメソッドを作りました。
Standard MIDI Fileとフォルダだけをリストアップし、さらにソートもしてくれます。
```swift:FileManager.default
extension FileManager {
    public func MIDIContents(ofDirectory: String, sort: NSSortDescriptor? = NSSortDescriptor(key: "", ascending: true), isPlaceFoldersOnTop: Bool = true) -> [String]
}
```

下記のTableViewのデリゲートメソッドでは、上記[String]のカウントを返せばいいですね。
```swift
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
```

### ファイル・フォルダ名
上記で得られた[String]の中身を表示すればOKです。
ファイルとフォルダを区別して表示すると、ファイルブラウザとして便利になります。

ひとまず簡単に表示するなら、こんな感じのソース、結果になります。
```swift:func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
    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
    }
```

![Simulator Screenshot - iPhone 16e - 2025-04-18 at 15.51.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/acc151ad-5bde-4655-ac0f-d75c83c3c083.png)

## タップして奥の階層へ〜Navigationの利用
デリゲートメソッド```func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)```でタップされたものの属性を確認します。
タップされたものがフォルダであれば、新しいviewControllerを作ってNavigation Controllerを使ってプッシュします。

```swift:func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
    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)    //次の階層へ遷移する
        }
    }
```
![Simulator Screen Recording - iPhone 16e - 2025-04-18 at 15.53.54.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/e65b511e-6e8c-4209-b95c-1713ff5dcdc9.gif)


これで、ルートフォルダから始まって、自由にディレクトリ内を行き来できるようになりました。

## MIDIFilebrowseViewに機能を追加
ブラウザとしては、こんな機能も欲しいです

1. ホームに戻る
2. キャンセル(何もせずに閉じる)
3. 選択を決定する

1.はNavigation Controllerの機能として用意されています。
2.は何もせずdismissすればいいです。
3.は選択されたファイルをNotificationで通知します。

これらを実現するため、viewControllerのツールバーにボタンを追加します。
これはソースコードで実装します。

```doneButton```はUIButtonのインスタンスを保持しておくため、プロパティを定義しておきます。
その理由は、```doneButton```はファイルを選択したときはenableに、そうでないときはdisableにしておきたいからです。

```swift:viewDidLoad()の中に追記、ツールバーを生成する
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も追記します。
```swift:ボタンの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)
}
```
![Simulator Screen Recording - iPhone 16e - 2025-04-18 at 16.37.56.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/cbaf0c06-6e09-47fd-8f51-c5e6ea16409a.gif)

これでファイルブラウザらしくなりました！

## 前回選択したファイルからブラウザをスタートする
ここまではMIDIFilebrowseView.swiftで機能を実装しましたが、ここからはViewController.swiftにも機能を実装していきます。

### 選択したファイルを覚えておく
まずは、MIDIFilebrowseViewで選択したファイルを記憶します。
MIDIFilebrowseViewからはNotificationでファイルパスを通知するので、ViewControllerで受け取ったファイルパスをUserDefaultを使って保存します。
ViewController.swiftでNotificationのobserver登録、selectorを設定します。
```swift:ViewController.swiftのviewDidLoad()内に追記
NotificationCenter.default.addObserver(self, selector: #selector(fileSelected(_:)), name: MIDIFilebrowseView.fileSelectFinishNotification, object: nil)
```

```swift:ファイル選択の通知を受けて保存する
@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```というプロパティを持たせていました。
```swift
private var currentDirectory = FileManager.default.documentPath!
```
初期値がルートになっていて、ここからスタートするようになっていました。

まずは、ViewControllerからMIDIFilebrowseViewへ保存していたファイルパスを渡します。
MIDIFilebrowseViewには```selectedFile```というプロパティを追加しておきます。
```swift
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を解析する
まずは有効なファイルかどうかを確認します。
```swift
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フォルダより前のパスの一部がアプリ起動のたびに変わります。

```:Application〜Documents間の数字が変わる
/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/ショパン/バラード/バラード第４番ヘ短調Op.52.mid"
```
Documentsから```/ショパン/バラード/```と二つのフォルダ階層の下にある```バラード第４番ヘ短調Op.52.mid```が選ばれていることがわかります。
Navigation Controllerとしては
* ホーム
* ショパン
* バラード

というviewControllerが必要で、これを手動で生成します。
UINavigationControllerのviewControllerの仕様を確認します。

## NavigationControllerのviewController管理方法
遷移するviewControllerは```navigationController.viewControllers```に格納されています。
表示されているviewControllerは```navigationController.viewControllers.last```です。

ファイルブラウザの場合、奥の階層へ行くたびに```navigationController.viewControllers```の中身が増えていきます。
戻るときは一つずつ```navigationController.viewControllers```を戻っていき、不要になった```navigationController.viewControllers.last```が削除されます。

### ルート以降のディレクトリから開始するなら...
以上のことを踏まえて、ルートディレクトリ以降から開始する場合は、

1. 表示したいディレクトリのviewControllerはNavigationControllerが作ってくれる
2. ルートを含めた表示したいディレクトリより前のviewControllerを手動で作る
3. 作ったviewControllerを```navigationController.viewControllers```に挿入

という方法がよさそうです。
selectedFileが渡されたとき、階層を解析してこれらの処理をしていきます。

## MIDIFilebrowseViewにselectedFileを渡した時の処理
```swift
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
                    }
                }
            }
        }
    }
}
```
![Simulator Screen Recording - iPhone 16e - 2025-04-18 at 19.02.52.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/3e3762e3-9ac8-4717-b523-bd3c657cf3cd.gif)

これで、ルートから表示されるところまでのviewControllerをNavigation Contorollerが持っているので、選択したファイルのあるディレクトリから、遷移して戻ることができます。

## 渡されたファイルを選択状態にする
渡されたファイルのディレクトリ内にあるファイルの一覧は表示されますが、どのファイルが渡されたかが画面上でわかりません。
そこで、viewControllerが表示される前に選択状態にしておきます。
```swift:override func viewWillAppear(_ animated: Bool)
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
    }
}
```
![Simulator Screen Recording - iPhone 16e - 2025-04-18 at 19.12.58.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202602/171ffd31-7492-458b-8389-23bd0069533f.gif)

## 最後に
これでXcodeプロジェクトのViewControllerに選択したファイルの情報を渡すことができ、ファイルブラウザでの表示・選択が実現できました。
これを使ってStandard MIDI Fileの再生アプリを作っていきます。

Xcodeプロジェクトは[GitHub](https://github.com/tunaingot/filebrowser)からダウンロードできます。
参考になれば幸いです。