How to Build a File Manager in SwiftUI
Wrap FileManager.default in an @Observable service class that reads a directory's contents into a [URL] array, then drive a NavigationStack + List with it. Folder taps push a new level; swipe actions trigger create, rename, and delete.
@Observable final class FileStore {
var items: [URL] = []
var root: URL
init(root: URL = .documentsDirectory) {
self.root = root
reload()
}
func reload() {
items = (try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isDirectoryKey],
options: .skipsHiddenFiles
)) ?? []
}
func createFolder(named name: String) throws {
let url = root.appending(path: name)
try FileManager.default.createDirectory(at: url,
withIntermediateDirectories: false)
reload()
}
func delete(_ url: URL) throws {
try FileManager.default.removeItem(at: url)
reload()
}
}
Full implementation
The full implementation adds a recursive NavigationStack path so tapping any folder pushes a new FileManagerView initialised with that folder's URL. Rename is handled via an .alert with a TextField, and a toolbar button exposes the "New Folder" action. Everything is driven by a single @Observable class so SwiftUI automatically re-renders when the file system changes.
import SwiftUI
// MARK: - Model
@Observable
final class FileStore {
var items: [URL] = []
private(set) var root: URL
init(root: URL = .documentsDirectory) {
self.root = root
reload()
}
func reload() {
items = (try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey],
options: .skipsHiddenFiles
))?.sorted { $0.lastPathComponent < $1.lastPathComponent } ?? []
}
func isDirectory(_ url: URL) -> Bool {
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
}
func createFolder(named name: String) throws {
let dest = root.appending(path: name)
try FileManager.default.createDirectory(
at: dest, withIntermediateDirectories: false)
reload()
}
func rename(_ url: URL, to newName: String) throws {
let dest = url.deletingLastPathComponent().appending(path: newName)
try FileManager.default.moveItem(at: url, to: dest)
reload()
}
func delete(_ url: URL) throws {
try FileManager.default.removeItem(at: url)
reload()
}
func move(_ url: URL, to folder: URL) throws {
let dest = folder.appending(path: url.lastPathComponent)
try FileManager.default.moveItem(at: url, to: dest)
reload()
}
}
// MARK: - Views
struct FileManagerRootView: View {
var body: some View {
NavigationStack {
FileManagerView(store: FileStore())
}
}
}
struct FileManagerView: View {
@State var store: FileStore
@State private var showNewFolder = false
@State private var newFolderName = ""
@State private var renameTarget: URL?
@State private var renameText = ""
@State private var errorMessage: String?
var body: some View {
List {
ForEach(store.items, id: \.absoluteString) { url in
row(for: url)
}
}
.navigationTitle(store.root.lastPathComponent)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Folder", systemImage: "folder.badge.plus") {
newFolderName = ""
showNewFolder = true
}
}
}
// New folder alert
.alert("New Folder", isPresented: $showNewFolder) {
TextField("Folder name", text: $newFolderName)
.autocorrectionDisabled()
Button("Create") {
guard !newFolderName.isEmpty else { return }
try? store.createFolder(named: newFolderName)
}
Button("Cancel", role: .cancel) {}
}
// Rename alert
.alert("Rename", isPresented: Binding(
get: { renameTarget != nil },
set: { if !$0 { renameTarget = nil } }
)) {
TextField("New name", text: $renameText)
.autocorrectionDisabled()
Button("Rename") {
if let target = renameTarget, !renameText.isEmpty {
try? store.rename(target, to: renameText)
}
renameTarget = nil
}
Button("Cancel", role: .cancel) { renameTarget = nil }
}
}
@ViewBuilder
private func row(for url: URL) -> some View {
let isDir = store.isDirectory(url)
Group {
if isDir {
NavigationLink(value: url) {
Label(url.lastPathComponent, systemImage: "folder.fill")
.foregroundStyle(.primary)
}
.navigationDestination(for: URL.self) { dest in
FileManagerView(store: FileStore(root: dest))
}
} else {
Label(url.lastPathComponent, systemImage: "doc.fill")
.foregroundStyle(.secondary)
}
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
try? store.delete(url)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
renameText = url.lastPathComponent
renameTarget = url
} label: {
Label("Rename", systemImage: "pencil")
}
.tint(.orange)
}
.accessibilityLabel(
isDir ? "Folder: \(url.lastPathComponent)"
: "File: \(url.lastPathComponent)"
)
}
}
// MARK: - Preview
#Preview {
FileManagerRootView()
}
How it works
-
@Observable FileStore — Using the Swift 5.9
@Observablemacro (iOS 17+) instead ofObservableObjectmeans SwiftUI only re-renders views that actually readstore.items. Thereload()method callsFileManager.default.contentsOfDirectory(at:includingPropertiesForKeys:options:)with.isDirectoryKeyand.fileSizeKeypre-fetched so laterresourceValuescalls are cache hits. -
NavigationStack + navigationDestination — Tapping a folder row pushes a brand-new
FileManagerView(with its ownFileStorescoped to that subdirectory) viaNavigationLink(value: url)and.navigationDestination(for: URL.self). This pattern supports arbitrary depth without manual path management. -
Swipe actions for CRUD —
.swipeActions(edge: .trailing)surfaces Delete (red, destructive role) and Rename (orange) without cluttering the row. Each action calls the correspondingFileManagermethod and thenreload()so the list refreshes instantly. -
Alert-driven rename and new-folder — Both dialogs embed a
TextFieldinside.alert, which became fully supported with an interactive text field in iOS 16+. BindingrenameTargetas an optional URL drives the rename alert'sisPresentedso it always has the right context. -
Accessibility labels — Each row gets an explicit
.accessibilityLabelthat distinguishes "Folder:" from "File:" so VoiceOver users understand the item type without relying on the SF Symbol alone.
Variants
Show file size and modification date
// Add to FileStore.reload():
items = (try? FileManager.default.contentsOfDirectory(
at: root,
includingPropertiesForKeys: [
.isDirectoryKey,
.fileSizeKey,
.contentModificationDateKey
],
options: .skipsHiddenFiles
))?.sorted { $0.lastPathComponent < $1.lastPathComponent } ?? []
// Helper on FileStore:
func subtitle(for url: URL) -> String {
let vals = try? url.resourceValues(
forKeys: [.fileSizeKey, .contentModificationDateKey])
var parts: [String] = []
if let bytes = vals?.fileSize {
parts.append(ByteCountFormatter.string(
fromByteCount: Int64(bytes), countStyle: .file))
}
if let date = vals?.contentModificationDate {
parts.append(date.formatted(.relative(presentation: .named)))
}
return parts.joined(separator: " · ")
}
// In the row label:
Label {
VStack(alignment: .leading, spacing: 2) {
Text(url.lastPathComponent)
Text(store.subtitle(for: url))
.font(.caption)
.foregroundStyle(.secondary)
}
} icon: {
Image(systemName: store.isDirectory(url) ? "folder.fill" : "doc.fill")
}
Filtering by file extension
Add a @State private var filter: String = "" to FileManagerView and a .searchable(text: $filter) modifier on the List. Then filter the displayed items in a computed property:
store.items.filter { filter.isEmpty || $0.pathExtension == filter }.
This works well for document-picker-style managers where the user only cares about, say, .json or .csv files.
Common pitfalls
-
Sandbox boundaries on iOS. Your app can only access its own
Documents/,Caches/, andtmp/directories (plus iCloud containers if entitled). Attempting to reach outside these paths throws aCocoaError.fileReadNoPermission. UseURL.documentsDirectory(iOS 16+) instead of constructing paths manually — it resolves to the correct sandbox location. -
Calling FileManager on the main actor. For large directories,
contentsOfDirectorycan block the main thread long enough to drop frames. Movereload()to aTask { await MainActor.run { … } }pattern or mark itnonisolatedand marshal results back with@MainActorfor production use. -
NavigationLink double-firing on iOS 17. Placing
.navigationDestination(for:)inside aForEachrow rather than directly on theListorNavigationStackbody can cause it to fire multiple times. Always attach.navigationDestinationat theListor view body level, not inside the cell.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a file manager in SwiftUI for iOS 17+. Use FileManager, @Observable, NavigationStack, and List with swipe actions. Support: browse directories recursively, create folder, rename, delete. Make it accessible (VoiceOver labels for files and folders). Add a #Preview with realistic sample data using a temp directory.
In Soarias's Build phase, drop this prompt into the active sprint task to have Claude Code scaffold the FileStore service and all CRUD views in one shot, leaving you to wire up any app-specific navigation or iCloud entitlements.
Related
FAQ
Does this work on iOS 16?
Mostly yes, with two tweaks. Replace @Observable with @ObservedObject / ObservableObject + @Published, and swap URL.documentsDirectory for FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]. The .alert with embedded TextField was introduced in iOS 16, so that part works on both.
How do I let users pick files from outside the sandbox (e.g., Files app)?
Use UIDocumentPickerViewController (wrapped via UIViewControllerRepresentable) or the SwiftUI fileImporter modifier introduced in iOS 14. Both hand you a security-scoped URL — call url.startAccessingSecurityScopedResource() before reading and stopAccessingSecurityScopedResource() when done. FileManager alone cannot reach outside the sandbox without these entitlements.
What's the UIKit equivalent?
In UIKit you'd build a UITableViewController backed by the same FileManager.default.contentsOfDirectory call, push a new controller for each subdirectory, and use UITableViewRowAction (or UIContextualAction) for swipe-to-delete/rename. The underlying FileManager API is identical — only the UI layer changes. SwiftUI's declarative style cuts the boilerplate significantly for this use case.
Last reviewed: 2026-05-11 by the Soarias team.