Closed3

SwiftUI メニューバーmacOSアプリで特定ディレクトリのファイル一覧を表示したい

slowhandslowhand

fileImporter

https://developer.apple.com/forums/thread/749733

👆 こちらにfileImporterを使う様なことが書かれている。fileImporterとは?

https://developer.apple.com/documentation/swiftui/view/fileimporter(ispresented:allowedcontenttypes:oncompletion:)

ExampleにもDirectoryを選択するやり方が書かれていそう。
シンプルな実装例:

import SwiftUI

struct ContentView: View {
    @State private var showFileImporter = false

    var body: some View {
            Button("Open") {
                showFileImporter = true
            }
            .fileImporter(
                isPresented: $showFileImporter,
                allowedContentTypes: [.directory]
            ) { result in
                 switch result {
                 case .success(let directory):
                     print(directory)
                 case .failure(let error):
                     print(error)
                 }
            }
    }
}

directoryURL で返ってくる。
ただ、これだと MenuBarExtra で実行しても選択ダイアログが表示されなかった。

slowhandslowhand

LLMに聞いてみる

質問と回答
  • 質問
MenuBarExtra を使って SwiftUIでmacOSアプリを作成しています。
メニューの中にある特定のディレクトリのファイルやディレクトリ一覧を表示したいと思っているんですが、どんな方法がありますか?
  • 回答

以下は MenuBarExtra を使って「特定ディレクトリの内容をメニューに動的表示する」ための代表的なアプローチと実装のポイントです。MenuBarExtra は menu スタイルなら純粋なプルダウンメニュー、window スタイルなら自由レイアウトのポップオーバーを使い分けられるので、項目数や UI の複雑さによって設計を切り替えるのがコツです。


1. 基本構成

1-1 Scene 宣言

@main
struct DirListerApp: App {
    var body: some Scene {
        // パスは後述の FileImporter などで動的に取得してもよい
        MenuBarExtra {
            DirectoryMenu(path: URL(fileURLWithPath: "/Users/youruser/Documents"))
        } label: {
            Label("Docs", systemImage: "folder.fill")
        }
        // ▼必要に応じて .window に切り替える
        .menuBarExtraStyle(.menu)
    }
}
  • MenuBarExtra は macOS 13 以降の専用シーン([developer.apple.com][5])
  • menuBarExtraStyle(.menu / .window) でレイアウトが変わる。ListScrollView を使いたい場合は .window が必須。

1-2 ディレクトリ読み込みコンポーネント

struct DirectoryMenu: View {
    let path: URL
    @State private var contents: [URL] = []

    var body: some View {
        // メニュー形式の場合は Button 群、window 形式の場合は List / ScrollView へ差し替え
        Group {
            if contents.isEmpty { Text("No items") }
            else {
                ForEach(contents, id: \.self) { url in
                    Button(url.lastPathComponent) {
                        NSWorkspace.shared.open(url) // クリックで Finder を開く例
                    }
                }
            }
        }
        .task {                       // 非同期でも UI が固まらない
            contents = load()
        }
    }

    private func load() -> [URL] {
        let fm = FileManager.default
        guard let enumr = fm.enumerator(
            at: path,
            includingPropertiesForKeys: [.isDirectoryKey],
            options: [.skipsHiddenFiles,
                      .skipsPackageDescendants,
                      .skipsSubdirectoryDescendants] // 上位階層だけ shallow に走査
        ) else { return [] }

        return enumr
            .compactMap { $0 as? URL }
            .sorted { $0.lastPathComponent.localizedCompare($1.lastPathComponent) == .orderedAscending }
    }
}
  • FileManager.enumerator(at:includingPropertiesForKeys:options:) を shallow に使えば巨大フォルダでも数 ms で走査できる。
  • 旧式の enumerator(atPath:) を深掘りで回すと CPU と I/O が跳ね上がるため .skipsSubdirectoryDescendants は必須。

2. UI スタイル別の実装戦略

スタイル 想定件数 実装例 注意点
.menu (既定) 10〜数十件 Button / Divider を直列で メニュー幅・高さが OS に制限される。長いタイトルは自動省略。
.window 数百件以上や複雑 UI List, ScrollView, 検索バー, アイコン付き Row など自由 List を置くとスクロール可能。frame(width:height:) でウィンドウサイズを固定可([nilcoalescing.com][2])。

3. パフォーマンスとリアクティブ更新

  1. バックグラウンド読み込み
    Task.detached {} で I/O を分離する、または @MainActor を外して load すると UI ブロッキングを防げる。
  2. ライブ更新
    DispatchSourceFileSystemObjectFSEventsAsyncStream でラップしてディレクトリ変更を監視すると、ファイル追加・削除が即反映できる。
  3. 巨大フォルダ/ネットワークボリューム
    Spotlight の NSMetadataQuery 検索や非同期 chunk 読み込みで逐次 append し、先に UI を表示しておく方式も有効。

4. サンドボックスと権限

  • Mac App Store 提出や Hardened Runtime 有効化時は App Sandbox がかかる。固定パスを読む場合は com.apple.security.files.read-only などの entitlement が必要。
  • ユーザにディレクトリを選ばせる場合は @Environment(\.openURL) + FileImporter でセキュリティスコープ付きブックマークを保存し、次回以降自動マウントすると安全。

5. さらに便利にするアイディア

  • 検索フィールド.window スタイルで TextField を置き、@Published var query で動的フィルタ。
  • サブメニュー:ファイル種別ごとに Menu をネストし、階層を浅く保つ。
  • ドラッグ & ドロップonDrag / onDrop を追加すれば Finder へ直接ドラッグできる。
  • Dock・アプリスイッチャ非表示:Info.plist に LSUIElement = YES を設定して完全メニューバー常駐型にする。

まとめ

  • 小規模リストなら MenuBarExtra + .menuButton 群が最短実装。
  • 大量アイテムやカスタム UI なら .window に切り替えて List/ScrollView を使う。
  • ディスク I/O は shallow + バックグラウンドでFileManager オプションで深掘りを避けるだけで体感数百倍速い。
  • Sandbox 権限とライブ更新 を組み合わせると、ユーザ操作なしでも安全かつリアルタイムに同期できる。

これらの組み合わせで、大量のファイルを扱う高度なメニューバーアプリから、シンプルなユーティリティまで柔軟に構築できます。

  • パスは後述の FileImporter などで動的に取得してもよい とあるが、何か方法があるのか?
    • やっぱりできなさそうで、隠しWindowGroupを用意するか、NSOpenPanelを直接呼ぶなどの別のアプローチが必要になるらしい
    • 一旦ディレクトリは固定で進める
  • ファイル一覧の読み込みは FileManager.default を使えばいける?
slowhandslowhand

FileManager使って一覧取得

まず一覧表示するために、権限が必要そう。XCodeで「Target」>「Signing & Capabilities」>「App Sandbox」の「File Access Type」を見てもユーザー選択したものか、特定の「XXX Folder」の権限しか編集できなさそう。

一度ユーザーに特定のディレクトリを選択してもらい、権限を保存しておく必要がありそう。
👇雑なサンプル

            if let data = UserDefaults.standard.data(forKey: "UserSelectedBookmark") {
                // 既に保存済みの場合
                var stale = false
                if let scopedURL = try? URL(resolvingBookmarkData: data,
                                            options: [.withSecurityScope],
                                            bookmarkDataIsStale: &stale),
                   scopedURL.startAccessingSecurityScopedResource() {
                   // ここで権限を使ってやりたいことをやる
                   scopedURL.stopAccessingSecurityScopedResource()
                }
            } else {
                // ユーザーにディレクトリを選択してもらい、権限を保存する
                let panel = NSOpenPanel()
                panel.directoryURL = URL(fileURLWithPath: "....") // 初期表示
                panel.canChooseDirectories = true
                panel.canChooseFiles = false
                panel.begin { result in
                    guard result == .OK, let url = panel.url else { return }
                    let bookmark = try? url.bookmarkData(options: .withSecurityScope,
                                                        includingResourceValuesForKeys: nil,
                                                        relativeTo: nil)
                    UserDefaults.standard.set(bookmark, forKey: "UserSelectedBookmark")
                }
            }

ファイル or ディレクトリ一覧取得

    private func load(path: URL) -> [URL] {
        return try! FileManager.default.contentsOfDirectory(
            at: path,
            includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
            options: [.skipsHiddenFiles])
    }
このスクラップは2ヶ月前にクローズされました