How to implement a document picker in SwiftUI
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
-
.fileImportermodifier — Applied directly to theNavigationStack, it presents the system Files sheet whenevershowImporterflips totrue. TheallowedContentTypesarray filters which files the user can select;UTTypevalues like.plainText,.json, and.commaSeparatedTextare built-in constants in UniformTypeIdentifiers. -
Security-scoped resource access — The URL returned by the picker is a security-scoped bookmark. You must call
url.startAccessingSecurityScopedResource()before reading andurl.stopAccessingSecurityScopedResource()afterward. Thedeferblock inreadFile(at:)guarantees release even ifString(contentsOf:)throws. -
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 manualSpacer-and-VStackempty-state pattern with zero boilerplate. -
Error alert with a binding bridge — Instead of a separate
@State var showAlert: Bool, a computedBinding<Bool>derived fromerrorMessage != nildrives the.alert. This keeps state minimal and ensures the alert dismisses correctly by settingerrorMessage = nil. -
Accessibility — The toolbar button carries an
.accessibilityLabelthat 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
-
Forgetting
startAccessingSecurityScopedResource(). On iOS 17 the system sandbox strictly enforces this — reading the URL without the access call silently returns empty data or throws a permissions error. Always pair start/stop, preferably withdefer. -
Blocking the main thread.
String(contentsOf:)is a synchronous network/disk call. For large files wrap the read in aTask { await MainActor.run { … } }or useasyncfile APIs (FileHandle) to avoid UI jank. -
Mismatched UTType in
DocumentGroup. The UTType you declare inreadableContentTypesmust exactly match (or be a parent of) the type declared in your app's Info.plist underCFBundleDocumentTypes. A mismatch means the system document browser won't surface your app as an option for that file type. -
.fileImportervsDocumentGroup— pick one model. Mixing both in the same app causes confusing UX. Use.fileImporterwhen picking files is a secondary action (e.g., importing an attachment). UseDocumentGroupwhen the whole app is centred around editing a file format.
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.