iOSアプリ開発でアプリ用のローカルストレージを作成する場合

Xcodeで作成するアプリケーションで複数の動画を再生するアプリを作成するとき、もし、プロジェクトに直接組み込んだ場合、動画ファイルを入れ替えるのにアプリをビルドし直す必要があるという懸念点がありました。

そこで、iOSのデバイスにあるストレージを活用してアプリと動画を切り離して作成するためにどうすればいいか考えました。

## iOSのdocumentを使用
![ファイル.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957418/0230c066-21b1-132b-9b39-e7112c0d1efa.png)

写真のような保存場所がiphonやiPadにはあります。
ここをアプリで使用する動画の保存場所として使用することを試しました。
` 使用する方法は試したところ２つありました`

## 1. 直接フォルダから指定する
アプリを起動してフォルダを手動で指定する方法です
この方法でも使用することは出来ますが、アクセス権限などが厳しいため、毎回指定し直す必要が出たり、動画を自動再生することが出来ないなど制限が大きいため、現実的な方法ではありませんでした。
[参考にした記事のリンク](https://appdev-room.com/swift-permission-documents#google_vignette "参考記事のリンク")

## 2. アプリ用のフォルダを作成し、その中に保存する
※こちらのやり方が正しいようです
[参考にした記事のリンク](https://appdev-room.com/swift-permission-documents#google_vignette "参考記事のリンク")

## アプリの設定ファイルの追記
#### プロジェクト名-Info.plist
```
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- ✅ iTunes でのファイル共有を有効化 -->
    <key>UIFileSharingEnabled</key>
    <true/>

    <!-- ✅ 他のアプリからのファイルアクセスを許可 -->
    <key>LSSupportsOpeningDocumentsInPlace</key>
    <true/>

    <!-- ✅ バックグラウンド再生を許可 -->
    <key>UIBackgroundModes</key>
    <array>
        <string>audio</string> <!-- ✅ バックグラウンドで音声・動画を再生 -->
        <string>fetch</string> <!-- ✅ バックグラウンドでデータ取得 -->
        <string>processing</string> <!-- ✅ バックグラウンド処理を許可 -->
    </array>

    <!-- ✅ App Transport Security (ATS) を無効化 -->
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key>
        <true/>
    </dict>
</dict>
</plist>
```
# SwiftUIのコード

#### ContentView.swift
```
import SwiftUI
import SwiftData

struct ContentView: View {
    @EnvironmentObject var userSettings: UserSettings // ✅ `UserSettings` を環境オブジェクトとして使用
    var body: some View {
        FileManagerView().environmentObject(UserSettings())
    }

}

#Preview {
    ContentView()
}
```

#### FileManagerView.swift
```
import SwiftUI

struct FileManagerView: View {
    @StateObject private var fileManagerHelper = FileManagerHelper.shared
    @State private var selectedVideoURL: URL? // 🎯 選択した動画のURL
    @State private var isVideoPlayerPresented = false // 🎥 VideoPlayerView の表示制御
    @State private var debugMessage: String = "📂 ファイルを選択してください" // 🛠 デバッグ用メッセージ
    @State private var isShowingDeleteAlert = false // 🗑 削除アラートの表示制御
    @State private var fileToDelete: String? // 🗑 削除対象のファイル

    var body: some View {
        NavigationView {
            VStack {
                List {
                    // 📂 MyAppData 内のファイル一覧を表示
                    Section(header: Text("📂 MyAppDataのファイル一覧")) {
                        ForEach(fileManagerHelper.appFiles, id: \.self) { file in
                            HStack {
                                // 🎥 動画をタップで再生
                                Text(file)
                                    .onTapGesture {
                                        if let videoURL = getVideoURL(fileName: file) {
                                            debugMessage = "🎥 選択された動画: \(file)" // ✅ デバッグメッセージ更新
                                            selectedVideoURL = videoURL
                                            isVideoPlayerPresented = true
                                        } else {
                                            debugMessage = "❌ \(file) のURLを取得できませんでした"
                                        }
                                    }
                                    .contentShape(Rectangle()) // 🎯 タップ領域を `Text` のみに限定

                                Spacer() // 🔹 削除ボタンと動画名の間隔を確保

                                // 🗑 削除ボタン (タップすると確認アラートを表示)
                                Button(action: {
                                    fileToDelete = file
                                    isShowingDeleteAlert = true
                                }) {
                                    Image(systemName: "trash")
                                        .foregroundColor(.red)
                                }
                                .buttonStyle(BorderlessButtonStyle()) // 🎯 `List` 内での誤動作防止
                            }
                        }
                    }
                }
                .listStyle(InsetGroupedListStyle())

                // 🔽 デバッグ情報を表示
                Text(debugMessage)
                    .foregroundColor(.gray)
                    .padding()

                Button(action: {
                    debugMessage = "🔄 ファイルリストを更新中..."
                    fileManagerHelper.fetchAppFiles() // 🔄 MyAppData の中身を更新
                }) {
                    HStack {
                        Image(systemName: "arrow.clockwise")
                        Text("更新")
                    }
                    .padding()
                    .frame(maxWidth: .infinity)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
                    .padding(.horizontal)
                }
            }
            .navigationTitle("MyAppDataの管理")
            .onAppear {
                debugMessage = "📂 MyAppData のファイルを取得中..."
                fileManagerHelper.fetchAppFiles() // 初回表示時にリストを取得
            }
            // 🎥 動画プレイヤー表示用の `fullScreenCover`
            .fullScreenCover(isPresented: $isVideoPlayerPresented) {
                if let videoURL = selectedVideoURL {
                    VideoPlayerView(videoURL: videoURL)
                } else {
                    VStack {
                        Text("⚠️ `selectedVideoURL` が nil なので動画を再生できません")
                            .foregroundColor(.red)
                            .padding()
                        Button("閉じる") {
                            isVideoPlayerPresented = false
                        }
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(10)
                    }
                }
            }
            // 🗑 削除確認アラート
            .alert(isPresented: $isShowingDeleteAlert) {
                Alert(
                    title: Text("削除確認"),
                    message: Text("\(fileToDelete ?? "") を削除しますか？"),
                    primaryButton: .destructive(Text("削除")) {
                        if let fileName = fileToDelete {
                            fileManagerHelper.deleteAppFile(fileName: fileName)
                            fileManagerHelper.fetchAppFiles() // 🔄 削除後リスト更新
                            debugMessage = "🗑️ \(fileName) を削除しました"
                        }
                        fileToDelete = nil
                    },
                    secondaryButton: .cancel {
                        fileToDelete = nil
                    }
                )
            }
        }
    }

    /// 🎯 指定した `fileName` の動画ファイルのURLを取得
    private func getVideoURL(fileName: String) -> URL? {
        guard let appFolderPath = fileManagerHelper.getAppFolder() else {
            debugMessage = "❌ `MyAppData` フォルダの取得に失敗"
            return nil
        }
        let videoURL = appFolderPath.appendingPathComponent(fileName)
        
        if FileManager.default.fileExists(atPath: videoURL.path) {
            debugMessage = "✅ `\(fileName)` は存在します"
            return videoURL
        } else {
            debugMessage = "❌ `\(fileName)` が見つかりません"
            return nil
        }
    }
}

```

# FileManagerHelper.swift
今回の１番重要なファイルは` FileManagerHelper.swift `になります。
```
import Foundation

class FileManagerHelper: ObservableObject {
    static let shared = FileManagerHelper()
    private let fileManager = FileManager.default

    @Published var currentFiles: [String] = [] // 現在表示しているフォルダ内のファイル・フォルダ一覧
    @Published var currentFolderPath: URL? // 現在のフォルダのパス
    private let movieFolder = "Movies"

    private init() {
        fetchAppFiles()
    }

    /// 📂 Documentsフォルダのパスを取得
    func getDocumentsDirectory() -> URL? {
        return fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
    }

    /// 📂 MyAppDataフォルダのパスを取得 or 作成
    func getAppFolder() -> URL? {
        guard let documentsPath = getDocumentsDirectory() else { return nil }
        let appFolderPath = documentsPath.appendingPathComponent(movieFolder)

        if !fileManager.fileExists(atPath: appFolderPath.path) {
            try? fileManager.createDirectory(at: appFolderPath, withIntermediateDirectories: true)
        }
        return appFolderPath
    }



    /// 📜 MyAppDataフォルダ内のファイル・フォルダ一覧を取得
    func fetchAppFiles() {
        guard let appFolderPath = getAppFolder() else { return }
        fetchFiles(in: appFolderPath)
    }

    /// 📜 指定したフォルダの中のファイル・フォルダ一覧を取得
    func fetchFiles(in folderPath: URL) {
        do {
            let items = try fileManager.contentsOfDirectory(at: folderPath, includingPropertiesForKeys: nil)
            let sortedItems = items.map { $0.lastPathComponent }
            DispatchQueue.main.async {
                self.currentFolderPath = folderPath
                self.currentFiles = sortedItems
            }
        } catch {
            print("❌ ファイル一覧取得失敗: \(error)")
        }
    }

    /// 📂 指定したパスがフォルダかどうかを判定
    func isDirectory(at path: URL) -> Bool {
        var isDir: ObjCBool = false
        return fileManager.fileExists(atPath: path.path, isDirectory: &isDir) && isDir.boolValue
    }
    /// 🔄 指定したファイルを MyAppData フォルダへコピー
    func copyFileToAppFolder(fileName: String) {
        guard let documentsPath = getDocumentsDirectory(),
              let appFolderPath = getAppFolder() else {
            print("❌ フォルダの取得に失敗")
            return
        }

        let sourceURL = documentsPath.appendingPathComponent(fileName)
        let destinationURL = appFolderPath.appendingPathComponent(fileName)

        do {
            try fileManager.copyItem(at: sourceURL, to: destinationURL)
            print("✅ \(fileName) を MyAppData にコピー成功")
            fetchAppFiles()
        } catch {
            print("❌ ファイルコピー失敗: \(error)")
        }
    }

    /// 🗑️ 指定したファイルまたはフォルダを削除
    func deleteItem(at path: URL) {
        do {
            try fileManager.removeItem(at: path)
            print("🗑️ \(path.lastPathComponent) を削除しました")
            if let folderPath = currentFolderPath {
                fetchFiles(in: folderPath) // 現在のフォルダを再読み込み
            } else {
                fetchAppFiles()
            }
        } catch {
            print("❌ 削除失敗: \(error)")
        }
    }
}
```


# ViewController.swift
```
import UIKit

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()

        let button = UIButton()
        button.frame = CGRect(x: 0, y:0, width: 100 , height: 100)
        button.backgroundColor = .orange
        button.center = self.view.center
        button.addTarget(self, action: #selector(createFile), for: .touchUpInside)
        self.view.addSubview(button)
    }
    
    @objc  func createFile() {
        let fileManager = FileManager.default
        let docPath =  NSHomeDirectory() + "/Documents"
        let filePath = docPath + "/sample.txt"
        
        if !fileManager.fileExists(atPath: filePath) {
            fileManager.createFile(atPath:filePath, contents: nil, attributes: [:])
        }else{
            print("既に存在します。")
        }
    }
}
```