```html SwiftUI: How to Build a File Manager (iOS 17+, 2026)

How to Build a File Manager in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: FileManager Updated: May 11, 2026
TL;DR

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

  1. @Observable FileStore — Using the Swift 5.9 @Observable macro (iOS 17+) instead of ObservableObject means SwiftUI only re-renders views that actually read store.items. The reload() method calls FileManager.default.contentsOfDirectory(at:includingPropertiesForKeys:options:) with .isDirectoryKey and .fileSizeKey pre-fetched so later resourceValues calls are cache hits.
  2. NavigationStack + navigationDestination — Tapping a folder row pushes a brand-new FileManagerView (with its own FileStore scoped to that subdirectory) via NavigationLink(value: url) and .navigationDestination(for: URL.self). This pattern supports arbitrary depth without manual path management.
  3. Swipe actions for CRUD.swipeActions(edge: .trailing) surfaces Delete (red, destructive role) and Rename (orange) without cluttering the row. Each action calls the corresponding FileManager method and then reload() so the list refreshes instantly.
  4. Alert-driven rename and new-folder — Both dialogs embed a TextField inside .alert, which became fully supported with an interactive text field in iOS 16+. Binding renameTarget as an optional URL drives the rename alert's isPresented so it always has the right context.
  5. Accessibility labels — Each row gets an explicit .accessibilityLabel that 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

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.

```