The problem
I wanted to import audio files in an iOS 14.0+ app that I build for work using SwiftUI 2.0, but I ran into trouble with __security-scoped URL__s and styling, and decided to share my experience ...
File Picker
With the release of iOS 14 beta 6, Apple has provided us with a new view modifier enabling users to import an existing file from their iOS devices or their iCloud Drive. First, let's review this new fileImporter()
modifier ...
The fileImporter
modifier
The fileImporter(isPresented:allowedContentTypes:allowsMultipleSelection:onCompletion:)
method allows users to import one or more files.
Declaration
func fileImporter(
isPresented: Binding<Bool>,
allowedContentTypes: [UTType],
allowsMultipleSelection: Bool,
onCompletion: @escaping (Result<[URL], Error>) -> Void
) -> some View
Parameters
- isPresented: A binding to whether the file picker interface should be shown.
- allowedContentTypes: The list of supported content types which can be imported.
- allowsMultipleSelection: A boolean indicating wether users can select and import multiple files at the same time.
- onCompletion: A callback that will be invoked when the operation has succeeded.
How it works
- Set the variable binded by
isPresented
to true to display the file picker. - When the files to import have been selected,
isPresented
will be automatically set back to false before theonCompletion
callback is called. - If the user cancels the import,
isPresented
will be automatically set back to false and theonCompletion
callback will not called. - When the
onCompletion
callback is called, theresult
is either a.success
or a.failure
. - In case of
.success
,result
will contain the list of __URL__s for the files to import.
Note
While allowedContentTypes
can be modified at the same time the file importer is presented, it will have no immediate effect and will only apply the next time the file importer is presented.
Example Code
Let's import one audio file and play it to test our implementation:
import SwiftUI
import AVFoundation
...
@State private var isImporting = false
var body: some View {
Button(action: {
isImporting = true
}) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 40))
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.audio],
allowsMultipleSelection: false
) { result in
if case .success = result {
do {
let audioURL: URL = try result.get().first!
let audioPlayer = try AVAudioPlayer(contentsOf: audioURL)
audioPlayer.delegate = ...
audioPlayer.prepareToPlay()
audioPlayer.play() // ← ERROR raised here
} catch {
let nsError = error as NSError
fatalError("File Import Error \(nsError), \(nsError.userInfo)")
}
} else {
print("File Import Failed")
}
}
}
...
What happened?!
The Error
This is the error I got in the XCode console when I ran my real implementation:
2021-04-23 10:00:01.123456+0900 MyApp[10691:1234567] Could not signal service com.apple.WebKit.WebContent: 113: Could not find specified service
2021-04-23 10:00:01.123456+0900 MyApp[10691:1234567] Could not signal service com.apple.WebKit.WebContent: 113: Could not find specified service
Playback initialization for "file:///private/var/mobile/Containers/Shared/AppGroup/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/File%20Provider%20Storage/Voice/TestFile.m4a" file has failed: Error Domain=NSOSStatusErrorDomain Code=-54 "(null)".
Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value: file MyApp/AudioPlayer.swift, line 123
What does this mean?
It means that apps on iOS are sandboxed, they have unlimited access to the files inside their sandbox but cannot access files outside their sandbox without the proper permissions.
How can we get the permission to access files outside the app sandbox?
Security Permissions
Once you get the list of __URL__s for the files to import, you have to get the permissions to access them.
The startAccessingSecurityScopedResource()
method
Declaration
func startAccessingSecurityScopedResource() -> Bool
Return value
The function returns true if the request to access the file succeeded and false otherwise.
How it works
When you obtain a security-scoped URL, you cannot immediately use the file it points to.
To gain access to the file, you must call the startAccessingSecurityScopedResource()
method (or its Core Foundation equivalent, the CFURLStartAccessingSecurityScopedResource(_:)
function) on the security-scoped URL, which adds the file location to your app’s sandbox, thus making the file available to your app.
After successfully gaining access to a file, you must relinquish access to the file as soon as you are done with it.
To do that, call the stopAccessingSecurityScopedResource()
method (or its Core Foundation equivalent, the CFURLStopAccessingSecurityScopedResource(_:)
function) on the URL to relinquish access, and immediately lose access to the file.
Warning
If you fail to relinquish your access to file-system resources when you no longer need them, your app leaks kernel resources. If sufficient kernel resources are leaked, your app loses its ability to add file-system locations to its sandbox until relaunched.
Example Code
...
@State var audioFiles: Array<MediaFileObject> = Array<MediaFileObject>()
@State private var isImporting = false
var body: some View {
Button(action: {
isImporting = true
}) {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 40))
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.audio],
allowsMultipleSelection: false
) { result in
if case .success = result {
do {
let audioURL: URL = try result.get().first!
if audioURL.startAccessingSecurityScopedResource() {
audioFiles.append(AudioObject(id: UUID().uuidString, url: audioURL))
}
} catch {
let nsError = error as NSError
fatalError("File Import Error \(nsError), \(nsError.userInfo)")
}
} else {
print("File Import Failed")
}
}
}
...
File Types
You can use common system data types for resources that you load, save, or open from other apps. Or you can define your own file and data types if necessary.
In my code I used the .audio
type which represents all kind of audio files and belongs to the "Image, Audio, and Video Base Types" category:
- image: A base type that represents image data.
- audio: A type that represents audio that doesn’t contain video.
- audiovisualContent: A base type that represents data that contains video content that may or may not also include audio.
- movie: A base type representing media formats that may contain both video and audio.
- video: A type that represents video that doesn’t contain audio.
Styling
Styling is still not really straightforward in SwiftUI 2.0 (hoping for SwiftUI 3.0) but I found a few ways to change the file picker appearance:
-
UINavigationBar.appearance().tintColor: UIColor
: Is the color of the toolbar items in the file picker's header. -
UITabBar.appearance().barTintColor: UIColor
: Is the background color of the tabbar at the bottom of the file picker. -
UITabBar.appearance().tintColor: UIColor
: Is the color of the currently selected tabbar item. -
UITabBar.appearance().unselectedItemTintColor: UIColor
: Is the color of the currently not selected toolbar items.
Be careful ! As using UIKit settings this way can affect the appearance of navigation bars and tab bars in other parts of your application.
Last Words
I hope this article will help you to implement file import in your app.
The SwiftUI 1.0 way?
I tried to implement a file picker to import audio files another way (without fileImporter()
), but I could not make it work!
The fileExporter
modifier
The fileExporter(isPresented:document:contentType:defaultFilename:onCompletion:)
method allows users to export an in-memory document to a file on disk.
The fileMover
modifier
The fileMover(isPresented:file:onCompletion:)
method allows users to move an existing file to a new location.