The SwiftUI way of importing a file from the device
Handling PDF import states in SwiftUI by enums
There are many instances where a user may want to import a PDF into an app. However, during the importing process, different phases may occur with varying results. For example, the PDF could initially be in a loading state, but after some time, it might fail to load, resulting in an error. To represent the various states of this process and simultaneously update the views when the state changes, we can use an enum
. In this article, we will demonstrate how to implement a PDF importer in SwiftUI.
PDF importer functions
Our PDF importer implements various functions. Our initial step is to display the native file importer. Following this, we transition to a loading state until the selected PDF is fully loaded. In case an error occurs during the loading process, we present an error message. Once the PDF is successfully loaded, we proceed to show it in a preview. Additionally, if the user closes the file importer without selecting a PDF, we display an empty state.
To achieve this, we will create:
- Enum: represent states
- ViewModel: manage and store states
- Views: display pdfs, error, spinner and empty state
PDF importer state enum — Reflect the states
For the PDF import process, we have four different states:
- Empty: user did not select a PDF
- Loading: user did select a PDF and it’s loading
- Loaded: user did select a PDF and it should be displayed
- Error: user did select a PDF and an error occurred
The resulting enum
looks like this:
enum PdfImporterState: Equatable {
case loading
case error(PDFError)
case loaded(PDFDocument)
case empty
static func == (
lhs: PdfImporterViewModel.PdfImporterState,
rhs: PdfImporterViewModel.PdfImporterState
) -> Bool {
switch (lhs, rhs) {
case (.loading, .loading):
return true
case (.loaded, .loaded):
return false
case (.error(let lhsError), .error(let rhsError)):
return lhsError.prexif == rhsError.prexif
case (.empty, .empty):
return true
default:
return false
}
}
}
We created an enum with the 4 specified cases. For error
we can set a specific error, like for example when the selected PDF file is corrupt. For loaded
we can set the selected pdf
. To achieve automatic reloading by diffing, we make the enum
Equatable
and define when a case should be the equal.
ViewModel
For updating the view by state changes of the PDFImporterState
we create a view model as an ObservableObject
.
class PdfImporterViewModel: ObservableObject {
@Published var pdfImporterState: PdfImporterState = .empty
}
The view model for now only holds a Published
variable, PDFImporterState
, with the default state .empty
.
Views
To start the implementation of the UI, we create a main view, which we name PDFImporterView
. Additionally, we create the views PDFImporterLoadingView
, PDFImporterLoadedView
, PDFImporterErrorView
, EmptyPDFImporterView
. In the PDFImporterView
we can now define which view to display based on the PDFImporterState
.
struct PdfImporterView: View {
@StateObject var viewModel: PdfImporterViewModel = PdfImporterViewModel()
var body: some View {
ZStack {
switch viewModel.pdfImporterState {
case .loading:
PdfImporterLoadingView()
case .loaded(let selecedPdf):
PdfImporterLoadedView(
selectedPdf: selecedPdf,
backToFileImportAction: viewModel.showPdfImporter
)
case .error(let error):
PdfImporterErrorView(
pdfError: error,
backToFileImportAction: viewModel.showPdfImporter
)
case .empty:
EmptyPdfImporterView(
backToFileImportAction: viewModel.showPdfImporter
)
}
}
}
}
In the view, we instantiate our view model as a StateObject
. Through this, we can access the PDFImporterState
. By switching through the different states, we can assign them the needed view. For the loaded state, we can directly retrieve the selected PDF and pass it to the PDFImporterLoadedView
. Similarly, we handle the error state in the same manner.
PdfImporterLoadedView
Because SwiftUI does not have its own PDF viewer, we use the PDFView
from UIKit. For using a UIView
in SwiftUI, we need to write a UIViewRepresentable
like this:
struct PDFKitView: UIViewRepresentable {
let pdf: PDFDocument
func makeUIView(context: UIViewRepresentableContext<PDFKitView>) -> PDFView {
let pdfView = PDFView()
pdfView.autoScales = true
return pdfView
}
func updateUIView(_ uiView: PDFView, context: UIViewRepresentableContext<PDFKitView>) {
DispatchQueue.main.async {
uiView.document = pdf
}
}
}
When initializing the view, we set the selected PDF. In makeUIView
we create the PDFView
and set it to autoscale
, to let UIKit handle the sizing of the views. In updateUIView
we set the selected PDF as the document of the view. We perform the update on the main thread, because the file importer call will be from the background. We assign the PDF in the update function to ensure that the user can also change the selected PDF and the view updates accordingly.
Furthermore, we aim to create a view positioned above the PDFKitView
to change the PDF. To achieve this, we’ll design a bottom sheet with a button, a white background, and a shadow effect.
struct PdfImporterBottomSheet: View {
var backToFileImportAction: (() -> Void)?
var body: some View {
VStack(spacing: 0) {
Button(
Texts.importAnotherPdf,
action: backToFileImportAction ?? {}
)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Dimens.unit20)
.padding(.horizontal, Dimens.unit20)
.background(.white)
.background(
Color.white
.shadow(
color: Color.black.opacity(Dimens.opacity16),
radius: Dimens.unit8
)
)
}
}
Later, when the user clicks on the button, we want to open the file importer. For this, we initialize the view with the function backToFileImportAction
. It should be optional because in some cases we do not want to enable this function.
After creating these two components, we will put them together in a VStack
:
struct PdfImporterLoadedView: View {
let selectedPdf: PDFDocument
let backToFileImportAction: () -> Void
var body: some View {
VStack(spacing: 0) {
PDFKitView(pdf: selectedPdf)
PdfImporterBottomSheet(
backToFileImportAction: backToFileImportAction
)
}
}
}
We initialize the view with the selected pdf
and the backToFileImportAction
function. Now we have a view for previewing the selected PDF.
PdfImporterLoadingView
Some PDFs can be big and will take some time to load. To create a good user experience for the user, we want to show a loading view.
struct PdfImporterLoadingView: View {
var body: some View {
VStack(alignment: .center, spacing: 0) {
Spacer()
ProgressView()
.frame(width: Dimens.unit56, height: Dimens.unit56)
.padding(.vertical, Dimens.unit16)
.padding(.bottom, Dimens.unit8)
Text(Texts.loadingPdf)
Spacer()
PdfImporterBottomSheet()
}
.frame(maxWidth: .infinity)
}
}
The PDFImporterLoadingView
contains a progress view and an information text. Furthermore, we use the PDFImporterBottomSheet
again. But this time we do not assign the backToFileImportAction
because when the PDF is loading, we do not want the user to select another PDF.
EmptyPdfImporterView
When the user closes the file importer without selecting a PDF we want to show a view with a message, that he did not select a PDF and a button to import PDF. The view looks like this:
struct EmptyPdfImporterView: View {
let backToFileImportAction: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(ImageNames.empty)
.resizable()
.frame(
width: Dimens.imageSize,
height: Dimens.imageSize
)
.scaledToFit()
.padding(.bottom, Dimens.unit40)
Text(Texts.emptyPdfTitle)
.font(.title)
.multilineTextAlignment(.center)
.padding(.bottom, Dimens.unit16)
Text(Texts.emptyPdfDescription)
.multilineTextAlignment(.center)
Spacer()
Button(
Texts.backtToFileImportButtonTitle,
action: backToFileImportAction
)
.buttonStyle(.borderedProminent)
.padding(.bottom, Dimens.unit16)
}
.padding(.horizontal, Dimens.unit20)
.preferredColorScheme(.light)
}
}
PdfImporterErrorView
The last view which we need is the PDFImporterErrorView
. This view will be used to display an error, which can occur when importing the selected pdf
. To achieve this, we start with creating a protocol for a generic error in the app.
protocol PDFError {
var prexif: String { get }
var illustrationName: String { get }
var title: String { get }
var description: String { get }
var buttonTitle: String { get }
}
The error has an illustrationName
, a title
, a description
and a buttonTitle
. The prefix is for distinguishing different errors.
Now we create a specific error for importing PDFs:
struct PDFImportError: PDFError, Error {
var prexif: String = "PDFImportError"
var illustrationName: String = ImageNames.error
var title: String = Texts.importErrorTitle
var description: String = Texts.importErrorDescription
var buttonTitle: String = Texts.importAnotherPdf
}
For this, we create a struct
with the PDFError
protocol and Error
. Then set the properties.
Now we can create the PDFImporterErrorView
:
struct PdfImporterErrorView: View {
let pdfError: PDFError
let backToFileImportAction: () -> Void
var body: some View {
VStack(spacing: 0) {
Spacer()
Image(pdfError.illustrationName)
.resizable()
.scaledToFit()
.frame(height: Dimens.imageSize)
.padding(.bottom, Dimens.unit40)
Text(pdfError.title)
.font(.title)
.padding(.bottom, Dimens.unit16)
Text(pdfError.description)
.multilineTextAlignment(.center)
Spacer()
Button(
pdfError.buttonTitle,
action: backToFileImportAction
)
.buttonStyle(.borderedProminent)
.padding(.bottom, Dimens.unit16)
}
.padding(.horizontal, Dimens.unit20)
.background(.white)
}
}
We initialize the view with the backToFileImportAction
and a PDFError
. By using the protocol, we ensure that we can also display other errors of this type. The view displays the defined image, title and description. Like all other Views
it also holds the button for the backToFileImportAction
.
Importing PDFs
For importing PDFs, we can use the modifier. To show the file importer when we need it, we define a new Published
variable, isPresentingPdfImport,
in the view model with a corresponding function for showing it.
class PdfImporterViewModel: ObservableObject {
@Published var isPresentingPdfImport: Bool = true
@Published var pdfImporterState: PdfImporterState = .empty
/// Shows pdf importer
public func showPdfImporter() {
isPresentingPdfImport = true
}
}
In the PDFImporterView
we now call the fileImporter
modifier on the ZStack
. To manage the presenting of the importer, we assign the published variable from the view model isPresentingPdfImport
as a Binding
. Now, if the variable is true, the file importer will be displayed.
ZStack {
…
}
.fileImporter(
isPresented: $viewModel.isPresentingPdfImport,
allowedContentTypes: [.pdf]
) { result in
viewModel.pdfWasSelected(result: result)
}
The last step remaining is to listen to the results of the file importer. For this, we use the onCompletion
of the file importer and call the function pdfWasSelected
from the view model and pass the result of the onCompletion
as a parameter.
In the view model we add the following function:
public func pdfWasSelected(result: Result<URL, Error>) {
pdfImporterState = .loading
/// Load pdf in background
switch result {
case .success(let url):
Task {
/// Check Security
guard
url.startAccessingSecurityScopedResource(),
let pdfDocument = PDFDocument(url: url)
else {
/// startAccessingSecurityScopedResource returns false e.g. when trying to upload virus
url.stopAccessingSecurityScopedResource()
Task.detached { @MainActor in
self.pdfImporterState = .error(PDFImportError())
}
return
}
Task.detached { @MainActor in
self.pdfImporterState = .loaded(pdfDocument)
}
url.stopAccessingSecurityScopedResource()
}
case .failure:
/// Error from apple fileimporter (corrupt files for e.g.)
pdfImporterState = .error(PDFImportError())
}
}
We begin by loading the PDF in the background using Task
. Following this, we switch for the results. For success
, we receive the URL, where the PDF is stored on the device, and create a PDFDocument
from it. It’s important to use startAccessingSecurityScopedResource
to ensure the PDF is free from security issues. If any issues arise, we set the pdfImporterState
to error
on the main thread, with our created PDFImportError
. If the PDF is valid, we set the pdfImporterState
to loaded
on the main thread, with the pdfDocument
obtained from the URL. It’s important to call stopAccessingSecurityScopedResource
to ensure correct access to user files. In case of failure within the switch statement, we again set pdfImporterState
to our defined error
state on the main thread.
Conclusion
This covers all you need for importing PDFs using file importing and effectively communicating the different states to the user.
If you’re interested in exploring a comprehensive example and encountering alternative error scenarios, check out our GitHub repository:
Ready to dive in and see how it works for yourself? Give our example code a try, and we’re excited to see how you implement different states with the use of enums
. (By Laura Siewert)