It might not be an often case, but apps sometimes need to access and import files out of their sandbox, such as the device they run on or the iCloud Drive. To manage that, there is a system-provided picker that does all the heavy job, and SwiftUI makes it easy to integrate it to any app with the fileImporter view modifier. Let’s get to know it, and how to properly read files once presented.
Presenting the file importer
To meet and demonstrate how everything works properly, we’ll work on a really simple app that lets us present the built-in file picker, select one or more text files, process them, and show their content in a SwiftUI list.
The file picker is presented using the fileImporter view modifier, and similarly to sheets and alerts, we need a state property to control whether it’s presented or not:
|
1 2 3 |
@State private var showFileImporter = false |
A button triggers its appearance as shown next:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
NavigationStack { List { // we’ll add content later } .navigationTitle(“File Importer Demo”) .toolbar { ToolbarItem(placement: .primaryAction) { // Show the file picker on tap. Button { showFileImporter = true } label: { Label(“Pick Files”, systemImage: “tray.and.arrow.down”) } } } } |
The fileImporter modifier usually accepts four arguments:
- The binding value of the presented state property (
$showFileImporter). - A collection of the desired content types (file types) to view and pick.
- A boolean value indicating whether multiple file selection is allowed or not.
- A completion handler to handle the selected file or files.
In this example we’ll set the .text content type as we’ll open text files, and allow multiple file selection:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
NavigationStack { … } .fileImporter( isPresented: $showFileImporter, allowedContentTypes: [.text], allowsMultipleSelection: true ) { result in } |
Note that, in order to make the available content types visible in the source code, it’s necessary to import the UniformTypeIdentifiers framework:
|
1 2 3 |
import UniformTypeIdentifiers |
The result parameter in the completion handler is a Result<[URL], any Error> value. It contains:
- Either a collection of URL objects,
- or an error if importing fails for some reason.
Having seen how to prepare and present the file importer, it’s time to meet some essential details regarding file handling.
Handling imported files
We can check the result value and proceed to file handling either in the block of the completion handler, or in a separate method like the next one:
|
1 2 3 4 5 |
private func handleImportedFiles(result: Result<[URL], any Error>) { } |
The usual way to “read” result, a Result value, is using a switch statement:
|
1 2 3 4 5 6 7 8 9 10 |
switch result { case .success(let urls): // … handle imported files case .failure(let error): print(error.localizedDescription) // … handle error properly } |
There’s not much to do in the failure case here, but in a real app this should be treated properly.
The interesting part is the success case, where the collection of URL objects is given as argument (urls). Since we need to process multiple files, we’ll get started with an iteration:
|
1 2 3 4 5 |
for url in urls { } |
Here it comes the essential detail we should not skip. Selected files reside out of the app’s sandbox, and they are not accessible by default. Before trying to open or process each file, we need to:
- Request for access to the file through the URL object; on success, the system grants our app with a temporary security scope so we can read each file.
- Do any processing necessary.
- End the access by relinquishing the security scope.
The above steps are represented in code right next:
|
1 2 3 4 5 |
guard url.startAccessingSecurityScopedResource() else { continue } readFile(at: url) url.stopAccessingSecurityScopedResource() |
The guard statement ensures that the file is not processed if access is not granted. The startAccessingSecurityScopedResource() and stopAccessingSecurityScopedResource(), methods available through the url object, start and stop access to the file respectively. I’d recommend to check out the documentation for more information.
Note:
If necessary, you can copy files from their original URL to a local folder in the app’s sandbox.
The handleImportedFiles(result:) is now complete:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
private func handleImportedFiles(result: Result<[URL], any Error>) { switch result { case .success(let urls): for url in urls { // Request access to each file. guard url.startAccessingSecurityScopedResource() else { continue } // Read file contents. readFile(at: url) // End access. url.stopAccessingSecurityScopedResource() } case .failure(let error): print(error.localizedDescription) // … handle error properly } } |
An additional tip
We called stopAccessingSecurityScopedResource() method after the file processing is done in readFile(at:). That’s fine here, but if you have more complex code that throws errors or returns early, you should make sure that stopAccessingSecurityScopedResource() is called in all cases, otherwise the granted security scope is not relinquished.
Probably, it would be more practical to call it using defer like so:
|
1 2 3 4 5 6 |
guard url.startAccessingSecurityScopedResource() else { return } defer { url.stopAccessingSecurityScopedResource() } // … do the file processing … |
Reading and displaying file contents
This part aims to make this example complete by adding the missing pieces in the presented code. The way to process files once access is granted depends on the requirements of each app. Here, the purpose is to read and show the contents of the imported text files. To keep things clear, we’ll keep two pieces of data; the file name and the content. We can use a custom type to keep them:
|
1 2 3 4 5 6 7 |
struct ImportedFile: Identifiable { let id = UUID() let name: String let content: String } |
Instances of the above type will be presented in a List, so conforming to Identifiable protocol is necessary.
Additionally, we also need an ImportedFile collection, so we can store the data we want:
|
1 2 3 |
@State private var importedFiles = [ImportedFile]() |
Now, we can go ahead and implement the readFile(at:) method that we called previously in handleImportedFiles(result:). It reads the file contents as a Data object, tries to convert it into a String value, and then creates a new ImportedFile instance which is appended into the importedFiles array:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
private func readFile(at url: URL) { do { // Read file contents. let data = try Data(contentsOf: url) // Get loaded contents as a String value. guard let content = String(data: data, encoding: .utf8) else { return } // Append all data to importedFiles array. importedFiles.append( ImportedFile(name: url.lastPathComponent, content: content) ) } catch { print(error.localizedDescription) } } |
Lastly, and with all the above in place, let’s display all read file contents in a List. The datasource is the importedFiles array:
|
1 2 3 4 5 6 7 8 9 10 11 |
List(importedFiles) { file in VStack(alignment: .leading, spacing: 6) { Text(file.name) .font(.headline) Text(file.content) .foregroundStyle(.secondary) } } |
Wrapping up
Most probably, fileImporter is not a well-known modifier, but it’s a great built-in mechanism to present a system-provided interface to pick files out of the app’s sandbox. Configuring and presenting it is a fairly simple task, as you’ve seen in the previous parts. But it’s crucial to remember requesting access before processing any files, and then stopping it. I hope you found this post valuable. Thanks for reading!