0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-18

背景

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

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

Simulator Screen Recording - iPhone 16e - 2025-04-18 at 08.43.04.gif

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

を追加しておきます。
スクリーンショット 2025-04-18 9.07.38.png

ファイル構成

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

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

スクリーンショット 2025-04-18 7.56.32.png

Storyboard

Main

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

スクリーンショット 2025-04-18 9.29.23.png

ファイルブラウザ

MIDIFilebrowseViewという名称のStoryboardを作ります。
TableViewを持ったViewControllerを、Navigation Controllerに入れ込みます。
TableViewの中にはTable View Cellを配置しておきます。

スクリーンショット 2025-04-18 8.21.07.png

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

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

class LMIDIFilebrowseView: UIViewController, UINavigationControllerDelegate, UITableViewDelegate, UITableViewDataSource

ファイル情報の取得

FileManagerを使います。
ファイルブラウザを実現する上で、表示に必要な情報は下記の感じですね。

  1. フォルダ内のフォルダ・ファイルの数
  2. ファイル・フォルダ名
  3. ファイル OR フォルダ

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

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

フォルダ内のフォルダ・ファイルの数

FileManagerで取得できます。

FileManager.default
open func contentsOfDirectory(atPath path: String) throws -> [String]

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

ちなみに、私はこのメソッドを利用して下記のようなメソッドを作りました。
Standard MIDI Fileとフォルダだけをリストアップし、さらにソートもしてくれます。

FileManager.default
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)
    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

タップして奥の階層へ〜Navigationの利用

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

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

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

MIDIFilebrowseViewに機能を追加

ブラウザとしては、こんな機能も欲しいです

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

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

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

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

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も追記します。

ボタンの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

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

前回選択したファイルからブラウザをスタートする

ここまではMIDIFilebrowseView.swiftで機能を実装しましたが、ここからはViewController.swiftにも機能を実装していきます。

選択したファイルを覚えておく

まずは、MIDIFilebrowseViewで選択したファイルを記憶します。
MIDIFilebrowseViewからはNotificationでファイルパスを通知するので、ViewControllerで受け取ったファイルパスをUserDefaultを使って保存します。
ViewController.swiftでNotificationのobserver登録、selectorを設定します。

ViewController.swiftのviewDidLoad()内に追記
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フォルダより前のパスの一部がアプリ起動のたびに変わります。

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/ショパン/バラード/バラード第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が削除されます。

ルート以降のディレクトリから開始するなら...

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

  1. 表示したいディレクトリのviewControllerはNavigationControllerが作ってくれる
  2. ルートを含めた表示したいディレクトリより前のviewControllerを手動で作る
  3. 作った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
                    }
                }
            }
        }
    }
}

Simulator Screen Recording - iPhone 16e - 2025-04-18 at 19.02.52.gif

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

渡されたファイルを選択状態にする

渡されたファイルのディレクトリ内にあるファイルの一覧は表示されますが、どのファイルが渡されたかが画面上でわかりません。
そこで、viewControllerが表示される前に選択状態にしておきます。

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

最後に

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

XcodeプロジェクトはGitHubからダウンロードできます。
参考になれば幸いです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?