```html SwiftUI: How to Implement Document Picker (iOS 17+, 2026)

How to implement a document picker in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: DocumentGroup, fileImporter, fileExporter, UTType Updated: May 12, 2026
TL;DR

Attach .fileImporter(isPresented:allowedContentTypes:onCompletion:) to any SwiftUI view to let users pick files from Files.app or iCloud Drive. For full document-based apps, replace WindowGroup with DocumentGroup in your App struct and conform your model to FileDocument.

import SwiftUI
import UniformTypeIdentifiers

struct ContentView: View {
    @State private var showPicker = false
    @State private var pickedText  = ""

    var body: some View {
        Button("Open File") { showPicker = true }
            .fileImporter(
                isPresented: $showPicker,
                allowedContentTypes: [.plainText, .json]
            ) { result in
                switch result {
                case .success(let url):
                    guard url.startAccessingSecurityScopedResource() else { return }
                    defer { url.stopAccessingSecurityScopedResource() }
                    pickedText = (try? String(contentsOf: url)) ?? ""
                case .failure(let error):
                    print(error.localizedDescription)
                }
            }
    }
}

Full implementation

The example below wires up a complete file-import flow: the user taps a toolbar button, chooses a plain-text or JSON file, and the app reads its contents into a scrollable text view. Security-scoped resource access is handled correctly with a defer block so the bookmark is always released even if reading throws. A separate errorMessage state drives an .alert for graceful error handling.

import SwiftUI
import UniformTypeIdentifiers

// MARK: - View

struct DocumentPickerDemoView: View {
    @State private var showImporter   = false
    @State private var fileContents   = ""
    @State private var fileName       = "No file loaded"
    @State private var errorMessage: String?

    var body: some View {
        NavigationStack {
            Group {
                if fileContents.isEmpty {
                    ContentUnavailableView(
                        "No Document",
                        systemImage: "doc.text",
                        description: Text("Tap the toolbar button to pick a file.")
                    )
                } else {
                    ScrollView {
                        Text(fileContents)
                            .font(.system(.body, design: .monospaced))
                            .padding()
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .accessibilityLabel("File contents of \(fileName)")
                    }
                }
            }
            .navigationTitle(fileName)
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        showImporter = true
                    } label: {
                        Label("Open File", systemImage: "folder.badge.plus")
                    }
                    .accessibilityLabel("Open a file from Files")
                }

                ToolbarItem(placement: .destructiveAction) {
                    if !fileContents.isEmpty {
                        Button("Clear", role: .destructive) {
                            fileContents = ""
                            fileName     = "No file loaded"
                        }
                    }
                }
            }
        }
        // ── File importer ──────────────────────────────────────────────
        .fileImporter(
            isPresented: $showImporter,
            allowedContentTypes: [.plainText, .json, .commaSeparatedText],
            allowsMultipleSelection: false
        ) { result in
            handleImportResult(result)
        }
        // ── Error alert ───────────────────────────────────────────────
        .alert("Import Failed", isPresented: Binding(
            get:  { errorMessage != nil },
            set:  { if !$0 { errorMessage = nil } }
        )) {
            Button("OK", role: .cancel) { errorMessage = nil }
        } message: {
            Text(errorMessage ?? "")
        }
    }

    // MARK: - Helpers

    private func handleImportResult(_ result: Result<[URL], Error>) {
        switch result {
        case .success(let urls):
            guard let url = urls.first else { return }
            readFile(at: url)
        case .failure(let error):
            errorMessage = error.localizedDescription
        }
    }

    private func readFile(at url: URL) {
        guard url.startAccessingSecurityScopedResource() else {
            errorMessage = "Permission denied for \(url.lastPathComponent)."
            return
        }
        defer { url.stopAccessingSecurityScopedResource() }

        do {
            fileContents = try String(contentsOf: url, encoding: .utf8)
            fileName     = url.lastPathComponent
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: - Preview

#Preview {
    DocumentPickerDemoView()
}

How it works

  1. .fileImporter modifier — Applied directly to the NavigationStack, it presents the system Files sheet whenever showImporter flips to true. The allowedContentTypes array filters which files the user can select; UTType values like .plainText, .json, and .commaSeparatedText are built-in constants in UniformTypeIdentifiers.
  2. Security-scoped resource access — The URL returned by the picker is a security-scoped bookmark. You must call url.startAccessingSecurityScopedResource() before reading and url.stopAccessingSecurityScopedResource() afterward. The defer block in readFile(at:) guarantees release even if String(contentsOf:) throws.
  3. ContentUnavailableView — Introduced in iOS 17, this built-in component renders a centred icon + title + description when there's nothing to show. It replaces the old manual Spacer-and-VStack empty-state pattern with zero boilerplate.
  4. Error alert with a binding bridge — Instead of a separate @State var showAlert: Bool, a computed Binding<Bool> derived from errorMessage != nil drives the .alert. This keeps state minimal and ensures the alert dismisses correctly by setting errorMessage = nil.
  5. Accessibility — The toolbar button carries an .accessibilityLabel that describes intent ("Open a file from Files") rather than just icon name. The scrollable text view labels itself with the loaded file name so VoiceOver users know the context without reading every character.

Variants

Export a file with .fileExporter

import SwiftUI
import UniformTypeIdentifiers

struct ExportDemoView: View {
    @State private var showExporter = false
    let report = "Name,Score\nAlice,95\nBob,88"

    var body: some View {
        Button("Export CSV") { showExporter = true }
            .fileExporter(
                isPresented: $showExporter,
                document: CSVDocument(contents: report),
                contentType: .commaSeparatedText,
                defaultFilename: "report"
            ) { result in
                if case .failure(let error) = result {
                    print(error.localizedDescription)
                }
            }
    }
}

// Minimal FileDocument conformance
struct CSVDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.commaSeparatedText] }
    var contents: String

    init(contents: String) { self.contents = contents }
    init(configuration: ReadConfiguration) throws {
        let data = configuration.file.regularFileContents ?? Data()
        contents = String(decoding: data, as: UTF8.self)
    }
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: Data(contents.utf8))
    }
}

#Preview { ExportDemoView() }

Full document-based app with DocumentGroup

For apps that live entirely around a file format (think a notes or markdown editor), swap WindowGroup for DocumentGroup in your App struct. iOS handles Open Recent, the file browser, and iCloud sync automatically.

@main
struct NoteApp: App {
    var body: some Scene {
        // DocumentGroup replaces WindowGroup entirely.
        // iOS presents the system document browser on launch.
        DocumentGroup(newDocument: NoteDocument()) { config in
            NoteEditorView(document: config.$document)
        }
    }
}

// FileDocument for a simple plain-text note
struct NoteDocument: FileDocument {
    static var readableContentTypes: [UTType] { [.plainText] }
    var text: String = ""

    init(text: String = "") { self.text = text }
    init(configuration: ReadConfiguration) throws {
        let data = configuration.file.regularFileContents ?? Data()
        text = String(decoding: data, as: UTF8.self)
    }
    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        FileWrapper(regularFileWithContents: Data(text.utf8))
    }
}

struct NoteEditorView: View {
    @Binding var document: NoteDocument
    var body: some View {
        TextEditor(text: $document.text)
            .padding()
            .accessibilityLabel("Note editor")
    }
}

#Preview {
    NoteEditorView(document: .constant(NoteDocument(text: "Hello!")))
}

Allowing multiple file selection

Pass allowsMultipleSelection: true to .fileImporter — the result closure then receives [URL] with all selected files. Iterate with for url in urls { … }, calling the security-scoped access pair for each URL individually. Each URL's bookmark is independent, so release them one at a time inside the loop.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a document picker in SwiftUI for iOS 17+.
Use DocumentGroup and fileImporter with UTType.
Support plain text and JSON file types.
Handle security-scoped resource access with defer.
Make it accessible (VoiceOver labels on toolbar buttons and content).
Add a #Preview with realistic sample data.

In Soarias's Build phase, paste this prompt directly into the active screen's context so Claude Code can scaffold the FileDocument conformance and wire up the picker modifier without leaving your editor.

Related

FAQ

Does this work on iOS 16?

.fileImporter and .fileExporter are available back to iOS 14, so the basic picker patterns work on iOS 16. However, ContentUnavailableView (used in the full example) requires iOS 17. Conditionally swap it for a plain VStack with a Label if you need iOS 16 support. DocumentGroup also works from iOS 14.

Can I save a reference to the file URL and open it later?

Yes — persist a security-scoped bookmark rather than the raw URL. Call url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) and store the resulting Data in UserDefaults or SwiftData. Later, resolve it with URL(resolvingBookmarkData:options:.withSecurityScope…) and call startAccessingSecurityScopedResource() before use. Raw URL strings become stale across app launches.

What's the UIKit equivalent?

UIKit uses UIDocumentPickerViewController with a UIDocumentPickerDelegate. You set forOpeningContentTypes: [UTType] on the controller and receive the selected URLs in documentPicker(_:didPickDocumentsAt:). The SwiftUI .fileImporter modifier is a direct, declarative wrapper around the same underlying sheet — no need to reach into UIKit for standard use cases.

Last reviewed: 2026-05-12 by the Soarias team.

```